feat: lower memory dedupe threshold for more aggressive cleaning

This commit is contained in:
root
2026-07-07 16:45:30 +02:00
parent c92f238d2d
commit 97be0f1d00
68 changed files with 15783 additions and 15783 deletions
+27 -27
View File
@@ -1,27 +1,27 @@
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
+28 -28
View File
@@ -1,28 +1,28 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
},
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false,
"port": 5180
}
]
}
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
},
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false,
"port": 5180
}
]
}
+22 -22
View File
@@ -1,22 +1,22 @@
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Env / local
*.env
.DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Env / local
*.env
.DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
+48 -48
View File
@@ -1,48 +1,48 @@
# AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Zed, Kilo Code, Claude Code lesen diese Datei).
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle)
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren.
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
# AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Zed, Kilo Code, Claude Code lesen diese Datei).
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle)
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren.
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
+50 -50
View File
@@ -1,50 +1,50 @@
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
## Aktueller Zustand
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
sauber gehalten und eingefroren, nicht neu erfunden.
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
## Nordstern & Randbedingungen (WICHTIG für den Review)
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern)
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
`Europe/Berlin` umgestellt.
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
## Was NICHT empfohlen werden soll (bereits entschieden)
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
## Letzter Sicherungspunkt
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
## Aktueller Zustand
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
sauber gehalten und eingefroren, nicht neu erfunden.
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
## Nordstern & Randbedingungen (WICHTIG für den Review)
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern)
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
`Europe/Berlin` umgestellt.
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
## Was NICHT empfohlen werden soll (bereits entschieden)
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
## Letzter Sicherungspunkt
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
+152 -152
View File
@@ -1,152 +1,152 @@
"""
Mission Control 2.0 — dünner FastAPI-Einstieg.
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-
Server (proxyt /api hierher), daher CORS für localhost offen.
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import (
agent,
connect,
console,
gateway_proxy,
health,
hermes_ui,
maintenance,
memory,
models,
routing,
system,
voice,
)
from routers import reminders as reminders_router
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__)).
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
log.info(
"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()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info(
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
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).
app.state.gw_client = httpx.AsyncClient(
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
finally:
for task in tasks:
_ = task.cancel()
await app.state.gw_client.aclose()
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(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
) -> httpx.Response:
resp = await call_next(request)
if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache"
return resp
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(
voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router(
reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — 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")
_DIST_ROOT = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}")
def spa(full_path: str):
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
try:
target = (FRONTEND_DIST / full_path).resolve()
if target.is_relative_to(_DIST_ROOT) and target.is_file():
return FileResponse(target)
except (ValueError, OSError):
pass
index = FRONTEND_DIST / "index.html"
if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
return FileResponse(
index, headers={"Cache-Control": "no-cache, must-revalidate"}
)
return {"detail": "frontend not built"}
"""
Mission Control 2.0 — dünner FastAPI-Einstieg.
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-
Server (proxyt /api hierher), daher CORS für localhost offen.
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import (
agent,
connect,
console,
gateway_proxy,
health,
hermes_ui,
maintenance,
memory,
models,
routing,
system,
voice,
)
from routers import reminders as reminders_router
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__)).
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
log.info(
"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()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info(
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
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).
app.state.gw_client = httpx.AsyncClient(
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
finally:
for task in tasks:
_ = task.cancel()
await app.state.gw_client.aclose()
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(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
) -> httpx.Response:
resp = await call_next(request)
if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache"
return resp
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(
voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router(
reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — 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")
_DIST_ROOT = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}")
def spa(full_path: str):
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
try:
target = (FRONTEND_DIST / full_path).resolve()
if target.is_relative_to(_DIST_ROOT) and target.is_file():
return FileResponse(target)
except (ValueError, OSError):
pass
index = FRONTEND_DIST / "index.html"
if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
return FileResponse(
index, headers={"Cache-Control": "no-cache, must-revalidate"}
)
return {"detail": "frontend not built"}
+136 -136
View File
@@ -1,136 +1,136 @@
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI. Wie die Box-Konsole bindet
# es NUR an Loopback (127.0.0.1:7681, base-path /hermes-terminal) und wird von MC2 same-origin
# durchgereicht (routers/console.py → /hermes-terminal/). Login per `su` (Box-Passwort).
HERMES_TERMINAL_UPSTREAM = os.environ.get("MC_HERMES_TERMINAL_UPSTREAM", "http://127.0.0.1:7681").rstrip("/")
HERMES_TERMINAL_PATH = "/hermes-terminal/"
# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI. Wie die Box-Konsole bindet
# es NUR an Loopback (127.0.0.1:7681, base-path /hermes-terminal) und wird von MC2 same-origin
# durchgereicht (routers/console.py → /hermes-terminal/). Login per `su` (Box-Passwort).
HERMES_TERMINAL_UPSTREAM = os.environ.get("MC_HERMES_TERMINAL_UPSTREAM", "http://127.0.0.1:7681").rstrip("/")
HERMES_TERMINAL_PATH = "/hermes-terminal/"
# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
+56 -56
View File
@@ -1,56 +1,56 @@
import sys
from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return
cfg = read_config()
models = cfg.get("models", {})
for name, spec in models.items():
if not isinstance(spec, dict):
continue
cmd = spec.get("cmd", "")
if not cmd:
continue
print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role
aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"):
if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg)
print("Migration completed successfully!")
if __name__ == "__main__":
migrate()
import sys
from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return
cfg = read_config()
models = cfg.get("models", {})
for name, spec in models.items():
if not isinstance(spec, dict):
continue
cmd = spec.get("cmd", "")
if not cmd:
continue
print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role
aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"):
if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg)
print("Migration completed successfully!")
if __name__ == "__main__":
migrate()
+60 -60
View File
@@ -1,60 +1,60 @@
{
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
"version": "2026-07-03",
"models": [
{
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
"family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF",
"family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF",
"family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "vision", "name": "Qwen3-VL-30B-A3B-Instruct", "repo": "Qwen/Qwen3-VL-30B-A3B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
}
]
}
{
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
"version": "2026-07-03",
"models": [
{
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
"family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF",
"family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF",
"family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "vision", "name": "Qwen3-VL-30B-A3B-Instruct", "repo": "Qwen/Qwen3-VL-30B-A3B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
}
]
}
+121 -121
View File
@@ -1,121 +1,121 @@
import os
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage
from services.router_logic import LANES, choose_for_lane
from services.routing_policy import load_policy
router = APIRouter(prefix="/v1")
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
_LANG_DIRECTIVE = os.environ.get(
"MC_GATEWAY_LANG_DIRECTIVE",
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
def _inject_language(body: dict, alias: str) -> None:
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
Persona (SOUL.md) regelt die Sprache selbst."""
if not _LANG_DIRECTIVE or alias == "hermes":
return
msgs = body.get("messages")
if not isinstance(msgs, list):
return
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
if first_sys is None:
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
elif isinstance(first_sys.get("content"), str):
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet.
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
@router.get("/models")
async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
data = r.json()
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
# Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
# Clients fragen aber genau damit an → als eigene Einträge einblenden.
ctx_map: dict[str, int] = {}
alias_entries: list[dict] = []
try:
from services import llamaswap
for m in llamaswap.list_models():
ctx = m.get("ctx")
if ctx:
for api_id in m.get("api_ids", []):
ctx_map[api_id] = ctx
for alias in m.get("aliases", []):
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
"description": f"Alias für {m['name']}"}
if ctx:
entry["context_length"] = ctx
alias_entries.append(entry)
except Exception:
pass
if isinstance(data, dict) and isinstance(data.get("data"), list):
for entry in data["data"]:
if (ctx := ctx_map.get(entry.get("id"))):
entry.setdefault("context_length", ctx)
data["data"] = lanes + alias_entries + data["data"]
return JSONResponse(data, status_code=r.status_code)
async def _proxy(path: str, request: Request):
body = await request.json()
requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"):
lane = requested.lower()
alias, reason = choose_for_lane(lane, body)
body["model"] = alias
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
else:
alias = requested
routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
pol = load_policy()
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}"
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if body.get("stream"):
async def gen():
async with client.stream("POST", url, json=body, timeout=None) as r:
async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias)
yield chunk
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
r = await client.post(url, json=body, timeout=600.0)
resp_json = r.json()
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
@router.post("/chat/completions")
async def chat_completions(request: Request):
return await _proxy("/v1/chat/completions", request)
@router.post("/completions")
async def completions(request: Request):
return await _proxy("/v1/completions", request)
import os
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage
from services.router_logic import LANES, choose_for_lane
from services.routing_policy import load_policy
router = APIRouter(prefix="/v1")
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
_LANG_DIRECTIVE = os.environ.get(
"MC_GATEWAY_LANG_DIRECTIVE",
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
def _inject_language(body: dict, alias: str) -> None:
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
Persona (SOUL.md) regelt die Sprache selbst."""
if not _LANG_DIRECTIVE or alias == "hermes":
return
msgs = body.get("messages")
if not isinstance(msgs, list):
return
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
if first_sys is None:
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
elif isinstance(first_sys.get("content"), str):
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet.
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
@router.get("/models")
async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
data = r.json()
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
# Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
# Clients fragen aber genau damit an → als eigene Einträge einblenden.
ctx_map: dict[str, int] = {}
alias_entries: list[dict] = []
try:
from services import llamaswap
for m in llamaswap.list_models():
ctx = m.get("ctx")
if ctx:
for api_id in m.get("api_ids", []):
ctx_map[api_id] = ctx
for alias in m.get("aliases", []):
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
"description": f"Alias für {m['name']}"}
if ctx:
entry["context_length"] = ctx
alias_entries.append(entry)
except Exception:
pass
if isinstance(data, dict) and isinstance(data.get("data"), list):
for entry in data["data"]:
if (ctx := ctx_map.get(entry.get("id"))):
entry.setdefault("context_length", ctx)
data["data"] = lanes + alias_entries + data["data"]
return JSONResponse(data, status_code=r.status_code)
async def _proxy(path: str, request: Request):
body = await request.json()
requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"):
lane = requested.lower()
alias, reason = choose_for_lane(lane, body)
body["model"] = alias
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
else:
alias = requested
routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
pol = load_policy()
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}"
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if body.get("stream"):
async def gen():
async with client.stream("POST", url, json=body, timeout=None) as r:
async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias)
yield chunk
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
r = await client.post(url, json=body, timeout=600.0)
resp_json = r.json()
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
@router.post("/chat/completions")
async def chat_completions(request: Request):
return await _proxy("/v1/chat/completions", request)
@router.post("/completions")
async def completions(request: Request):
return await _proxy("/v1/completions", request)
+130 -130
View File
@@ -1,130 +1,130 @@
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
zurückgegeben statt zu crashen.
"""
import logging
import os
import subprocess
from fastapi import APIRouter
from pydantic import BaseModel
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
from services import backup as backup_svc
from services import maintenance
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models
from services.pricing import compute_savings
from services.system import system_status
from services.token_stats import get_stats
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status")
def status() -> dict:
return system_status()
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _voice_reachable() -> bool:
try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
return {
"services": [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-Terminal", "unit": "hermes-terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
"hermes_terminal": a["terminal_url"],
},
}
@router.post("/system/backup")
def backup() -> dict:
return backup_svc.backup_now()
@router.get("/system/backups")
def backups() -> dict:
return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel):
service: str
@router.post("/system/restart")
def restart(req: RestartReq) -> dict:
"""Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit):
der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von
der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste
(ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
Sprache/MCP schlug fehl."""
return maintenance.restart_service(req.service)
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats")
def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
role_map: dict[str, str | None] = {}
try:
for m in list_models():
role_map[m["name"].lower()] = m.get("role")
for alias in m.get("aliases", []):
role_map[alias.lower()] = m.get("role")
except Exception:
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
return compute_savings(get_stats(), role_map)
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
zurückgegeben statt zu crashen.
"""
import logging
import os
import subprocess
from fastapi import APIRouter
from pydantic import BaseModel
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
from services import backup as backup_svc
from services import maintenance
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models
from services.pricing import compute_savings
from services.system import system_status
from services.token_stats import get_stats
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status")
def status() -> dict:
return system_status()
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _voice_reachable() -> bool:
try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
return {
"services": [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-Terminal", "unit": "hermes-terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
"hermes_terminal": a["terminal_url"],
},
}
@router.post("/system/backup")
def backup() -> dict:
return backup_svc.backup_now()
@router.get("/system/backups")
def backups() -> dict:
return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel):
service: str
@router.post("/system/restart")
def restart(req: RestartReq) -> dict:
"""Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit):
der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von
der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste
(ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
Sprache/MCP schlug fehl."""
return maintenance.restart_service(req.service)
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats")
def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
role_map: dict[str, str | None] = {}
try:
for m in list_models():
role_map[m["name"].lower()] = m.get("role")
for alias in m.get("aliases", []):
role_map[alias.lower()] = m.get("role")
except Exception:
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
return compute_savings(get_stats(), role_map)
+357 -357
View File
@@ -1,357 +1,357 @@
"""
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
"""
import logging
import os
import time
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
TurnTrace,
get_metrics,
get_trace,
park,
record_stage,
)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR)
try:
from guard import wrap_untrusted
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
def wrap_untrusted(text: str, label: str = "") -> str:
return text
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
multi = len(image_urls) > 1
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
"Keine Einleitung, keine Wiederholung der Frage. "
if multi else
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}})
try:
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
"messages": [{"role": "user", "content": content}],
})
r.raise_for_status()
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
except Exception as exc:
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return ""
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
class TTSIn(BaseModel):
text: str
engine: str = "piper"
voice: str = ""
language: str = ""
ref_path: str = ""
class ChatIn(BaseModel):
text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
session_key: str = "" # optional: Langzeit-Memory-Scope
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
model: str = ""
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
class AnnounceIn(BaseModel):
text: str # die Meldung (wird von Lucy gesprochen)
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
class AlarmIn(BaseModel):
text: str # die Alarm-Meldung
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
@router.post("/alarm")
def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints."""
text = (body.text or "").strip()
if not text:
raise HTTPException(400, "Leere Meldung.")
subject = (body.subject or "[Alarm]").strip()
try:
item = announce.add(text, subject, body.source or "alarm", "normal")
except ValueError as exc:
raise HTTPException(400, str(exc))
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
return {"ok": True, "item": item}
@router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc:
raise HTTPException(400, str(exc))
@router.get("/voice/announcements")
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
return announce.list_after(after, limit)
@router.get("/voice/health")
def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001
out["error"] = str(exc)
return out
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics()
@router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)}
@router.get("/voice/voices")
def voice_voices() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@router.post("/voice/stt")
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
_t0 = time.perf_counter()
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
@router.post("/voice/turn")
async def voice_turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
@router.get("/voice/reference")
def voice_get_reference() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
return {"active": False, "error": str(exc)}
@router.delete("/voice/reference")
def voice_clear_reference() -> dict:
try:
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
@router.post("/voice/tts")
async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc:
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
@router.post("/voice/chat")
async def voice_chat(body: ChatIn) -> StreamingResponse:
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
if not HERMES_API_KEY:
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
headers = {
"Authorization": f"Bearer {HERMES_API_KEY}",
"X-Hermes-Session-Id": body.session_id,
}
if body.session_key:
headers["X-Hermes-Session-Key"] = body.session_key
async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True
first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter()
desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r:
if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return
async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb()
first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
trace.note_first_content()
first_content = False
yield chunk
except httpx.HTTPError as exc:
trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream")
"""
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
"""
import logging
import os
import time
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
TurnTrace,
get_metrics,
get_trace,
park,
record_stage,
)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR)
try:
from guard import wrap_untrusted
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
def wrap_untrusted(text: str, label: str = "") -> str:
return text
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
multi = len(image_urls) > 1
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
"Keine Einleitung, keine Wiederholung der Frage. "
if multi else
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}})
try:
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
"messages": [{"role": "user", "content": content}],
})
r.raise_for_status()
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
except Exception as exc:
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return ""
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
class TTSIn(BaseModel):
text: str
engine: str = "piper"
voice: str = ""
language: str = ""
ref_path: str = ""
class ChatIn(BaseModel):
text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
session_key: str = "" # optional: Langzeit-Memory-Scope
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
model: str = ""
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
class AnnounceIn(BaseModel):
text: str # die Meldung (wird von Lucy gesprochen)
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
class AlarmIn(BaseModel):
text: str # die Alarm-Meldung
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
@router.post("/alarm")
def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints."""
text = (body.text or "").strip()
if not text:
raise HTTPException(400, "Leere Meldung.")
subject = (body.subject or "[Alarm]").strip()
try:
item = announce.add(text, subject, body.source or "alarm", "normal")
except ValueError as exc:
raise HTTPException(400, str(exc))
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
return {"ok": True, "item": item}
@router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc:
raise HTTPException(400, str(exc))
@router.get("/voice/announcements")
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
return announce.list_after(after, limit)
@router.get("/voice/health")
def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001
out["error"] = str(exc)
return out
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics()
@router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)}
@router.get("/voice/voices")
def voice_voices() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@router.post("/voice/stt")
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
_t0 = time.perf_counter()
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
@router.post("/voice/turn")
async def voice_turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
@router.get("/voice/reference")
def voice_get_reference() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
return {"active": False, "error": str(exc)}
@router.delete("/voice/reference")
def voice_clear_reference() -> dict:
try:
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
@router.post("/voice/tts")
async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc:
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
@router.post("/voice/chat")
async def voice_chat(body: ChatIn) -> StreamingResponse:
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
if not HERMES_API_KEY:
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
headers = {
"Authorization": f"Bearer {HERMES_API_KEY}",
"X-Hermes-Session-Id": body.session_id,
}
if body.session_key:
headers["X-Hermes-Session-Key"] = body.session_key
async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True
first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter()
desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r:
if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return
async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb()
first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
trace.note_first_content()
first_content = False
yield chunk
except httpx.HTTPError as exc:
trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream")
+264 -264
View File
@@ -1,264 +1,264 @@
"""
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
"""
import logging
import os
import re
import httpx
import psutil
from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try:
from ruamel.yaml import YAML
p = HERMES_HOME / "config.yaml"
if p.exists():
with p.open(encoding="utf-8") as f:
cfg = YAML().load(f) or {}
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
return str(m.get("default") or m.get("model") or "auto")
except Exception:
log.debug("_active_brain_name: Lesefehler", exc_info=True)
return "auto"
def hermes_brain_info() -> dict:
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default),
plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
from services import llamaswap
models = llamaswap.list_models()
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
bl = brain.lower()
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \
or next((m for m in models if bl in (m["name"] or "").lower()), None)
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
current = None
if cur:
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
"params_b": cur_params, "quant": cur.get("quant"),
"size_bytes": cur.get("size_bytes"),
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
# Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
budget = None
try:
from services.budget import footprint_gb, gtt_budget_gb
groups = llamaswap.list_groups()
persist = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
persist.update(g.get("members") or [])
cur_name = cur["name"] if cur else None
brain_gb = footprint_gb(cur) if cur else 0.0
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
warm = brain_gb + sum(footprint_gb(m) for m in models
if m["name"] in persist and m["name"] != cur_name)
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
gtt = gtt_budget_gb()
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
budget = {
"gtt_gb": gtt,
"brain_gb": round(brain_gb, 1),
"warm_projected_gb": round(warm, 1),
"largest_ondemand_gb": round(largest_od, 1),
"fits": (warm + largest_od) <= gtt,
"free_after_gb": round(gtt - warm - largest_od, 1),
}
except Exception:
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
return {"current": current, "recommended": None, "update_available": False, "budget": budget}
def _reach(url: str, path: str = "") -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{url}{path}").status_code < 500
except httpx.HTTPError:
return False
def _count_enabled_mcp_servers() -> int:
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return 0
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict):
return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0
def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto"
except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
return {
"gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt.
"terminal_url": HERMES_TERMINAL_PATH,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path).
"terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"),
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
}
def set_agent_brain(model_id: str) -> dict:
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
from services import llamaswap
models = {m["name"]: m for m in llamaswap.list_models()}
if model_id not in models:
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."}
old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
if model_id == old:
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
try:
from services.llamaswap import set_ttl
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
if model_id not in brains:
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
set_ttl(model_id, 0)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
try:
llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
update_brain_model("hermes") # 3) Config + Restart
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
warning = None
try:
b = hermes_brain_info().get("budget") or {}
if b and not b.get("fits", True):
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
except Exception:
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
return {"ok": True, "old": old, "new": model_id, "warning": warning}
def update_brain_model(new_model: str) -> bool:
from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
return False
"""
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
"""
import logging
import os
import re
import httpx
import psutil
from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try:
from ruamel.yaml import YAML
p = HERMES_HOME / "config.yaml"
if p.exists():
with p.open(encoding="utf-8") as f:
cfg = YAML().load(f) or {}
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
return str(m.get("default") or m.get("model") or "auto")
except Exception:
log.debug("_active_brain_name: Lesefehler", exc_info=True)
return "auto"
def hermes_brain_info() -> dict:
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default),
plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
from services import llamaswap
models = llamaswap.list_models()
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
bl = brain.lower()
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \
or next((m for m in models if bl in (m["name"] or "").lower()), None)
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
current = None
if cur:
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
"params_b": cur_params, "quant": cur.get("quant"),
"size_bytes": cur.get("size_bytes"),
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
# Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
budget = None
try:
from services.budget import footprint_gb, gtt_budget_gb
groups = llamaswap.list_groups()
persist = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
persist.update(g.get("members") or [])
cur_name = cur["name"] if cur else None
brain_gb = footprint_gb(cur) if cur else 0.0
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
warm = brain_gb + sum(footprint_gb(m) for m in models
if m["name"] in persist and m["name"] != cur_name)
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
gtt = gtt_budget_gb()
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
budget = {
"gtt_gb": gtt,
"brain_gb": round(brain_gb, 1),
"warm_projected_gb": round(warm, 1),
"largest_ondemand_gb": round(largest_od, 1),
"fits": (warm + largest_od) <= gtt,
"free_after_gb": round(gtt - warm - largest_od, 1),
}
except Exception:
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
return {"current": current, "recommended": None, "update_available": False, "budget": budget}
def _reach(url: str, path: str = "") -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{url}{path}").status_code < 500
except httpx.HTTPError:
return False
def _count_enabled_mcp_servers() -> int:
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return 0
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict):
return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0
def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto"
except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
return {
"gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt.
"terminal_url": HERMES_TERMINAL_PATH,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path).
"terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"),
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
}
def set_agent_brain(model_id: str) -> dict:
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
from services import llamaswap
models = {m["name"]: m for m in llamaswap.list_models()}
if model_id not in models:
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."}
old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
if model_id == old:
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
try:
from services.llamaswap import set_ttl
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
if model_id not in brains:
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
set_ttl(model_id, 0)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
try:
llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
update_brain_model("hermes") # 3) Config + Restart
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
warning = None
try:
b = hermes_brain_info().get("budget") or {}
if b and not b.get("fits", True):
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
except Exception:
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
return {"ok": True, "old": old, "new": model_id, "warning": warning}
def update_brain_model(new_model: str) -> bool:
from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
return False
+205 -205
View File
@@ -1,205 +1,205 @@
"""
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB):
• Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident —
seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist`
in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last
verdrängte damals die ganze Gruppe).
• Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss
deren Footprint mit einplanen.
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss —
und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein).
Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine
ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides.
"""
import re
import psutil
from services.fit import (
QUANT_BYTES_PER_PARAM,
estimate_memory_gb,
extract_params_b,
max_ctx_in_budget,
)
HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung
def gtt_budget_gb() -> float:
"""GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest
amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve."""
try:
with open("/proc/cmdline") as f:
m = re.search(r"amdgpu\.gttsize=(\d+)", f.read())
if m:
return round(int(m.group(1)) / 1024.0, 1)
except Exception:
pass
return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1)
def params_of_model(model: dict) -> float:
"""Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und
Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs
(size_bytes = nur erster Teil → ignoriert) ab."""
caps = model.get("capabilities") or {}
quant = model.get("quant") or "Q4_K_M"
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
return ck, cv
def _real_kv_gb(model: dict, ctx: int) -> float | None:
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
from services import gguf_meta
path = model.get("gguf_path")
if not path:
return None
meta = gguf_meta.arch_meta(path)
if not meta:
return None
ck, cv = _cache_types(model.get("cmd") or "")
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
def footprint_gb(model: dict) -> float:
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
quant = model.get("quant") or "Q4_K_M"
ctx = int(model.get("ctx") or 32768)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb = params_of_model(model)
weights = max(pb * bpp, size_gb)
kv = _real_kv_gb(model, ctx)
if kv is None:
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
return weights + max(kv, 0.0)
def params_b_for(name: str) -> float:
"""Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst,
sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe."""
from services import catalog
meta = catalog.meta_for_name(name) if name else None
if meta and meta.get("total_params_b"):
return float(meta["total_params_b"])
return extract_params_b(name)
def _coresident_members(groups: dict) -> set:
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
(Ko-Residenz, z.B. brains = Hirn+embed+vision). Seit dem `persistent`-Fix (03.07.2026)
überlebt die Gruppe auch on-demand-Last: Coder/heavy laden DANEBEN, nicht an ihre
Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere
Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key."""
out: set = set()
for g in (groups or {}).values():
if isinstance(g, dict) and g.get("swap") is False:
out.update(g.get("members") or [])
return out
def reserved_gb(role: str | None) -> dict:
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente
`swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben.
- Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
Gruppen-Mitgliedern → reserviert deren Summe.
- Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe →
reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die
Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte).
"""
from services import llamaswap
models = llamaswap.list_models()
groups = llamaswap.list_groups()
cores = _coresident_members(groups)
brain = next((m for m in models if (m.get("role") == "hermes")), None)
brain_gb = footprint_gb(brain) if brain else 0.0
role = (role or "").strip().lower()
holder = next((m for m in models if (m.get("role") == role)), None) if role else None
holder_name = holder["name"] if holder else None
# Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen.
in_group = role == "hermes" or bool(holder_name and holder_name in cores)
if in_group:
others = sum(footprint_gb(m) for m in models
if m["name"] in cores and m["name"] != holder_name)
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
# on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren.
warm = sum(footprint_gb(m) for m in models if m["name"] in cores)
return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb}
def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict:
"""Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem
bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz)."""
gtt = gtt_budget_gb()
r = reserved_gb(role)
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
ctx = max_ctx_in_budget(params_b, quant, budget)
return {
"ctx": ctx,
"gtt_gb": gtt,
"reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1),
"mode": r["mode"],
}
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
"""
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB):
• Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident —
seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist`
in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last
verdrängte damals die ganze Gruppe).
• Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss
deren Footprint mit einplanen.
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss —
und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein).
Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine
ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides.
"""
import re
import psutil
from services.fit import (
QUANT_BYTES_PER_PARAM,
estimate_memory_gb,
extract_params_b,
max_ctx_in_budget,
)
HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung
def gtt_budget_gb() -> float:
"""GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest
amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve."""
try:
with open("/proc/cmdline") as f:
m = re.search(r"amdgpu\.gttsize=(\d+)", f.read())
if m:
return round(int(m.group(1)) / 1024.0, 1)
except Exception:
pass
return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1)
def params_of_model(model: dict) -> float:
"""Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und
Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs
(size_bytes = nur erster Teil → ignoriert) ab."""
caps = model.get("capabilities") or {}
quant = model.get("quant") or "Q4_K_M"
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
return ck, cv
def _real_kv_gb(model: dict, ctx: int) -> float | None:
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
from services import gguf_meta
path = model.get("gguf_path")
if not path:
return None
meta = gguf_meta.arch_meta(path)
if not meta:
return None
ck, cv = _cache_types(model.get("cmd") or "")
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
def footprint_gb(model: dict) -> float:
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
quant = model.get("quant") or "Q4_K_M"
ctx = int(model.get("ctx") or 32768)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb = params_of_model(model)
weights = max(pb * bpp, size_gb)
kv = _real_kv_gb(model, ctx)
if kv is None:
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
return weights + max(kv, 0.0)
def params_b_for(name: str) -> float:
"""Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst,
sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe."""
from services import catalog
meta = catalog.meta_for_name(name) if name else None
if meta and meta.get("total_params_b"):
return float(meta["total_params_b"])
return extract_params_b(name)
def _coresident_members(groups: dict) -> set:
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
(Ko-Residenz, z.B. brains = Hirn+embed+vision). Seit dem `persistent`-Fix (03.07.2026)
überlebt die Gruppe auch on-demand-Last: Coder/heavy laden DANEBEN, nicht an ihre
Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere
Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key."""
out: set = set()
for g in (groups or {}).values():
if isinstance(g, dict) and g.get("swap") is False:
out.update(g.get("members") or [])
return out
def reserved_gb(role: str | None) -> dict:
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente
`swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben.
- Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
Gruppen-Mitgliedern → reserviert deren Summe.
- Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe →
reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die
Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte).
"""
from services import llamaswap
models = llamaswap.list_models()
groups = llamaswap.list_groups()
cores = _coresident_members(groups)
brain = next((m for m in models if (m.get("role") == "hermes")), None)
brain_gb = footprint_gb(brain) if brain else 0.0
role = (role or "").strip().lower()
holder = next((m for m in models if (m.get("role") == role)), None) if role else None
holder_name = holder["name"] if holder else None
# Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen.
in_group = role == "hermes" or bool(holder_name and holder_name in cores)
if in_group:
others = sum(footprint_gb(m) for m in models
if m["name"] in cores and m["name"] != holder_name)
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
# on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren.
warm = sum(footprint_gb(m) for m in models if m["name"] in cores)
return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb}
def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict:
"""Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem
bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz)."""
gtt = gtt_budget_gb()
r = reserved_gb(role)
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
ctx = max_ctx_in_budget(params_b, quant, budget)
return {
"ctx": ctx,
"gtt_gb": gtt,
"reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1),
"mode": r["mode"],
}
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
+179 -179
View File
@@ -1,179 +1,179 @@
"""
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import math
import os
import time
from datetime import datetime
import httpx
import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout"
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception:
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
return []
def _age_days(last_modified, now_ts: float) -> float:
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
if not last_modified:
return 540.0
try:
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
except Exception:
return 540.0
def _score(m: dict, now_ts: float) -> float:
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
- Capability: mehr Parameter (log-skaliert),
- Popularity: Downloads (log-skaliert).
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor
Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen."""
fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0)
cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0
pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0
return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware.
Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
now_ts = time.time()
return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: -_score(m, now_ts),
)
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0 and not catalog.entries():
raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
tags = [str(t) for t in (m.get("tags") or [])]
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"caps": capabilities(name=rid, hf={"tags": tags}),
})
cats = []
for c in CATEGORIES:
role = c["role"]
# 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
# macht die Empfehlung präzise statt Namens-Raterei.
cat_entries = sorted(catalog.entries_for_role(role),
key=lambda e: -catalog.stack_score(e, ram_gb))
cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
if m["fit"]["level"] != "too_tight"]
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
hf_ranked = rank_runnable(by_cat[role])
seen = {catalog._norm(m["repo"]) for m in cat_models}
extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
combined = cat_models + extra
if combined:
cats.append({
"role": role, "title": c["title"], "icon": c["icon"],
"models": combined[:6],
# Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
"recommended": (cat_models[0]["repo"] if cat_models
else (hf_ranked[0]["repo"] if hf_ranked else None)),
})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception:
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
return data
def load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception:
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
return None
def safe_discover(ram_gb: float) -> dict | None:
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
cached = load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached
try:
return refresh_discover(ram_gb)
except Exception:
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
return cached
"""
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import math
import os
import time
from datetime import datetime
import httpx
import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout"
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception:
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
return []
def _age_days(last_modified, now_ts: float) -> float:
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
if not last_modified:
return 540.0
try:
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
except Exception:
return 540.0
def _score(m: dict, now_ts: float) -> float:
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
- Capability: mehr Parameter (log-skaliert),
- Popularity: Downloads (log-skaliert).
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor
Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen."""
fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0)
cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0
pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0
return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware.
Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
now_ts = time.time()
return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: -_score(m, now_ts),
)
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0 and not catalog.entries():
raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
tags = [str(t) for t in (m.get("tags") or [])]
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"caps": capabilities(name=rid, hf={"tags": tags}),
})
cats = []
for c in CATEGORIES:
role = c["role"]
# 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
# macht die Empfehlung präzise statt Namens-Raterei.
cat_entries = sorted(catalog.entries_for_role(role),
key=lambda e: -catalog.stack_score(e, ram_gb))
cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
if m["fit"]["level"] != "too_tight"]
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
hf_ranked = rank_runnable(by_cat[role])
seen = {catalog._norm(m["repo"]) for m in cat_models}
extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
combined = cat_models + extra
if combined:
cats.append({
"role": role, "title": c["title"], "icon": c["icon"],
"models": combined[:6],
# Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
"recommended": (cat_models[0]["repo"] if cat_models
else (hf_ranked[0]["repo"] if hf_ranked else None)),
})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception:
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
return data
def load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception:
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
return None
def safe_discover(ram_gb: float) -> dict | None:
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
cached = load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached
try:
return refresh_discover(ram_gb)
except Exception:
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
return cached
+41 -41
View File
@@ -1,41 +1,41 @@
"""Token-Erfassung für den Builtin-Gateway.
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
"""
import json
import logging
from services.token_stats import increment_tokens
log = logging.getLogger(__name__)
def record_usage(usage: dict | None, model: str) -> None:
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
if not usage:
return
prompt = usage.get("prompt_tokens", 0)
completion = usage.get("completion_tokens", 0)
if prompt or completion:
increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"usage"' not in chunk:
return
text = chunk.decode("utf-8", errors="ignore")
for line in text.splitlines():
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
record_usage(json.loads(data_str).get("usage"), model)
except json.JSONDecodeError:
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
"""Token-Erfassung für den Builtin-Gateway.
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
"""
import json
import logging
from services.token_stats import increment_tokens
log = logging.getLogger(__name__)
def record_usage(usage: dict | None, model: str) -> None:
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
if not usage:
return
prompt = usage.get("prompt_tokens", 0)
completion = usage.get("completion_tokens", 0)
if prompt or completion:
increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"usage"' not in chunk:
return
text = chunk.decode("utf-8", errors="ignore")
for line in text.splitlines():
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
record_usage(json.loads(data_str).get("usage"), model)
except json.JSONDecodeError:
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
+266 -266
View File
@@ -1,266 +1,266 @@
"""
GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem
GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell
**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative
Decoding in llama.cpp — sonst: "draft model vocab type must match target").
Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald
`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab
identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten
Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung
beim Laden bleibt der letzte Schiedsrichter.
"""
import hashlib
import struct
from functools import lru_cache
# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md)
_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \
_T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13)
_SCALAR_FMT = {
_T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
}
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
class _Reader:
def __init__(self, f):
self.f = f
def read(self, n: int) -> bytes:
b = self.f.read(n)
if len(b) != n:
raise EOFError("unerwartetes Dateiende beim GGUF-Parsen")
return b
def u32(self) -> int:
return struct.unpack("<I", self.read(4))[0]
def u64(self) -> int:
return struct.unpack("<Q", self.read(8))[0]
def gstr(self) -> str:
n = self.u64()
return self.read(n).decode("utf-8", "replace")
def scalar(self, vtype: int):
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
fmt = _SCALAR_FMT.get(vtype)
if not fmt:
self.skip_value(vtype)
return None
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
def skip_value(self, vtype: int) -> None:
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
weiterzuschieben). Arrays werden elementweise konsumiert."""
if vtype == _T_STRING:
self.f.seek(self.u64(), 1)
elif vtype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[vtype], 1)
elif vtype == _T_ARRAY:
etype = self.u32()
count = self.u64()
if etype == _T_STRING:
for _ in range(count):
self.f.seek(self.u64(), 1)
elif etype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[etype] * count, 1)
else:
raise ValueError(f"unbekannter Array-Elementtyp {etype}")
else:
raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}")
def _read_fingerprint(path: str) -> dict | None:
"""Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF."""
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None,
"tokens_sha": None}
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY:
etype = r.u32()
count = r.u64()
fp["n_vocab"] = count
if etype != _T_STRING:
return None
# ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste
# (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …).
h = hashlib.sha256()
h.update(count.to_bytes(8, "little"))
for _ in range(count):
n = r.u64()
h.update(r.read(n))
fp["tokens_sha"] = h.hexdigest()
# model/pre kommen vor tokens → wir haben alles. Abbrechen.
break
if key in _WANT_STRINGS and vtype == _T_STRING:
val = r.gstr()
if key == "tokenizer.ggml.model":
fp["model"] = val
elif key == "tokenizer.ggml.pre":
fp["pre"] = val
else:
fp["arch"] = val
else:
r.skip_value(vtype)
if fp["model"] is None and fp["n_vocab"] is None:
return None
return fp
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=256)
def _cached(path: str, mtime: float, size: int) -> tuple | None:
fp = _read_fingerprint(path)
if fp is None:
return None
return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha"))
def fingerprint(path: str) -> dict | None:
"""Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size).
Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar."""
import os
try:
st = os.stat(path)
except OSError:
return None
t = _cached(path, st.st_mtime, st.st_size)
if t is None:
return None
return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]}
def vocab_key(path: str) -> tuple | None:
"""ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256
der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht
nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität
verlangt llama.cpp für Speculative Decoding."""
fp = fingerprint(path)
if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"):
return None
return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"])
def compatible(target_path: str, draft_path: str) -> bool | None:
"""True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar
(eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'."""
a = vocab_key(target_path)
b = vocab_key(draft_path)
if a is None or b is None:
return None
return a == b
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
_ARCH_WANT = (
".block_count", ".attention.head_count_kv", ".attention.head_count",
".attention.key_length", ".attention.value_length", ".embedding_length",
".context_length",
)
def _read_arch_meta(path: str) -> dict | None:
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
raw: dict = {}
arch = None
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "general.architecture" and vtype == _T_STRING:
arch = r.gstr()
continue
if key == "tokenizer.ggml.tokens":
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
if suf is not None and vtype in _SCALAR_SIZE:
raw[suf] = r.scalar(vtype)
else:
r.skip_value(vtype)
n_layers = raw.get(".block_count")
n_head = raw.get(".attention.head_count")
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
n_embd = raw.get(".embedding_length")
hd_k = raw.get(".attention.key_length") \
or (int(n_embd / n_head) if (n_embd and n_head) else None)
hd_v = raw.get(".attention.value_length") or hd_k
if not (n_layers and n_head_kv and hd_k and hd_v):
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=128)
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
return _read_arch_meta(path)
def arch_meta(path: str) -> dict | None:
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
oder unvollständig."""
import os
try:
st = os.stat(path)
except OSError:
return None
return _arch_cached(path, st.st_mtime, st.st_size)
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
_KV_BPE = {
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
}
_GIB = 1024 ** 3
def _bpe(cache_type: str | None) -> float:
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
k = per_tok * meta["head_dim_k"] * _bpe(ck)
v = per_tok * meta["head_dim_v"] * _bpe(cv)
return (k + v) / _GIB
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
return kv_cache_gb(meta, 1, ck, cv)
"""
GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem
GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell
**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative
Decoding in llama.cpp — sonst: "draft model vocab type must match target").
Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald
`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab
identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten
Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung
beim Laden bleibt der letzte Schiedsrichter.
"""
import hashlib
import struct
from functools import lru_cache
# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md)
_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \
_T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13)
_SCALAR_FMT = {
_T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
}
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
class _Reader:
def __init__(self, f):
self.f = f
def read(self, n: int) -> bytes:
b = self.f.read(n)
if len(b) != n:
raise EOFError("unerwartetes Dateiende beim GGUF-Parsen")
return b
def u32(self) -> int:
return struct.unpack("<I", self.read(4))[0]
def u64(self) -> int:
return struct.unpack("<Q", self.read(8))[0]
def gstr(self) -> str:
n = self.u64()
return self.read(n).decode("utf-8", "replace")
def scalar(self, vtype: int):
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
fmt = _SCALAR_FMT.get(vtype)
if not fmt:
self.skip_value(vtype)
return None
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
def skip_value(self, vtype: int) -> None:
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
weiterzuschieben). Arrays werden elementweise konsumiert."""
if vtype == _T_STRING:
self.f.seek(self.u64(), 1)
elif vtype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[vtype], 1)
elif vtype == _T_ARRAY:
etype = self.u32()
count = self.u64()
if etype == _T_STRING:
for _ in range(count):
self.f.seek(self.u64(), 1)
elif etype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[etype] * count, 1)
else:
raise ValueError(f"unbekannter Array-Elementtyp {etype}")
else:
raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}")
def _read_fingerprint(path: str) -> dict | None:
"""Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF."""
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None,
"tokens_sha": None}
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY:
etype = r.u32()
count = r.u64()
fp["n_vocab"] = count
if etype != _T_STRING:
return None
# ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste
# (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …).
h = hashlib.sha256()
h.update(count.to_bytes(8, "little"))
for _ in range(count):
n = r.u64()
h.update(r.read(n))
fp["tokens_sha"] = h.hexdigest()
# model/pre kommen vor tokens → wir haben alles. Abbrechen.
break
if key in _WANT_STRINGS and vtype == _T_STRING:
val = r.gstr()
if key == "tokenizer.ggml.model":
fp["model"] = val
elif key == "tokenizer.ggml.pre":
fp["pre"] = val
else:
fp["arch"] = val
else:
r.skip_value(vtype)
if fp["model"] is None and fp["n_vocab"] is None:
return None
return fp
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=256)
def _cached(path: str, mtime: float, size: int) -> tuple | None:
fp = _read_fingerprint(path)
if fp is None:
return None
return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha"))
def fingerprint(path: str) -> dict | None:
"""Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size).
Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar."""
import os
try:
st = os.stat(path)
except OSError:
return None
t = _cached(path, st.st_mtime, st.st_size)
if t is None:
return None
return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]}
def vocab_key(path: str) -> tuple | None:
"""ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256
der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht
nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität
verlangt llama.cpp für Speculative Decoding."""
fp = fingerprint(path)
if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"):
return None
return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"])
def compatible(target_path: str, draft_path: str) -> bool | None:
"""True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar
(eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'."""
a = vocab_key(target_path)
b = vocab_key(draft_path)
if a is None or b is None:
return None
return a == b
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
_ARCH_WANT = (
".block_count", ".attention.head_count_kv", ".attention.head_count",
".attention.key_length", ".attention.value_length", ".embedding_length",
".context_length",
)
def _read_arch_meta(path: str) -> dict | None:
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
raw: dict = {}
arch = None
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "general.architecture" and vtype == _T_STRING:
arch = r.gstr()
continue
if key == "tokenizer.ggml.tokens":
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
if suf is not None and vtype in _SCALAR_SIZE:
raw[suf] = r.scalar(vtype)
else:
r.skip_value(vtype)
n_layers = raw.get(".block_count")
n_head = raw.get(".attention.head_count")
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
n_embd = raw.get(".embedding_length")
hd_k = raw.get(".attention.key_length") \
or (int(n_embd / n_head) if (n_embd and n_head) else None)
hd_v = raw.get(".attention.value_length") or hd_k
if not (n_layers and n_head_kv and hd_k and hd_v):
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=128)
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
return _read_arch_meta(path)
def arch_meta(path: str) -> dict | None:
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
oder unvollständig."""
import os
try:
st = os.stat(path)
except OSError:
return None
return _arch_cached(path, st.st_mtime, st.st_size)
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
_KV_BPE = {
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
}
_GIB = 1024 ** 3
def _bpe(cache_type: str | None) -> float:
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
k = per_tok * meta["head_dim_k"] * _bpe(ck)
v = per_tok * meta["head_dim_v"] * _bpe(cv)
return (k + v) / _GIB
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
return kv_cache_gb(meta, 1, ck, cv)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -161,7 +161,7 @@ def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.9"))
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
async def auto_dedupe_loop() -> None:
+58 -58
View File
@@ -1,58 +1,58 @@
"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
eingesparte Summe. Wird vom System-Router dünn aufgerufen.
"""
import os
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
PRICING: dict[str, tuple[float, float]] = {
"heavy": (15.0, 75.0),
"coder": (3.0, 15.0),
"hermes": (1.0, 5.0),
"fast": (0.15, 0.60),
"scout": (0.15, 0.60),
"vision": (0.15, 0.60),
"reasoning": (0.15, 0.60),
}
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
# damit historische Ersparnis erhalten bleibt.
BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92"))
def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis.
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
"""
prompt = stats.get("prompt_tokens", 0)
completion = stats.get("completion_tokens", 0)
modeled_p = modeled_c = 0
saved_usd = 0.0
for m_name, m_tokens in (stats.get("models") or {}).items():
mp = m_tokens.get("prompt", 0)
mc = m_tokens.get("completion", 0)
modeled_p += mp
modeled_c += mc
role = role_map.get(m_name, m_name)
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
baseline_p = max(0, prompt - modeled_p)
baseline_c = max(0, completion - modeled_c)
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
"saved_usd": round(saved_usd, 2),
"saved_eur": round(saved_usd * USD_TO_EUR, 2),
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()},
}
"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
eingesparte Summe. Wird vom System-Router dünn aufgerufen.
"""
import os
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
PRICING: dict[str, tuple[float, float]] = {
"heavy": (15.0, 75.0),
"coder": (3.0, 15.0),
"hermes": (1.0, 5.0),
"fast": (0.15, 0.60),
"scout": (0.15, 0.60),
"vision": (0.15, 0.60),
"reasoning": (0.15, 0.60),
}
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
# damit historische Ersparnis erhalten bleibt.
BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92"))
def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis.
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
"""
prompt = stats.get("prompt_tokens", 0)
completion = stats.get("completion_tokens", 0)
modeled_p = modeled_c = 0
saved_usd = 0.0
for m_name, m_tokens in (stats.get("models") or {}).items():
mp = m_tokens.get("prompt", 0)
mc = m_tokens.get("completion", 0)
modeled_p += mp
modeled_c += mc
role = role_map.get(m_name, m_name)
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
baseline_p = max(0, prompt - modeled_p)
baseline_c = max(0, completion - modeled_c)
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
"saved_usd": round(saved_usd, 2),
"saved_eur": round(saved_usd * USD_TO_EUR, 2),
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()},
}
+127 -127
View File
@@ -1,127 +1,127 @@
"""
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
bleiben in router_logic.py im Code.
"""
import json
import os
import threading
from pathlib import Path
from config import MODELS_DIR
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
def _env_bool(name: str, default: str) -> bool:
return os.environ.get(name, default) not in ("0", "false", "")
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
DEFAULTS: dict = {
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
}
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
FIELDS: list[dict] = [
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
]
_LOCK = threading.Lock()
_CACHE: dict = {"mtime": None, "policy": None}
def _read_file() -> dict:
try:
with open(POLICY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _coerce(patch: dict) -> dict:
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
spec = {f["key"]: f for f in FIELDS}
out: dict = {}
for k, v in (patch or {}).items():
f = spec.get(k)
if not f:
continue # unbekannte Keys still verwerfen
if f["type"] == "int":
iv = int(v)
lo, hi = f.get("min", 1), f.get("max", 10**9)
if not (lo <= iv <= hi):
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
out[k] = iv
elif f["type"] == "bool":
out[k] = bool(v)
else: # str
sv = str(v).strip()
if k != "coder_lite" and not sv:
raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv
return out
def _coerce_safe(patch: dict) -> dict:
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
try:
return _coerce(patch)
except (ValueError, TypeError):
return {}
def load_policy() -> dict:
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
try:
mtime = POLICY_PATH.stat().st_mtime
except OSError:
mtime = None
with _LOCK:
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
merged = {**DEFAULTS}
if mtime is not None:
merged.update(_coerce_safe(_read_file()))
_CACHE["mtime"] = mtime
_CACHE["policy"] = merged
return dict(_CACHE["policy"])
def save_policy(patch: dict) -> dict:
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None
return current
def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
"""
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
bleiben in router_logic.py im Code.
"""
import json
import os
import threading
from pathlib import Path
from config import MODELS_DIR
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
def _env_bool(name: str, default: str) -> bool:
return os.environ.get(name, default) not in ("0", "false", "")
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
DEFAULTS: dict = {
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
}
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
FIELDS: list[dict] = [
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
]
_LOCK = threading.Lock()
_CACHE: dict = {"mtime": None, "policy": None}
def _read_file() -> dict:
try:
with open(POLICY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _coerce(patch: dict) -> dict:
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
spec = {f["key"]: f for f in FIELDS}
out: dict = {}
for k, v in (patch or {}).items():
f = spec.get(k)
if not f:
continue # unbekannte Keys still verwerfen
if f["type"] == "int":
iv = int(v)
lo, hi = f.get("min", 1), f.get("max", 10**9)
if not (lo <= iv <= hi):
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
out[k] = iv
elif f["type"] == "bool":
out[k] = bool(v)
else: # str
sv = str(v).strip()
if k != "coder_lite" and not sv:
raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv
return out
def _coerce_safe(patch: dict) -> dict:
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
try:
return _coerce(patch)
except (ValueError, TypeError):
return {}
def load_policy() -> dict:
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
try:
mtime = POLICY_PATH.stat().st_mtime
except OSError:
mtime = None
with _LOCK:
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
merged = {**DEFAULTS}
if mtime is not None:
merged.update(_coerce_safe(_read_file()))
_CACHE["mtime"] = mtime
_CACHE["policy"] = merged
return dict(_CACHE["policy"])
def save_policy(patch: dict) -> dict:
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None
return current
def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
+99 -99
View File
@@ -1,99 +1,99 @@
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
"""
import atexit
import json
import logging
import threading
import time
from pathlib import Path
from config import HERMES_HOME
STATS_FILE = HERMES_HOME / "token_stats.json"
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
log = logging.getLogger(__name__)
_lock = threading.Lock()
_stats: dict | None = None
_dirty = False
_last_flush = 0.0
def _load_from_disk() -> dict:
if not STATS_FILE.exists():
return dict(_BASELINE)
try:
with open(STATS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
data.setdefault("prompt_tokens", 0)
data.setdefault("completion_tokens", 0)
data.setdefault("models", {})
return data
except (OSError, json.JSONDecodeError):
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
return dict(_BASELINE)
def _ensure_loaded() -> dict:
global _stats
if _stats is None:
_stats = _load_from_disk()
return _stats
def _write(stats: dict) -> None:
try:
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATS_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(stats, f)
tmp.replace(STATS_FILE)
except OSError:
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
def get_stats() -> dict:
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
with _lock:
return json.loads(json.dumps(_ensure_loaded()))
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
global _dirty, _last_flush
with _lock:
stats = _ensure_loaded()
stats["prompt_tokens"] += prompt
stats["completion_tokens"] += completion
if model:
m = stats.setdefault("models", {}).setdefault(
model.lower(), {"prompt": 0, "completion": 0})
m["prompt"] += prompt
m["completion"] += completion
_dirty = True
now = time.monotonic()
if now - _last_flush >= FLUSH_INTERVAL:
_write(stats)
_dirty = False
_last_flush = now
def flush() -> None:
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
global _dirty
with _lock:
if _dirty and _stats is not None:
_write(_stats)
_dirty = False
atexit.register(flush)
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
"""
import atexit
import json
import logging
import threading
import time
from pathlib import Path
from config import HERMES_HOME
STATS_FILE = HERMES_HOME / "token_stats.json"
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
log = logging.getLogger(__name__)
_lock = threading.Lock()
_stats: dict | None = None
_dirty = False
_last_flush = 0.0
def _load_from_disk() -> dict:
if not STATS_FILE.exists():
return dict(_BASELINE)
try:
with open(STATS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
data.setdefault("prompt_tokens", 0)
data.setdefault("completion_tokens", 0)
data.setdefault("models", {})
return data
except (OSError, json.JSONDecodeError):
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
return dict(_BASELINE)
def _ensure_loaded() -> dict:
global _stats
if _stats is None:
_stats = _load_from_disk()
return _stats
def _write(stats: dict) -> None:
try:
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATS_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(stats, f)
tmp.replace(STATS_FILE)
except OSError:
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
def get_stats() -> dict:
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
with _lock:
return json.loads(json.dumps(_ensure_loaded()))
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
global _dirty, _last_flush
with _lock:
stats = _ensure_loaded()
stats["prompt_tokens"] += prompt
stats["completion_tokens"] += completion
if model:
m = stats.setdefault("models", {}).setdefault(
model.lower(), {"prompt": 0, "completion": 0})
m["prompt"] += prompt
m["completion"] += completion
_dirty = True
now = time.monotonic()
if now - _last_flush >= FLUSH_INTERVAL:
_write(stats)
_dirty = False
_last_flush = now
def flush() -> None:
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
global _dirty
with _lock:
if _dirty and _stats is not None:
_write(_stats)
_dirty = False
atexit.register(flush)
+129 -129
View File
@@ -1,129 +1,129 @@
"""
Hält das ganze WARM-SET (Hirn + Augen/vision + Gedächtnis/embed) dauerhaft warm.
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
Set über deploy/warmup.sh nach — NICHT nur das Hirn (sonst blieben Augen+embed kalt, live
vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
raus, verdrängt also nie ein gerade genutztes Modell.
warmup.sh deckt korrekt ab: fast+vision über /v1/chat/completions, embed über /v1/embeddings
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast vision') + MC_WARMUP_EMBED (Default 'embed').
"""
import asyncio
import logging
import os
import subprocess
import httpx
from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
_loop: asyncio.AbstractEventLoop | None = None
_wake: asyncio.Event | None = None
def nudge() -> None:
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
solange der Wächter (noch) nicht läuft."""
if _loop is not None and _wake is not None and not _loop.is_closed():
try:
_loop.call_soon_threadsafe(_wake.set)
except RuntimeError:
pass
def _run_warmup() -> bool:
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
False, wenn das Skript fehlt."""
if not os.path.exists(_WARMUP_SH):
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
return False
try:
subprocess.Popen(["bash", _WARMUP_SH],
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
return False
def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
try:
from services import llamaswap
groups = llamaswap.list_groups() or {}
except Exception:
return set()
want: set[str] = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
want.update(g.get("members") or [])
return want
async def _warmset_missing() -> list[str] | None:
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
try:
async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {}
except Exception:
return None
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
want = _warmset_members()
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
return [] if ready else ["<leer>"]
return [m for m in want if m not in ready]
async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
global _loop, _wake
_loop = asyncio.get_running_loop()
_wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True:
try:
missing = await _warmset_missing()
if missing:
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
_run_warmup()
except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
try:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
_run_warmup()
except asyncio.TimeoutError:
pass
"""
Hält das ganze WARM-SET (Hirn + Augen/vision + Gedächtnis/embed) dauerhaft warm.
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
Set über deploy/warmup.sh nach — NICHT nur das Hirn (sonst blieben Augen+embed kalt, live
vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
raus, verdrängt also nie ein gerade genutztes Modell.
warmup.sh deckt korrekt ab: fast+vision über /v1/chat/completions, embed über /v1/embeddings
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast vision') + MC_WARMUP_EMBED (Default 'embed').
"""
import asyncio
import logging
import os
import subprocess
import httpx
from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
_loop: asyncio.AbstractEventLoop | None = None
_wake: asyncio.Event | None = None
def nudge() -> None:
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
solange der Wächter (noch) nicht läuft."""
if _loop is not None and _wake is not None and not _loop.is_closed():
try:
_loop.call_soon_threadsafe(_wake.set)
except RuntimeError:
pass
def _run_warmup() -> bool:
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
False, wenn das Skript fehlt."""
if not os.path.exists(_WARMUP_SH):
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
return False
try:
subprocess.Popen(["bash", _WARMUP_SH],
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
return False
def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
try:
from services import llamaswap
groups = llamaswap.list_groups() or {}
except Exception:
return set()
want: set[str] = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
want.update(g.get("members") or [])
return want
async def _warmset_missing() -> list[str] | None:
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
try:
async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {}
except Exception:
return None
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
want = _warmset_members()
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
return [] if ready else ["<leer>"]
return [m for m in want if m not in ready]
async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
global _loop, _wake
_loop = asyncio.get_running_loop()
_wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True:
try:
missing = await _warmset_missing()
if missing:
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
_run_warmup()
except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
try:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
_run_warmup()
except asyncio.TimeoutError:
pass
+115 -115
View File
@@ -1,115 +1,115 @@
# Auto-generiert von provision.sh - per Mission Control erweiterbar
healthCheckTimeout: 300
globalTTL: 0
models:
Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
ttl: 0
aliases:
- hermes
- fast
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
Qwen3.5-122B-A10B:
cmd: |
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- heavy
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-VL-8B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
# ttl 0 (03.07.): Mitglied des Immer-bereit-Sets — ein Selbstauslöser widerspräche dem UI-Versprechen.
ttl: 0
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
context: 32768
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- embed
Qwen3-Coder-30B-A3B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-30B-A3B-Instruct-GGUF/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 300
aliases:
- coder-lite
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
gpt-oss-120b:
# heavy-Kandidat (Bench 02.07.: tg 54-55 t/s vs. Qwen3.5-122B 23,5 t/s = 2,3x, 60 statt
# 73 GB). Noch OHNE heavy-Alias — erst Qualitaets-Check (deutsch/Reasoning), dann Entscheid.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-VL-30B-A3B-Instruct:
# Lucys AUGEN (vision, seit 03.07. live via set_role/set_group): MoE 3B aktiv, versteht die
# Bildschirm-SZENE statt nur OCR. Als brains-Mitglied MUSS ttl:0 sein — persistent schuetzt nur
# gegen Verdraengung, NICHT gegen ttl-Selbstentladen (sonst faellt es alle 5 Min raus).
cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 0
capabilities:
in: [text, image]
out: [text]
context: 32768
GLM-4.6V-Flash:
cmd: |
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
ttl: 300
aliases:
- scout
capabilities:
in: [text, image]
out: [text]
tools: true
context: 131072
groups:
brains:
swap: false
# llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der
# MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen!
persist: true
persistent: true
members:
- Qwen3-Embedding-0.6B
- Qwen3-VL-8B-Instruct
- Qwen3.6-35B-A3B
# Auto-generiert von provision.sh - per Mission Control erweiterbar
healthCheckTimeout: 300
globalTTL: 0
models:
Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
ttl: 0
aliases:
- hermes
- fast
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
Qwen3.5-122B-A10B:
cmd: |
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- heavy
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-VL-8B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
# ttl 0 (03.07.): Mitglied des Immer-bereit-Sets — ein Selbstauslöser widerspräche dem UI-Versprechen.
ttl: 0
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
context: 32768
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- embed
Qwen3-Coder-30B-A3B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-30B-A3B-Instruct-GGUF/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 300
aliases:
- coder-lite
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
gpt-oss-120b:
# heavy-Kandidat (Bench 02.07.: tg 54-55 t/s vs. Qwen3.5-122B 23,5 t/s = 2,3x, 60 statt
# 73 GB). Noch OHNE heavy-Alias — erst Qualitaets-Check (deutsch/Reasoning), dann Entscheid.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-VL-30B-A3B-Instruct:
# Lucys AUGEN (vision, seit 03.07. live via set_role/set_group): MoE 3B aktiv, versteht die
# Bildschirm-SZENE statt nur OCR. Als brains-Mitglied MUSS ttl:0 sein — persistent schuetzt nur
# gegen Verdraengung, NICHT gegen ttl-Selbstentladen (sonst faellt es alle 5 Min raus).
cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 0
capabilities:
in: [text, image]
out: [text]
context: 32768
GLM-4.6V-Flash:
cmd: |
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
ttl: 300
aliases:
- scout
capabilities:
in: [text, image]
out: [text]
tools: true
context: 131072
groups:
brains:
swap: false
# llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der
# MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen!
persist: true
persistent: true
members:
- Qwen3-Embedding-0.6B
- Qwen3-VL-8B-Instruct
- Qwen3.6-35B-A3B
+186 -186
View File
@@ -1,186 +1,186 @@
---
name: orchestrator
description: "Orchestrieren wie ein Manager: einen groesseren Auftrag in Teil-Tasks zerlegen, jeden SERIELL an das passende Worker-Modell delegieren (worker.sh, Ein-Schuss via llama-swap), die Ergebnisse einsammeln + zusammensetzen, per Fremd-Kritiker gaten und als Telegram-Vorschlag zurueckgeben. NIE selbst mergen/deployen — der Commander ist das Gate."
version: 1.0.0
author: MC2 (Autonomie — Orchestrator)
platforms: [linux]
metadata:
hermes:
tags: [orchestrator, delegation, werkstatt, mc2, fliessband]
---
# Orchestrator — du managst, du fuehrst nicht alles selbst aus
Nutze diesen Skill, wenn ein Auftrag zu gross fuer EINEN Rutsch ist, sich aber in wenige klar
abgegrenzte Teil-Tasks zerlegen laesst (ueberwiegend Code-Bau). Du bist der **Manager**: du
zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse
zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet.
Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt
(Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst
die Integration + das Gate.
## Warum seriell (und warum das ok ist)
Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein
Modell laden → arbeiten → tauschen → naechstes. Orchestrierung ist ein **Fliessband, kein Schwarm.**
Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal.
## Scope-Check zuerst (klein anfangen)
Passt: ein Auftrag, der in **24 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 12-Datei-Bau-Schritt
(neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in
wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung):
dann NUR einen **Plan** als Text im Telegram-Vorschlag liefern, KEINEN Code — der Commander schneidet
dann kleiner.
## Leitplanken (nicht verhandelbar)
- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf
stammen. Schreibst du den Code selbst (auch wenn der Teil-Task „einfach genug" wirkt), statt ihn
vom Worker zu holen, ist der Lauf GESCHEITERT — sag das ehrlich, statt Eigenbau als Orchestrierung
auszugeben. Belege jeden Teil-Task im Vorschlag mit dem `worker.sh`-Aufruf UND den ersten Zeilen
seiner Roh-Ausgabe. (Warum so streng: das Manager-Modell kann kleine Tasks selbst — genau dann
faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.)
- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh`
sind installiert, aber NICHT auf deinem PATH → immer mit vollem Pfad aufrufen (nie bloss `worker.sh`).
Glaubst du, sie fehlen, IRRST du dich: erst `ls -la ~/.hermes/scripts/worker.sh` ausfuehren und den
Pfad lesen, dann nutzen. „Skript fehlt" ist KEIN gueltiger Grund, den Schritt selbst zu machen.
- **Propose-only.** Am Ende steht IMMER ein Telegram-Vorschlag; die Entscheidung trifft der Commander.
NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten.
- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt.
- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen.
Fuer die Umsetzung von Code nutzt du **`Qwen3-Coder-Next`** (laedt klein neben dem Warm-Set).
Fuer die initiale **Planung** nutzt du explizit den Chef-Gutachter **`gpt-oss-120b`** (heavy),
auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen
Hintergrund-Prozess gewollt.
- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes
Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind
zusammenklebt, produziert leisen Muell.
- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen.
- Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen.
## Ablauf
### 1. Beratung & Zerlegen (Manager plant mit dem Chef)
Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan:
1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer:
```bash
printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt
```
2. Lies den Plan (`cat /tmp/orch-plan.txt`) und nutze ihn, um den Auftrag in eine **geordnete, nummerierte Liste von Teil-Tasks** zu zerlegen.
3. Fuer jeden Teil-Task entscheide: (a) Artefakt, (b) Worker (`Qwen3-Coder-Next` + `build`/`refactor`), (c) Kontext.
### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags)
```
cd ~/mission-control-v2 && git fetch -q origin \
&& git worktree add /tmp/orch-<slug> -b orchestrator/<slug> origin/main
mkdir -p /tmp/orch-<slug>-work # Ablage fuer rohe Worker-Ausgaben
```
### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell)
Fuer jeden Bau-Teil-Task:
1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits
fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht.
2. Ruf den Worker auf und fang die Ausgabe roh ab:
```
printf '%s' "$eingabe" | WORKER_ROLE=build ~/.hermes/scripts/worker.sh > /tmp/orch-<slug>-work/<n>.out
```
(Default-Modell ist `Qwen3-Coder-Next`. Fuer einen bewusst anderen Worker `WORKER_MODEL=<echte-id>`.)
3. **Pruefe die Ausgabe, bevor du sie verwendest:** Beginnt sie mit `FEHLT:`, hast du zu wenig
Kontext gegeben → nachliefern und erneut rufen. Hat der Worker versehentlich einen ```-Codezaun
oder Prosa drumherum gesetzt, entferne ihn. Dann schreibe das saubere Artefakt mit deinen
Datei-Tools an seinen Platz im Worktree (`/tmp/orch-<slug>/...`). Der Worker hat KEINE
Datei-Haende — das Schreiben machst DU.
### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende)
Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung,
Namen). Dann das Selbst-Gate, was zutrifft:
- Python geaendert → `python3 -m py_compile <dateien>` (und wenn ein Test dabei ist und ohne
schwere Deps laeuft: den Test im Worktree ausfuehren).
- Shell geaendert → `bash -n <dateien>`
- Frontend (`frontend/src/...`) → auf der Box gibt es KEIN Node. Im Vorschlag ausweisen:
„Gate eingeschraenkt: tsc/Build laeuft erst beim Merge auf dem PC."
- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):**
- `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du
wirklich nur im Worktree gearbeitet hast — ein gruener Health-curl allein reicht NICHT, weil
der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht
bemerkt).
- `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben.
- Ist `git status` NICHT leer: die fremden Aenderungen im Live-Checkout mit
`git -C ~/mission-control-v2 checkout -- <datei>` zuruecknehmen (deine Arbeit liegt sicher im
Worktree) und im Vorschlag ehrlich erwaehnen.
### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht)
Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben
Grund wie in der Werkstatt („wer seine eigenen Hausaufgaben benotet, stimmt sich selbst zu"). **NICHT
`delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision).
**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert
untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen):
```
git -C /tmp/orch-<slug> add -A
```
Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind):
```
EINGABE="AUFTRAG (im Wortlaut):
<der urspruengliche Auftrag>
ZERLEGUNG (deine Teil-Tasks, je 1 Zeile):
<1..n>
GIT-DIFF des Worktrees:
$(git -C /tmp/orch-<slug> diff --cached origin/main)"
```
Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben.
**Kritik A — Kompetenz** (`Qwen3-Coder-Next`, starker Coder, vom Bauen noch geladen → KEIN Swap):
```
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh
```
**Kritik B — Fremd-Blick** (`GLM-4.6V-Flash`, andere Modellfamilie, andere blinde Flecken):
```
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code FREMDBLICK_MODEL=GLM-4.6V-Flash FREMDBLICK_MAXTOK=3000 ~/.hermes/scripts/fremdblick.sh
```
Reihenfolge bewusst: erst A (Coder-Next ist noch warm), dann B (ein Swap zu GLM). Beide beginnen mit
`URTEIL: <ABGELEHNT | FREIGABE-MIT-VORBEHALT | FREIGABE>`. **Warte auf beide.**
**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den
betroffenen Teil-Task neu an den Worker, integrieren, BEIDE Kritiker erneut). `FREIGABE-MIT-VORBEHALT`
→ die Vorbehalte adressieren oder im Vorschlag ehrlich ausweisen (NICHT wegdiskutieren). Nur wenn
BEIDE tragen → Vorschlag. Beide Urteile KONKRET in den Vorschlag (welcher Kritiker was sagte). Faellt
ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X
fiel aus" vermerken (NICHT als Freigabe zaehlen).
> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB
> kalt laden und Lucys Warm-Set stoeren. Er prueft separat NACHTS im Leerlauf ueber die offenen
> Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind.
- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen
Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den
Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander.
- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich
„⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln).
### 6. Telegram-Vorschlag (in LUCYS Stimme)
`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" "<text>"` — an den Commander, nicht
als anonymer Job: erst 12 Saetze, was gebaut wurde und was er jetzt entscheiden soll; dann knapp:
- die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging),
- geaenderte/neue Dateien · Kern des Diffs,
- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"),
- Branch-Name · Frage „merge oder verwerfen?"
### 7. Nichts loeschen
Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat.
## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief
Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein
Zugriff aufs Repo. Er sieht NUR, was du ihm auf stdin gibst. Deshalb:
- Gib ihm den **relevanten Ausschnitt** mit — die betroffene Datei (oder den betroffenen Block),
benachbarte Muster zum Nachahmen, die genaue Signatur/den Namen, den er treffen muss, und bei
Folge-Schritten die bereits fertigen Artefakte, auf die er aufbaut.
- Sei praezise beim Zielartefakt: „Gib den VOLLSTAENDIGEN Inhalt der neuen Datei `X` aus" bzw. „Gib
einen unified Diff gegen den folgenden Stand von `Y` aus".
- Zu wenig Kontext → der Worker raet oder meldet `FEHLT:`. Zu viel unnoetiger Kontext → er verliert
den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht.
## Nach dem Commander-Entscheid (kommt als neuer Auftrag)
- „verwerfen" → `git worktree remove /tmp/orch-<slug> --force && git branch -D orchestrator/<slug>`
(+ `rm -rf /tmp/orch-<slug>-work`; Remote-Branch loeschen, falls gepusht).
- „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort).
Du pushst NIE nach main.
---
name: orchestrator
description: "Orchestrieren wie ein Manager: einen groesseren Auftrag in Teil-Tasks zerlegen, jeden SERIELL an das passende Worker-Modell delegieren (worker.sh, Ein-Schuss via llama-swap), die Ergebnisse einsammeln + zusammensetzen, per Fremd-Kritiker gaten und als Telegram-Vorschlag zurueckgeben. NIE selbst mergen/deployen — der Commander ist das Gate."
version: 1.0.0
author: MC2 (Autonomie — Orchestrator)
platforms: [linux]
metadata:
hermes:
tags: [orchestrator, delegation, werkstatt, mc2, fliessband]
---
# Orchestrator — du managst, du fuehrst nicht alles selbst aus
Nutze diesen Skill, wenn ein Auftrag zu gross fuer EINEN Rutsch ist, sich aber in wenige klar
abgegrenzte Teil-Tasks zerlegen laesst (ueberwiegend Code-Bau). Du bist der **Manager**: du
zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse
zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet.
Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt
(Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst
die Integration + das Gate.
## Warum seriell (und warum das ok ist)
Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein
Modell laden → arbeiten → tauschen → naechstes. Orchestrierung ist ein **Fliessband, kein Schwarm.**
Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal.
## Scope-Check zuerst (klein anfangen)
Passt: ein Auftrag, der in **24 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 12-Datei-Bau-Schritt
(neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in
wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung):
dann NUR einen **Plan** als Text im Telegram-Vorschlag liefern, KEINEN Code — der Commander schneidet
dann kleiner.
## Leitplanken (nicht verhandelbar)
- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf
stammen. Schreibst du den Code selbst (auch wenn der Teil-Task „einfach genug" wirkt), statt ihn
vom Worker zu holen, ist der Lauf GESCHEITERT — sag das ehrlich, statt Eigenbau als Orchestrierung
auszugeben. Belege jeden Teil-Task im Vorschlag mit dem `worker.sh`-Aufruf UND den ersten Zeilen
seiner Roh-Ausgabe. (Warum so streng: das Manager-Modell kann kleine Tasks selbst — genau dann
faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.)
- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh`
sind installiert, aber NICHT auf deinem PATH → immer mit vollem Pfad aufrufen (nie bloss `worker.sh`).
Glaubst du, sie fehlen, IRRST du dich: erst `ls -la ~/.hermes/scripts/worker.sh` ausfuehren und den
Pfad lesen, dann nutzen. „Skript fehlt" ist KEIN gueltiger Grund, den Schritt selbst zu machen.
- **Propose-only.** Am Ende steht IMMER ein Telegram-Vorschlag; die Entscheidung trifft der Commander.
NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten.
- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt.
- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen.
Fuer die Umsetzung von Code nutzt du **`Qwen3-Coder-Next`** (laedt klein neben dem Warm-Set).
Fuer die initiale **Planung** nutzt du explizit den Chef-Gutachter **`gpt-oss-120b`** (heavy),
auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen
Hintergrund-Prozess gewollt.
- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes
Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind
zusammenklebt, produziert leisen Muell.
- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen.
- Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen.
## Ablauf
### 1. Beratung & Zerlegen (Manager plant mit dem Chef)
Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan:
1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer:
```bash
printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt
```
2. Lies den Plan (`cat /tmp/orch-plan.txt`) und nutze ihn, um den Auftrag in eine **geordnete, nummerierte Liste von Teil-Tasks** zu zerlegen.
3. Fuer jeden Teil-Task entscheide: (a) Artefakt, (b) Worker (`Qwen3-Coder-Next` + `build`/`refactor`), (c) Kontext.
### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags)
```
cd ~/mission-control-v2 && git fetch -q origin \
&& git worktree add /tmp/orch-<slug> -b orchestrator/<slug> origin/main
mkdir -p /tmp/orch-<slug>-work # Ablage fuer rohe Worker-Ausgaben
```
### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell)
Fuer jeden Bau-Teil-Task:
1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits
fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht.
2. Ruf den Worker auf und fang die Ausgabe roh ab:
```
printf '%s' "$eingabe" | WORKER_ROLE=build ~/.hermes/scripts/worker.sh > /tmp/orch-<slug>-work/<n>.out
```
(Default-Modell ist `Qwen3-Coder-Next`. Fuer einen bewusst anderen Worker `WORKER_MODEL=<echte-id>`.)
3. **Pruefe die Ausgabe, bevor du sie verwendest:** Beginnt sie mit `FEHLT:`, hast du zu wenig
Kontext gegeben → nachliefern und erneut rufen. Hat der Worker versehentlich einen ```-Codezaun
oder Prosa drumherum gesetzt, entferne ihn. Dann schreibe das saubere Artefakt mit deinen
Datei-Tools an seinen Platz im Worktree (`/tmp/orch-<slug>/...`). Der Worker hat KEINE
Datei-Haende — das Schreiben machst DU.
### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende)
Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung,
Namen). Dann das Selbst-Gate, was zutrifft:
- Python geaendert → `python3 -m py_compile <dateien>` (und wenn ein Test dabei ist und ohne
schwere Deps laeuft: den Test im Worktree ausfuehren).
- Shell geaendert → `bash -n <dateien>`
- Frontend (`frontend/src/...`) → auf der Box gibt es KEIN Node. Im Vorschlag ausweisen:
„Gate eingeschraenkt: tsc/Build laeuft erst beim Merge auf dem PC."
- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):**
- `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du
wirklich nur im Worktree gearbeitet hast — ein gruener Health-curl allein reicht NICHT, weil
der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht
bemerkt).
- `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben.
- Ist `git status` NICHT leer: die fremden Aenderungen im Live-Checkout mit
`git -C ~/mission-control-v2 checkout -- <datei>` zuruecknehmen (deine Arbeit liegt sicher im
Worktree) und im Vorschlag ehrlich erwaehnen.
### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht)
Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben
Grund wie in der Werkstatt („wer seine eigenen Hausaufgaben benotet, stimmt sich selbst zu"). **NICHT
`delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision).
**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert
untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen):
```
git -C /tmp/orch-<slug> add -A
```
Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind):
```
EINGABE="AUFTRAG (im Wortlaut):
<der urspruengliche Auftrag>
ZERLEGUNG (deine Teil-Tasks, je 1 Zeile):
<1..n>
GIT-DIFF des Worktrees:
$(git -C /tmp/orch-<slug> diff --cached origin/main)"
```
Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben.
**Kritik A — Kompetenz** (`Qwen3-Coder-Next`, starker Coder, vom Bauen noch geladen → KEIN Swap):
```
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh
```
**Kritik B — Fremd-Blick** (`GLM-4.6V-Flash`, andere Modellfamilie, andere blinde Flecken):
```
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code FREMDBLICK_MODEL=GLM-4.6V-Flash FREMDBLICK_MAXTOK=3000 ~/.hermes/scripts/fremdblick.sh
```
Reihenfolge bewusst: erst A (Coder-Next ist noch warm), dann B (ein Swap zu GLM). Beide beginnen mit
`URTEIL: <ABGELEHNT | FREIGABE-MIT-VORBEHALT | FREIGABE>`. **Warte auf beide.**
**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den
betroffenen Teil-Task neu an den Worker, integrieren, BEIDE Kritiker erneut). `FREIGABE-MIT-VORBEHALT`
→ die Vorbehalte adressieren oder im Vorschlag ehrlich ausweisen (NICHT wegdiskutieren). Nur wenn
BEIDE tragen → Vorschlag. Beide Urteile KONKRET in den Vorschlag (welcher Kritiker was sagte). Faellt
ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X
fiel aus" vermerken (NICHT als Freigabe zaehlen).
> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB
> kalt laden und Lucys Warm-Set stoeren. Er prueft separat NACHTS im Leerlauf ueber die offenen
> Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind.
- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen
Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den
Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander.
- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich
„⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln).
### 6. Telegram-Vorschlag (in LUCYS Stimme)
`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" "<text>"` — an den Commander, nicht
als anonymer Job: erst 12 Saetze, was gebaut wurde und was er jetzt entscheiden soll; dann knapp:
- die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging),
- geaenderte/neue Dateien · Kern des Diffs,
- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"),
- Branch-Name · Frage „merge oder verwerfen?"
### 7. Nichts loeschen
Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat.
## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief
Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein
Zugriff aufs Repo. Er sieht NUR, was du ihm auf stdin gibst. Deshalb:
- Gib ihm den **relevanten Ausschnitt** mit — die betroffene Datei (oder den betroffenen Block),
benachbarte Muster zum Nachahmen, die genaue Signatur/den Namen, den er treffen muss, und bei
Folge-Schritten die bereits fertigen Artefakte, auf die er aufbaut.
- Sei praezise beim Zielartefakt: „Gib den VOLLSTAENDIGEN Inhalt der neuen Datei `X` aus" bzw. „Gib
einen unified Diff gegen den folgenden Stand von `Y` aus".
- Zu wenig Kontext → der Worker raet oder meldet `FEHLT:`. Zu viel unnoetiger Kontext → er verliert
den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht.
## Nach dem Commander-Entscheid (kommt als neuer Auftrag)
- „verwerfen" → `git worktree remove /tmp/orch-<slug> --force && git branch -D orchestrator/<slug>`
(+ `rm -rf /tmp/orch-<slug>-work`; Remote-Branch loeschen, falls gepusht).
- „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort).
Du pushst NIE nach main.
+52 -52
View File
@@ -1,52 +1,52 @@
# Antigravity-Review-Prompt — Mission Control 2.0
Prompt zum Einfügen in Antigravity 2.0 (Modell: **Gemini 3.1 Pro, High-Modus**). Projektordner =
dieses Repo. `.aiexclude` hält `.venv`/`node_modules`/`dist` aus dem Kontext.
---
Du bist ein erfahrener Senior-Reviewer und führst ein **vollständiges, ehrliches Code- und
Architektur-Review** dieses Projekts (Mission Control 2.0, kurz MC2) durch.
**Zuerst lesen — das ist der Rahmen, weiche nicht davon ab:**
1. `AGENTS.md` — die verbindlichen Projekt-Regeln.
2. `SAVEPOINT.md` — wo das Projekt gerade steht, Nordstern und Randbedingungen.
3. `README.md` und `docs/` — Kontext.
**Was MC2 ist:** die Web-Admin-Konsole einer **100 % lokalen** KI-Appliance (AMD Strix Halo, 128 GB,
llama.cpp-Vulkan). Backend = FastAPI (`backend/`, Dienst auf :9001), Frontend = React/Vite/Tailwind
(`frontend/`). Sidecars: `mem0_service/`, `voice_service/`, `mcp/`, `hermes/`, `client/`.
**Nordstern:** Das System muss **über Jahre ohne Wartung** laufen, betrieben von einem
**Nicht-Entwickler**. Robustheit, Einfachheit und Selbstheilung schlagen Features.
## Dein Auftrag
Prüfe das gesamte Repo (außer dem, was `.aiexclude` ausschließt). Suche gezielt nach:
- **Korrektheits-Bugs & Race Conditions** — besonders in `backend/services/` und den Routern.
- **Betriebs-Fragilität:** Was bricht bei **Reboot**, **Update** oder **Teil-Ausfall**? Was ist nicht
reboot-/idempotenz-fest? (Der Betreiber kann es nicht selbst debuggen.)
- **Sicherheitslücken** — Auth/Token-Handling, Command-Allowlists, Prompt-Injection-Guards,
Pfad-/Eingabe-Validierung, `sudo -n`-Nutzung. (Nur MELDEN, nichts an der Security-Config ändern.)
- **Ressourcen-Lecks & Fehlerbehandlung** — offene Clients/Dateien, verschluckte Exceptions,
fehlende Timeouts.
- **Toter/redundanter Code, DRY-/KISS-Verstöße, Über-Komplexität** — Vereinfachungs-Chancen.
- **Doku-/Realitäts-Drift** — Widersprüche zwischen `AGENTS.md`/Kommentaren und dem Code
(z. B. Zeitzone UTC vs. Europe/Berlin).
- **Wartbarkeit** — würde ein Fremder das in einem Jahr noch verstehen und sicher anfassen können?
## Harte Leitplanken (nicht empfehlen)
- KEINE Cloud-Migration, KEIN Voll-Rewrite, KEINE schweren neuen Frameworks.
- KEINE Modell-/Engine-Wechsel, die nicht in ~124 GB passen; keine Cloud-LLMs.
- `frontend/dist` bleibt bewusst im git (Box hat kein Node) — nicht als Fehler werten.
- Security-Config nur MELDEN, nie „einfach ändern".
- Bevorzuge **Vereinfachung und Härtung** vor neuen Features.
## Ausgabeformat (auf DEUTSCH)
1. **Gesamturteil** (35 Sätze): Wie gesund ist das Projekt? Trägt es den Nordstern „Jahre ohne Wartung"?
2. **Befunde, nach Schwere sortiert** — je Befund:
- Schweregrad **P0** (bricht/Sicherheit) · **P1** (echter Bug/Fragilität) · **P2** (Wartbarkeit) · **P3** (nice-to-have)
- `datei:zeile` · was ist das Problem · **warum es zählt** (konkretes Fehlszenario) · **konkreter Fix**
3. **Positives** — was bewusst gut gelöst ist (damit es nicht „wegoptimiert" wird).
4. **Was ich bewusst NICHT ändern würde** — und warum (Respekt vor den getroffenen Entscheidungen).
Sei ehrlich und konkret. Erfinde keine Probleme, um die Liste zu füllen. Wenn etwas gut ist, sag das.
# Antigravity-Review-Prompt — Mission Control 2.0
Prompt zum Einfügen in Antigravity 2.0 (Modell: **Gemini 3.1 Pro, High-Modus**). Projektordner =
dieses Repo. `.aiexclude` hält `.venv`/`node_modules`/`dist` aus dem Kontext.
---
Du bist ein erfahrener Senior-Reviewer und führst ein **vollständiges, ehrliches Code- und
Architektur-Review** dieses Projekts (Mission Control 2.0, kurz MC2) durch.
**Zuerst lesen — das ist der Rahmen, weiche nicht davon ab:**
1. `AGENTS.md` — die verbindlichen Projekt-Regeln.
2. `SAVEPOINT.md` — wo das Projekt gerade steht, Nordstern und Randbedingungen.
3. `README.md` und `docs/` — Kontext.
**Was MC2 ist:** die Web-Admin-Konsole einer **100 % lokalen** KI-Appliance (AMD Strix Halo, 128 GB,
llama.cpp-Vulkan). Backend = FastAPI (`backend/`, Dienst auf :9001), Frontend = React/Vite/Tailwind
(`frontend/`). Sidecars: `mem0_service/`, `voice_service/`, `mcp/`, `hermes/`, `client/`.
**Nordstern:** Das System muss **über Jahre ohne Wartung** laufen, betrieben von einem
**Nicht-Entwickler**. Robustheit, Einfachheit und Selbstheilung schlagen Features.
## Dein Auftrag
Prüfe das gesamte Repo (außer dem, was `.aiexclude` ausschließt). Suche gezielt nach:
- **Korrektheits-Bugs & Race Conditions** — besonders in `backend/services/` und den Routern.
- **Betriebs-Fragilität:** Was bricht bei **Reboot**, **Update** oder **Teil-Ausfall**? Was ist nicht
reboot-/idempotenz-fest? (Der Betreiber kann es nicht selbst debuggen.)
- **Sicherheitslücken** — Auth/Token-Handling, Command-Allowlists, Prompt-Injection-Guards,
Pfad-/Eingabe-Validierung, `sudo -n`-Nutzung. (Nur MELDEN, nichts an der Security-Config ändern.)
- **Ressourcen-Lecks & Fehlerbehandlung** — offene Clients/Dateien, verschluckte Exceptions,
fehlende Timeouts.
- **Toter/redundanter Code, DRY-/KISS-Verstöße, Über-Komplexität** — Vereinfachungs-Chancen.
- **Doku-/Realitäts-Drift** — Widersprüche zwischen `AGENTS.md`/Kommentaren und dem Code
(z. B. Zeitzone UTC vs. Europe/Berlin).
- **Wartbarkeit** — würde ein Fremder das in einem Jahr noch verstehen und sicher anfassen können?
## Harte Leitplanken (nicht empfehlen)
- KEINE Cloud-Migration, KEIN Voll-Rewrite, KEINE schweren neuen Frameworks.
- KEINE Modell-/Engine-Wechsel, die nicht in ~124 GB passen; keine Cloud-LLMs.
- `frontend/dist` bleibt bewusst im git (Box hat kein Node) — nicht als Fehler werten.
- Security-Config nur MELDEN, nie „einfach ändern".
- Bevorzuge **Vereinfachung und Härtung** vor neuen Features.
## Ausgabeformat (auf DEUTSCH)
1. **Gesamturteil** (35 Sätze): Wie gesund ist das Projekt? Trägt es den Nordstern „Jahre ohne Wartung"?
2. **Befunde, nach Schwere sortiert** — je Befund:
- Schweregrad **P0** (bricht/Sicherheit) · **P1** (echter Bug/Fragilität) · **P2** (Wartbarkeit) · **P3** (nice-to-have)
- `datei:zeile` · was ist das Problem · **warum es zählt** (konkretes Fehlszenario) · **konkreter Fix**
3. **Positives** — was bewusst gut gelöst ist (damit es nicht „wegoptimiert" wird).
4. **Was ich bewusst NICHT ändern würde** — und warum (Respekt vor den getroffenen Entscheidungen).
Sei ehrlich und konkret. Erfinde keine Probleme, um die Liste zu füllen. Wenn etwas gut ist, sag das.
+75 -75
View File
@@ -1,75 +1,75 @@
# Kickoff: Voll-Audit MC2 (Front+Backend) + Box-SSH + Optimierungsplan
> Für eine **frische Claude-Code-Session mit echtem Terminal** (direkter SSH-Zugang zur Box).
> Aufgabe vom Nutzer: front/backend komplett scannen (IST-Zustand), via SSH auf die Box gehen
> (ausdrücklich erlaubt), und am Ende einen **sauberen, strukturierten Plan** liefern, wie es
> weitergeht — inkl. Optimierungen für ALLE LLM-Rollen und einem **neuen `scout`-Modell**.
> Erst auditieren/planen — keine destruktiven Änderungen ohne Plan-Freigabe.
## Zugänge
- **Repo (Dev-PC):** `F:\Coding Stuff\mission-control-2` (Windows). Auf der Box: `~/mission-control-v2`.
- **Box:** `ssh hitonabi@192.168.178.151` (key-based, funktioniert non-interaktiv). Hostname
`tobisniceaiarbeitstier`, AMD Ryzen AI MAX+ 395 (Strix Halo), 122 GB RAM, GTT ~124 GB, Vulkan/RADV.
- **Wichtige Box-Pfade:** `/etc/llama-swap/config.yaml` (Engine, `-watch-config`),
`~/.hermes/config.yaml` (Hermes-Agent = Lucys Hirn), `/srv/models/` (GGUFs),
`/opt/llamacpp-vulkan/llama-server` (Engine v9843).
- **Dienste (systemd --user):** `hermes-gateway` (:8642), `hermes-webui` (:8787), llama-swap (:8080),
MC2-Gateway (:9001). Live-Logs: `journalctl --user -u hermes-gateway -f`.
## Architektur (verifiziert 30.06.2026)
- **llama-swap** (:8080) lädt GGUFs; Rollen = llama-swap **`aliases`**; Warm-Bleiben über
`groups: { brains: { swap:false } }` + `ttl`.
- **MC2-Gateway** (:9001/v1, FastAPI) mit virtuellen Lanes `chat`(→fast/heavy) & `coding`(→coder_lite/coder).
Code: `backend/services/router_logic.py`, `routing_policy.py`, `roles.py`, `llamaswap.py`, `budget.py`,
`fit.py`; Router `backend/routers/{models,routing,voice}.py`. Frontend: `frontend/src/views/ModelsView.tsx`,
`components/models/*`, `SystemDrawer.tsx`.
- **Hermes-Agent** (:8642) = Lucys Hirn (`~/.hermes/config.yaml: model.default`), Delegation `heavy`.
- **Lucy-Client** (`client/lucy-desktop`, Electron) spawnt lokal pocket-tts (:8130); Persona+Umlaut-Logik
im Client (`renderer/src/config.ts`, `lib/voice/useVoiceAgent.ts`).
- **Features sind eigene Rollen** (hirn-unabhängig): Sehen=`Qwen3-VL-8B`, Hören=Whisper(`stt`),
Sprechen=pocket-tts, Embedding=`Qwen3-Embedding-0.6B`(ttl 0, immer warm), Gedächtnis=Mem0/MCP.
## Modelle / Rollen (Stand jetzt)
| Rolle (alias) | Modell | aktiv | ctt/ttl | Notiz |
|---|---|---|---|---|
| fast | Qwen3.6-35B-A3B | 3B | ttl 0, --parallel 2, mmproj | chat-Lane Default |
| heavy | Qwen3.5-122B-A10B | 10B | ttl 600 | Delegation-Ziel |
| coder | Qwen3-Coder-Next | — | ttl 600, spec draft-simple (Qwen3-0.6B) | |
| coder_lite | Qwen3-Coder-30B-A3B | 3B | ttl 300 | coding-Lane Default |
| vision | Qwen3-VL-8B-Instruct | — | ttl 300 | Screen-Beschreibung |
| (embedding) | Qwen3-Embedding-0.6B | — | ttl 0 | immer warm |
| **brain (NEU, Lucy)** | **gemma-4-26B-A4B-it** | 4B | ttl 0, **noch Alias `scout`** | Hermes default seit 30.06. |
## Bereits gemacht (30.06.)
- Lucys Hirn `fast`**`gemma-4-26B-A4B-it`** (`~/.hermes/config.yaml model.default`, Backup
`~/.hermes/config.yaml.gemma-swap.bak`, hermes-gateway neu gestartet, verifiziert).
- Umlaut-Fix: Client-Stammliste erweitert (`useVoiceAgent.ts`) **und** Server-Netz `_fix_umlauts`
(`client/lucy-tts/pocket_server.py`). Hermes-System-Prompt fordert echte Umlaute (Client-Persona).
- pocket-tts getunt: A1 safetensors-Voice-Cache, A2 Referenz-Cleaning, B3 FP-Gate skip kurze Audios,
FAST_FIRST (kurzer 1. Chunk, TTFB ~1,3s), seriell (WORKERS=0) als Default. Details: `docs/LUCY_TTS_PLAN.md`.
## Offene Probleme / Aufgaben
1. **Brain-Rolle fehlt als Konzept.** gemma läuft als Alias `scout`, ist NICHT in `brains: swap:false`
→ unter Speicherdruck evakuierbar trotz ttl 0. Detail-Brief: **`docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md`**
(Backend+Frontend-Umbau: erstklassige `brain`-Rolle, auto-warm/ko-resident, Hermes-Sync).
2. **`scout`-Rolle jetzt vakant** (gemma war scout, ist jetzt Hirn). **Neues scout-Modell suchen:**
multimodaler Allrounder, klein/MoE (schnell, KO-Residenz-freundlich), Tools wünschenswert. Tagesaktuell
recherchieren + Empfehlung.
3. **MTP-Speculative-Decoding für gemma** (1,52× Durchsatz, 0 Qualitätsverlust): Engine kann `draft-mtp`
✓; MTP-Drafter `gemma-4-26B-A4B-it-assistant` (~0,4B) muss nach `/srv/models/`, gemma-cmd ergänzen
(`-md <assistant.gguf> --spec-type draft-mtp --spec-draft-n-max/-n-min`; **alte** `--draft-max/-min`
sind entfernt!). MC2-UI/Backend kennt MTP-Drafter nicht (zeigte „Vocab ?") → erweitern. (Im Brain-Brief.)
4. **Optimierung ALLER Rollen prüfen:** je Modell ctx/quant/ttl/Warm-Gruppe/Parallelität/Spec-Decoding
(MTP für Gemma; Draft-Eignung für Qwen-Modelle) gegen das GTT-/RAM-Budget (~124 GB) und reale t/s
auf Strix Halo (bandbreitenlimitiert → MoE bevorzugt; dichte Modelle langsam). KO-Residenz-Set
sinnvoll definieren (was muss gleichzeitig warm sein für Lucy + IDE-Coding?).
## Deliverable
Ein **strukturierter Plan** (z.B. `docs/OPTIMIZATION_PLAN.md`): IST-Zustand → konkrete Maßnahmen je Rolle
(mit Begründung/Zahlen) → Reihenfolge/Risiko/Revert → offene Entscheidungen. Erst Plan, dann Umsetzung
nach Freigabe. Alles reversibel (Config-Backups vor jeder Änderung).
## Leitplanken
- Features (Sehen/Hören/Sprechen/Embedding/Gedächtnis) + IDE-Lanes dürfen NICHT brechen.
- Vor jeder Box-Config-Änderung Backup; llama-swap reloadt per `-watch-config`.
- Keine destruktiven Aktionen ohne Plan-Freigabe des Nutzers.
# Kickoff: Voll-Audit MC2 (Front+Backend) + Box-SSH + Optimierungsplan
> Für eine **frische Claude-Code-Session mit echtem Terminal** (direkter SSH-Zugang zur Box).
> Aufgabe vom Nutzer: front/backend komplett scannen (IST-Zustand), via SSH auf die Box gehen
> (ausdrücklich erlaubt), und am Ende einen **sauberen, strukturierten Plan** liefern, wie es
> weitergeht — inkl. Optimierungen für ALLE LLM-Rollen und einem **neuen `scout`-Modell**.
> Erst auditieren/planen — keine destruktiven Änderungen ohne Plan-Freigabe.
## Zugänge
- **Repo (Dev-PC):** `F:\Coding Stuff\mission-control-2` (Windows). Auf der Box: `~/mission-control-v2`.
- **Box:** `ssh hitonabi@192.168.178.151` (key-based, funktioniert non-interaktiv). Hostname
`tobisniceaiarbeitstier`, AMD Ryzen AI MAX+ 395 (Strix Halo), 122 GB RAM, GTT ~124 GB, Vulkan/RADV.
- **Wichtige Box-Pfade:** `/etc/llama-swap/config.yaml` (Engine, `-watch-config`),
`~/.hermes/config.yaml` (Hermes-Agent = Lucys Hirn), `/srv/models/` (GGUFs),
`/opt/llamacpp-vulkan/llama-server` (Engine v9843).
- **Dienste (systemd --user):** `hermes-gateway` (:8642), `hermes-webui` (:8787), llama-swap (:8080),
MC2-Gateway (:9001). Live-Logs: `journalctl --user -u hermes-gateway -f`.
## Architektur (verifiziert 30.06.2026)
- **llama-swap** (:8080) lädt GGUFs; Rollen = llama-swap **`aliases`**; Warm-Bleiben über
`groups: { brains: { swap:false } }` + `ttl`.
- **MC2-Gateway** (:9001/v1, FastAPI) mit virtuellen Lanes `chat`(→fast/heavy) & `coding`(→coder_lite/coder).
Code: `backend/services/router_logic.py`, `routing_policy.py`, `roles.py`, `llamaswap.py`, `budget.py`,
`fit.py`; Router `backend/routers/{models,routing,voice}.py`. Frontend: `frontend/src/views/ModelsView.tsx`,
`components/models/*`, `SystemDrawer.tsx`.
- **Hermes-Agent** (:8642) = Lucys Hirn (`~/.hermes/config.yaml: model.default`), Delegation `heavy`.
- **Lucy-Client** (`client/lucy-desktop`, Electron) spawnt lokal pocket-tts (:8130); Persona+Umlaut-Logik
im Client (`renderer/src/config.ts`, `lib/voice/useVoiceAgent.ts`).
- **Features sind eigene Rollen** (hirn-unabhängig): Sehen=`Qwen3-VL-8B`, Hören=Whisper(`stt`),
Sprechen=pocket-tts, Embedding=`Qwen3-Embedding-0.6B`(ttl 0, immer warm), Gedächtnis=Mem0/MCP.
## Modelle / Rollen (Stand jetzt)
| Rolle (alias) | Modell | aktiv | ctt/ttl | Notiz |
|---|---|---|---|---|
| fast | Qwen3.6-35B-A3B | 3B | ttl 0, --parallel 2, mmproj | chat-Lane Default |
| heavy | Qwen3.5-122B-A10B | 10B | ttl 600 | Delegation-Ziel |
| coder | Qwen3-Coder-Next | — | ttl 600, spec draft-simple (Qwen3-0.6B) | |
| coder_lite | Qwen3-Coder-30B-A3B | 3B | ttl 300 | coding-Lane Default |
| vision | Qwen3-VL-8B-Instruct | — | ttl 300 | Screen-Beschreibung |
| (embedding) | Qwen3-Embedding-0.6B | — | ttl 0 | immer warm |
| **brain (NEU, Lucy)** | **gemma-4-26B-A4B-it** | 4B | ttl 0, **noch Alias `scout`** | Hermes default seit 30.06. |
## Bereits gemacht (30.06.)
- Lucys Hirn `fast`**`gemma-4-26B-A4B-it`** (`~/.hermes/config.yaml model.default`, Backup
`~/.hermes/config.yaml.gemma-swap.bak`, hermes-gateway neu gestartet, verifiziert).
- Umlaut-Fix: Client-Stammliste erweitert (`useVoiceAgent.ts`) **und** Server-Netz `_fix_umlauts`
(`client/lucy-tts/pocket_server.py`). Hermes-System-Prompt fordert echte Umlaute (Client-Persona).
- pocket-tts getunt: A1 safetensors-Voice-Cache, A2 Referenz-Cleaning, B3 FP-Gate skip kurze Audios,
FAST_FIRST (kurzer 1. Chunk, TTFB ~1,3s), seriell (WORKERS=0) als Default. Details: `docs/LUCY_TTS_PLAN.md`.
## Offene Probleme / Aufgaben
1. **Brain-Rolle fehlt als Konzept.** gemma läuft als Alias `scout`, ist NICHT in `brains: swap:false`
→ unter Speicherdruck evakuierbar trotz ttl 0. Detail-Brief: **`docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md`**
(Backend+Frontend-Umbau: erstklassige `brain`-Rolle, auto-warm/ko-resident, Hermes-Sync).
2. **`scout`-Rolle jetzt vakant** (gemma war scout, ist jetzt Hirn). **Neues scout-Modell suchen:**
multimodaler Allrounder, klein/MoE (schnell, KO-Residenz-freundlich), Tools wünschenswert. Tagesaktuell
recherchieren + Empfehlung.
3. **MTP-Speculative-Decoding für gemma** (1,52× Durchsatz, 0 Qualitätsverlust): Engine kann `draft-mtp`
✓; MTP-Drafter `gemma-4-26B-A4B-it-assistant` (~0,4B) muss nach `/srv/models/`, gemma-cmd ergänzen
(`-md <assistant.gguf> --spec-type draft-mtp --spec-draft-n-max/-n-min`; **alte** `--draft-max/-min`
sind entfernt!). MC2-UI/Backend kennt MTP-Drafter nicht (zeigte „Vocab ?") → erweitern. (Im Brain-Brief.)
4. **Optimierung ALLER Rollen prüfen:** je Modell ctx/quant/ttl/Warm-Gruppe/Parallelität/Spec-Decoding
(MTP für Gemma; Draft-Eignung für Qwen-Modelle) gegen das GTT-/RAM-Budget (~124 GB) und reale t/s
auf Strix Halo (bandbreitenlimitiert → MoE bevorzugt; dichte Modelle langsam). KO-Residenz-Set
sinnvoll definieren (was muss gleichzeitig warm sein für Lucy + IDE-Coding?).
## Deliverable
Ein **strukturierter Plan** (z.B. `docs/OPTIMIZATION_PLAN.md`): IST-Zustand → konkrete Maßnahmen je Rolle
(mit Begründung/Zahlen) → Reihenfolge/Risiko/Revert → offene Entscheidungen. Erst Plan, dann Umsetzung
nach Freigabe. Alles reversibel (Config-Backups vor jeder Änderung).
## Leitplanken
- Features (Sehen/Hören/Sprechen/Embedding/Gedächtnis) + IDE-Lanes dürfen NICHT brechen.
- Vor jeder Box-Config-Änderung Backup; llama-swap reloadt per `-watch-config`.
- Keine destruktiven Aktionen ohne Plan-Freigabe des Nutzers.
+62 -62
View File
@@ -1,62 +1,62 @@
# Backup & Restore
Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git,
Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre.
## Was im Backup ist
- **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`)
- **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/`
- **Engine:** `/etc/llama-swap/config.yaml`
Nicht enthalten (bewusst): GGUF-Modelle, MC2-Code (Git), venvs, systemd-Units (aus `deploy.sh`).
Jedes Backup ist ein Tarball `mc2-state-<zeitstempel>.tar.gz` unter `/srv/models/mc2-backups/`,
`chmod 600` (enthält `.env`). Es werden die letzten **14** behalten.
## Off-Box-Spiegel (C12) — zweite Kopie WEG von der Box
Nach jedem lokalen Backup spiegelt `backup.sh` die Tarballs per `rsync` auf ein **zweites Gerät**
(Default: **Proxmox-Host** `root@192.168.178.108:/var/lib/vz/mc2-backups`). Stirbt die NVMe der
Box, liegen die Backups noch auf dem Proxmox. Die Retention (14) wird per `--delete` mitgezogen,
`chmod 600` bleibt erhalten. Der Off-Box-Sync ist **best-effort**: schlägt er fehl, gilt das
lokale Backup trotzdem als erfolgreich (Warnung im Log).
- **Auth:** eigener SSH-Key `~/.ssh/mc2_offsite` (nur für dieses Backup), im Proxmox in
`root/.ssh/authorized_keys` **gehärtet** eingetragen: `from="<Box-IP>"`, kein Port-/Agent-/
X11-Forwarding, kein PTY → der Key kann nur rsync-Backup, keinen Voll-Root-Fernzugang.
- **Ziel/Key überschreibbar:** `MC_BACKUP_OFFSITE` (leer = Off-Box aus) und `MC_BACKUP_OFFSITE_KEY`.
## Backup erstellen
- **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status:
`systemctl --user list-timers mc2-backup.timer`
- **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh`
- **UI:** Wartungs-Drawer → „Snapshot erstellen"
## Wiederherstellen (Restore)
Restore läuft **nur per CLI auf der Box** (bewusst — er stoppt Dienste und überschreibt Configs).
Vor dem Zurückspielen macht das Skript automatisch ein Sicherheits-Backup des aktuellen Zustands.
```bash
cd ~/mission-control-v2
bash deploy/restore.sh --list # vorhandene Backups anzeigen
bash deploy/restore.sh --dry-run latest # zeigen, was passieren würde
bash deploy/restore.sh latest # neuestes wiederherstellen (mit Rückfrage)
bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup
```
Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`,
`hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) →
Dienste starten → Health-Check.
### Wenn die Box-Platte tot ist (Off-Box-Restore)
`restore.sh` kennt den Off-Box-Spiegel: fehlt ein Backup lokal, holt es sich das Skript
automatisch vom Proxmox. Kein manuelles Kopieren nötig.
```bash
bash deploy/restore.sh --list # zeigt lokal UND Off-Box (Proxmox)
bash deploy/restore.sh --pull-offsite # ganzen Off-Box-Bestand nach lokal spiegeln
bash deploy/restore.sh latest # zieht das jüngste — vom Proxmox, falls lokal leer
```
## Erledigt
- **Off-Box-Spiegel (C12):** rsync auf den Proxmox-Host, live E2E verifiziert (2026-07-03).
Box ist Bare Metal (kein Proxmox-Gast → kein vzdump), daher aktiver Push statt vzdump-Pull.
# Backup & Restore
Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git,
Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre.
## Was im Backup ist
- **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`)
- **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/`
- **Engine:** `/etc/llama-swap/config.yaml`
Nicht enthalten (bewusst): GGUF-Modelle, MC2-Code (Git), venvs, systemd-Units (aus `deploy.sh`).
Jedes Backup ist ein Tarball `mc2-state-<zeitstempel>.tar.gz` unter `/srv/models/mc2-backups/`,
`chmod 600` (enthält `.env`). Es werden die letzten **14** behalten.
## Off-Box-Spiegel (C12) — zweite Kopie WEG von der Box
Nach jedem lokalen Backup spiegelt `backup.sh` die Tarballs per `rsync` auf ein **zweites Gerät**
(Default: **Proxmox-Host** `root@192.168.178.108:/var/lib/vz/mc2-backups`). Stirbt die NVMe der
Box, liegen die Backups noch auf dem Proxmox. Die Retention (14) wird per `--delete` mitgezogen,
`chmod 600` bleibt erhalten. Der Off-Box-Sync ist **best-effort**: schlägt er fehl, gilt das
lokale Backup trotzdem als erfolgreich (Warnung im Log).
- **Auth:** eigener SSH-Key `~/.ssh/mc2_offsite` (nur für dieses Backup), im Proxmox in
`root/.ssh/authorized_keys` **gehärtet** eingetragen: `from="<Box-IP>"`, kein Port-/Agent-/
X11-Forwarding, kein PTY → der Key kann nur rsync-Backup, keinen Voll-Root-Fernzugang.
- **Ziel/Key überschreibbar:** `MC_BACKUP_OFFSITE` (leer = Off-Box aus) und `MC_BACKUP_OFFSITE_KEY`.
## Backup erstellen
- **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status:
`systemctl --user list-timers mc2-backup.timer`
- **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh`
- **UI:** Wartungs-Drawer → „Snapshot erstellen"
## Wiederherstellen (Restore)
Restore läuft **nur per CLI auf der Box** (bewusst — er stoppt Dienste und überschreibt Configs).
Vor dem Zurückspielen macht das Skript automatisch ein Sicherheits-Backup des aktuellen Zustands.
```bash
cd ~/mission-control-v2
bash deploy/restore.sh --list # vorhandene Backups anzeigen
bash deploy/restore.sh --dry-run latest # zeigen, was passieren würde
bash deploy/restore.sh latest # neuestes wiederherstellen (mit Rückfrage)
bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup
```
Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`,
`hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) →
Dienste starten → Health-Check.
### Wenn die Box-Platte tot ist (Off-Box-Restore)
`restore.sh` kennt den Off-Box-Spiegel: fehlt ein Backup lokal, holt es sich das Skript
automatisch vom Proxmox. Kein manuelles Kopieren nötig.
```bash
bash deploy/restore.sh --list # zeigt lokal UND Off-Box (Proxmox)
bash deploy/restore.sh --pull-offsite # ganzen Off-Box-Bestand nach lokal spiegeln
bash deploy/restore.sh latest # zieht das jüngste — vom Proxmox, falls lokal leer
```
## Erledigt
- **Off-Box-Spiegel (C12):** rsync auf den Proxmox-Host, live E2E verifiziert (2026-07-03).
Box ist Bare Metal (kein Proxmox-Gast → kein vzdump), daher aktiver Push statt vzdump-Pull.
+106 -106
View File
@@ -1,106 +1,106 @@
# Claude-Code-Auftrag: „Brain"-Rolle für Lucy (Front- + Backend-Umbau)
> Ziel: Lucys Hirn (aktuell `gemma-4-26B-A4B-it`) als **erstklassige, dauer-warme Rolle** im MC2-Stack
> verankern — statt es als `scout` zu führen, das nach `ttl` entladen wird. Features (Sehen/Hören/
> Sprechen/Embedding) dürfen NICHT verloren gehen, KO-Residenz + Budget müssen sauber bleiben.
## Kontext / Architektur (Ist-Zustand, verifiziert 30.06.)
- **Engine:** llama-swap (config: `/etc/llama-swap/config.yaml` auf der Box, läuft mit `-watch-config`).
Rollen werden als llama-swap **`aliases`** gesetzt; Warm-Bleiben über `groups:` (eine Gruppe
`brains: { swap: false }`) + `ttl`. Installierte Modelle: `Qwen3.6-35B-A3B` (fast, ttl 0),
`Qwen3.5-122B-A10B` (heavy, ttl 600), `Qwen3-Coder-Next` (coder, ttl 600),
`Qwen3-Coder-30B-A3B-Instruct` (coder_lite, ttl 300), `Qwen3-VL-8B-Instruct` (vision, ttl 300),
`Qwen3-Embedding-0.6B` (ttl 0 = immer warm), `gemma-4-26B-A4B-it` (ttl 180).
- **Gateway:** MC2 builtin auf `:9001/v1` mit virtuellen Lanes `chat` (→ fast/heavy) und
`coding` (→ coder_lite/coder). Code: `backend/services/router_logic.py`, `routing_policy.py`.
**Lucy nutzt die Lanes NICHT** — sie ist der Hermes-Agent.
- **Lucy-Hirn:** Hermes-Agent (`:8642`), Brain = `~/.hermes/config.yaml``model.default`
(jetzt `gemma-4-26B-A4B-it`), Delegation = `heavy`. Persona/Prompt kommt aus dem **Client**
(`client/lucy-desktop/src/renderer/src/config.ts`), nicht aus der Box-Config.
- **Hardware:** Strix Halo (Ryzen AI Max+ 395), 122 GB, GTT ~124 GB, bandbreitenlimitiert →
MoE mit wenig aktiven Params ist schnell, dichte Modelle langsam.
## Root-Cause des Bugs
1. Kanonische Rollen `ROLE_IDS = {fast, heavy, coder, vision, scout}`**keine `brain`/Agent-Rolle**.
(Kommentar im Code: „kein agent/reasoning mehr".)
2. Es gibt keine Verknüpfung „Hirn-Modell ⇒ muss warm bleiben". gemma trägt den Alias `scout`,
ist NICHT in der `brains`-Gruppe, `ttl 180` → entlädt im Leerlauf → Lucy lädt kalt nach (~510 s).
3. `roles.py` referenziert noch eine `hermes`-Rolle, die in `ROLE_IDS` nicht existiert → Inkonsistenz.
## Aufgabe
### Backend (`backend/`)
1. **Neue erstklassige Rolle `brain` einführen** (oder `hermes` reaktivieren) und in ALLEN
Quellen synchronisieren (heute dupliziert!):
- `services/llamaswap.py``ROLE_IDS`
- `services/sources.py``ROLE_IDS` + Titel/Icon (analog `scout: {title, icon}`)
- `services/maintenance.py` (Modell-Upgrade-Rollenliste)
- `services/roles.py``_capability_suit`/`_pref`/`_reason` für `brain` (Tools Pflicht,
mittlere Größe + MoE bevorzugt, niedrige Latenz). `hermes`-Altlast bereinigen.
2. **Rolle `brain` ⇒ automatisch warm + ko-resident.** Beim Zuweisen der `brain`-Rolle
(`POST /api/models/{model_id}/role`, `routers/models.py`):
- Modell via `set_group("brains", members=[...], swap=False, persist=True)` in die Warm-Gruppe
aufnehmen (bestehender Helper in `llamaswap.py`).
- `ttl: 0` setzen (oder hoch), vorheriges Brain-Modell aus `brains` entfernen + ttl entspannen.
- Embedding (`Qwen3-Embedding-0.6B`, ttl 0) MUSS warm bleiben — nicht verdrängen.
3. **Single Source of Truth für „Lucys Hirn":** beim Setzen der `brain`-Rolle auch Hermes'
`~/.hermes/config.yaml``model.default` auf das Modell setzen + `systemctl --user restart
hermes-gateway` (mit Backup der config). So bleibt MC2-UI-Auswahl ↔ Hermes-Brain konsistent.
4. **Budget/KO-Residenz-Safety:** Brain(warm) + Embedding(warm) + on-demand Vision + Coder müssen
in GTT (~124 GB) passen. `services/budget.py`/`fit.py` nutzen, bei OOM warnen (nicht hart laden).
5. **Lanes/IDEs unangetastet lassen:** chat/coding-Routing (`router_logic.py`) bleibt wie es ist;
`fast` bleibt für die chat-Lane warm.
### Frontend (`frontend/src/`)
1. **Rollen-Taxonomie** um `brain` erweitern (Badges/Titel/Icon) — Duplikat zur Backend-Liste
finden & angleichen (`components/models/*`, `views/ModelsView.tsx`, evtl. `ModelBadges.ROLES`).
gemma darf NICHT mehr als `scout` erscheinen.
2. **Rollen-Zuweisungs-Modal:** `brain` auswählbar; Warm-/KO-Residenz-Status anzeigen
(ist das Brain in `brains`? warm? ttl?). Empfehlung via bestehendem `/api/roles/{role}/recommend`.
3. **Warm-Set / GTT-Budget sichtbar machen** (Dashboard/Models): was ist gerade ko-resident,
passt es ins Budget? (nutzt `/api/models` `running` + budget-Infos).
4. Optional: „Lucy-Hirn"-Selector, der die `brain`-Rolle setzt (treibt Backend #2 + #3).
### Zusatz: MTP-Speculative-Decoding für Gemma (Durchsatz für agentische Arbeit)
Lucy ist ein **voller Agent** (Tools/MCP/PC-Control/Vision/Delegation) — Durchsatz zählt, nicht nur
TTFB. Gemma 4 bringt **MTP** (Multi-Token-Prediction) mit → 1,52× schneller bei null Qualitätsverlust.
- **Engine kann es bereits:** `llama-server` v9843 listet `--spec-type … draft-mtp …`. ✓
- **Vocab-kompatibler „Draft" = Gemmas eigener MTP-Kopf** `gemma-4-26B-A4B-it-assistant` (~0,4B,
identischer Tokenizer per Konstruktion). Quelle z.B. `unsloth/gemma-4-26B-A4B-it-GGUF` (enthält den
MTP-Drafter, PR ggml-org/llama.cpp#23398). Muss nach `/srv/models/...` geladen werden (noch nicht da).
- **Flag-Änderung beachten:** `--draft-max/--draft-min` sind ENTFERNT → `--spec-draft-n-max` /
`--spec-draft-n-min`. Drafter laden via `-md <assistant.gguf> --spec-type draft-mtp`. Exakte Flags
des Builds mit `llama-server --help` gegenprüfen.
- **MC2-Bug:** die Spec-Draft-Auswahl (UI + Backend) kennt **nur klassische Drafts** (vergleicht stur
`n_vocab`/`pre`) und bot fälschlich nur den Qwen-Draft an („Vocab ?"). **Erweitern:** MTP-Drafter
(`gemma4_assistant`-Arch) als gültigen, vocab-kompatiblen Draft erkennen, den passenden `-assistant`-
GGUF anbieten, und beim Aktivieren `-md … --spec-type draft-mtp --spec-draft-n-max/-n-min` in die
llama-swap-cmd schreiben. Co-Residenz: Drafter ~0,4B → vernachlässigbar.
## Akzeptanzkriterien
- [ ] `gemma-4-26B-A4B-it` wird in der UI als **`brain`** geführt (nicht `scout`).
- [ ] Es bleibt **warm**: in `brains: swap:false`, `ttl 0`; überlebt > alter ttl Leerlauf
(Verifikation: `curl :8080/running` nach >5 Min Idle zeigt das Modell weiterhin `ready`).
- [ ] Lucy antwortet ohne Kalt-Nachladen nach Leerlauf.
- [ ] Vision (`Qwen3-VL-8B`), Embedding (`Qwen3-Embedding-0.6B`), Coder bleiben funktionsfähig &
ko-resident; **kein OOM**; IDE-Lanes (chat/coding) unverändert.
- [ ] Brain-Wechsel in der UI aktualisiert llama-swap (Warm-Gruppe) UND Hermes `model.default`
(+ Restart), reversibel (Backup).
## Sofort-Hotfix (unabhängig vom Umbau — macht Lucy JETZT warm)
Auf der Box, bis der saubere Umbau steht:
```bash
# gemma als immer-warm + in die brains-Gruppe (Backup zuerst!)
cp /etc/llama-swap/config.yaml /etc/llama-swap/config.yaml.bak
# gemma ttl 180 -> 0 und groups.brains.members um gemma-4-26B-A4B-it ergänzen
# (manuell editieren oder via MC2 set_group), dann:
# llama-swap reloadt per -watch-config automatisch.
```
## Wichtige Dateien (Einstieg)
- Backend: `services/llamaswap.py` (Rollen=aliases, `set_role_alias`, `set_group`, `register_model`),
`services/roles.py`, `services/sources.py`, `services/routing_policy.py`,
`services/router_logic.py`, `services/budget.py`, `services/fit.py`, `routers/models.py`,
`routers/routing.py`.
- Frontend: `views/ModelsView.tsx`, `components/models/*`, `components/SystemDrawer.tsx`.
- Box (nicht im Repo): `/etc/llama-swap/config.yaml`, `~/.hermes/config.yaml`.
# Claude-Code-Auftrag: „Brain"-Rolle für Lucy (Front- + Backend-Umbau)
> Ziel: Lucys Hirn (aktuell `gemma-4-26B-A4B-it`) als **erstklassige, dauer-warme Rolle** im MC2-Stack
> verankern — statt es als `scout` zu führen, das nach `ttl` entladen wird. Features (Sehen/Hören/
> Sprechen/Embedding) dürfen NICHT verloren gehen, KO-Residenz + Budget müssen sauber bleiben.
## Kontext / Architektur (Ist-Zustand, verifiziert 30.06.)
- **Engine:** llama-swap (config: `/etc/llama-swap/config.yaml` auf der Box, läuft mit `-watch-config`).
Rollen werden als llama-swap **`aliases`** gesetzt; Warm-Bleiben über `groups:` (eine Gruppe
`brains: { swap: false }`) + `ttl`. Installierte Modelle: `Qwen3.6-35B-A3B` (fast, ttl 0),
`Qwen3.5-122B-A10B` (heavy, ttl 600), `Qwen3-Coder-Next` (coder, ttl 600),
`Qwen3-Coder-30B-A3B-Instruct` (coder_lite, ttl 300), `Qwen3-VL-8B-Instruct` (vision, ttl 300),
`Qwen3-Embedding-0.6B` (ttl 0 = immer warm), `gemma-4-26B-A4B-it` (ttl 180).
- **Gateway:** MC2 builtin auf `:9001/v1` mit virtuellen Lanes `chat` (→ fast/heavy) und
`coding` (→ coder_lite/coder). Code: `backend/services/router_logic.py`, `routing_policy.py`.
**Lucy nutzt die Lanes NICHT** — sie ist der Hermes-Agent.
- **Lucy-Hirn:** Hermes-Agent (`:8642`), Brain = `~/.hermes/config.yaml``model.default`
(jetzt `gemma-4-26B-A4B-it`), Delegation = `heavy`. Persona/Prompt kommt aus dem **Client**
(`client/lucy-desktop/src/renderer/src/config.ts`), nicht aus der Box-Config.
- **Hardware:** Strix Halo (Ryzen AI Max+ 395), 122 GB, GTT ~124 GB, bandbreitenlimitiert →
MoE mit wenig aktiven Params ist schnell, dichte Modelle langsam.
## Root-Cause des Bugs
1. Kanonische Rollen `ROLE_IDS = {fast, heavy, coder, vision, scout}`**keine `brain`/Agent-Rolle**.
(Kommentar im Code: „kein agent/reasoning mehr".)
2. Es gibt keine Verknüpfung „Hirn-Modell ⇒ muss warm bleiben". gemma trägt den Alias `scout`,
ist NICHT in der `brains`-Gruppe, `ttl 180` → entlädt im Leerlauf → Lucy lädt kalt nach (~510 s).
3. `roles.py` referenziert noch eine `hermes`-Rolle, die in `ROLE_IDS` nicht existiert → Inkonsistenz.
## Aufgabe
### Backend (`backend/`)
1. **Neue erstklassige Rolle `brain` einführen** (oder `hermes` reaktivieren) und in ALLEN
Quellen synchronisieren (heute dupliziert!):
- `services/llamaswap.py``ROLE_IDS`
- `services/sources.py``ROLE_IDS` + Titel/Icon (analog `scout: {title, icon}`)
- `services/maintenance.py` (Modell-Upgrade-Rollenliste)
- `services/roles.py``_capability_suit`/`_pref`/`_reason` für `brain` (Tools Pflicht,
mittlere Größe + MoE bevorzugt, niedrige Latenz). `hermes`-Altlast bereinigen.
2. **Rolle `brain` ⇒ automatisch warm + ko-resident.** Beim Zuweisen der `brain`-Rolle
(`POST /api/models/{model_id}/role`, `routers/models.py`):
- Modell via `set_group("brains", members=[...], swap=False, persist=True)` in die Warm-Gruppe
aufnehmen (bestehender Helper in `llamaswap.py`).
- `ttl: 0` setzen (oder hoch), vorheriges Brain-Modell aus `brains` entfernen + ttl entspannen.
- Embedding (`Qwen3-Embedding-0.6B`, ttl 0) MUSS warm bleiben — nicht verdrängen.
3. **Single Source of Truth für „Lucys Hirn":** beim Setzen der `brain`-Rolle auch Hermes'
`~/.hermes/config.yaml``model.default` auf das Modell setzen + `systemctl --user restart
hermes-gateway` (mit Backup der config). So bleibt MC2-UI-Auswahl ↔ Hermes-Brain konsistent.
4. **Budget/KO-Residenz-Safety:** Brain(warm) + Embedding(warm) + on-demand Vision + Coder müssen
in GTT (~124 GB) passen. `services/budget.py`/`fit.py` nutzen, bei OOM warnen (nicht hart laden).
5. **Lanes/IDEs unangetastet lassen:** chat/coding-Routing (`router_logic.py`) bleibt wie es ist;
`fast` bleibt für die chat-Lane warm.
### Frontend (`frontend/src/`)
1. **Rollen-Taxonomie** um `brain` erweitern (Badges/Titel/Icon) — Duplikat zur Backend-Liste
finden & angleichen (`components/models/*`, `views/ModelsView.tsx`, evtl. `ModelBadges.ROLES`).
gemma darf NICHT mehr als `scout` erscheinen.
2. **Rollen-Zuweisungs-Modal:** `brain` auswählbar; Warm-/KO-Residenz-Status anzeigen
(ist das Brain in `brains`? warm? ttl?). Empfehlung via bestehendem `/api/roles/{role}/recommend`.
3. **Warm-Set / GTT-Budget sichtbar machen** (Dashboard/Models): was ist gerade ko-resident,
passt es ins Budget? (nutzt `/api/models` `running` + budget-Infos).
4. Optional: „Lucy-Hirn"-Selector, der die `brain`-Rolle setzt (treibt Backend #2 + #3).
### Zusatz: MTP-Speculative-Decoding für Gemma (Durchsatz für agentische Arbeit)
Lucy ist ein **voller Agent** (Tools/MCP/PC-Control/Vision/Delegation) — Durchsatz zählt, nicht nur
TTFB. Gemma 4 bringt **MTP** (Multi-Token-Prediction) mit → 1,52× schneller bei null Qualitätsverlust.
- **Engine kann es bereits:** `llama-server` v9843 listet `--spec-type … draft-mtp …`. ✓
- **Vocab-kompatibler „Draft" = Gemmas eigener MTP-Kopf** `gemma-4-26B-A4B-it-assistant` (~0,4B,
identischer Tokenizer per Konstruktion). Quelle z.B. `unsloth/gemma-4-26B-A4B-it-GGUF` (enthält den
MTP-Drafter, PR ggml-org/llama.cpp#23398). Muss nach `/srv/models/...` geladen werden (noch nicht da).
- **Flag-Änderung beachten:** `--draft-max/--draft-min` sind ENTFERNT → `--spec-draft-n-max` /
`--spec-draft-n-min`. Drafter laden via `-md <assistant.gguf> --spec-type draft-mtp`. Exakte Flags
des Builds mit `llama-server --help` gegenprüfen.
- **MC2-Bug:** die Spec-Draft-Auswahl (UI + Backend) kennt **nur klassische Drafts** (vergleicht stur
`n_vocab`/`pre`) und bot fälschlich nur den Qwen-Draft an („Vocab ?"). **Erweitern:** MTP-Drafter
(`gemma4_assistant`-Arch) als gültigen, vocab-kompatiblen Draft erkennen, den passenden `-assistant`-
GGUF anbieten, und beim Aktivieren `-md … --spec-type draft-mtp --spec-draft-n-max/-n-min` in die
llama-swap-cmd schreiben. Co-Residenz: Drafter ~0,4B → vernachlässigbar.
## Akzeptanzkriterien
- [ ] `gemma-4-26B-A4B-it` wird in der UI als **`brain`** geführt (nicht `scout`).
- [ ] Es bleibt **warm**: in `brains: swap:false`, `ttl 0`; überlebt > alter ttl Leerlauf
(Verifikation: `curl :8080/running` nach >5 Min Idle zeigt das Modell weiterhin `ready`).
- [ ] Lucy antwortet ohne Kalt-Nachladen nach Leerlauf.
- [ ] Vision (`Qwen3-VL-8B`), Embedding (`Qwen3-Embedding-0.6B`), Coder bleiben funktionsfähig &
ko-resident; **kein OOM**; IDE-Lanes (chat/coding) unverändert.
- [ ] Brain-Wechsel in der UI aktualisiert llama-swap (Warm-Gruppe) UND Hermes `model.default`
(+ Restart), reversibel (Backup).
## Sofort-Hotfix (unabhängig vom Umbau — macht Lucy JETZT warm)
Auf der Box, bis der saubere Umbau steht:
```bash
# gemma als immer-warm + in die brains-Gruppe (Backup zuerst!)
cp /etc/llama-swap/config.yaml /etc/llama-swap/config.yaml.bak
# gemma ttl 180 -> 0 und groups.brains.members um gemma-4-26B-A4B-it ergänzen
# (manuell editieren oder via MC2 set_group), dann:
# llama-swap reloadt per -watch-config automatisch.
```
## Wichtige Dateien (Einstieg)
- Backend: `services/llamaswap.py` (Rollen=aliases, `set_role_alias`, `set_group`, `register_model`),
`services/roles.py`, `services/sources.py`, `services/routing_policy.py`,
`services/router_logic.py`, `services/budget.py`, `services/fit.py`, `routers/models.py`,
`routers/routing.py`.
- Frontend: `views/ModelsView.tsx`, `components/models/*`, `components/SystemDrawer.tsx`.
- Box (nicht im Repo): `/etc/llama-swap/config.yaml`, `~/.hermes/config.yaml`.
+326 -326
View File
@@ -1,326 +1,326 @@
# Konzept: Bare-Metal-Wiederaufbau + First-Run-Wizard (VOLLSTÄNDIG)
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, 3D-Avatare, Voice/Klonstimme,
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
> **1:1 zurück** · **neu/Default** · **weglassen**.
>
> Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut
> noch nichts. Stand: 2026-06-28.
---
## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt
Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
| Modus | Bedeutung |
|---|---|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). |
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). |
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
entweder den exakten alten Stand oder kann gezielt entrümpeln.
---
## 2. Was es schon gibt (wiederverwenden)
| Asset | Datei | Deckt ab |
|---|---|---|
| Code-Deploy | `deploy/deploy.sh` | git pull + venvs + Units + Restart. **Setzt Engine/llama-swap/Dirs/venvs voraus.** |
| Zustands-Backup | `deploy/backup.sh` + `mc2-backup.{service,timer}` | **Aktuell** (live): mem0, `~/.hermes/{config.yaml,.env,plugins}`, llama-swap-config. Retention 14. **Für echtes 1:1 zu erweitern** — fertiges Snippet in **Anhang B** (§5). |
| Restore | `deploy/restore.sh` | Spielt Tarball zurück (mit Pre-Restore-Sicherung). |
| Engine | `deploy/provision-engine.sh` | llama.cpp Vulkan/RADV-Build (root). |
| Voice-Setup | `voice_service/install.sh` | venv + Piper-Binary + dt. Piper-Stimmen + Chatterbox (best-effort). |
| Updates | `deploy/update-engine.sh`, `update-swap.sh` | Laufende Engine/Router-Updates. |
| Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. |
---
## 3. VOLLSTÄNDIGER Komponenten-Katalog
> **Scan-verifiziert (2026-06-28)** gegen `backend/config.py`, alle `backend/services/*` & `routers/*`,
> `frontend/src/{nav.ts,views,components/voice}`, `voice_service/app.py`, `mem0_service/`, `deploy/*`.
> Alle persistenten Schreibpfade, Dienste, Secrets und Env-Vars sind unten erfasst.
Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git ·
🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt.
„Im Backup?" = deckt der **aktuelle** `backup.sh` es ab.
### A · OS & System (L0, root/sudo, einmalig)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a |
| System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein |
| Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein |
| Chrome-Libs (Browser): `agent-browser install --with-deps` | — | ⬇️ apt | nein |
| Verzeichnis-Layout: `/srv/models/{,mem0,mc2-backups,drafts}`, `/etc/llama-swap/` | — | ⬇️ mkdir+chown | nein |
### B · Inferenz-Engine + Router (L1, root)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| llama.cpp (Vulkan) → `llama-server` | `/opt/llamacpp-vulkan`, `/usr/local/bin/llama-server` | ⬇️ provision-engine.sh / update-engine.sh (ggml-org Release) | nein (re-build) |
| **llama-swap** Binary (Router `:8080`) | `/usr/local/bin/llama-swap` | ⬇️ **bekannt:** `mostlygeek/llama-swap`-Release (Rezept in `update-swap.sh`) | nein (re-download) |
| **llama-swap systemd-System-Unit** | `/etc/systemd/system/llama-swap.service` (+ `.d/` Drop-ins) | ⬇️ Bootstrap legt sie an — **kompletter Unit-Inhalt in Anhang A** (von der Box abgegriffen; Drop-ins via provision-engine.sh) | nein |
| llama-swap-Config (Modelle/Rollen) | `/etc/llama-swap/config.yaml` | 📦 | **ja** |
| Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein |
### C · MC2-App (L2, userspace)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
| Frontend (gebaut, inkl. **3D-Avatar `avatar.vrm`**) | `frontend/dist`, `frontend/public/avatar.vrm` | 🌐 Git | n/a |
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
### D · 3D-Avatar (Sprechen-Tab)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a |
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 Git | n/a |
| **Eigene/zusätzliche Avatare** (falls Nutzer welche ablegt) | **TODO: Ablageort definieren** (z.B. `/srv/models/avatars/` + DB-Verweis) | 📦/🆕 | **nein (Lücke)** |
> Heute ist der Avatar **fest** (`avatar.vrm`, kommt mit dem Git-Frontend zurück). Wenn künftig
> Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht.
### E · Voice-Sidecar (STT + TTS + Klonstimme)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) |
| STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein |
| Piper-Binary + dt. Stimmen (thorsten/kerstin) | `~/.voice/piper`, `~/.voice/voices` | ⬇️ install.sh | nein |
| Chatterbox (Premium-TTS, CPU-torch) | venv | ⬇️ install.sh (best-effort) | nein |
| **Klonstimme / Voice-Referenz-Audio** (Nutzer) | `~/.voice/refs/ref.wav` (`VOICE_REFS_DIR`, `/api/voice/reference`) | 📦 | **nein → Anhang B** |
| ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja |
| Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a |
| Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein |
### F · Mem0 (Langzeitgedächtnis)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) |
| **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** |
| Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja |
| Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein |
### G · Hermes-Agent (das Herz)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) |
| Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein |
| Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein |
| **`config.yaml` 1:1** (Toolsets, MCP, Engine, Browser, Personalities, alles) | `~/.hermes/config.yaml` | 📦 | **ja** |
| **`.env` (Secrets: TELEGRAM_BOT_TOKEN, API_SERVER_KEY, ElevenLabs …)** | `~/.hermes/.env` | 📦/🔑 | **ja** |
| Plugins | `~/.hermes/plugins/` | 📦 | ja |
| **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** |
| Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** |
| Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | ⏭️ skip | nein |
| **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** |
| Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein |
| Hermes-Terminal (ttyd → `hermes chat`, `:7681`) | systemd-User `hermes-terminal` | ⬇️ deploy.sh (+ttyd apt) | nein |
### H · MCP-Server (4)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| `mission-control-memory`, `-stack` (MC2-venv) | `~/mission-control-v2/mcp/*.py` | 🌐 Git | über config.yaml |
| `hermes-pc-control`, `hermes-web-fetch` (Hermes-venv) | dito | 🌐 Git | über config.yaml |
| MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja |
### I · Browser-Stack
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| agent-browser (npm global) | `~/.local/...`, symlink `~/.hermes/node/bin` | ⬇️ npm i -g | nein |
| Chrome (engine) + System-Libs | `~/.agent-browser/browsers/` + apt-Libs | ⬇️ install --with-deps | nein |
| lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein |
| Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja |
### J · Modelle (GGUF — der große Brocken)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| GGUF-Modelle (Brain, fast, heavy, coder, vision, embedding …) | `/srv/models/` | ⬇️ Re-Download aus llama-swap-Manifest | **nein (zu groß, bewusst)** |
### K · Extern (anderer Rechner)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) |
| Hermes-WebUI (nesquena, optional, `:8787`) | `~/hermes-webui` | ⬇️ git clone | nein |
---
## 4. Ziel-Architektur
### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf
- **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`).
- **Getrennt nach sudo-Bedarf**: `bootstrap-root.sh` (L0+L1, bewusst mit sudo) + `bootstrap.sh`
(L2+, sudo-frei = Kern ist `deploy.sh`). Passt zum Nordstern „Runtime ohne sudo".
- **Mündet in den Wizard**: startet MC2 im First-Run-Modus, gibt Wizard-URL aus.
Phasen: `0 Vorflug → 1[sudo] System+Dirs → 2[sudo] Engine+llama-swap → 3 Code(MC2+Hermes+Node)
→ 4 venvs(backend/mem0/voice/hermes) → 5 Browser → 6 deploy.sh(Units/enable/restart)
→ 7 Restore(optional) → 8 Wizard hoch + postcheck`.
### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert)
MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte:
1. **Systemcheck** — Live-Ampel je Komponente aus §3 (`GET /api/setup/status` + `…/system/services`).
2. **Restore-Quelle wählen** — Backup-Tarball erkennen → globaler Modus „Alles 1:1" / „selektiv" / „frisch".
3. **Komponenten-Auswahl (Kernstück)** — pro Katalog-Eintrag aus §3 eine Kachel mit
📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden.
4. **Netzwerk & Identität** — Box-IP (→ `HERMES_TERMINAL_URL`, `PC_EXECUTOR_URL`), Hostname.
5. **Secrets** 🔑 — `TELEGRAM_BOT_TOKEN`, erlaubte User-ID, `API_SERVER_KEY` (oder generieren),
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
9. **Externe Checkliste** — PC-Executor (Windows-Task), Hermes-WebUI als Haken.
10. **Verifikation & Abschluss**`stack-postcheck.sh` + `hermes-postcheck.sh` → grün/rot-Liste → Dashboard.
**Technik:** neuer Router `backend/routers/setup.py`
(`GET /api/setup/status`, `POST /api/setup/{secrets,network,hermes,components,finish}`).
First-Run-Gate: MC2 prüft beim Boot Marker `~/.mc2-setup-done` bzw. Pflicht-Secrets → leitet auf `/setup`.
---
## 5. Backup-Scope für echtes 1:1 ERWEITERN (umzusetzen — Snippet in Anhang B)
Der **aktuelle** `backup.sh` (live auf der Box, unverändert) reicht für „1:1" nicht. Für die Bau-Session
liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarball um:
- **Voice-Klonstimme** → `~/.voice/refs/` (`ref.wav`)
- **Token-/Ersparnis-Statistik** → `~/.hermes/token_stats.json`
- **Hermes-Skills** → `~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen)
- **Hermes-Sessions/History** → `~/.hermes/sessions/`
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
mit neu starten.
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D).
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
→ Aufgabe der Bau-Session: `backup.sh` + `restore.sh` um diese Pfade erweitern (mit klarer
„opt-in für große/optionale Teile"-Logik), und den MANIFEST-Inhalt entsprechend.
---
## 6. Secrets-Strategie
- Single Source: `~/.hermes/.env` (chmod 600), im Tarball (selbst 600).
- Rebuild ohne Backup → Wizard-Schritt 5 erzeugt sie. `API_SERVER_KEY` generierbar; Telegram/ElevenLabs liefert Nutzer.
- Nie ins Git, nie in Logs, nie in die MC2-DB. **Off-Box-Spiegelung des Tarballs noch offen** ([[mc2-backup-restore]]).
## 7. Modell-Strategie
- **A (empfohlen):** `/etc/llama-swap/config.yaml` (im Backup) listet alle Modelle → Wizard leitet Repos/Quants ab und lädt automatisch (`/api/models/install`). „Ein Klick, lädt über Nacht."
- **B:** geführte Discover-Neuauswahl je Rolle. Reihenfolge Brain → heavy → Rest, danach `warmup.sh`.
---
## 8. Implementierungs-Reihenfolge (neue Session)
1.**Box-Verifikation erledigt (2026-06-28, nur gelesen — nichts verändert):** llama-swap.service-Inhalt
in **Anhang A**; skills/sessions/refs/token_stats existieren (Pfade in §3 verifiziert); Backup-Erweiterung
als fertiges Snippet in **Anhang B**. **Keine offenen Box-Fragen mehr** außer §10·36. → mit Schritt 2 starten.
2. `backup.sh`/`restore.sh` um die 1:1-Lücken erweitern (§5).
3. `deploy/bootstrap.sh` + `bootstrap-root.sh` (Phasen, State-Marker); llama-swap-Install skripten.
4. `backend/routers/setup.py` + First-Run-Gate.
5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views
(Cockpit/AgentView/Sprechen) wiederverwenden.
6. Modell-Restore-aus-Manifest.
7. **Abnahme in frischer VM** (§9).
Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
## 9. Abnahmekriterien
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), 3D-Avatar, Browser funktionieren.
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
- Idempotenz: zweiter Lauf = No-op.
## 10. Offene Entscheidungen (vor dem Bau)
1. ~~llama-swap systemd-Unit-Inhalt~~**geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
2. ~~Voice-Referenz-Audio-Ort~~**geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
3. **Eigene Avatare**: künftiger Upload-/Ablage-Mechanismus + Backup-Pfad (einziger offener 1:1-Daten-Punkt).
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
## 11. Referenzen
- `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]]
- [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor)
- [[voice-sprechen-feature]] (Voice + 3D-Avatar), [[mem0-memory-architecture]], [[mc2-backup-restore]]
- `deploy/{deploy,backup,restore,provision-engine,stack-postcheck,warmup}.sh`, `voice_service/install.sh`
---
## Anhang A — `llama-swap.service` (1:1 von der Box abgegriffen, 2026-06-28)
Base-Unit nach `/etc/systemd/system/llama-swap.service` (root). User/GFX sind boxspezifisch
(hitonabi, gfx1151 → HSA 11.5.1) — auf anderer Hardware anpassen. Die `.d/`-Drop-ins
(`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an.
```ini
[Unit]
Description=llama-swap (lokaler LLM Router)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=hitonabi
Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:8080 --watch-config
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
```
```ini
# /etc/systemd/system/llama-swap.service.d/vulkan.conf
[Service]
Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan
```
```ini
# /etc/systemd/system/llama-swap.service.d/warmup.conf
[Service]
ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh
```
**Erstinstall-Reihenfolge:** `bash deploy/update-swap.sh` (Binary) → Unit kopieren →
`sudo bash deploy/provision-engine.sh` (Engine+Drop-ins) → `sudo systemctl enable --now llama-swap`.
---
## Anhang B — Backup-Scope-Erweiterung für echtes 1:1 (fertiges Snippet)
In `deploy/backup.sh` ergänzen (Variablen oben: `VOICE="${VOICE_HOME:-$HOME/.voice}"`,
Stage zusätzlich `"$STAGE/voice"`):
```bash
# 1:1-Erweiterung (scan-verifiziert 2026-06-28):
[ -f "$HERMES/token_stats.json" ] && cp -a "$HERMES/token_stats.json" "$STAGE/hermes/" || true
[ -d "$HERMES/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true
[ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true
[ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true
```
tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern:
```bash
tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" .
```
In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und
`voice-service` mit neustarten:
```bash
VOICE="${VOICE_HOME:-$HOME/.voice}"
SERVICES="mem0-service voice-service mission-control-2 hermes-gateway"
[ -f "$STAGE/hermes/token_stats.json" ] && cp -a "$STAGE/hermes/token_stats.json" "$HERMES/"
[ -d "$STAGE/hermes/skills" ] && { mkdir -p "$HERMES/skills"; cp -a "$STAGE/hermes/skills/." "$HERMES/skills/"; }
[ -d "$STAGE/hermes/sessions" ] && { mkdir -p "$HERMES/sessions"; cp -a "$STAGE/hermes/sessions/." "$HERMES/sessions/"; }
[ -d "$STAGE/voice/refs" ] && { mkdir -p "$VOICE/refs"; cp -a "$STAGE/voice/refs/." "$VOICE/refs/"; }
```
# Konzept: Bare-Metal-Wiederaufbau + First-Run-Wizard (VOLLSTÄNDIG)
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, 3D-Avatare, Voice/Klonstimme,
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
> **1:1 zurück** · **neu/Default** · **weglassen**.
>
> Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut
> noch nichts. Stand: 2026-06-28.
---
## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt
Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
| Modus | Bedeutung |
|---|---|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). |
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). |
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
entweder den exakten alten Stand oder kann gezielt entrümpeln.
---
## 2. Was es schon gibt (wiederverwenden)
| Asset | Datei | Deckt ab |
|---|---|---|
| Code-Deploy | `deploy/deploy.sh` | git pull + venvs + Units + Restart. **Setzt Engine/llama-swap/Dirs/venvs voraus.** |
| Zustands-Backup | `deploy/backup.sh` + `mc2-backup.{service,timer}` | **Aktuell** (live): mem0, `~/.hermes/{config.yaml,.env,plugins}`, llama-swap-config. Retention 14. **Für echtes 1:1 zu erweitern** — fertiges Snippet in **Anhang B** (§5). |
| Restore | `deploy/restore.sh` | Spielt Tarball zurück (mit Pre-Restore-Sicherung). |
| Engine | `deploy/provision-engine.sh` | llama.cpp Vulkan/RADV-Build (root). |
| Voice-Setup | `voice_service/install.sh` | venv + Piper-Binary + dt. Piper-Stimmen + Chatterbox (best-effort). |
| Updates | `deploy/update-engine.sh`, `update-swap.sh` | Laufende Engine/Router-Updates. |
| Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. |
---
## 3. VOLLSTÄNDIGER Komponenten-Katalog
> **Scan-verifiziert (2026-06-28)** gegen `backend/config.py`, alle `backend/services/*` & `routers/*`,
> `frontend/src/{nav.ts,views,components/voice}`, `voice_service/app.py`, `mem0_service/`, `deploy/*`.
> Alle persistenten Schreibpfade, Dienste, Secrets und Env-Vars sind unten erfasst.
Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git ·
🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt.
„Im Backup?" = deckt der **aktuelle** `backup.sh` es ab.
### A · OS & System (L0, root/sudo, einmalig)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a |
| System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein |
| Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein |
| Chrome-Libs (Browser): `agent-browser install --with-deps` | — | ⬇️ apt | nein |
| Verzeichnis-Layout: `/srv/models/{,mem0,mc2-backups,drafts}`, `/etc/llama-swap/` | — | ⬇️ mkdir+chown | nein |
### B · Inferenz-Engine + Router (L1, root)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| llama.cpp (Vulkan) → `llama-server` | `/opt/llamacpp-vulkan`, `/usr/local/bin/llama-server` | ⬇️ provision-engine.sh / update-engine.sh (ggml-org Release) | nein (re-build) |
| **llama-swap** Binary (Router `:8080`) | `/usr/local/bin/llama-swap` | ⬇️ **bekannt:** `mostlygeek/llama-swap`-Release (Rezept in `update-swap.sh`) | nein (re-download) |
| **llama-swap systemd-System-Unit** | `/etc/systemd/system/llama-swap.service` (+ `.d/` Drop-ins) | ⬇️ Bootstrap legt sie an — **kompletter Unit-Inhalt in Anhang A** (von der Box abgegriffen; Drop-ins via provision-engine.sh) | nein |
| llama-swap-Config (Modelle/Rollen) | `/etc/llama-swap/config.yaml` | 📦 | **ja** |
| Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein |
### C · MC2-App (L2, userspace)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
| Frontend (gebaut, inkl. **3D-Avatar `avatar.vrm`**) | `frontend/dist`, `frontend/public/avatar.vrm` | 🌐 Git | n/a |
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
### D · 3D-Avatar (Sprechen-Tab)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a |
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 Git | n/a |
| **Eigene/zusätzliche Avatare** (falls Nutzer welche ablegt) | **TODO: Ablageort definieren** (z.B. `/srv/models/avatars/` + DB-Verweis) | 📦/🆕 | **nein (Lücke)** |
> Heute ist der Avatar **fest** (`avatar.vrm`, kommt mit dem Git-Frontend zurück). Wenn künftig
> Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht.
### E · Voice-Sidecar (STT + TTS + Klonstimme)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) |
| STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein |
| Piper-Binary + dt. Stimmen (thorsten/kerstin) | `~/.voice/piper`, `~/.voice/voices` | ⬇️ install.sh | nein |
| Chatterbox (Premium-TTS, CPU-torch) | venv | ⬇️ install.sh (best-effort) | nein |
| **Klonstimme / Voice-Referenz-Audio** (Nutzer) | `~/.voice/refs/ref.wav` (`VOICE_REFS_DIR`, `/api/voice/reference`) | 📦 | **nein → Anhang B** |
| ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja |
| Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a |
| Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein |
### F · Mem0 (Langzeitgedächtnis)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) |
| **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** |
| Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja |
| Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein |
### G · Hermes-Agent (das Herz)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) |
| Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein |
| Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein |
| **`config.yaml` 1:1** (Toolsets, MCP, Engine, Browser, Personalities, alles) | `~/.hermes/config.yaml` | 📦 | **ja** |
| **`.env` (Secrets: TELEGRAM_BOT_TOKEN, API_SERVER_KEY, ElevenLabs …)** | `~/.hermes/.env` | 📦/🔑 | **ja** |
| Plugins | `~/.hermes/plugins/` | 📦 | ja |
| **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** |
| Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** |
| Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | ⏭️ skip | nein |
| **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** |
| Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein |
| Hermes-Terminal (ttyd → `hermes chat`, `:7681`) | systemd-User `hermes-terminal` | ⬇️ deploy.sh (+ttyd apt) | nein |
### H · MCP-Server (4)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| `mission-control-memory`, `-stack` (MC2-venv) | `~/mission-control-v2/mcp/*.py` | 🌐 Git | über config.yaml |
| `hermes-pc-control`, `hermes-web-fetch` (Hermes-venv) | dito | 🌐 Git | über config.yaml |
| MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja |
### I · Browser-Stack
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| agent-browser (npm global) | `~/.local/...`, symlink `~/.hermes/node/bin` | ⬇️ npm i -g | nein |
| Chrome (engine) + System-Libs | `~/.agent-browser/browsers/` + apt-Libs | ⬇️ install --with-deps | nein |
| lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein |
| Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja |
### J · Modelle (GGUF — der große Brocken)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| GGUF-Modelle (Brain, fast, heavy, coder, vision, embedding …) | `/srv/models/` | ⬇️ Re-Download aus llama-swap-Manifest | **nein (zu groß, bewusst)** |
### K · Extern (anderer Rechner)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) |
| Hermes-WebUI (nesquena, optional, `:8787`) | `~/hermes-webui` | ⬇️ git clone | nein |
---
## 4. Ziel-Architektur
### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf
- **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`).
- **Getrennt nach sudo-Bedarf**: `bootstrap-root.sh` (L0+L1, bewusst mit sudo) + `bootstrap.sh`
(L2+, sudo-frei = Kern ist `deploy.sh`). Passt zum Nordstern „Runtime ohne sudo".
- **Mündet in den Wizard**: startet MC2 im First-Run-Modus, gibt Wizard-URL aus.
Phasen: `0 Vorflug → 1[sudo] System+Dirs → 2[sudo] Engine+llama-swap → 3 Code(MC2+Hermes+Node)
→ 4 venvs(backend/mem0/voice/hermes) → 5 Browser → 6 deploy.sh(Units/enable/restart)
→ 7 Restore(optional) → 8 Wizard hoch + postcheck`.
### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert)
MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte:
1. **Systemcheck** — Live-Ampel je Komponente aus §3 (`GET /api/setup/status` + `…/system/services`).
2. **Restore-Quelle wählen** — Backup-Tarball erkennen → globaler Modus „Alles 1:1" / „selektiv" / „frisch".
3. **Komponenten-Auswahl (Kernstück)** — pro Katalog-Eintrag aus §3 eine Kachel mit
📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden.
4. **Netzwerk & Identität** — Box-IP (→ `HERMES_TERMINAL_URL`, `PC_EXECUTOR_URL`), Hostname.
5. **Secrets** 🔑 — `TELEGRAM_BOT_TOKEN`, erlaubte User-ID, `API_SERVER_KEY` (oder generieren),
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
9. **Externe Checkliste** — PC-Executor (Windows-Task), Hermes-WebUI als Haken.
10. **Verifikation & Abschluss**`stack-postcheck.sh` + `hermes-postcheck.sh` → grün/rot-Liste → Dashboard.
**Technik:** neuer Router `backend/routers/setup.py`
(`GET /api/setup/status`, `POST /api/setup/{secrets,network,hermes,components,finish}`).
First-Run-Gate: MC2 prüft beim Boot Marker `~/.mc2-setup-done` bzw. Pflicht-Secrets → leitet auf `/setup`.
---
## 5. Backup-Scope für echtes 1:1 ERWEITERN (umzusetzen — Snippet in Anhang B)
Der **aktuelle** `backup.sh` (live auf der Box, unverändert) reicht für „1:1" nicht. Für die Bau-Session
liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarball um:
- **Voice-Klonstimme** → `~/.voice/refs/` (`ref.wav`)
- **Token-/Ersparnis-Statistik** → `~/.hermes/token_stats.json`
- **Hermes-Skills** → `~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen)
- **Hermes-Sessions/History** → `~/.hermes/sessions/`
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
mit neu starten.
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D).
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
→ Aufgabe der Bau-Session: `backup.sh` + `restore.sh` um diese Pfade erweitern (mit klarer
„opt-in für große/optionale Teile"-Logik), und den MANIFEST-Inhalt entsprechend.
---
## 6. Secrets-Strategie
- Single Source: `~/.hermes/.env` (chmod 600), im Tarball (selbst 600).
- Rebuild ohne Backup → Wizard-Schritt 5 erzeugt sie. `API_SERVER_KEY` generierbar; Telegram/ElevenLabs liefert Nutzer.
- Nie ins Git, nie in Logs, nie in die MC2-DB. **Off-Box-Spiegelung des Tarballs noch offen** ([[mc2-backup-restore]]).
## 7. Modell-Strategie
- **A (empfohlen):** `/etc/llama-swap/config.yaml` (im Backup) listet alle Modelle → Wizard leitet Repos/Quants ab und lädt automatisch (`/api/models/install`). „Ein Klick, lädt über Nacht."
- **B:** geführte Discover-Neuauswahl je Rolle. Reihenfolge Brain → heavy → Rest, danach `warmup.sh`.
---
## 8. Implementierungs-Reihenfolge (neue Session)
1.**Box-Verifikation erledigt (2026-06-28, nur gelesen — nichts verändert):** llama-swap.service-Inhalt
in **Anhang A**; skills/sessions/refs/token_stats existieren (Pfade in §3 verifiziert); Backup-Erweiterung
als fertiges Snippet in **Anhang B**. **Keine offenen Box-Fragen mehr** außer §10·36. → mit Schritt 2 starten.
2. `backup.sh`/`restore.sh` um die 1:1-Lücken erweitern (§5).
3. `deploy/bootstrap.sh` + `bootstrap-root.sh` (Phasen, State-Marker); llama-swap-Install skripten.
4. `backend/routers/setup.py` + First-Run-Gate.
5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views
(Cockpit/AgentView/Sprechen) wiederverwenden.
6. Modell-Restore-aus-Manifest.
7. **Abnahme in frischer VM** (§9).
Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
## 9. Abnahmekriterien
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), 3D-Avatar, Browser funktionieren.
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
- Idempotenz: zweiter Lauf = No-op.
## 10. Offene Entscheidungen (vor dem Bau)
1. ~~llama-swap systemd-Unit-Inhalt~~**geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
2. ~~Voice-Referenz-Audio-Ort~~**geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
3. **Eigene Avatare**: künftiger Upload-/Ablage-Mechanismus + Backup-Pfad (einziger offener 1:1-Daten-Punkt).
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
## 11. Referenzen
- `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]]
- [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor)
- [[voice-sprechen-feature]] (Voice + 3D-Avatar), [[mem0-memory-architecture]], [[mc2-backup-restore]]
- `deploy/{deploy,backup,restore,provision-engine,stack-postcheck,warmup}.sh`, `voice_service/install.sh`
---
## Anhang A — `llama-swap.service` (1:1 von der Box abgegriffen, 2026-06-28)
Base-Unit nach `/etc/systemd/system/llama-swap.service` (root). User/GFX sind boxspezifisch
(hitonabi, gfx1151 → HSA 11.5.1) — auf anderer Hardware anpassen. Die `.d/`-Drop-ins
(`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an.
```ini
[Unit]
Description=llama-swap (lokaler LLM Router)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=hitonabi
Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:8080 --watch-config
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
```
```ini
# /etc/systemd/system/llama-swap.service.d/vulkan.conf
[Service]
Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan
```
```ini
# /etc/systemd/system/llama-swap.service.d/warmup.conf
[Service]
ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh
```
**Erstinstall-Reihenfolge:** `bash deploy/update-swap.sh` (Binary) → Unit kopieren →
`sudo bash deploy/provision-engine.sh` (Engine+Drop-ins) → `sudo systemctl enable --now llama-swap`.
---
## Anhang B — Backup-Scope-Erweiterung für echtes 1:1 (fertiges Snippet)
In `deploy/backup.sh` ergänzen (Variablen oben: `VOICE="${VOICE_HOME:-$HOME/.voice}"`,
Stage zusätzlich `"$STAGE/voice"`):
```bash
# 1:1-Erweiterung (scan-verifiziert 2026-06-28):
[ -f "$HERMES/token_stats.json" ] && cp -a "$HERMES/token_stats.json" "$STAGE/hermes/" || true
[ -d "$HERMES/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true
[ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true
[ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true
```
tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern:
```bash
tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" .
```
In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und
`voice-service` mit neustarten:
```bash
VOICE="${VOICE_HOME:-$HOME/.voice}"
SERVICES="mem0-service voice-service mission-control-2 hermes-gateway"
[ -f "$STAGE/hermes/token_stats.json" ] && cp -a "$STAGE/hermes/token_stats.json" "$HERMES/"
[ -d "$STAGE/hermes/skills" ] && { mkdir -p "$HERMES/skills"; cp -a "$STAGE/hermes/skills/." "$HERMES/skills/"; }
[ -d "$STAGE/hermes/sessions" ] && { mkdir -p "$HERMES/sessions"; cp -a "$STAGE/hermes/sessions/." "$HERMES/sessions/"; }
[ -d "$STAGE/voice/refs" ] && { mkdir -p "$VOICE/refs"; cp -a "$STAGE/voice/refs/." "$VOICE/refs/"; }
```
+149 -149
View File
@@ -1,149 +1,149 @@
# Lucy TTS — Optimierungs- & Strategieplan
> Ziel: **Lucys Stimme läuft 100 % lokal & on-device** (kein Box-Zwang, keine Cloud).
> Primärengine: **Kyutai pocket-tts 2.1.0** (CPU). Strategische Alternative: **F5-TTS** (GPU/ONNX).
> Stand: 2026-06-30. Resume-fähig im Stil von `docs/STATUS.md`.
## Leitentscheidung: Hardware
- **pocket-tts ist CPU-gebunden** — Kyutai bestätigt: *kein* GPU-Speedup (Batch 1, 100M Params).
**RDNA2 vs. RDNA4 ist für pocket irrelevant.** Lucy läuft dort, wo der beste CPU steht.
- **GPU zählt nur für F5-TTS** (non-autoregressiv, große Matmuls). Dort gewinnt **RDNA4 (9070 XT) + DirectML**
deutlich gegen RDNA2 → falls F5 die Lucy-Stimme wird, läuft sie auf der RDNA4-Maschine.
- **Konsequenz:** `oute n_gpu_layers=999` und „pocket auf ROCm/Vulkan" werden **eingestellt**.
---
## Phase A — Sofort-Wins (risikoarm, pocket_server.py)
**A1 — Voice → safetensors exportieren (einmalig)**
- Statt `get_state_for_audio_prompt(ref)` bei jedem Boot: einmal `export_model_state(...)``lucy_voice.safetensors`.
- Server lädt beim Start nur noch die safetensors (liest kvcache, keine Klon-Rechnung) → schnellerer Start.
- Fallback behalten: wenn safetensors fehlt → aus `ref.mp3` klonen + direkt exportieren.
**A2 — Referenz säubern**
- Kyutai: Sample-Qualität wird *mitreproduziert*. Sauber entrauschte/normalisierte `ref` → weniger Artefakte.
- Schritt in `_prep_ref()` ergänzen (Denoise/Highpass/Lautheit), Ergebnis cachen.
**A3 — temp-Sweep gegen Kollaps**
- Hypothese: `temp=0.9` treibt die best-of-N-Regenerationen. Niedriger testen (0.70.85).
- Akzeptanz: gleiche/bessere Natürlichkeit bei **messbar weniger Regenerationen** (= weniger Latenz).
*Gate A:* A1A3 live, Kollaps-Rate & TTFB protokolliert.
---
## Phase B — Benchmark-Harness (datenbasiert tunen)
**B1 — `bench_lucy.py`** (lokal, CPU)
- Misst je Konfig: **RTF**, **TTFB**, **Kollaps-/Regenerations-Rate**, Whisper-Rücktranskription (Verständlichkeit).
- Sweep-Achsen: `lsd_decode_steps` (6/8/10/12), `temp`, `noise_clamp`, `quantize` on/off, `frames_after_eos`.
- Feste Testsätze (kurz/mittel/lang, wie in `ptts_test.py`).
**B2 — CPU-Parallelität**
- pocket nutzt nur **2 Kerne** → mehrere **Satz-Worker parallel** statt globalem `LOCK`.
- Messen: Wall-Clock langer Antworten bei N Workern (1/2/3/4) vs. Qualität/Last.
*Gate B:* dokumentierte Best-Config (RTF + Kollaps-Rate) als neue Defaults in `pocket_server.py`.
---
## Phase C — Runtime-Eval (lokal schneller + Python-3.14-frei)
**C1 — ONNX-Pfade testen**
- Kandidaten: **PocketTTS.cpp** (Single-File C++/ONNX, CLI+HTTP+FFI) und **sherpa-onnx** (Windows, viele Bindings).
- Ziel: torch-CPU schlagen **und** das Python-3.14-Packaging-Problem umgehen.
- Vergleich gegen Phase-B-Baseline (gleicher Benchmark).
**C2 — Server-Vertrag prüfen**
- Fertige **OpenAI-kompatible Streaming-Server** (teddybear082 / ai-joe-git) gegen den aktuellen Custom-Proxy halten.
- Nur übernehmen, wenn Stimm-Wächter (F0 + Fingerabdruck) erhalten/abbildbar bleiben.
*Gate C:* Entscheidung „torch-CPU behalten" vs. „auf ONNX-Runtime wechseln" (mit Zahlen).
---
## Phase D — Strategische Weiche: pocket vs. F5
**D1 — F5 fair gegen pocket messen**
- `lucy-f5/` (F5-TTS DE, ONNX + DirectML, **RDNA4/9070 XT**) ist **non-autoregressiv → strukturell kein
Kollaps/Männerstimme/Wiederholung** (genau pockets Schmerz).
- Gleicher Benchmark wie Phase B: RTF, TTFB, Natürlichkeit, Stabilität.
**D2 — Entscheidung**
- **pocket gewinnt** (gut genug stabil, CPU, „nur lokal"-Ideal): pocket = Lucy-Stimme, F5 verworfen/geparkt.
- **F5 gewinnt** (Stabilität schlägt den Latenz-Overhead der Wächter): F5 = Lucy-Stimme auf RDNA4,
**pocket bleibt schneller CPU-Fallback** (z. B. wenn keine GPU verfügbar).
**Beobachtungsposten (extern):** distilliertes **`german`** (statt `german_24l`) ist noch nicht released
(Kyutai fixt Distillations-Datenqualität). Sobald da → größter Einzel-Win (Tempo + Stabilität),
dann `german_24l → german` swappen. Release-Feed im Blick behalten.
---
## Reihenfolge & Quick-Start
1. **A1 + A2 + A3** (heute) — sofort spürbar, kein Risiko.
2. **B1 + B2** — Zahlen sammeln, Defaults härten.
3. **C1/C2** — Runtime-Wechsel nur wenn Benchmark es trägt.
4. **D1/D2** — finale Stimm-Architektur entscheiden.
## Mess-Ergebnisse & finale Defaults (30.06., 9700X, german_24l)
Alles auf der lokalen Maschine gemessen (Logs: `sweep_b2.log`, `ttfb_test.log`, `sweep_b2_result.json`).
**B2-Sweep (6-Satz-Antwort, ~21s Audio):**
| Konfig | Wall | TTFB | RTF |
|---|---|---|---|
| seriell (1×alle Kerne) | 15,8s | **3,6s** | 0,74 |
| 2w×3t | 12,9s | 6,2s | 0,62 |
| 3w×2t | 10,3s | 6,8s | 0,50 |
| 4w×2t | 9,7s | 8,2s | **0,44** |
Erkenntnis: pocket ist **speicherbandbreiten-gebunden**. Der Pool verbessert nur die Gesamt-Wall-Clock
(Batch), verschlechtert aber die **TTFB** deutlich. Seriell liefert RTF 0,74 < 1 → generiert schneller
als Echtzeit → Streaming spielt **lückenlos** und startet am schnellsten. **Für Lucys Live-Stimme
gewinnt seriell.** Pool bleibt Opt-in für Batch (`/tts` ganze Datei): Sweet Spot **4w×2t** / **3w×2t**.
**B3 (FP-Drift-Gate überspringt kurze Audios) + kurzer erster Chunk:**
Der MFCC-Fingerabdruck ist auf <2s Audio unzuverlässig (sim ~0,84 < 0,94) → löste 3× Fehlalarm-
Regenerierung aus. B3 prüft den Drift erst ab `LUCY_FP_MIN_S=2.0`s stimmhafter Dauer; der F0-
Männerstimmen-Wächter bleibt immer aktiv. Effekt (Drift-Warnungen pro Lauf: ~6 → 1):
| | TTFB | Wall |
|---|---|---|
| kurzer 1. Chunk, ohne B3 | 4,71s | 19,9s |
| gebündelt (alt) | 3,74s | 15,8s |
| **kurzer 1. Chunk, mit B3** | **1,31s** | 16,6s |
→ Lucy spricht nach **1,3s** statt 3,7s, Wall ~gleich, weiter lückenlos.
**Finale Defaults in `pocket_server.py`:**
`LUCY_WORKERS=0` (seriell) · `LUCY_FAST_FIRST=1` (kurzer 1. Chunk) · `LUCY_FP_MIN_S=2.0` (B3) ·
`LUCY_REF_CLEAN=1` (A2) · Voice aus `lucy_voice.safetensors` (A1). Pool-Opt-in für Batch:
`LUCY_WORKERS=4 LUCY_WORKER_THREADS=2`.
**Offen / nächster Hebel:** distilliertes `german`-Modell (statt `german_24l`) abwarten — größter
Qualität/Tempo-Sprung. Optional: TTFB weiter drücken über kürzeres erstes Wort / `lsd`-Tuning nur
für den ersten Chunk.
## Umlaute (ae/oe/ue) — Fix (30.06.)
Symptom: Lucy spricht manchmal „u-e" statt „ü". Ursache: das LLM (Hermes/Qwen) gibt gelegentlich
ASCII-Ersatzschreibweisen (ueber/schoen/maerz) aus; pocket liest sie wörtlich. (ß vs ss ist
akustisch identisch -> ignoriert.)
**Wurzel-Fix (empfohlen, auf der Box im Hermes-System-Prompt/Persona ergänzen):**
> „Schreibe ausschließlich korrektes Deutsch mit echten Umlauten (ä, ö, ü) und ß. Verwende niemals
> die Ersatzschreibweisen ae, oe, ue oder ss anstelle von Umlauten."
**Schutznetz (in `pocket_server.py`, Default an `LUCY_UMLAUT_FIX=1`):** `_fix_umlauts()` wandelt NUR
bekannte deutsche Umlaut-Ganzwörter zurück (Ganzwort + gängige Flexionsendungen, case-erhaltend).
Verifiziert (`umlaut_test.py`): konvertiert über/für/größe/natürlich/mögliche/Gespräche; lässt
neue/aktuell/Steuer/Quelle/Feuer/Frauen/genau/blaue unverändert. Grenzen: reine Wortliste, deckt
nicht jedes seltene Wort — erweiterbar via `LUCY_UMLAUT_EXTRA="wort1,wort2"`. Der Wurzel-Fix bleibt
die zuverlässige Lösung.
## Referenzen
- pocket-tts README (GPU-kein-Speedup, export-voice, Sprachen): github.com/kyutai-labs/pocket-tts
- Multilingual-Ankündigung (DE = `german_24l`, undistilliert): kyutai.org/blog/2026-05-04-pocket-tts-multilingual
- Tech-Report / Performance: kyutai.org/blog/2026-01-13-pocket-tts · deepwiki.com/kyutai-labs/pocket-tts
# Lucy TTS — Optimierungs- & Strategieplan
> Ziel: **Lucys Stimme läuft 100 % lokal & on-device** (kein Box-Zwang, keine Cloud).
> Primärengine: **Kyutai pocket-tts 2.1.0** (CPU). Strategische Alternative: **F5-TTS** (GPU/ONNX).
> Stand: 2026-06-30. Resume-fähig im Stil von `docs/STATUS.md`.
## Leitentscheidung: Hardware
- **pocket-tts ist CPU-gebunden** — Kyutai bestätigt: *kein* GPU-Speedup (Batch 1, 100M Params).
**RDNA2 vs. RDNA4 ist für pocket irrelevant.** Lucy läuft dort, wo der beste CPU steht.
- **GPU zählt nur für F5-TTS** (non-autoregressiv, große Matmuls). Dort gewinnt **RDNA4 (9070 XT) + DirectML**
deutlich gegen RDNA2 → falls F5 die Lucy-Stimme wird, läuft sie auf der RDNA4-Maschine.
- **Konsequenz:** `oute n_gpu_layers=999` und „pocket auf ROCm/Vulkan" werden **eingestellt**.
---
## Phase A — Sofort-Wins (risikoarm, pocket_server.py)
**A1 — Voice → safetensors exportieren (einmalig)**
- Statt `get_state_for_audio_prompt(ref)` bei jedem Boot: einmal `export_model_state(...)``lucy_voice.safetensors`.
- Server lädt beim Start nur noch die safetensors (liest kvcache, keine Klon-Rechnung) → schnellerer Start.
- Fallback behalten: wenn safetensors fehlt → aus `ref.mp3` klonen + direkt exportieren.
**A2 — Referenz säubern**
- Kyutai: Sample-Qualität wird *mitreproduziert*. Sauber entrauschte/normalisierte `ref` → weniger Artefakte.
- Schritt in `_prep_ref()` ergänzen (Denoise/Highpass/Lautheit), Ergebnis cachen.
**A3 — temp-Sweep gegen Kollaps**
- Hypothese: `temp=0.9` treibt die best-of-N-Regenerationen. Niedriger testen (0.70.85).
- Akzeptanz: gleiche/bessere Natürlichkeit bei **messbar weniger Regenerationen** (= weniger Latenz).
*Gate A:* A1A3 live, Kollaps-Rate & TTFB protokolliert.
---
## Phase B — Benchmark-Harness (datenbasiert tunen)
**B1 — `bench_lucy.py`** (lokal, CPU)
- Misst je Konfig: **RTF**, **TTFB**, **Kollaps-/Regenerations-Rate**, Whisper-Rücktranskription (Verständlichkeit).
- Sweep-Achsen: `lsd_decode_steps` (6/8/10/12), `temp`, `noise_clamp`, `quantize` on/off, `frames_after_eos`.
- Feste Testsätze (kurz/mittel/lang, wie in `ptts_test.py`).
**B2 — CPU-Parallelität**
- pocket nutzt nur **2 Kerne** → mehrere **Satz-Worker parallel** statt globalem `LOCK`.
- Messen: Wall-Clock langer Antworten bei N Workern (1/2/3/4) vs. Qualität/Last.
*Gate B:* dokumentierte Best-Config (RTF + Kollaps-Rate) als neue Defaults in `pocket_server.py`.
---
## Phase C — Runtime-Eval (lokal schneller + Python-3.14-frei)
**C1 — ONNX-Pfade testen**
- Kandidaten: **PocketTTS.cpp** (Single-File C++/ONNX, CLI+HTTP+FFI) und **sherpa-onnx** (Windows, viele Bindings).
- Ziel: torch-CPU schlagen **und** das Python-3.14-Packaging-Problem umgehen.
- Vergleich gegen Phase-B-Baseline (gleicher Benchmark).
**C2 — Server-Vertrag prüfen**
- Fertige **OpenAI-kompatible Streaming-Server** (teddybear082 / ai-joe-git) gegen den aktuellen Custom-Proxy halten.
- Nur übernehmen, wenn Stimm-Wächter (F0 + Fingerabdruck) erhalten/abbildbar bleiben.
*Gate C:* Entscheidung „torch-CPU behalten" vs. „auf ONNX-Runtime wechseln" (mit Zahlen).
---
## Phase D — Strategische Weiche: pocket vs. F5
**D1 — F5 fair gegen pocket messen**
- `lucy-f5/` (F5-TTS DE, ONNX + DirectML, **RDNA4/9070 XT**) ist **non-autoregressiv → strukturell kein
Kollaps/Männerstimme/Wiederholung** (genau pockets Schmerz).
- Gleicher Benchmark wie Phase B: RTF, TTFB, Natürlichkeit, Stabilität.
**D2 — Entscheidung**
- **pocket gewinnt** (gut genug stabil, CPU, „nur lokal"-Ideal): pocket = Lucy-Stimme, F5 verworfen/geparkt.
- **F5 gewinnt** (Stabilität schlägt den Latenz-Overhead der Wächter): F5 = Lucy-Stimme auf RDNA4,
**pocket bleibt schneller CPU-Fallback** (z. B. wenn keine GPU verfügbar).
**Beobachtungsposten (extern):** distilliertes **`german`** (statt `german_24l`) ist noch nicht released
(Kyutai fixt Distillations-Datenqualität). Sobald da → größter Einzel-Win (Tempo + Stabilität),
dann `german_24l → german` swappen. Release-Feed im Blick behalten.
---
## Reihenfolge & Quick-Start
1. **A1 + A2 + A3** (heute) — sofort spürbar, kein Risiko.
2. **B1 + B2** — Zahlen sammeln, Defaults härten.
3. **C1/C2** — Runtime-Wechsel nur wenn Benchmark es trägt.
4. **D1/D2** — finale Stimm-Architektur entscheiden.
## Mess-Ergebnisse & finale Defaults (30.06., 9700X, german_24l)
Alles auf der lokalen Maschine gemessen (Logs: `sweep_b2.log`, `ttfb_test.log`, `sweep_b2_result.json`).
**B2-Sweep (6-Satz-Antwort, ~21s Audio):**
| Konfig | Wall | TTFB | RTF |
|---|---|---|---|
| seriell (1×alle Kerne) | 15,8s | **3,6s** | 0,74 |
| 2w×3t | 12,9s | 6,2s | 0,62 |
| 3w×2t | 10,3s | 6,8s | 0,50 |
| 4w×2t | 9,7s | 8,2s | **0,44** |
Erkenntnis: pocket ist **speicherbandbreiten-gebunden**. Der Pool verbessert nur die Gesamt-Wall-Clock
(Batch), verschlechtert aber die **TTFB** deutlich. Seriell liefert RTF 0,74 < 1 → generiert schneller
als Echtzeit → Streaming spielt **lückenlos** und startet am schnellsten. **Für Lucys Live-Stimme
gewinnt seriell.** Pool bleibt Opt-in für Batch (`/tts` ganze Datei): Sweet Spot **4w×2t** / **3w×2t**.
**B3 (FP-Drift-Gate überspringt kurze Audios) + kurzer erster Chunk:**
Der MFCC-Fingerabdruck ist auf <2s Audio unzuverlässig (sim ~0,84 < 0,94) → löste 3× Fehlalarm-
Regenerierung aus. B3 prüft den Drift erst ab `LUCY_FP_MIN_S=2.0`s stimmhafter Dauer; der F0-
Männerstimmen-Wächter bleibt immer aktiv. Effekt (Drift-Warnungen pro Lauf: ~6 → 1):
| | TTFB | Wall |
|---|---|---|
| kurzer 1. Chunk, ohne B3 | 4,71s | 19,9s |
| gebündelt (alt) | 3,74s | 15,8s |
| **kurzer 1. Chunk, mit B3** | **1,31s** | 16,6s |
→ Lucy spricht nach **1,3s** statt 3,7s, Wall ~gleich, weiter lückenlos.
**Finale Defaults in `pocket_server.py`:**
`LUCY_WORKERS=0` (seriell) · `LUCY_FAST_FIRST=1` (kurzer 1. Chunk) · `LUCY_FP_MIN_S=2.0` (B3) ·
`LUCY_REF_CLEAN=1` (A2) · Voice aus `lucy_voice.safetensors` (A1). Pool-Opt-in für Batch:
`LUCY_WORKERS=4 LUCY_WORKER_THREADS=2`.
**Offen / nächster Hebel:** distilliertes `german`-Modell (statt `german_24l`) abwarten — größter
Qualität/Tempo-Sprung. Optional: TTFB weiter drücken über kürzeres erstes Wort / `lsd`-Tuning nur
für den ersten Chunk.
## Umlaute (ae/oe/ue) — Fix (30.06.)
Symptom: Lucy spricht manchmal „u-e" statt „ü". Ursache: das LLM (Hermes/Qwen) gibt gelegentlich
ASCII-Ersatzschreibweisen (ueber/schoen/maerz) aus; pocket liest sie wörtlich. (ß vs ss ist
akustisch identisch -> ignoriert.)
**Wurzel-Fix (empfohlen, auf der Box im Hermes-System-Prompt/Persona ergänzen):**
> „Schreibe ausschließlich korrektes Deutsch mit echten Umlauten (ä, ö, ü) und ß. Verwende niemals
> die Ersatzschreibweisen ae, oe, ue oder ss anstelle von Umlauten."
**Schutznetz (in `pocket_server.py`, Default an `LUCY_UMLAUT_FIX=1`):** `_fix_umlauts()` wandelt NUR
bekannte deutsche Umlaut-Ganzwörter zurück (Ganzwort + gängige Flexionsendungen, case-erhaltend).
Verifiziert (`umlaut_test.py`): konvertiert über/für/größe/natürlich/mögliche/Gespräche; lässt
neue/aktuell/Steuer/Quelle/Feuer/Frauen/genau/blaue unverändert. Grenzen: reine Wortliste, deckt
nicht jedes seltene Wort — erweiterbar via `LUCY_UMLAUT_EXTRA="wort1,wort2"`. Der Wurzel-Fix bleibt
die zuverlässige Lösung.
## Referenzen
- pocket-tts README (GPU-kein-Speedup, export-voice, Sprachen): github.com/kyutai-labs/pocket-tts
- Multilingual-Ankündigung (DE = `german_24l`, undistilliert): kyutai.org/blog/2026-05-04-pocket-tts-multilingual
- Tech-Report / Performance: kyutai.org/blog/2026-01-13-pocket-tts · deepwiki.com/kyutai-labs/pocket-tts
+281 -281
View File
@@ -1,281 +1,281 @@
# Komplett-Review Mission Control 2 + Lucy — 02.07.2026
**Methodik:** IST-Zustand live erhoben (Box per SSH, lokaler Lucy-PC, Working Tree `lucy-v2` inkl. uncommitted) — nicht aus Docs/Memory übernommen. SOLL-Zustand in 3 Recherche-Runden tagesaktuell (Stand 02.07.2026) ermittelt. Alle früheren Verdikte wurden neu auf den Prüfstand gestellt. Scope: alles außer Security.
---
## 1. Management-Summary
| Bereich | Zustand | Kernaussage |
|---|---|---|
| LLM-Stack (Box) | ✅ stark, 1 Bug | Vulkan-Pfad richtig, Qwen3.6+MTP läuft; aber chat-Lane kaputt (Autostart-Fund war Fehlalarm) |
| Lucy Voice-Latenz | 🔴 3,55 s | 2026-Ziel ist 0,50,8 s. Schuld sind STT (2,0 s) und VAD-Timer (0,9 s), **nicht** das LLM (TTFT 65 ms) |
| Lucy Avatar/App | ✅ sehr ausgereift | VRMA-Mocap, Lippensync v2, MateEngine-Features komplett; Electron 10 Major-Versionen alt |
| Backend/Frontend-Code | 🟡 solide | 3 Monolithen, etwas DRY-Schuld, dist/ im Git; keine Rot-Flaggen |
| Ökosystem-Aktualität | 🟡 | Hermes 1 Release hinter (v0.18.0 vom 01.07.); pocket-tts, mem0, llama-swap, three-vrm aktuell |
| Verdikte | ✅ alle bestätigt | Pocket TTS, Electron, Qwen3.6-Hirn, kein ZeroClaw, Mem0 — alle bestehen die Re-Prüfung |
**Die zwei größten Hebel:**
1. **STT-Tausch** (faster-whisper medium → Parakeet-TDT 0.6B v3): ~2,0 s → ~0,2 s pro Äußerung
2. **VAD-Tausch** (RMS-Eigenbau → Silero VAD v6.2): Turn-Ende 900 → ~450 ms bei besserer Genauigkeit
Zusammen: Voice-to-Voice von ~3,55 s auf realistisch **≤1,2 s**, ohne neue Hardware.
---
## 2. IST-Zustand (live verifiziert)
### 2.1 Box (192.168.178.151 — Strix Halo, gfx1151, 122 GB, Vulkan)
| Komponente | Installiert | Aktuell (02.07.2026) | Bewertung |
|---|---|---|---|
| llama.cpp | b9843 (86b94708f) | b9859 (01.07.) | ✅ nur ~16 Builds dahinter |
| llama-swap | v233 (29.06.) | v233 | ✅ aktuell |
| Hermes Agent | **v0.17.0** (19.06.) | **v0.18.0** (01.07.) | 🟡 Update lohnt (3 P0 + 493 P1 gefixt) |
| mem0ai / chromadb | 2.0.8 / 1.5.9 | 2.0.8 | ✅ aktuell |
| Kernel | 7.0.0-27-generic | — | ✅ |
**Live-Modell-Lineup** (`/etc/llama-swap/config.yaml`, live gelesen — der Repo-Snapshot `deploy/llama-swap.config.yaml` ist **veraltet** und zeigt noch gemma-4):
| Alias | Modell | ctx | Besonderheiten |
|---|---|---|---|
| hermes (Brain) | Qwen3.6-35B-A3B (UD-Q4_K_M, MTP-GGUF) | 65536 | `--spec-type draft-mtp --spec-draft-n-max 3 --parallel 1 -cram 16384`, **kein** cache-reuse |
| heavy | Qwen3.5-122B-A10B | 32768 | cache-reuse 256, ttl 600 |
| coder | Qwen3-Coder-Next | 131072 | cache-reuse 256, ttl 600 |
| coder-lite | Qwen3-Coder-30B-A3B | 131072 | cache-reuse 256, ttl 300 |
| vision | Qwen3-VL-8B | 32768 | mmproj, in brains |
| embed | Qwen3-Embedding-0.6B | 8192 | ttl 0, in brains |
| scout | GLM-4.6V-Flash | 131072 | mmproj, ttl 300 |
Gruppe `brains` (swap:false, persist:true) = embed + vision + Qwen3.6. Alle Modelle: `-ngl 999 -fa on --no-mmap --jinja`. **Kein Modell nutzt KV-Cache-Quantisierung** (alles F16).
**Dienste live:** llama-swap, hermes-gateway, hermes-terminal, hermes-webui, mem0-service, voice-service — alle running. Ports 7681/8080/8642/8650/8765/8787/9001 belegt.
### 2.2 🔴 Live-Bugs (selbst reproduziert)
**B1 — ~~MC2-Backend ohne Autostart~~ FEHLALARM (korrigiert 02.07., nachverifiziert).**
:9001 läuft als **User-Unit** `mission-control-2.service` (`~/.config/systemd/user/`, enabled, `Linger=yes` → reboot-fest). Die erste SSH-Prüfung sah User-Units nicht (fehlendes XDG_RUNTIME_DIR). Einziger echter Fund: Die **stale v1-System-Unit** `mission-control.service` (disabled, /opt/mission-control:9000) liegt noch in /etc/systemd/system und stiftet Verwirrung → bei Gelegenheit mit sudo entfernen (kosmetisch, kein Risiko).
**B2 — chat-Lane kaputt (live reproduziert).**
```
POST /v1/chat/completions {"model":"chat",...}
→ {"error":"no router for requested model","src":"llama-swap"}
```
Die Routing-Policy routet `chat → fast ↔ heavy`, aber llama-swap kennt den Alias `fast` nicht mehr (Qwen3.6 hat nur noch den Alias `hermes`). Lucy ist nicht betroffen (sie geht über den Hermes-Agenten :8642), aber jeder Client der chat-Lane bekommt Fehler. Fix: `fast` als zweiten Alias eintragen **oder** Policy auf `hermes` umstellen.
**B3 — Config-Drift.**
`deploy/llama-swap.config.yaml` ist untracked UND veraltet (gemma-4-Ära). Zusätzlich fehlen im Install-Template [`backend/config.py:33`](../backend/config.py) die Produktions-Tunings (cache-reuse, parallel, MTP) → neu installierte Modelle driften von der Box-Realität weg.
### 2.3 Gemessene Latenzkette Lucy
Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap.
| Stufe | Messwert | Anteil am Problem |
|---|---|---|
| VAD-Turn-Ende | **900 ms** Fix-Timer (RMS-Eigenbau, [useVAD.ts](../client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts)) | groß |
| STT (faster-whisper medium int8, Box-CPU) | **avg 2.046 ms · p50 2.092 ms · p95 2.529 ms** (n=13) | **dominant** |
| LLM TTFT (Qwen3.6 warm, direkt :8080) | **65 ms** (24 Tokens in 334 ms) | ✅ kein Problem |
| TTS erster Ton (pocket, RTF 0,44 + LEAD_IN 0,35 s) | ~0,51 s für den ersten Satz | mittel |
| **Voice-to-Voice gesamt** | **~3,55 s** | Ziel 2026: **0,50,8 s** |
**Fazit: Das teuerste Glied ist NICHT das LLM.** STT + VAD-Timer fressen zusammen ~3 s. Genau dort setzt die Roadmap an.
### 2.4 Lokaler PC (RX 9070 XT / RDNA4)
- Lucy-App zum Prüfzeitpunkt nicht gestartet (kein Electron-Prozess, :8130 frei)
- `ptts-venv`: pocket-tts **2.1.0** (= neueste Version), torch 2.12.1+cpu (bewusst CPU), onnxruntime 1.27.0, fastapi 0.138.1
- GPU-Treiber 32.0.31021.5001
- [client/lucy-tts/](../client/lucy-tts/) (3 GB): Produktions-TTS mit Stimmen-Wächter (F0-Floor 160 Hz, MFCC-Fingerprint ≥0,94, Best-of-N), Phase A+B des TTS-Plans abgeschlossen (beste Config: 4 Worker × 2 Threads, RTF 0,44)
- [client/lucy-f5/](../client/lucy-f5/) (5,3 GB): F5-TTS-ONNX/DirectML-Experiment — **Phase C/D (Finalentscheid pocket vs. F5) offen**
- Lucy-Desktop: Electron **33** (aktuell: **43** vom 30.06. — 2 Jahre Chromium-Rückstand), three-vrm 3.4/animation 3.5.4 (≈ aktuell), TS strict
### 2.5 Code-Qualität (3 Explore-Agents über das ganze Repo)
**Backend (4.889 LOC, 11 Router / 28 Services):** insgesamt sauber, Multi-Sidecar-Architektur durchdacht. Findings:
- C1: Install-Template ohne Produktions-Tunings ([backend/config.py:33](../backend/config.py)) → Drift
- C2: `_describe_images()` blockiert den Voice-Chat bis 120 s synchron ([backend/routers/voice.py](../backend/routers/voice.py))
- C3: Thinking-Deaktivierung 3× dupliziert (voice.py, gateway.py, mem0_service/app.py) — DRY
- C4: Junk-Fact-Regex im Mem0-Sidecar hartcodiert (Wartungspunkt)
- Dead Code: `backend/services/pricing.py`, vermutlich `token_stats.py`
- Uncommitted Änderungen (voice.py No-Think, connect.py IDE-only-`coding`, mc2-memory-Guards, Mem0-Junk-Guard, voice_service install.sh) sind **alle konsistent und sinnvoll** — nur eben nicht committet
**Frontend (React 18/Vite 6/Tailwind 4/TanStack 5):**
- Voice-Umzug in die Desktop-App sauber (keine toten Imports/Nav-Reste) ✅
- UI-Rework (roleMeta/Health/Expert/WarmSet) vollständig; coder_lite↔coder-lite via ALIAS gelöst
- F1: [Cockpit.tsx](../frontend/src/views/models/Cockpit.tsx) (990 Z) + [SystemDrawer.tsx](../frontend/src/components/SystemDrawer.tsx) (934 Z) Monolithen
- F2: 🟡 `frontend/dist/`-Asset-Stand auf lucy-v2 inkonsistent (alte gelöscht, neue untracked) — dist-im-Git selbst ist Absicht (Box hat kein Node), avatar.vrm ist via .gitignore korrekt draußen
- F4: keine globale Error Boundary
- MC3-Prototyp ([frontend/src/mc3/](../frontend/src/mc3/)) non-destruktiv hinter `#mc3`, untracked
**Lucy-Desktop:**
- L1: [useVoiceAgent.ts](../client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts) (377 Z) monolithisch (SSE, Chunking, Tools, Perf, Vision in einem Hook)
- L2: kein Retry/Backoff beim TTS-Warmup (Crash → 2-min-Spinner)
- L3: VAD = reines RMS mit 900-ms-Stille-Regel
- L4: Echo-Schutz = 450-ms-Cooldown-Workaround (kein echtes Barge-in)
- Neues [voice-core/](../client/lucy-desktop/src/renderer/src/voice-core/)-Adapter-Layer (STT/TTS austauschbar) ist die richtige Architektur ✅
- H1: ~23 Dateien uncommitted, darunter die neue voice-core-Architektur → Verlustrisiko
---
## 3. SOLL-Zustand (Recherche, Stand 02.07.2026)
### 3.1 Voice-Latenz — der Stand der Technik
- 2026-Zielmarke: **500800 ms voice-to-voice**; Grundprinzip: **Streaming auf jeder Stufe** (STT-Partials sparen 200400 ms, TTS startet auf Teiltext, VAD+Turn-Taking-Budget 150300 ms) — [Latenz-Guide](https://futureagi.com/blog/how-to-optimize-voice-agent-latency-2026/), [Latenz-Budget-Design](https://smallest.ai/blog/designing-voice-assistants-stt-llm-tts-tools-and-latency-budget)
- **STT:** [Parakeet-TDT 0.6B v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) — 25 EU-Sprachen inkl. Deutsch, 6,32 % WER (besser als Whisper large-v3 mit 7,44 %), extrem schnell auch auf CPU, keine Silence-Halluzination. Deploy-Wege: [onnx-asr](https://pypi.org/project/onnx-asr/) (unterstützt CPU **und** DirectML → 9070 XT), fertige [OpenAI-kompatible FastAPI-Wrapper](https://github.com/groxaxo/parakeet-tdt-0.6b-v3-fastapi-openai). Kyutais Streaming-STT mit eingebautem semantic VAD ([delayed-streams-modeling](https://github.com/kyutai-labs/delayed-streams-modeling)) wäre architektonisch ideal, ist aber **nur EN/FR** → für Deutsch raus.
- **VAD:** [Silero VAD v6](https://github.com/snakers4/silero-vad/releases/tag/v6.0) (v6.2, ONNX): 16 % Fehler auf Noisy-Data vs. v5 — klar besser als der RMS-Eigenbau; erlaubt kürzeres Endpointing (~450 ms) bei weniger Fehlstarts.
- **Semantische Turn-Detection:** Smart Turn V2 (leichtgewichtig), [Easy Turn](https://arxiv.org/pdf/2509.23938) (akustisch+linguistisch, 4 Turn-States, open source).
- **TTS:** pocket-tts 2.1.0 ist Stand der Technik für CPU-Realtime ([kyutai](https://kyutai.org/blog/2026-01-13-pocket-tts/)). Challenger (CosyVoice2-0.5B, Chatterbox-Turbo) bieten keinen klaren Vorteil. → Einziger offener Entscheid bleibt das hauseigene F5-Experiment (Phase C/D).
### 3.2 Box maximieren
- **Vulkan/RADV bleibt der schnellste Pfad auf Strix Halo** (schlägt ROCm/HIP bei pp und tg) — die Box ist richtig aufgestellt ([llm-tracker Strix-Halo](https://llm-tracker.info/_TOORG/Strix-Halo), [Strix-Halo-Guide](https://github.com/hogeheer499-commits/strix-halo-guide)). ROCm 7.2/RDNA4 betrifft nur den lokalen PC.
- **Host-Memory-Prompt-Cache:** `--cache-ram` + Prefix-Restore bringt bis **93 % TTFT-Reduktion** bei Agent-Workloads mit wachsender Session ([llama.cpp #20574](https://github.com/ggml-org/llama.cpp/discussions/20574)). `-cram 16384` ist gesetzt; das Zusammenspiel mit dem (am hermes-Alias entfernten) `--cache-reuse` gehört gebencht.
- **KV-Cache-Quantisierung ungenutzt:** `-ctk/-ctv q8_0` halbiert den KV-Bedarf (relevant bei coder@131k, hermes@65k) bei praktisch verlustfreier Qualität; [TurboQuant](https://github.com/ggml-org/llama.cpp/discussions/20969) (3-bit, near-lossless ab 13B) steht vor der Integration — beobachten.
- **„MoE Speculative Trap":** Ein aktueller [Strix-Halo-Deep-Dive](https://dev.to/agustinsacco/breaking-the-moe-speculative-trap-460-ts-on-amd-strix-halo-446d) zu exakt Qwen3.6-35B-A3B zeigt: Spec-Verify kann bei sparsem MoE pro Verify-Pass fast alle 35B Gewichte anfassen — in seinem Setup war **ohne** Draft (parallel=1, KV Q8_0) 43,1 t/s vs. 17,7 t/s. Das widerspricht der eigenen Box-Messung (+35 % MIT MTP). Konsequenz: **beide Betriebsarten benchen**, auch bei vollem Kontext ([Decode-Drop bis 64 % dokumentiert](https://kmarble.dev/posts/strix-halo-full-context-decode-drops/)).
- **llama-swap-Features ungenutzt:** `capabilities` (v225), `-config-dir`-Fragmente (v230), Swap-Matrix/Groups V2 ([Releases](https://github.com/mostlygeek/llama-swap/releases)).
- Modell-Landschaft 128 GB für heavy-Kandidaten: gpt-oss-120B, Mistral Small 4 (119B-A6B), Llama 4 Scout — nur mit Bench-Gate.
### 3.3 Ökosystem
- **Hermes v0.18.0 „Judgment"** (01.07.): 3 P0 + 493 P1 gefixt, Background-Fan-out für Subagents, `/learn`-Skills, Memory-Graph in der Desktop-App ([Releases](https://github.com/NousResearch/hermes-agent/releases)). **Plugin-API:** `sync_turn()` bekommt optional `messages` (voller Turn-Kontext inkl. Tool-Calls); Legacy-Signatur bleibt → **mc2-memory ist update-kompatibel** und kann später Tool-Ergebnisse mitlernen ([Memory-Provider-Doku](https://hermes-agent.nousresearch.com/docs/developer-guide/memory-provider-plugin)).
- **Mem0 v3-Algorithmus** ([Migration](https://docs.mem0.ai/migration/oss-v2-to-v3)): Single-Pass-Extraktion (~2× schneller), Hybrid-Retrieval (semantisch + BM25 + Entity-Graph, ohne externe Graph-DB), +20 LoCoMo / +26 LongMemEval. 2.0.8 installiert, `custom_instructions` schon migriert → Status final verifizieren.
- **Electron 43** (30.06.), Chromium 150 / Node 24 — Lucy ist auf 33.
- **Vision:** Qwen3-VL ist aktuelle Generation ✅; [Qwen3-VL-30B-A3B](https://github.com/qwenlm/qwen3-vl) (MoE, 3B aktiv, top auf GUI-Benchmarks) wäre der Upgrade-Kandidat für die Bildschirm-Sicht.
- **Lokaler GPU-Klon:** [AMD Lemonade](https://runaihome.com/blog/amd-lemonade-local-llm-server-npu-gpu-guide-2026/) (llama.cpp-Vulkan + whisper.cpp + Kokoro, OpenAI-kompatibel, RDNA4-Support) als fertiger Unterbau; llama.cpp-Vulkan ist der [verlässlichste 9070-XT-Pfad](https://digtvbg.com/blog/llama-server-vulkan-rdna4-vllm-rocm-benchmark/).
### 3.4 Verdikte — Ergebnis der Re-Prüfung
| Verdikt | Ergebnis | Begründung |
|---|---|---|
| Pocket TTS bleibt | ✅ **bestätigt** | 2.1.0 aktuell, RTF 0,44 optimiert, Wächter-System; Challenger ohne klaren Vorteil. F5-Entscheid (Phase C/D) bleibt als einziger offener Punkt |
| Electron bleibt (nicht Tauri) | ✅ **bestätigt** | Alle nativen Features implementiert; aber Major-Update 33→43 einplanen |
| Qwen3.6 als Lucy-Hirn | ✅ **live bestätigt** | Läuft mit MTP, TTFT 65 ms; gemma-4 ist komplett raus |
| ZeroClaw bleibt tot | ✅ **bestätigt** | Kein neuer Anlass |
| Mem0 als Memory-Layer | ✅ **bestätigt** (neu geprüft) | Zep/Hindsight benchen höher, sind aber schwerer/teils closed; Mem0: beste Integration, v3-Algo, deployt |
---
## 4. Roadmap (Aufwand/Nutzen, Voice-Speed-first)
### P0 — Live-Bugs & Betriebssicherheit (Stunden)
| # | Maßnahme | Nutzen |
|---|---|---|
| 1 | ~~Autostart fixen~~ **erledigt als Fehlalarm** — User-Unit `mission-control-2` mit Linger ist reboot-fest; nur stale v1-Unit bei Gelegenheit löschen (sudo) | Klarheit |
| 2 | chat-Lane fixen (`fast`-Alias ergänzen oder Policy auf `hermes`) | Live-Bug, alle chat-Clients betroffen |
| 3 | Box-Config → Repo syncen + `deploy/` versionieren; Template-Drift C1 fixen | Quelle der Wahrheit |
| 4 | Hermes v0.17.0 → v0.18.0 + Health-Brain-Check | 496 Upstream-Fixes |
| 5 | Commit-Hygiene (voice-core, MC3, Lucy-Fixes); `frontend/dist/` in .gitignore | Verlustrisiko weg |
### P1 — Voice-Latenz: 3,55 s → Ziel ≤1,2 s (Tage)
| # | Maßnahme | Erwartung |
|---|---|---|
| 6 | **STT-Tausch**: Parakeet-TDT 0.6B v3 statt faster-whisper medium. Variante A: Box-CPU (onnx-asr im voice_service); Variante B: lokal 9070 XT (DirectML). A/B: Deutsch-WER + Latenz | **~2,0 s → ~0,2 s** |
| 7 | **VAD-Tausch**: Silero VAD v6.2 (ONNX) statt RMS in useVAD.ts; Endpointing 900 → ~450 ms | **450 ms** |
| 8 | TTS-TTFA: LEAD_IN 0,35 s prüfen, Erster-Satz-kurz-Regel messen (lucy_perf) | 100300 ms |
| 9 | **Brain-Bench-Matrix** am hermes-Alias: MTP n-max 3↔4↔ohne (Speculative-Trap-These) × ±KV Q8_0 × cache-reuse/cram × -b/-ub — bei Kurz- UND Voll-Kontext | Bestes Setup evidenzbasiert |
| 10 | voice_metrics ausbauen: VAD→STT→Agent→First-Audio je Turn | Messbarkeit |
| 10b | KV Q8_0 für coder@131k/heavy erwägen | Warm-Set-Reserve |
### P2 — Lucy v2 Kern & Code-Gesundheit (12 Wochen)
| # | Maßnahme |
|---|---|
| 11 | Semantische Turn-Detection (Smart Turn V2 / Easy Turn) auf Parakeet aufsetzen; Barge-in-Vorstufe statt 450-ms-Cooldown |
| 12 | F5-TTS Phase C/D abschließen → Finalentscheid pocket vs. F5 (Benchmark-Gate) |
| 13 | useVoiceAgent-Refactor in Hooks + TTS-Retry/Backoff; Electron 33→43 |
| 14 | Backend C2 (Vision async) + C3 (DRY) + Dead Code; Frontend Cockpit/SystemDrawer-Split + globale Error Boundary |
### P3 — Strategisch (nach Signal)
| # | Maßnahme |
|---|---|
| 15 | Mem0-v3-Status final verifizieren; Junk-Regex durch v3-Retrieval entschärfen; mc2-memory auf `sync_turn(messages)` heben |
| 16 | MC3-Vollbau; llama-swap-Features (capabilities, config-dir, Swap-Matrix) |
| 17 | heavy-Bench: Qwen3.5-122B vs. gpt-oss-120B / Mistral Small 4; Vision-Upgrade Qwen3-VL-30B-A3B für Bildschirm-Sicht |
| 18 | Lokaler GPU-Klon: AMD Lemonade auf der 9070 XT evaluieren |
---
## 5. Findings-Katalog (Referenz)
| ID | Schwere | Fund | Ort |
|---|---|---|---|
| B1 | ⚪ (Fehlalarm) | :9001 läuft als User-Unit mit Linger — reboot-fest; nur stale v1-Unit als Kosmetik-Rest | Box, /etc/systemd/system/mission-control.service (v1) |
| B2 | 🔴 | chat-Lane → fast-Alias existiert nicht mehr | Box, Routing-Policy ↔ /etc/llama-swap/config.yaml |
| B3 | 🟡 | deploy/llama-swap.config.yaml untracked + veraltet | Repo |
| C1 | 🟡 | Install-Template ohne Produktions-Tunings | backend/config.py:33 |
| C2 | 🟡 | Vision-Beschreibung blockiert Chat bis 120 s | backend/routers/voice.py |
| C3 | 🟢 | Thinking-Disable 3× dupliziert | voice.py / gateway.py / mem0_service/app.py |
| C4 | 🟢 | Junk-Fact-Regex hartcodiert | mem0_service/app.py |
| C5 | ⚪ (Fehlalarm) | pricing.py + token_stats.py sind IN BENUTZUNG (system.py /token_stats-Endpoint, gateway_stream) — kein Dead Code | backend/services/ |
| F1 | 🟡 | Monolithen 990/934 Z | frontend/src/views/models/Cockpit.tsx, components/SystemDrawer.tsx |
| F2 | 🟡 (korrigiert) | dist/ im Git ist ABSICHT (Box hat kein Node, deploy = git reset --hard; s. deploy/build.sh) — echter Fund war nur der inkonsistente Asset-Stand auf lucy-v2 (behoben durch Rebuild+Commit). Build-on-Box/CI wäre P3-Option | frontend/dist/ |
| F4 | 🟢 | Keine globale Error Boundary | frontend/src/App.tsx |
| L1 | 🟡 | useVoiceAgent monolithisch (377 Z) | client/lucy-desktop/.../lib/voice/useVoiceAgent.ts |
| L2 | 🟡 | Kein TTS-Warmup-Retry | ebd. + main/index.ts |
| L3 | 🔴 | RMS-VAD mit 900-ms-Timer | client/lucy-desktop/.../lib/voice/useVAD.ts |
| L4 | 🟡 | Echo-Cooldown statt Barge-in | ebd. |
| H1 | 🟡 | ~23 Dateien uncommitted (inkl. voice-core, MC3) | lucy-v2 Working Tree |
---
## 6. Nachtrag: P0/P1-Umsetzung (02.07.2026)
**P0 komplett:** chat-Lane gefixt (fast-Alias), Hermes v0.18.0 (Postcheck grün), Config versioniert, Template-Drift behoben, Git aufgeräumt. B1 war Fehlalarm (s.o.).
**P1-6 STT:** Parakeet-TDT 0.6B v3 (onnx-asr, int8, Box-CPU) als Default — **A/B gemessen: 0,45 s vs. 2,44 s** (whisper-medium) bei identischem Transkript. Live deployt, auch via :9001 verifiziert.
**P1-7 VAD:** Silero v5 (vad-web 0.0.30) statt RMS — Endpointing 900→450 ms, WAV direkt (kein Opus-Umweg mehr). Build + Asset-Auslieferung verifiziert; Mikro-Test beim User.
**P1-9 Brain-Bench-Matrix** (Qwen3.6-35B-A3B, separater Port, Vulkan, je ~100 Gen-Token):
| Config | tg kurz | tg tief (15k) | Draft-Akzeptanz kurz |
|---|---|---|---|
| MTP n-max 3, KV f16 (live) | **78,6 t/s** | — (Probe-EOS) | 55 % |
| MTP n-max 4 | 63,7 | 100,9* | 39 % |
| ohne Spec | 62,4 | — (Probe-EOS) | — |
| **MTP n-max 3 + KV Q8_0** | **78,6** | 83,7 | 56 % |
| ohne Spec + KV Q8_0 | 62,1 | 57,0 | — |
*\*repetitiver Test-Text → unrealistisch hohe Draft-Akzeptanz (95 %); die Kurz-Spalte ist maßgeblich.*
**Bench-Verdikte:** (1) MTP n-max 3 bestätigt (+26 % vs. ohne Spec — die „MoE Speculative Trap" gilt auf diesem Vulkan/parallel-1-Setup NICHT; der Artikel testete ROCm + parallel 4). (2) n-max 4 lohnt nicht (Akzeptanz fällt auf 39 %). (3) **KV Q8_0 ist gratis** (identische t/s) und halbiert den KV-Speicher → für hermes in deploy/llama-swap.config.yaml übernommen; Live-Schaltung braucht User-Freigabe. Für coder/heavy vorher cache-reuse×KV-Quant-Verträglichkeit testen (Context-Shift mit quantisiertem KV ist in llama.cpp historisch heikel).
**P1-10:** `chat_first_content`-Metrik in voice.py — misst die echte Hirn-Latenz (Agent-Overhead + LLM-TTFT bis zum ersten Inhalts-Token) statt nur des SSE-Starts.
**P2-Nachtrag (02.07.):**
- **Profiling:** Folge-Turns 1,3 s, NEUE Sessions 5,612 s (Session-Prefill System-Prompt+Tools ~45k Tok). Mem0-Search unschuldig (18 ms). **Aber:** Die Mem0-Lern-Extraktion (~2,4 s je Turn, gleiche Qwen3.6-Instanz) blockiert bei `--parallel 1` den nächsten Voice-Turn in der Queue → Fix vorbereitet: `--parallel 2 -c 131072 + KV Q8_0` (2×65k-Slots, speicherneutral zu 1×65k f16). Live-Schaltung = User (deploy/llama-swap.config.yaml ist fertig).
- **C2 gefixt:** Bildschirm-Sicht läuft jetzt IM Stream (SSE startet sofort, `hermes.vision.progress`-Event → Lucy kann Warte-Ansage sprechen); Vision-Timeout 120→45 s (MC_VISION_TIMEOUT).
- **C3 bewertet:** nur 2 Backend-Stellen mit unterschiedlicher Gating-Logik (dritte im separaten Mem0-venv) → kein Shared-Helper erzwungen, Pattern dokumentiert.
- **C5 war Fehlalarm** (s. Findings-Tabelle).
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme).
## 7. Nachtrag 2: P2/P3-Vollausbau (02.07., zweite Session-Hälfte)
**Brain-Config LIVE DEPLOYT + verifiziert (02.07., User-Freigabe):** hermes läuft mit `-c 131072 --parallel 2 -ctk/-ctv q8_0` + MTP. Gemessen: **2 gleichzeitige Requests laufen echt parallel** (~1,9 s/2,0 s statt seriell — Mem0-Extraktion blockiert Voice-Turns nicht mehr), tg 66 t/s (leichter parallel-2-Abschlag vs. 78 solo, bewusster Trade), RAM 40/122 GB. **Voice-E2E: neue Session 0,85 s total, first_content 803 ms** (Morgen-Baseline: 5,612 s bei neuen Sessions). capabilities werden via /v1/models ausgeliefert.
| Punkt | Ergebnis |
|---|---|
| **P2-11 Turn-Detection** | ✅ Smart Turn v3.2 (8 MB ONNX) im Voice-Sidecar (`/turn`, ~110 ms warm) + Semantik-Hold im Client (bei „unfertig" bis 1,8 s auf Fortsetzung warten). **Zwei Upstream-Fallen live diagnostiziert:** Audio muss LINKS gepadded werden (sonst konstant „complete"), und der Output ist P(unfertig) — entgegen der Doku. Verifiziert: fertig=0,74→true, mitten im Wort=0,04→false |
| **P2-12 F5 Phase C/D** | ✅ **Verdikt: Pocket bleibt.** F5-ONNX auf 9070 XT/DirectML: RTF 0,59@NFE32 / 0,30@NFE16 — aber nicht streamfähig (TTFA = ganze Satzdauer), NFE16-Qualitätsrisiko, GPU-Dauerbelegung. F5 taugt als Offline-Renderer, nicht für den Dialog-Loop |
| **P2-13a Refactor** | ✅ useVoiceAgent 397→305 Z; textPipeline/visionIntent/perf als pure Module |
| **P2-13b Electron** | ✅ 33→43 (Chromium 150/Node 24), Boot-Smoke-Test grün (allow-scripts-Falle: install.js manuell) |
| **P2-14 Cockpit/SystemDrawer** | ⏸ **bewusst vertagt:** reiner Qualitäts-Refactor von live deployter UI; braucht eine eigene Session mit Browser-Verifikation (MC_API_TARGET-Workflow) statt eines Blind-Splits am Session-Ende |
| **P3-15 Mem0 v3** | ✅ verifiziert aktiv (BM25/Entity/Hybrid in 2.0.8); mc2-memory nutzt jetzt `sync_turn(messages)` — lernt Tool-NAMEN mit (Ergebnisse bewusst nicht: Poisoning-Vektor). Deployt, Postcheck grün |
| **P3-16 llama-swap** | ✅ `capabilities` (in/out/tools/context) je Modell in deploy-Config; Swap-Matrix aktuell unnötig (brains-Gruppe reicht) |
| **P3-17 Kandidaten** | ✅ **Beide gebencht + als testbare Modelle live deployt** (ohne Alias-Wechsel — Qualitäts-Entscheid beim User): (1) **gpt-oss-120B: tg 5455 t/s vs. heavy (Qwen3.5-122B) 23,5 t/s = 2,3× schneller bei 60 statt 73 GB** → klare Empfehlung für die heavy-Lane nach Deutsch-/Reasoning-Check (`model:"gpt-oss-120b"` am Gateway testen). (2) **Qwen3-VL-30B-A3B: tg 9092 t/s** → Bildschirm-Sicht-Upgrade nach Screenshot-Check (`MC_VISION_MODEL=Qwen3-VL-30B-A3B-Instruct`); brains-Budget-Frage: 19 vs. 6 GB warm |
| **P3-18 Lemonade** | ✅ **Verdikt: nicht als Unterbau.** lemonade-sdk 9.1.4 (Python) installiert + Server lief (OpenAI-API, gute Registry inkl. gpt-oss/GLM) — aber: Python-Edition **offiziell deprecated** (C++-Installer ist der Weg), Server blockiert komplett während Model-Downloads (Health tot >10 min bei 400-MB-Modell), Ryzen-AI-Hybrid auf 9700X unsupported. **Empfehlung für den lokalen GPU-Klon: llama.cpp-Vulkan + llama-swap — derselbe Stack wie die Box** (ein Betriebsmodell, ein Know-how, bewährte Configs). Lemonade-C++ nur, falls Whisper+TTS+LLM aus einer Hand gewünscht |
| **MC3-Vollbau** | ⛔ außerhalb des Review-Scopes — eigenes Projekt (Prototyp steht unter #mc3) |
---
## 8. Nachtrag 3: Trennung, Update-Playbook, Radikal-Aufräumen (02.07., Abend)
**Lucy = eigenes Repo:** `F:\Coding Stuff\lucy` ↔ Gitea `Hitonabi/lucy` (privat; Repo via API angelegt). Verzeichnisnamen unverändert (lucy-desktop/ + lucy-tts/) → alle Pfade/BATs funktionieren; Boot aus neuem Pfad verifiziert. MC2 = reiner Box-Stack (+ hermes-pc-Executor). lucy-f5 (Verdikt final) und hermes-voice (Vor-Lucy) gelöscht — Historie bleibt in MC2.
**Hermes-Update-Playbook LIVE + E2E-verifiziert (7/7 PASS):** Update-Job = Backup → update → **doctor** → Restart → erweiterter Postcheck (**Config-Drift-Scan** im Journal, **Tool-Smoke** via echtem Agenten, **Voice-Smoke** mit Retries). Update-Modal fasst anstehende Commits künftig per fast-Modell zusammen (Breaking Changes zuerst, gecacht). Beim E2E-Test sofort geliefert: doctor fand eine **ausstehende Config-Schema-Migration** (altes flaches `model: hermes` → neues Schema; per `doctor --fix` migriert, Gateway verifiziert) und der Voice-Smoke entlarvte eine **pipefail/SIGPIPE-Falle** im eigenen Check (head schließt Pipe → curl 23 → Fehlalarm; gefixt). Außerdem behoben: `approvals.mode auto→smart` (v0.18-Regression, einfache Fragen 25 s → 2,0 s).
**Radikal aufgeräumt:** Git: lucy-v2 gemerged + gelöscht (lokal+Gitea), nur noch `main`; alter WIP-Stash gedroppt; stale Worktree entfernt. Lokal: lucy-f5-Assets (~5 GB), lemonade-eval, box_recon/gemma_swap-Scratch gelöscht. Box: 22-GB-Duplikat `Qwen3.6-35B-A3B-GGUF` (non-MTP) + 6 leere Modell-Hüllen gelöscht, v1-Altlasten (mission-control-*.db/json) nach mc2-backups/v1-legacy archiviert, /tmp-Bench-Scratch weg. Modell-Verzeichnis = exakt die 9 referenzierten Modelle + drafts + Backups.
---
*Erhoben am 02.07.2026 durch Claude Code (Live-SSH auf Box, lokale PC-Prüfung, 3 Codebase-Agents, 3 Web-Recherche-Runden). Messwerte: /api/voice/metrics (n=13 STT, n=18 chat_ttfb), TTFT-Direktmessung llama-swap :8080.*
# Komplett-Review Mission Control 2 + Lucy — 02.07.2026
**Methodik:** IST-Zustand live erhoben (Box per SSH, lokaler Lucy-PC, Working Tree `lucy-v2` inkl. uncommitted) — nicht aus Docs/Memory übernommen. SOLL-Zustand in 3 Recherche-Runden tagesaktuell (Stand 02.07.2026) ermittelt. Alle früheren Verdikte wurden neu auf den Prüfstand gestellt. Scope: alles außer Security.
---
## 1. Management-Summary
| Bereich | Zustand | Kernaussage |
|---|---|---|
| LLM-Stack (Box) | ✅ stark, 1 Bug | Vulkan-Pfad richtig, Qwen3.6+MTP läuft; aber chat-Lane kaputt (Autostart-Fund war Fehlalarm) |
| Lucy Voice-Latenz | 🔴 3,55 s | 2026-Ziel ist 0,50,8 s. Schuld sind STT (2,0 s) und VAD-Timer (0,9 s), **nicht** das LLM (TTFT 65 ms) |
| Lucy Avatar/App | ✅ sehr ausgereift | VRMA-Mocap, Lippensync v2, MateEngine-Features komplett; Electron 10 Major-Versionen alt |
| Backend/Frontend-Code | 🟡 solide | 3 Monolithen, etwas DRY-Schuld, dist/ im Git; keine Rot-Flaggen |
| Ökosystem-Aktualität | 🟡 | Hermes 1 Release hinter (v0.18.0 vom 01.07.); pocket-tts, mem0, llama-swap, three-vrm aktuell |
| Verdikte | ✅ alle bestätigt | Pocket TTS, Electron, Qwen3.6-Hirn, kein ZeroClaw, Mem0 — alle bestehen die Re-Prüfung |
**Die zwei größten Hebel:**
1. **STT-Tausch** (faster-whisper medium → Parakeet-TDT 0.6B v3): ~2,0 s → ~0,2 s pro Äußerung
2. **VAD-Tausch** (RMS-Eigenbau → Silero VAD v6.2): Turn-Ende 900 → ~450 ms bei besserer Genauigkeit
Zusammen: Voice-to-Voice von ~3,55 s auf realistisch **≤1,2 s**, ohne neue Hardware.
---
## 2. IST-Zustand (live verifiziert)
### 2.1 Box (192.168.178.151 — Strix Halo, gfx1151, 122 GB, Vulkan)
| Komponente | Installiert | Aktuell (02.07.2026) | Bewertung |
|---|---|---|---|
| llama.cpp | b9843 (86b94708f) | b9859 (01.07.) | ✅ nur ~16 Builds dahinter |
| llama-swap | v233 (29.06.) | v233 | ✅ aktuell |
| Hermes Agent | **v0.17.0** (19.06.) | **v0.18.0** (01.07.) | 🟡 Update lohnt (3 P0 + 493 P1 gefixt) |
| mem0ai / chromadb | 2.0.8 / 1.5.9 | 2.0.8 | ✅ aktuell |
| Kernel | 7.0.0-27-generic | — | ✅ |
**Live-Modell-Lineup** (`/etc/llama-swap/config.yaml`, live gelesen — der Repo-Snapshot `deploy/llama-swap.config.yaml` ist **veraltet** und zeigt noch gemma-4):
| Alias | Modell | ctx | Besonderheiten |
|---|---|---|---|
| hermes (Brain) | Qwen3.6-35B-A3B (UD-Q4_K_M, MTP-GGUF) | 65536 | `--spec-type draft-mtp --spec-draft-n-max 3 --parallel 1 -cram 16384`, **kein** cache-reuse |
| heavy | Qwen3.5-122B-A10B | 32768 | cache-reuse 256, ttl 600 |
| coder | Qwen3-Coder-Next | 131072 | cache-reuse 256, ttl 600 |
| coder-lite | Qwen3-Coder-30B-A3B | 131072 | cache-reuse 256, ttl 300 |
| vision | Qwen3-VL-8B | 32768 | mmproj, in brains |
| embed | Qwen3-Embedding-0.6B | 8192 | ttl 0, in brains |
| scout | GLM-4.6V-Flash | 131072 | mmproj, ttl 300 |
Gruppe `brains` (swap:false, persist:true) = embed + vision + Qwen3.6. Alle Modelle: `-ngl 999 -fa on --no-mmap --jinja`. **Kein Modell nutzt KV-Cache-Quantisierung** (alles F16).
**Dienste live:** llama-swap, hermes-gateway, hermes-terminal, hermes-webui, mem0-service, voice-service — alle running. Ports 7681/8080/8642/8650/8765/8787/9001 belegt.
### 2.2 🔴 Live-Bugs (selbst reproduziert)
**B1 — ~~MC2-Backend ohne Autostart~~ FEHLALARM (korrigiert 02.07., nachverifiziert).**
:9001 läuft als **User-Unit** `mission-control-2.service` (`~/.config/systemd/user/`, enabled, `Linger=yes` → reboot-fest). Die erste SSH-Prüfung sah User-Units nicht (fehlendes XDG_RUNTIME_DIR). Einziger echter Fund: Die **stale v1-System-Unit** `mission-control.service` (disabled, /opt/mission-control:9000) liegt noch in /etc/systemd/system und stiftet Verwirrung → bei Gelegenheit mit sudo entfernen (kosmetisch, kein Risiko).
**B2 — chat-Lane kaputt (live reproduziert).**
```
POST /v1/chat/completions {"model":"chat",...}
→ {"error":"no router for requested model","src":"llama-swap"}
```
Die Routing-Policy routet `chat → fast ↔ heavy`, aber llama-swap kennt den Alias `fast` nicht mehr (Qwen3.6 hat nur noch den Alias `hermes`). Lucy ist nicht betroffen (sie geht über den Hermes-Agenten :8642), aber jeder Client der chat-Lane bekommt Fehler. Fix: `fast` als zweiten Alias eintragen **oder** Policy auf `hermes` umstellen.
**B3 — Config-Drift.**
`deploy/llama-swap.config.yaml` ist untracked UND veraltet (gemma-4-Ära). Zusätzlich fehlen im Install-Template [`backend/config.py:33`](../backend/config.py) die Produktions-Tunings (cache-reuse, parallel, MTP) → neu installierte Modelle driften von der Box-Realität weg.
### 2.3 Gemessene Latenzkette Lucy
Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap.
| Stufe | Messwert | Anteil am Problem |
|---|---|---|
| VAD-Turn-Ende | **900 ms** Fix-Timer (RMS-Eigenbau, [useVAD.ts](../client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts)) | groß |
| STT (faster-whisper medium int8, Box-CPU) | **avg 2.046 ms · p50 2.092 ms · p95 2.529 ms** (n=13) | **dominant** |
| LLM TTFT (Qwen3.6 warm, direkt :8080) | **65 ms** (24 Tokens in 334 ms) | ✅ kein Problem |
| TTS erster Ton (pocket, RTF 0,44 + LEAD_IN 0,35 s) | ~0,51 s für den ersten Satz | mittel |
| **Voice-to-Voice gesamt** | **~3,55 s** | Ziel 2026: **0,50,8 s** |
**Fazit: Das teuerste Glied ist NICHT das LLM.** STT + VAD-Timer fressen zusammen ~3 s. Genau dort setzt die Roadmap an.
### 2.4 Lokaler PC (RX 9070 XT / RDNA4)
- Lucy-App zum Prüfzeitpunkt nicht gestartet (kein Electron-Prozess, :8130 frei)
- `ptts-venv`: pocket-tts **2.1.0** (= neueste Version), torch 2.12.1+cpu (bewusst CPU), onnxruntime 1.27.0, fastapi 0.138.1
- GPU-Treiber 32.0.31021.5001
- [client/lucy-tts/](../client/lucy-tts/) (3 GB): Produktions-TTS mit Stimmen-Wächter (F0-Floor 160 Hz, MFCC-Fingerprint ≥0,94, Best-of-N), Phase A+B des TTS-Plans abgeschlossen (beste Config: 4 Worker × 2 Threads, RTF 0,44)
- [client/lucy-f5/](../client/lucy-f5/) (5,3 GB): F5-TTS-ONNX/DirectML-Experiment — **Phase C/D (Finalentscheid pocket vs. F5) offen**
- Lucy-Desktop: Electron **33** (aktuell: **43** vom 30.06. — 2 Jahre Chromium-Rückstand), three-vrm 3.4/animation 3.5.4 (≈ aktuell), TS strict
### 2.5 Code-Qualität (3 Explore-Agents über das ganze Repo)
**Backend (4.889 LOC, 11 Router / 28 Services):** insgesamt sauber, Multi-Sidecar-Architektur durchdacht. Findings:
- C1: Install-Template ohne Produktions-Tunings ([backend/config.py:33](../backend/config.py)) → Drift
- C2: `_describe_images()` blockiert den Voice-Chat bis 120 s synchron ([backend/routers/voice.py](../backend/routers/voice.py))
- C3: Thinking-Deaktivierung 3× dupliziert (voice.py, gateway.py, mem0_service/app.py) — DRY
- C4: Junk-Fact-Regex im Mem0-Sidecar hartcodiert (Wartungspunkt)
- Dead Code: `backend/services/pricing.py`, vermutlich `token_stats.py`
- Uncommitted Änderungen (voice.py No-Think, connect.py IDE-only-`coding`, mc2-memory-Guards, Mem0-Junk-Guard, voice_service install.sh) sind **alle konsistent und sinnvoll** — nur eben nicht committet
**Frontend (React 18/Vite 6/Tailwind 4/TanStack 5):**
- Voice-Umzug in die Desktop-App sauber (keine toten Imports/Nav-Reste) ✅
- UI-Rework (roleMeta/Health/Expert/WarmSet) vollständig; coder_lite↔coder-lite via ALIAS gelöst
- F1: [Cockpit.tsx](../frontend/src/views/models/Cockpit.tsx) (990 Z) + [SystemDrawer.tsx](../frontend/src/components/SystemDrawer.tsx) (934 Z) Monolithen
- F2: 🟡 `frontend/dist/`-Asset-Stand auf lucy-v2 inkonsistent (alte gelöscht, neue untracked) — dist-im-Git selbst ist Absicht (Box hat kein Node), avatar.vrm ist via .gitignore korrekt draußen
- F4: keine globale Error Boundary
- MC3-Prototyp ([frontend/src/mc3/](../frontend/src/mc3/)) non-destruktiv hinter `#mc3`, untracked
**Lucy-Desktop:**
- L1: [useVoiceAgent.ts](../client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts) (377 Z) monolithisch (SSE, Chunking, Tools, Perf, Vision in einem Hook)
- L2: kein Retry/Backoff beim TTS-Warmup (Crash → 2-min-Spinner)
- L3: VAD = reines RMS mit 900-ms-Stille-Regel
- L4: Echo-Schutz = 450-ms-Cooldown-Workaround (kein echtes Barge-in)
- Neues [voice-core/](../client/lucy-desktop/src/renderer/src/voice-core/)-Adapter-Layer (STT/TTS austauschbar) ist die richtige Architektur ✅
- H1: ~23 Dateien uncommitted, darunter die neue voice-core-Architektur → Verlustrisiko
---
## 3. SOLL-Zustand (Recherche, Stand 02.07.2026)
### 3.1 Voice-Latenz — der Stand der Technik
- 2026-Zielmarke: **500800 ms voice-to-voice**; Grundprinzip: **Streaming auf jeder Stufe** (STT-Partials sparen 200400 ms, TTS startet auf Teiltext, VAD+Turn-Taking-Budget 150300 ms) — [Latenz-Guide](https://futureagi.com/blog/how-to-optimize-voice-agent-latency-2026/), [Latenz-Budget-Design](https://smallest.ai/blog/designing-voice-assistants-stt-llm-tts-tools-and-latency-budget)
- **STT:** [Parakeet-TDT 0.6B v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) — 25 EU-Sprachen inkl. Deutsch, 6,32 % WER (besser als Whisper large-v3 mit 7,44 %), extrem schnell auch auf CPU, keine Silence-Halluzination. Deploy-Wege: [onnx-asr](https://pypi.org/project/onnx-asr/) (unterstützt CPU **und** DirectML → 9070 XT), fertige [OpenAI-kompatible FastAPI-Wrapper](https://github.com/groxaxo/parakeet-tdt-0.6b-v3-fastapi-openai). Kyutais Streaming-STT mit eingebautem semantic VAD ([delayed-streams-modeling](https://github.com/kyutai-labs/delayed-streams-modeling)) wäre architektonisch ideal, ist aber **nur EN/FR** → für Deutsch raus.
- **VAD:** [Silero VAD v6](https://github.com/snakers4/silero-vad/releases/tag/v6.0) (v6.2, ONNX): 16 % Fehler auf Noisy-Data vs. v5 — klar besser als der RMS-Eigenbau; erlaubt kürzeres Endpointing (~450 ms) bei weniger Fehlstarts.
- **Semantische Turn-Detection:** Smart Turn V2 (leichtgewichtig), [Easy Turn](https://arxiv.org/pdf/2509.23938) (akustisch+linguistisch, 4 Turn-States, open source).
- **TTS:** pocket-tts 2.1.0 ist Stand der Technik für CPU-Realtime ([kyutai](https://kyutai.org/blog/2026-01-13-pocket-tts/)). Challenger (CosyVoice2-0.5B, Chatterbox-Turbo) bieten keinen klaren Vorteil. → Einziger offener Entscheid bleibt das hauseigene F5-Experiment (Phase C/D).
### 3.2 Box maximieren
- **Vulkan/RADV bleibt der schnellste Pfad auf Strix Halo** (schlägt ROCm/HIP bei pp und tg) — die Box ist richtig aufgestellt ([llm-tracker Strix-Halo](https://llm-tracker.info/_TOORG/Strix-Halo), [Strix-Halo-Guide](https://github.com/hogeheer499-commits/strix-halo-guide)). ROCm 7.2/RDNA4 betrifft nur den lokalen PC.
- **Host-Memory-Prompt-Cache:** `--cache-ram` + Prefix-Restore bringt bis **93 % TTFT-Reduktion** bei Agent-Workloads mit wachsender Session ([llama.cpp #20574](https://github.com/ggml-org/llama.cpp/discussions/20574)). `-cram 16384` ist gesetzt; das Zusammenspiel mit dem (am hermes-Alias entfernten) `--cache-reuse` gehört gebencht.
- **KV-Cache-Quantisierung ungenutzt:** `-ctk/-ctv q8_0` halbiert den KV-Bedarf (relevant bei coder@131k, hermes@65k) bei praktisch verlustfreier Qualität; [TurboQuant](https://github.com/ggml-org/llama.cpp/discussions/20969) (3-bit, near-lossless ab 13B) steht vor der Integration — beobachten.
- **„MoE Speculative Trap":** Ein aktueller [Strix-Halo-Deep-Dive](https://dev.to/agustinsacco/breaking-the-moe-speculative-trap-460-ts-on-amd-strix-halo-446d) zu exakt Qwen3.6-35B-A3B zeigt: Spec-Verify kann bei sparsem MoE pro Verify-Pass fast alle 35B Gewichte anfassen — in seinem Setup war **ohne** Draft (parallel=1, KV Q8_0) 43,1 t/s vs. 17,7 t/s. Das widerspricht der eigenen Box-Messung (+35 % MIT MTP). Konsequenz: **beide Betriebsarten benchen**, auch bei vollem Kontext ([Decode-Drop bis 64 % dokumentiert](https://kmarble.dev/posts/strix-halo-full-context-decode-drops/)).
- **llama-swap-Features ungenutzt:** `capabilities` (v225), `-config-dir`-Fragmente (v230), Swap-Matrix/Groups V2 ([Releases](https://github.com/mostlygeek/llama-swap/releases)).
- Modell-Landschaft 128 GB für heavy-Kandidaten: gpt-oss-120B, Mistral Small 4 (119B-A6B), Llama 4 Scout — nur mit Bench-Gate.
### 3.3 Ökosystem
- **Hermes v0.18.0 „Judgment"** (01.07.): 3 P0 + 493 P1 gefixt, Background-Fan-out für Subagents, `/learn`-Skills, Memory-Graph in der Desktop-App ([Releases](https://github.com/NousResearch/hermes-agent/releases)). **Plugin-API:** `sync_turn()` bekommt optional `messages` (voller Turn-Kontext inkl. Tool-Calls); Legacy-Signatur bleibt → **mc2-memory ist update-kompatibel** und kann später Tool-Ergebnisse mitlernen ([Memory-Provider-Doku](https://hermes-agent.nousresearch.com/docs/developer-guide/memory-provider-plugin)).
- **Mem0 v3-Algorithmus** ([Migration](https://docs.mem0.ai/migration/oss-v2-to-v3)): Single-Pass-Extraktion (~2× schneller), Hybrid-Retrieval (semantisch + BM25 + Entity-Graph, ohne externe Graph-DB), +20 LoCoMo / +26 LongMemEval. 2.0.8 installiert, `custom_instructions` schon migriert → Status final verifizieren.
- **Electron 43** (30.06.), Chromium 150 / Node 24 — Lucy ist auf 33.
- **Vision:** Qwen3-VL ist aktuelle Generation ✅; [Qwen3-VL-30B-A3B](https://github.com/qwenlm/qwen3-vl) (MoE, 3B aktiv, top auf GUI-Benchmarks) wäre der Upgrade-Kandidat für die Bildschirm-Sicht.
- **Lokaler GPU-Klon:** [AMD Lemonade](https://runaihome.com/blog/amd-lemonade-local-llm-server-npu-gpu-guide-2026/) (llama.cpp-Vulkan + whisper.cpp + Kokoro, OpenAI-kompatibel, RDNA4-Support) als fertiger Unterbau; llama.cpp-Vulkan ist der [verlässlichste 9070-XT-Pfad](https://digtvbg.com/blog/llama-server-vulkan-rdna4-vllm-rocm-benchmark/).
### 3.4 Verdikte — Ergebnis der Re-Prüfung
| Verdikt | Ergebnis | Begründung |
|---|---|---|
| Pocket TTS bleibt | ✅ **bestätigt** | 2.1.0 aktuell, RTF 0,44 optimiert, Wächter-System; Challenger ohne klaren Vorteil. F5-Entscheid (Phase C/D) bleibt als einziger offener Punkt |
| Electron bleibt (nicht Tauri) | ✅ **bestätigt** | Alle nativen Features implementiert; aber Major-Update 33→43 einplanen |
| Qwen3.6 als Lucy-Hirn | ✅ **live bestätigt** | Läuft mit MTP, TTFT 65 ms; gemma-4 ist komplett raus |
| ZeroClaw bleibt tot | ✅ **bestätigt** | Kein neuer Anlass |
| Mem0 als Memory-Layer | ✅ **bestätigt** (neu geprüft) | Zep/Hindsight benchen höher, sind aber schwerer/teils closed; Mem0: beste Integration, v3-Algo, deployt |
---
## 4. Roadmap (Aufwand/Nutzen, Voice-Speed-first)
### P0 — Live-Bugs & Betriebssicherheit (Stunden)
| # | Maßnahme | Nutzen |
|---|---|---|
| 1 | ~~Autostart fixen~~ **erledigt als Fehlalarm** — User-Unit `mission-control-2` mit Linger ist reboot-fest; nur stale v1-Unit bei Gelegenheit löschen (sudo) | Klarheit |
| 2 | chat-Lane fixen (`fast`-Alias ergänzen oder Policy auf `hermes`) | Live-Bug, alle chat-Clients betroffen |
| 3 | Box-Config → Repo syncen + `deploy/` versionieren; Template-Drift C1 fixen | Quelle der Wahrheit |
| 4 | Hermes v0.17.0 → v0.18.0 + Health-Brain-Check | 496 Upstream-Fixes |
| 5 | Commit-Hygiene (voice-core, MC3, Lucy-Fixes); `frontend/dist/` in .gitignore | Verlustrisiko weg |
### P1 — Voice-Latenz: 3,55 s → Ziel ≤1,2 s (Tage)
| # | Maßnahme | Erwartung |
|---|---|---|
| 6 | **STT-Tausch**: Parakeet-TDT 0.6B v3 statt faster-whisper medium. Variante A: Box-CPU (onnx-asr im voice_service); Variante B: lokal 9070 XT (DirectML). A/B: Deutsch-WER + Latenz | **~2,0 s → ~0,2 s** |
| 7 | **VAD-Tausch**: Silero VAD v6.2 (ONNX) statt RMS in useVAD.ts; Endpointing 900 → ~450 ms | **450 ms** |
| 8 | TTS-TTFA: LEAD_IN 0,35 s prüfen, Erster-Satz-kurz-Regel messen (lucy_perf) | 100300 ms |
| 9 | **Brain-Bench-Matrix** am hermes-Alias: MTP n-max 3↔4↔ohne (Speculative-Trap-These) × ±KV Q8_0 × cache-reuse/cram × -b/-ub — bei Kurz- UND Voll-Kontext | Bestes Setup evidenzbasiert |
| 10 | voice_metrics ausbauen: VAD→STT→Agent→First-Audio je Turn | Messbarkeit |
| 10b | KV Q8_0 für coder@131k/heavy erwägen | Warm-Set-Reserve |
### P2 — Lucy v2 Kern & Code-Gesundheit (12 Wochen)
| # | Maßnahme |
|---|---|
| 11 | Semantische Turn-Detection (Smart Turn V2 / Easy Turn) auf Parakeet aufsetzen; Barge-in-Vorstufe statt 450-ms-Cooldown |
| 12 | F5-TTS Phase C/D abschließen → Finalentscheid pocket vs. F5 (Benchmark-Gate) |
| 13 | useVoiceAgent-Refactor in Hooks + TTS-Retry/Backoff; Electron 33→43 |
| 14 | Backend C2 (Vision async) + C3 (DRY) + Dead Code; Frontend Cockpit/SystemDrawer-Split + globale Error Boundary |
### P3 — Strategisch (nach Signal)
| # | Maßnahme |
|---|---|
| 15 | Mem0-v3-Status final verifizieren; Junk-Regex durch v3-Retrieval entschärfen; mc2-memory auf `sync_turn(messages)` heben |
| 16 | MC3-Vollbau; llama-swap-Features (capabilities, config-dir, Swap-Matrix) |
| 17 | heavy-Bench: Qwen3.5-122B vs. gpt-oss-120B / Mistral Small 4; Vision-Upgrade Qwen3-VL-30B-A3B für Bildschirm-Sicht |
| 18 | Lokaler GPU-Klon: AMD Lemonade auf der 9070 XT evaluieren |
---
## 5. Findings-Katalog (Referenz)
| ID | Schwere | Fund | Ort |
|---|---|---|---|
| B1 | ⚪ (Fehlalarm) | :9001 läuft als User-Unit mit Linger — reboot-fest; nur stale v1-Unit als Kosmetik-Rest | Box, /etc/systemd/system/mission-control.service (v1) |
| B2 | 🔴 | chat-Lane → fast-Alias existiert nicht mehr | Box, Routing-Policy ↔ /etc/llama-swap/config.yaml |
| B3 | 🟡 | deploy/llama-swap.config.yaml untracked + veraltet | Repo |
| C1 | 🟡 | Install-Template ohne Produktions-Tunings | backend/config.py:33 |
| C2 | 🟡 | Vision-Beschreibung blockiert Chat bis 120 s | backend/routers/voice.py |
| C3 | 🟢 | Thinking-Disable 3× dupliziert | voice.py / gateway.py / mem0_service/app.py |
| C4 | 🟢 | Junk-Fact-Regex hartcodiert | mem0_service/app.py |
| C5 | ⚪ (Fehlalarm) | pricing.py + token_stats.py sind IN BENUTZUNG (system.py /token_stats-Endpoint, gateway_stream) — kein Dead Code | backend/services/ |
| F1 | 🟡 | Monolithen 990/934 Z | frontend/src/views/models/Cockpit.tsx, components/SystemDrawer.tsx |
| F2 | 🟡 (korrigiert) | dist/ im Git ist ABSICHT (Box hat kein Node, deploy = git reset --hard; s. deploy/build.sh) — echter Fund war nur der inkonsistente Asset-Stand auf lucy-v2 (behoben durch Rebuild+Commit). Build-on-Box/CI wäre P3-Option | frontend/dist/ |
| F4 | 🟢 | Keine globale Error Boundary | frontend/src/App.tsx |
| L1 | 🟡 | useVoiceAgent monolithisch (377 Z) | client/lucy-desktop/.../lib/voice/useVoiceAgent.ts |
| L2 | 🟡 | Kein TTS-Warmup-Retry | ebd. + main/index.ts |
| L3 | 🔴 | RMS-VAD mit 900-ms-Timer | client/lucy-desktop/.../lib/voice/useVAD.ts |
| L4 | 🟡 | Echo-Cooldown statt Barge-in | ebd. |
| H1 | 🟡 | ~23 Dateien uncommitted (inkl. voice-core, MC3) | lucy-v2 Working Tree |
---
## 6. Nachtrag: P0/P1-Umsetzung (02.07.2026)
**P0 komplett:** chat-Lane gefixt (fast-Alias), Hermes v0.18.0 (Postcheck grün), Config versioniert, Template-Drift behoben, Git aufgeräumt. B1 war Fehlalarm (s.o.).
**P1-6 STT:** Parakeet-TDT 0.6B v3 (onnx-asr, int8, Box-CPU) als Default — **A/B gemessen: 0,45 s vs. 2,44 s** (whisper-medium) bei identischem Transkript. Live deployt, auch via :9001 verifiziert.
**P1-7 VAD:** Silero v5 (vad-web 0.0.30) statt RMS — Endpointing 900→450 ms, WAV direkt (kein Opus-Umweg mehr). Build + Asset-Auslieferung verifiziert; Mikro-Test beim User.
**P1-9 Brain-Bench-Matrix** (Qwen3.6-35B-A3B, separater Port, Vulkan, je ~100 Gen-Token):
| Config | tg kurz | tg tief (15k) | Draft-Akzeptanz kurz |
|---|---|---|---|
| MTP n-max 3, KV f16 (live) | **78,6 t/s** | — (Probe-EOS) | 55 % |
| MTP n-max 4 | 63,7 | 100,9* | 39 % |
| ohne Spec | 62,4 | — (Probe-EOS) | — |
| **MTP n-max 3 + KV Q8_0** | **78,6** | 83,7 | 56 % |
| ohne Spec + KV Q8_0 | 62,1 | 57,0 | — |
*\*repetitiver Test-Text → unrealistisch hohe Draft-Akzeptanz (95 %); die Kurz-Spalte ist maßgeblich.*
**Bench-Verdikte:** (1) MTP n-max 3 bestätigt (+26 % vs. ohne Spec — die „MoE Speculative Trap" gilt auf diesem Vulkan/parallel-1-Setup NICHT; der Artikel testete ROCm + parallel 4). (2) n-max 4 lohnt nicht (Akzeptanz fällt auf 39 %). (3) **KV Q8_0 ist gratis** (identische t/s) und halbiert den KV-Speicher → für hermes in deploy/llama-swap.config.yaml übernommen; Live-Schaltung braucht User-Freigabe. Für coder/heavy vorher cache-reuse×KV-Quant-Verträglichkeit testen (Context-Shift mit quantisiertem KV ist in llama.cpp historisch heikel).
**P1-10:** `chat_first_content`-Metrik in voice.py — misst die echte Hirn-Latenz (Agent-Overhead + LLM-TTFT bis zum ersten Inhalts-Token) statt nur des SSE-Starts.
**P2-Nachtrag (02.07.):**
- **Profiling:** Folge-Turns 1,3 s, NEUE Sessions 5,612 s (Session-Prefill System-Prompt+Tools ~45k Tok). Mem0-Search unschuldig (18 ms). **Aber:** Die Mem0-Lern-Extraktion (~2,4 s je Turn, gleiche Qwen3.6-Instanz) blockiert bei `--parallel 1` den nächsten Voice-Turn in der Queue → Fix vorbereitet: `--parallel 2 -c 131072 + KV Q8_0` (2×65k-Slots, speicherneutral zu 1×65k f16). Live-Schaltung = User (deploy/llama-swap.config.yaml ist fertig).
- **C2 gefixt:** Bildschirm-Sicht läuft jetzt IM Stream (SSE startet sofort, `hermes.vision.progress`-Event → Lucy kann Warte-Ansage sprechen); Vision-Timeout 120→45 s (MC_VISION_TIMEOUT).
- **C3 bewertet:** nur 2 Backend-Stellen mit unterschiedlicher Gating-Logik (dritte im separaten Mem0-venv) → kein Shared-Helper erzwungen, Pattern dokumentiert.
- **C5 war Fehlalarm** (s. Findings-Tabelle).
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme).
## 7. Nachtrag 2: P2/P3-Vollausbau (02.07., zweite Session-Hälfte)
**Brain-Config LIVE DEPLOYT + verifiziert (02.07., User-Freigabe):** hermes läuft mit `-c 131072 --parallel 2 -ctk/-ctv q8_0` + MTP. Gemessen: **2 gleichzeitige Requests laufen echt parallel** (~1,9 s/2,0 s statt seriell — Mem0-Extraktion blockiert Voice-Turns nicht mehr), tg 66 t/s (leichter parallel-2-Abschlag vs. 78 solo, bewusster Trade), RAM 40/122 GB. **Voice-E2E: neue Session 0,85 s total, first_content 803 ms** (Morgen-Baseline: 5,612 s bei neuen Sessions). capabilities werden via /v1/models ausgeliefert.
| Punkt | Ergebnis |
|---|---|
| **P2-11 Turn-Detection** | ✅ Smart Turn v3.2 (8 MB ONNX) im Voice-Sidecar (`/turn`, ~110 ms warm) + Semantik-Hold im Client (bei „unfertig" bis 1,8 s auf Fortsetzung warten). **Zwei Upstream-Fallen live diagnostiziert:** Audio muss LINKS gepadded werden (sonst konstant „complete"), und der Output ist P(unfertig) — entgegen der Doku. Verifiziert: fertig=0,74→true, mitten im Wort=0,04→false |
| **P2-12 F5 Phase C/D** | ✅ **Verdikt: Pocket bleibt.** F5-ONNX auf 9070 XT/DirectML: RTF 0,59@NFE32 / 0,30@NFE16 — aber nicht streamfähig (TTFA = ganze Satzdauer), NFE16-Qualitätsrisiko, GPU-Dauerbelegung. F5 taugt als Offline-Renderer, nicht für den Dialog-Loop |
| **P2-13a Refactor** | ✅ useVoiceAgent 397→305 Z; textPipeline/visionIntent/perf als pure Module |
| **P2-13b Electron** | ✅ 33→43 (Chromium 150/Node 24), Boot-Smoke-Test grün (allow-scripts-Falle: install.js manuell) |
| **P2-14 Cockpit/SystemDrawer** | ⏸ **bewusst vertagt:** reiner Qualitäts-Refactor von live deployter UI; braucht eine eigene Session mit Browser-Verifikation (MC_API_TARGET-Workflow) statt eines Blind-Splits am Session-Ende |
| **P3-15 Mem0 v3** | ✅ verifiziert aktiv (BM25/Entity/Hybrid in 2.0.8); mc2-memory nutzt jetzt `sync_turn(messages)` — lernt Tool-NAMEN mit (Ergebnisse bewusst nicht: Poisoning-Vektor). Deployt, Postcheck grün |
| **P3-16 llama-swap** | ✅ `capabilities` (in/out/tools/context) je Modell in deploy-Config; Swap-Matrix aktuell unnötig (brains-Gruppe reicht) |
| **P3-17 Kandidaten** | ✅ **Beide gebencht + als testbare Modelle live deployt** (ohne Alias-Wechsel — Qualitäts-Entscheid beim User): (1) **gpt-oss-120B: tg 5455 t/s vs. heavy (Qwen3.5-122B) 23,5 t/s = 2,3× schneller bei 60 statt 73 GB** → klare Empfehlung für die heavy-Lane nach Deutsch-/Reasoning-Check (`model:"gpt-oss-120b"` am Gateway testen). (2) **Qwen3-VL-30B-A3B: tg 9092 t/s** → Bildschirm-Sicht-Upgrade nach Screenshot-Check (`MC_VISION_MODEL=Qwen3-VL-30B-A3B-Instruct`); brains-Budget-Frage: 19 vs. 6 GB warm |
| **P3-18 Lemonade** | ✅ **Verdikt: nicht als Unterbau.** lemonade-sdk 9.1.4 (Python) installiert + Server lief (OpenAI-API, gute Registry inkl. gpt-oss/GLM) — aber: Python-Edition **offiziell deprecated** (C++-Installer ist der Weg), Server blockiert komplett während Model-Downloads (Health tot >10 min bei 400-MB-Modell), Ryzen-AI-Hybrid auf 9700X unsupported. **Empfehlung für den lokalen GPU-Klon: llama.cpp-Vulkan + llama-swap — derselbe Stack wie die Box** (ein Betriebsmodell, ein Know-how, bewährte Configs). Lemonade-C++ nur, falls Whisper+TTS+LLM aus einer Hand gewünscht |
| **MC3-Vollbau** | ⛔ außerhalb des Review-Scopes — eigenes Projekt (Prototyp steht unter #mc3) |
---
## 8. Nachtrag 3: Trennung, Update-Playbook, Radikal-Aufräumen (02.07., Abend)
**Lucy = eigenes Repo:** `F:\Coding Stuff\lucy` ↔ Gitea `Hitonabi/lucy` (privat; Repo via API angelegt). Verzeichnisnamen unverändert (lucy-desktop/ + lucy-tts/) → alle Pfade/BATs funktionieren; Boot aus neuem Pfad verifiziert. MC2 = reiner Box-Stack (+ hermes-pc-Executor). lucy-f5 (Verdikt final) und hermes-voice (Vor-Lucy) gelöscht — Historie bleibt in MC2.
**Hermes-Update-Playbook LIVE + E2E-verifiziert (7/7 PASS):** Update-Job = Backup → update → **doctor** → Restart → erweiterter Postcheck (**Config-Drift-Scan** im Journal, **Tool-Smoke** via echtem Agenten, **Voice-Smoke** mit Retries). Update-Modal fasst anstehende Commits künftig per fast-Modell zusammen (Breaking Changes zuerst, gecacht). Beim E2E-Test sofort geliefert: doctor fand eine **ausstehende Config-Schema-Migration** (altes flaches `model: hermes` → neues Schema; per `doctor --fix` migriert, Gateway verifiziert) und der Voice-Smoke entlarvte eine **pipefail/SIGPIPE-Falle** im eigenen Check (head schließt Pipe → curl 23 → Fehlalarm; gefixt). Außerdem behoben: `approvals.mode auto→smart` (v0.18-Regression, einfache Fragen 25 s → 2,0 s).
**Radikal aufgeräumt:** Git: lucy-v2 gemerged + gelöscht (lokal+Gitea), nur noch `main`; alter WIP-Stash gedroppt; stale Worktree entfernt. Lokal: lucy-f5-Assets (~5 GB), lemonade-eval, box_recon/gemma_swap-Scratch gelöscht. Box: 22-GB-Duplikat `Qwen3.6-35B-A3B-GGUF` (non-MTP) + 6 leere Modell-Hüllen gelöscht, v1-Altlasten (mission-control-*.db/json) nach mc2-backups/v1-legacy archiviert, /tmp-Bench-Scratch weg. Modell-Verzeichnis = exakt die 9 referenzierten Modelle + drafts + Backups.
---
*Erhoben am 02.07.2026 durch Claude Code (Live-SSH auf Box, lokale PC-Prüfung, 3 Codebase-Agents, 3 Web-Recherche-Runden). Messwerte: /api/voice/metrics (n=13 STT, n=18 chat_ttfb), TTFT-Direktmessung llama-swap :8080.*
+83 -83
View File
@@ -1,83 +1,83 @@
# Brief: ZeroClaw-PoC als Lucys schlanker Agent-Runtime (Latenz-Fix an der Wurzel)
> Für eine **frische Claude-Code-Session**. Aufgabe: ZeroClaw (ultraleichter Rust-Agent) als
> risikoarmen Proof-of-Concept neben Hermes aufsetzen und gegen Hermes benchmarken (Prompt-Größe +
> Latenz). Hermes bleibt **unangetastet** parallel laufen — null Risiko für die laufende Lucy.
## Warum (die ganze Vorgeschichte in Kürze)
Lucy (Hermes-Agent, Hirn = gemma-4-26B-A4B) braucht **ewig** zum Antworten (Output top, aber 2080 s/Turn).
Eine sehr lange Latenz-Jagd (2026-06-30) hat die Ursachen **definitiv** geklärt:
1. **mem0 nutzte `fast` (Qwen) als LLM** (`MEM0_LLM_MODEL=fast`) — das war noch von früher, als `fast` Lucys
Hirn WAR. Nach dem Umzug auf gemma blieb mem0 auf fast → jede Erinnerungs-Op lud fast → **verdrängte
gemmas Warm-Gruppe** → gemma-KV-Cache tot → 40 s Re-Prefill. (Halb-fertige Hirn-Migration.)
2. **gemma ist architektonisch langsam auf Vulkan/Strix-Halo.** Verifiziert am Box-GGUF:
`attention.key/value_length = 512` (head_dim 512!) vs. Qwen3.6 **256**, plus mixed sliding/full
attention → Flash-Attention auf RADV ineffizient → langsamer **Prefill** + **CPU-Spike** (teils
CPU-Fallback). gemma-GENERIERUNG ist ok (~93 t/s), gecachter Prompt **0,3 s** — nur **Cache-Misses**
(Prefill, ~1127 s) sind tödlich.
3. **gemmas Thinking ist per Default AN** (~90 Extra-Tokens/Antwort). Abschaltbar NUR via
`chat_template_kwargs:{enable_thinking:false}` (verifiziert; `/no_think` & `reasoning_effort` wirken nicht).
4. **Hermes selbst ist Schwergewicht** — DER eigentliche Overhead: 20k-Token-Prompt = **79 Tool-Schemas**
(~17,5k) + **Skills-Manifest** (27 Skills, ~8,7k) + **3-Schichten-Memory mit Auto-Injektion**. Die
Auto-Injektion **variiert den Prompt jeden Turn** → bricht gemmas Cache → Re-Prefill.
**Verdikt:** Auch nach JEDEM Fix (Eviction behoben: mem0/aux→fast + fast ko-resident; Slot geschützt;
Thinking aus; Auto-Injektion aus; RADV ✓; FA ✓; single-slot cache-reuse) blieb gemma **680 s,
unzuverlässig** — der Prozess lief stabil (kein Crash/Eviction), aber gemmas Prefill kommt mit Hermes'
pro-Turn-wechselndem 20k-Prompt nicht klar. **gemma ist auf dieser HW+Agent fundamental ungeeignet für
verlässlich niedrige Latenz.** Einziger gemma-Vorteil: **deutlich besseres Deutsch** (Qwen leakt englische
Wörter, z.B. „biggest"; gemma = natürlich + echte Umlaute).
## Die These des PoC
**Hermes' Schwergewicht (Skills + ChromaDB + Auto-Injektion) baut den fetten, instabilen Prompt.**
Ein schlanker Agent (**ZeroClaw**: 3,4 MB Rust, SQLite-Memory statt ChromaDB, minimaler Overhead, „ruthless
about cutting") würde einen **kleinen, STABILEN** Prompt bauen → gemmas langsamer Prefill hat kaum was zu
kauen → **Lucy schnell mit gemma UND Qwen** → gemmas Deutsch gerettet + Lucy entschlackt. Wurzel-Fix statt
Modell-Pflaster.
## PoC-Aufgabe (risikoarm, Hermes bleibt parallel)
1. **ZeroClaw** auf die Box holen (Rust-Binary; Repos: github.com/zeroclaw-labs/zeroclaw bzw.
github.com/elev8tion/zeroclaw — beides prüfen, das gepflegtere/passende nehmen). Eigener Port, **NICHT**
:8642 (Hermes) anfassen.
2. **An euer MC2-Gateway** hängen (OpenAI-kompatibel, `http://127.0.0.1:9001/v1`). Erst mit Modell `fast`
(Qwen, schnell) testen, dann `hermes` (gemma) für den Deutsch-Vergleich.
3. **Nur Kern-Tools** zuerst: Memory (ZeroClaw-eigenes SQLite ODER per MCP an mc2-memory) + 12 Stack-Tools.
Eure bestehenden MCP-Server (`hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch`) kann ZeroClaw als MCP-Client mitnutzen.
4. **Benchmark gegen Hermes (Zahlen, nicht Gefühl):**
- **Prompt-Größe** (prompt_tokens) eines simplen Turns: ZeroClaw vs. Hermes (Hermes ≈ 20.000).
- **Latenz** mehrerer Folge-Turns (Cache-Verhalten): wird der Prompt klein+stabil → cached gemma → schnell?
- Messmethode wie in der Saga: `curl` an den Agent + `/v1/chat/completions`-`usage.prompt_tokens` +
llama-swap-POST-Dauer (`journalctl -u llama-swap`).
5. **Entscheidung:** Wird Lucy mit ZeroClaw schnell (idealerweise mit gemma) → voller Umzug planen
(Persona, Memory-Migration mem0/Chroma→SQLite, MCP-Tools, Telegram/Voice). Wenn nicht → **Fallback:
Lucys Hirn auf Qwen3.6-35B-A3B** (schnell+verlässlich) + harte Deutsch-Direktive im System-Prompt.
## Box-Fakten (für den PoC)
- **Box:** `ssh hitonabi@192.168.178.151` (key-based). Strix Halo, 122 GB, GTT ~124 GB, Vulkan/RADV.
- **MC2-Gateway:** `:9001/v1` (OpenAI-kompat). Aliase: `hermes`→gemma-4-26B (Lucys Hirn), `fast`→Qwen3.6-35B,
`heavy`→Qwen3.5-122B, `coder`/`coder-lite`, `vision`→Qwen3-VL-8B, `embed`→Qwen3-Embedding.
- **llama-swap:** `:8080` (Engine v9843, Vulkan/RADV, `--list-devices` zeigt nur die RADV-GPU — kein
llvmpipe-Fallback). gemma-cmd: `-c 65536 -fa on --cache-reuse 256 -cram 16384` + MTP-Draft.
- **Hermes:** `:8642` (NousResearch), Config `~/.hermes/config.yaml`, API-Key in `~/.hermes/.env`
(`API_SERVER_KEY`). 4 MCP-Server aktiv. **NICHT anfassen — bleibt Lucys Live-Agent bis ZeroClaw steht.**
- **mem0-Sidecar:** `:8765` (`~/.mem0/`, Chroma unter `/srv/models/mem0/chroma`), `MEM0_LLM_MODEL=fast`,
`MEM0_EMBED_MODEL=embed`. Systemd-User-Unit `~/.config/systemd/user/mem0-service.service`.
- **brains-Gruppe** (immer warm, ko-resident): gemma + fast + embed + vision.
## Modell-Kandidaten (falls Fallback/Vergleich)
| Modell | Profil | Speed (Strix Halo) | Deutsch |
|---|---|---|---|
| gemma-4-26B-A4B | head_dim 512, Thinking, multimodal | langsam (Prefill) | **exzellent** |
| **Qwen3.6-35B-A3B** (auf Box) | head_dim 256, GQA kv=2 | **~85100 t/s** | gut, aber English-Leaks |
| Qwen3-30B-A3B-Instruct | gleiche Familie | 755 pp / 85 tg (RADV) | wie Qwen3.6 |
| LFM2-8B-A1B (Liquid) | 8B/1B aktiv, Hybrid Conv+MoE | **~170 t/s** | klein → unsicher Deutsch |
## Leitplanken
- **Hermes/Lucy bleibt live** — PoC läuft isoliert (eigener Port, eigene Memory-Datei). Null Risiko.
- Vor Box-Config-Änderungen Backup; llama-swap reloadt per `-watch-config`.
- Erst messen (Prompt-Größe + Latenz), dann über vollen Umzug entscheiden.
</content>
# Brief: ZeroClaw-PoC als Lucys schlanker Agent-Runtime (Latenz-Fix an der Wurzel)
> Für eine **frische Claude-Code-Session**. Aufgabe: ZeroClaw (ultraleichter Rust-Agent) als
> risikoarmen Proof-of-Concept neben Hermes aufsetzen und gegen Hermes benchmarken (Prompt-Größe +
> Latenz). Hermes bleibt **unangetastet** parallel laufen — null Risiko für die laufende Lucy.
## Warum (die ganze Vorgeschichte in Kürze)
Lucy (Hermes-Agent, Hirn = gemma-4-26B-A4B) braucht **ewig** zum Antworten (Output top, aber 2080 s/Turn).
Eine sehr lange Latenz-Jagd (2026-06-30) hat die Ursachen **definitiv** geklärt:
1. **mem0 nutzte `fast` (Qwen) als LLM** (`MEM0_LLM_MODEL=fast`) — das war noch von früher, als `fast` Lucys
Hirn WAR. Nach dem Umzug auf gemma blieb mem0 auf fast → jede Erinnerungs-Op lud fast → **verdrängte
gemmas Warm-Gruppe** → gemma-KV-Cache tot → 40 s Re-Prefill. (Halb-fertige Hirn-Migration.)
2. **gemma ist architektonisch langsam auf Vulkan/Strix-Halo.** Verifiziert am Box-GGUF:
`attention.key/value_length = 512` (head_dim 512!) vs. Qwen3.6 **256**, plus mixed sliding/full
attention → Flash-Attention auf RADV ineffizient → langsamer **Prefill** + **CPU-Spike** (teils
CPU-Fallback). gemma-GENERIERUNG ist ok (~93 t/s), gecachter Prompt **0,3 s** — nur **Cache-Misses**
(Prefill, ~1127 s) sind tödlich.
3. **gemmas Thinking ist per Default AN** (~90 Extra-Tokens/Antwort). Abschaltbar NUR via
`chat_template_kwargs:{enable_thinking:false}` (verifiziert; `/no_think` & `reasoning_effort` wirken nicht).
4. **Hermes selbst ist Schwergewicht** — DER eigentliche Overhead: 20k-Token-Prompt = **79 Tool-Schemas**
(~17,5k) + **Skills-Manifest** (27 Skills, ~8,7k) + **3-Schichten-Memory mit Auto-Injektion**. Die
Auto-Injektion **variiert den Prompt jeden Turn** → bricht gemmas Cache → Re-Prefill.
**Verdikt:** Auch nach JEDEM Fix (Eviction behoben: mem0/aux→fast + fast ko-resident; Slot geschützt;
Thinking aus; Auto-Injektion aus; RADV ✓; FA ✓; single-slot cache-reuse) blieb gemma **680 s,
unzuverlässig** — der Prozess lief stabil (kein Crash/Eviction), aber gemmas Prefill kommt mit Hermes'
pro-Turn-wechselndem 20k-Prompt nicht klar. **gemma ist auf dieser HW+Agent fundamental ungeeignet für
verlässlich niedrige Latenz.** Einziger gemma-Vorteil: **deutlich besseres Deutsch** (Qwen leakt englische
Wörter, z.B. „biggest"; gemma = natürlich + echte Umlaute).
## Die These des PoC
**Hermes' Schwergewicht (Skills + ChromaDB + Auto-Injektion) baut den fetten, instabilen Prompt.**
Ein schlanker Agent (**ZeroClaw**: 3,4 MB Rust, SQLite-Memory statt ChromaDB, minimaler Overhead, „ruthless
about cutting") würde einen **kleinen, STABILEN** Prompt bauen → gemmas langsamer Prefill hat kaum was zu
kauen → **Lucy schnell mit gemma UND Qwen** → gemmas Deutsch gerettet + Lucy entschlackt. Wurzel-Fix statt
Modell-Pflaster.
## PoC-Aufgabe (risikoarm, Hermes bleibt parallel)
1. **ZeroClaw** auf die Box holen (Rust-Binary; Repos: github.com/zeroclaw-labs/zeroclaw bzw.
github.com/elev8tion/zeroclaw — beides prüfen, das gepflegtere/passende nehmen). Eigener Port, **NICHT**
:8642 (Hermes) anfassen.
2. **An euer MC2-Gateway** hängen (OpenAI-kompatibel, `http://127.0.0.1:9001/v1`). Erst mit Modell `fast`
(Qwen, schnell) testen, dann `hermes` (gemma) für den Deutsch-Vergleich.
3. **Nur Kern-Tools** zuerst: Memory (ZeroClaw-eigenes SQLite ODER per MCP an mc2-memory) + 12 Stack-Tools.
Eure bestehenden MCP-Server (`hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch`) kann ZeroClaw als MCP-Client mitnutzen.
4. **Benchmark gegen Hermes (Zahlen, nicht Gefühl):**
- **Prompt-Größe** (prompt_tokens) eines simplen Turns: ZeroClaw vs. Hermes (Hermes ≈ 20.000).
- **Latenz** mehrerer Folge-Turns (Cache-Verhalten): wird der Prompt klein+stabil → cached gemma → schnell?
- Messmethode wie in der Saga: `curl` an den Agent + `/v1/chat/completions`-`usage.prompt_tokens` +
llama-swap-POST-Dauer (`journalctl -u llama-swap`).
5. **Entscheidung:** Wird Lucy mit ZeroClaw schnell (idealerweise mit gemma) → voller Umzug planen
(Persona, Memory-Migration mem0/Chroma→SQLite, MCP-Tools, Telegram/Voice). Wenn nicht → **Fallback:
Lucys Hirn auf Qwen3.6-35B-A3B** (schnell+verlässlich) + harte Deutsch-Direktive im System-Prompt.
## Box-Fakten (für den PoC)
- **Box:** `ssh hitonabi@192.168.178.151` (key-based). Strix Halo, 122 GB, GTT ~124 GB, Vulkan/RADV.
- **MC2-Gateway:** `:9001/v1` (OpenAI-kompat). Aliase: `hermes`→gemma-4-26B (Lucys Hirn), `fast`→Qwen3.6-35B,
`heavy`→Qwen3.5-122B, `coder`/`coder-lite`, `vision`→Qwen3-VL-8B, `embed`→Qwen3-Embedding.
- **llama-swap:** `:8080` (Engine v9843, Vulkan/RADV, `--list-devices` zeigt nur die RADV-GPU — kein
llvmpipe-Fallback). gemma-cmd: `-c 65536 -fa on --cache-reuse 256 -cram 16384` + MTP-Draft.
- **Hermes:** `:8642` (NousResearch), Config `~/.hermes/config.yaml`, API-Key in `~/.hermes/.env`
(`API_SERVER_KEY`). 4 MCP-Server aktiv. **NICHT anfassen — bleibt Lucys Live-Agent bis ZeroClaw steht.**
- **mem0-Sidecar:** `:8765` (`~/.mem0/`, Chroma unter `/srv/models/mem0/chroma`), `MEM0_LLM_MODEL=fast`,
`MEM0_EMBED_MODEL=embed`. Systemd-User-Unit `~/.config/systemd/user/mem0-service.service`.
- **brains-Gruppe** (immer warm, ko-resident): gemma + fast + embed + vision.
## Modell-Kandidaten (falls Fallback/Vergleich)
| Modell | Profil | Speed (Strix Halo) | Deutsch |
|---|---|---|---|
| gemma-4-26B-A4B | head_dim 512, Thinking, multimodal | langsam (Prefill) | **exzellent** |
| **Qwen3.6-35B-A3B** (auf Box) | head_dim 256, GQA kv=2 | **~85100 t/s** | gut, aber English-Leaks |
| Qwen3-30B-A3B-Instruct | gleiche Familie | 755 pp / 85 tg (RADV) | wie Qwen3.6 |
| LFM2-8B-A1B (Liquid) | 8B/1B aktiv, Hybrid Conv+MoE | **~170 t/s** | klein → unsicher Deutsch |
## Leitplanken
- **Hermes/Lucy bleibt live** — PoC läuft isoliert (eigener Port, eigene Memory-Datei). Null Risiko.
- Vor Box-Config-Änderungen Backup; llama-swap reloadt per `-watch-config`.
- Erst messen (Prompt-Größe + Latenz), dann über vollen Umzug entscheiden.
</content>
+118 -118
View File
@@ -1,118 +1,118 @@
# ZeroClaw-PoC — Ergebnis & Verdikt (2026-06-30)
> Durchführung des Plans aus `docs/ZEROCLAW_POC_BRIEF.md`. Hermes blieb die ganze Zeit
> **unangetastet** live (eigener Config-Dir, eigene SQLite-Memory, kein eigener Daemon-Port nötig
> PoC lief als kurzlebiger CLI-Prozess). Null Risiko für die laufende Lucy.
## Setup (reproduzierbar, alles auf der Box unter `~/zeroclaw-poc/`)
- **Binary:** ZeroClaw **v0.8.2** (`zeroclaw-labs/zeroclaw`, 32k ⭐, gepflegt die andere Repo
`elev8tion/zeroclaw` ist ein 23-Commit-Mini-Fork). Prebuilt `x86_64-unknown-linux-gnu`, SHA256
gegen `SHA256SUMS` verifiziert (`6b9f7e9d…`). Kein Rust-Toolchain auf der Box nötig.
- **Config:** isoliert via `--config-dir ~/zeroclaw-poc/config`. Provider = nativer `openai`
gegen das **MC2-Gateway** (`uri = http://127.0.0.1:9001/v1`, ZeroClaw hängt `/chat/completions`
selbst an; **base-URL, nicht der volle Pfad!**). Gateway braucht **keine Auth** (HTTP 200 offen).
- **Agent:** `[agents.lucy]`, `model_provider = openai.default`, `risk_profile = default`
(supervised), Memory-Backend = **sqlite**. Modellwahl pro Turn via `--model fast|hermes`.
- Stolpersteine gelöst: quickstart braucht TTY → headless via `config set --no-interactive`;
Secrets (`api_key`) brauchen `--no-interactive` + Wert; `risk_profiles.<key>` ist Map →
per `config set` nicht adressierbar, Block direkt in `config.toml` angehängt.
## Messungen (4 Folge-Turns, je frischer CLI-Prozess, identischer 13k-Prefix)
| Turn | FAST (Qwen3.6-35B) | HERMES (gemma-4-26B) | input_tokens |
|---|---|---|---|
| 1 | 429 ms | **937 ms** | ~13.059 |
| 2 | 534 ms | **493 ms** | ~13.056 |
| 3 | 337 ms | **340 ms** | ~13.060 |
| 4 | 465 ms | **465 ms** | ~13.061 |
`hermes`-Alias → verifiziert echtes `gemma-4-26B-A4B-it-…Q4_K_M.gguf` (kein Fallback auf fast);
gemma ist warm in der brains-Gruppe (llama-swap `/running`: gemma + Qwen3.6 + embed alle `ready`).
## Kernbefunde
1. **Prompt-Größe:** ZeroClaw baut **~13.05513.061 Tokens** pro simplem Turn — vs. Hermes **≈ 20.000**.
~35 % kleiner. (Noch weiter reduzierbar: Default-Toolset; PoC lief mit voller Tool-Palette.)
2. **Prompt-Stabilität:** Der Prefix ist **konstant** (13.05513.061, nur die User-Message variiert
um wenige Tokens). Genau das Gegenteil von Hermes' pro-Turn-wechselnder Auto-Injektion, die
gemmas Cache jeden Turn brach.
3. **Latenz (das Verdikt):** gemma antwortet unter ZeroClaw in **0,30,9 s/Turn** — gegenüber
**2080 s** unter Hermes. Der Latenz-Abfall 937 ms → ~340 ms ab Turn 2 zeigt den **warmen Cache**
(stabiler Prefix → cache-reuse greift). gemma ist hier sogar **gleichauf mit Qwen**.
## Verdikt
**Die PoC-These ist bestätigt.** Lucys Latenz-Problem war **nicht gemma**, sondern Hermes'
fetter, pro-Turn-instabiler 20k-Prompt, der gemmas Prefill-Cache zerschoss. Ein schlanker Agent
mit **kleinem, stabilem** Prompt macht **gemma sub-sekündlich** — und rettet damit gemmas
**exzellentes Deutsch** (kein Umstieg auf Qwen mit English-Leaks nötig).
**Empfehlung: voller Umzug auf ZeroClaw planen** (mit gemma als Hirn). Kein Fallback auf Qwen nötig.
## Umzug umgesetzt (2026-06-30, Etappe 2)
Lucy läuft als ZeroClaw-Agent auf der Box (`~/zeroclaw-poc/`), Daemon auf `127.0.0.1:8088`:
- **Persona:** openclaw-Workspace-Dateien in `config/agents/lucy/workspace/` (`IDENTITY.md`, `SOUL.md`,
`USER.md`, `AGENTS.md`) — Lucy spricht „Commander", echte Umlaute, `<emo:>`-Tags. (Workspace-Pfad =
`<config>/agents/<alias>/workspace/`, NICHT `config/data`!)
- **MCP-Tools:** 4 Server / 37 Tools (`[[mcp.servers]]` als **Array** mit `name`, nicht Tabelle!) +
Bundle `lucy_tools` an `agents.lucy.mcp_bundles`. Alle verbunden, autonom nutzbar.
- **Hirn:** gemma (`hermes`), Autonomie `level="full"`, Thinking aus via
`providers.models.openai.default.chat_template_kwargs.enable_thinking=false`.
- **Aux:** `classifier_provider`/`summary_provider``openai.fastaux` (Qwen); Auto-Hydrate/Save aus.
## 🔴 KV-Cache-Killer gefunden & gefixt (der eigentliche Latenz-Showstopper)
Daemon-Latenz war erst 522 s/Turn (wild schwankend), obwohl Direkt-ans-Gateway gemma einen stabilen
20k-Prompt in **180 ms** cached (call 1 kalt 19 s, call 24 = 180 ms, cached=19820). Per Mitschnitt-Proxy
(ZeroClaw→Proxy→Gateway) zwei Folge-Turns **byte-genau gedifft**:
1. **HAUPTSCHULDIGER — Tool-Reihenfolge:** ZeroClaw hängt die 37 MCP-Tools (im `tools`-Array UND im
`deferred_loading`-Textblock) in **zufälliger HashMap-Reihenfolge pro Turn** an → ~5k Token im Prompt
ändern sich jeden Turn → llama.cpp-KV-Cache ab da tot → Full-Re-Prefill.
2. **Neben-Killer — User-Timestamp:** ZeroClaw prefixt die User-Message mit `[YYYY-MM-DD HH:MM:SS ±ZZ:ZZ]`
(variiert jeden Turn; steht am Ende → kleiner).
3. **Ausgeschlossen:** Classifier (feuert gar nicht, 1 Call/Turn), mem0/Memory (feuert bei Chat nicht).
**Fix (Binary nicht kompilierbar → Wire-Ebene):** schlanker Python-**Normalisierungs-Proxy**
(`~/zeroclaw-poc/normalizing-proxy.py`, Port 9009) zwischen ZeroClaw und Gateway: **sortiert `tools`
deterministisch** + **strippt den Timestamp**. Beide Provider-URIs zeigen auf `:9009`. Ergebnis byte-stabil
verifiziert (System + Tools identisch, nur echte User-Message variiert). **Latenz 522 s → ~400 ms typisch**
(Cache-Hit), vereinzelt 25 s (gemma cache-reuse/MTP-Rest, evtl. via `-cram 16384`); Tool-Turn ~3,6 s.
gemma-cmd: `-c 65536 -fa on --cache-reuse 256 -cram 16384 + MTP-draft`, single-slot (`--parallel 1` implizit).
## gemma-4 Cache-Recherche (llama.cpp) + FINALER Hirn-Entscheid: Qwen3.6
Nach dem Proxy blieben Rest-Spitzen (26 s). Tagesaktuelle llama.cpp-Recherche bestätigt: **bekannter,
Gemma-4-spezifischer Bug.**
- **Gemma 4 nutzt „Shared KV Cache"** (letzte Layer teilen K/V mit früheren) → llama.cpp loggt wörtlich
*„cache reuse is not supported - ignoring n_cache_reuse"* → **euer `--cache-reuse 256` ist für gemma-4
wirkungslos.** ([Issue #21468](https://github.com/ggml-org/llama.cpp/issues/21468))
- **Fix [PR #22288](https://github.com/ggml-org/llama.cpp/pull/22288)** (merged 24.04.2026): braucht
**`--swa-full`** (hält ganze KV-History, kein Sliding-Window-Pruning). Caveat: cache-reuse ↔ mmproj
inkompatibel.
- **Getestet an der live gemma:** `--swa-full` testweise gesetzt → mehr saubere 400-ms-Hits, ABER weiter
~50 % Misses (26 s). Auch nach Toolset-Eindampfung (Prompt 18k→**4908 Tok** via
`risk_profiles.default.excluded_tools`, 18 Kern-Tools) blieb gemma 1,56 s **spitzig**.
- **Befund:** gemma-4 cached auf Strix Halo **fundamental unzuverlässig** (SWA + Shared-KV + MTP), egal wie
klein/stabil der Prompt. → live gemma wieder auf **Original zurückgesetzt** (Hermes unberührt;
`--swa-full` als getestete Option dokumentiert, falls Hermes es nutzen will).
**Qwen3.6-35B-A3B als Lucys Hirn (Standard-GQA, cache-reuse funktioniert):** mit Proxy + Lean-Tools
**konstant ~1,21,8 s/Turn, NULL Spitzen** (vs gemma 1,56 s spitzig). Deutsch gut (echte Umlaute, kein
EN-Leak in Tests; seltener Kohärenz-Wackler). **Das ist der Brief-Fallback — jetzt empirisch als richtige
Wahl bestätigt.** Lucy-Daemon läuft auf `model=fast`.
## Finaler Lucy-Stack (läuft, manuell/nohup)
```
Desktop/Webhook → ZeroClaw-Daemon :8088 (Persona, 18 Lean-Tools, Autonomie full)
→ Normalisierungs-Proxy :9009 (sort tools + strip timestamp) ← der Cache-Fix
→ MC2-Gateway :9001 → llama-swap :8080 → Qwen3.6 (fast) ← rock-solid Cache
```
## Nächste Schritte (offen)
- **Persistenz:** Proxy + Daemon als systemd-User-Services (vom User freizugeben).
- **Voice/Telegram** an den Daemon hängen (Desktop-Client postet aktuell an MC2 `/api/voice/chat`).
- **ZeroClaw-Tool-Order-Bug** upstream melden (nicht-deterministische Tool-Sortierung killt KV-Cache).
- Optional: Deutsch-Direktive in IDENTITY.md härten gegen Qwens seltene Wackler.
- Persona/System-Prompt (Lucys Charakter) in ZeroClaw übertragen.
- Memory-Migration: mem0/Chroma → ZeroClaws SQLite (oder MCP-Bridge an `mission-control-memory`).
- MCP-Tools anbinden: `hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch` (ZeroClaw als MCP-Client).
- Toolset auf Kern reduzieren → Prompt < 13k drücken (noch schnellerer Prefill).
- Kanäle: Telegram + Voice an ZeroClaw hängen; Gateway-/Daemon-Betrieb (eigener Port, nicht :8642).
- Erst nach grünem Umzug Hermes ablösen.
# ZeroClaw-PoC — Ergebnis & Verdikt (2026-06-30)
> Durchführung des Plans aus `docs/ZEROCLAW_POC_BRIEF.md`. Hermes blieb die ganze Zeit
> **unangetastet** live (eigener Config-Dir, eigene SQLite-Memory, kein eigener Daemon-Port nötig
> PoC lief als kurzlebiger CLI-Prozess). Null Risiko für die laufende Lucy.
## Setup (reproduzierbar, alles auf der Box unter `~/zeroclaw-poc/`)
- **Binary:** ZeroClaw **v0.8.2** (`zeroclaw-labs/zeroclaw`, 32k ⭐, gepflegt die andere Repo
`elev8tion/zeroclaw` ist ein 23-Commit-Mini-Fork). Prebuilt `x86_64-unknown-linux-gnu`, SHA256
gegen `SHA256SUMS` verifiziert (`6b9f7e9d…`). Kein Rust-Toolchain auf der Box nötig.
- **Config:** isoliert via `--config-dir ~/zeroclaw-poc/config`. Provider = nativer `openai`
gegen das **MC2-Gateway** (`uri = http://127.0.0.1:9001/v1`, ZeroClaw hängt `/chat/completions`
selbst an; **base-URL, nicht der volle Pfad!**). Gateway braucht **keine Auth** (HTTP 200 offen).
- **Agent:** `[agents.lucy]`, `model_provider = openai.default`, `risk_profile = default`
(supervised), Memory-Backend = **sqlite**. Modellwahl pro Turn via `--model fast|hermes`.
- Stolpersteine gelöst: quickstart braucht TTY → headless via `config set --no-interactive`;
Secrets (`api_key`) brauchen `--no-interactive` + Wert; `risk_profiles.<key>` ist Map →
per `config set` nicht adressierbar, Block direkt in `config.toml` angehängt.
## Messungen (4 Folge-Turns, je frischer CLI-Prozess, identischer 13k-Prefix)
| Turn | FAST (Qwen3.6-35B) | HERMES (gemma-4-26B) | input_tokens |
|---|---|---|---|
| 1 | 429 ms | **937 ms** | ~13.059 |
| 2 | 534 ms | **493 ms** | ~13.056 |
| 3 | 337 ms | **340 ms** | ~13.060 |
| 4 | 465 ms | **465 ms** | ~13.061 |
`hermes`-Alias → verifiziert echtes `gemma-4-26B-A4B-it-…Q4_K_M.gguf` (kein Fallback auf fast);
gemma ist warm in der brains-Gruppe (llama-swap `/running`: gemma + Qwen3.6 + embed alle `ready`).
## Kernbefunde
1. **Prompt-Größe:** ZeroClaw baut **~13.05513.061 Tokens** pro simplem Turn — vs. Hermes **≈ 20.000**.
~35 % kleiner. (Noch weiter reduzierbar: Default-Toolset; PoC lief mit voller Tool-Palette.)
2. **Prompt-Stabilität:** Der Prefix ist **konstant** (13.05513.061, nur die User-Message variiert
um wenige Tokens). Genau das Gegenteil von Hermes' pro-Turn-wechselnder Auto-Injektion, die
gemmas Cache jeden Turn brach.
3. **Latenz (das Verdikt):** gemma antwortet unter ZeroClaw in **0,30,9 s/Turn** — gegenüber
**2080 s** unter Hermes. Der Latenz-Abfall 937 ms → ~340 ms ab Turn 2 zeigt den **warmen Cache**
(stabiler Prefix → cache-reuse greift). gemma ist hier sogar **gleichauf mit Qwen**.
## Verdikt
**Die PoC-These ist bestätigt.** Lucys Latenz-Problem war **nicht gemma**, sondern Hermes'
fetter, pro-Turn-instabiler 20k-Prompt, der gemmas Prefill-Cache zerschoss. Ein schlanker Agent
mit **kleinem, stabilem** Prompt macht **gemma sub-sekündlich** — und rettet damit gemmas
**exzellentes Deutsch** (kein Umstieg auf Qwen mit English-Leaks nötig).
**Empfehlung: voller Umzug auf ZeroClaw planen** (mit gemma als Hirn). Kein Fallback auf Qwen nötig.
## Umzug umgesetzt (2026-06-30, Etappe 2)
Lucy läuft als ZeroClaw-Agent auf der Box (`~/zeroclaw-poc/`), Daemon auf `127.0.0.1:8088`:
- **Persona:** openclaw-Workspace-Dateien in `config/agents/lucy/workspace/` (`IDENTITY.md`, `SOUL.md`,
`USER.md`, `AGENTS.md`) — Lucy spricht „Commander", echte Umlaute, `<emo:>`-Tags. (Workspace-Pfad =
`<config>/agents/<alias>/workspace/`, NICHT `config/data`!)
- **MCP-Tools:** 4 Server / 37 Tools (`[[mcp.servers]]` als **Array** mit `name`, nicht Tabelle!) +
Bundle `lucy_tools` an `agents.lucy.mcp_bundles`. Alle verbunden, autonom nutzbar.
- **Hirn:** gemma (`hermes`), Autonomie `level="full"`, Thinking aus via
`providers.models.openai.default.chat_template_kwargs.enable_thinking=false`.
- **Aux:** `classifier_provider`/`summary_provider``openai.fastaux` (Qwen); Auto-Hydrate/Save aus.
## 🔴 KV-Cache-Killer gefunden & gefixt (der eigentliche Latenz-Showstopper)
Daemon-Latenz war erst 522 s/Turn (wild schwankend), obwohl Direkt-ans-Gateway gemma einen stabilen
20k-Prompt in **180 ms** cached (call 1 kalt 19 s, call 24 = 180 ms, cached=19820). Per Mitschnitt-Proxy
(ZeroClaw→Proxy→Gateway) zwei Folge-Turns **byte-genau gedifft**:
1. **HAUPTSCHULDIGER — Tool-Reihenfolge:** ZeroClaw hängt die 37 MCP-Tools (im `tools`-Array UND im
`deferred_loading`-Textblock) in **zufälliger HashMap-Reihenfolge pro Turn** an → ~5k Token im Prompt
ändern sich jeden Turn → llama.cpp-KV-Cache ab da tot → Full-Re-Prefill.
2. **Neben-Killer — User-Timestamp:** ZeroClaw prefixt die User-Message mit `[YYYY-MM-DD HH:MM:SS ±ZZ:ZZ]`
(variiert jeden Turn; steht am Ende → kleiner).
3. **Ausgeschlossen:** Classifier (feuert gar nicht, 1 Call/Turn), mem0/Memory (feuert bei Chat nicht).
**Fix (Binary nicht kompilierbar → Wire-Ebene):** schlanker Python-**Normalisierungs-Proxy**
(`~/zeroclaw-poc/normalizing-proxy.py`, Port 9009) zwischen ZeroClaw und Gateway: **sortiert `tools`
deterministisch** + **strippt den Timestamp**. Beide Provider-URIs zeigen auf `:9009`. Ergebnis byte-stabil
verifiziert (System + Tools identisch, nur echte User-Message variiert). **Latenz 522 s → ~400 ms typisch**
(Cache-Hit), vereinzelt 25 s (gemma cache-reuse/MTP-Rest, evtl. via `-cram 16384`); Tool-Turn ~3,6 s.
gemma-cmd: `-c 65536 -fa on --cache-reuse 256 -cram 16384 + MTP-draft`, single-slot (`--parallel 1` implizit).
## gemma-4 Cache-Recherche (llama.cpp) + FINALER Hirn-Entscheid: Qwen3.6
Nach dem Proxy blieben Rest-Spitzen (26 s). Tagesaktuelle llama.cpp-Recherche bestätigt: **bekannter,
Gemma-4-spezifischer Bug.**
- **Gemma 4 nutzt „Shared KV Cache"** (letzte Layer teilen K/V mit früheren) → llama.cpp loggt wörtlich
*„cache reuse is not supported - ignoring n_cache_reuse"* → **euer `--cache-reuse 256` ist für gemma-4
wirkungslos.** ([Issue #21468](https://github.com/ggml-org/llama.cpp/issues/21468))
- **Fix [PR #22288](https://github.com/ggml-org/llama.cpp/pull/22288)** (merged 24.04.2026): braucht
**`--swa-full`** (hält ganze KV-History, kein Sliding-Window-Pruning). Caveat: cache-reuse ↔ mmproj
inkompatibel.
- **Getestet an der live gemma:** `--swa-full` testweise gesetzt → mehr saubere 400-ms-Hits, ABER weiter
~50 % Misses (26 s). Auch nach Toolset-Eindampfung (Prompt 18k→**4908 Tok** via
`risk_profiles.default.excluded_tools`, 18 Kern-Tools) blieb gemma 1,56 s **spitzig**.
- **Befund:** gemma-4 cached auf Strix Halo **fundamental unzuverlässig** (SWA + Shared-KV + MTP), egal wie
klein/stabil der Prompt. → live gemma wieder auf **Original zurückgesetzt** (Hermes unberührt;
`--swa-full` als getestete Option dokumentiert, falls Hermes es nutzen will).
**Qwen3.6-35B-A3B als Lucys Hirn (Standard-GQA, cache-reuse funktioniert):** mit Proxy + Lean-Tools
**konstant ~1,21,8 s/Turn, NULL Spitzen** (vs gemma 1,56 s spitzig). Deutsch gut (echte Umlaute, kein
EN-Leak in Tests; seltener Kohärenz-Wackler). **Das ist der Brief-Fallback — jetzt empirisch als richtige
Wahl bestätigt.** Lucy-Daemon läuft auf `model=fast`.
## Finaler Lucy-Stack (läuft, manuell/nohup)
```
Desktop/Webhook → ZeroClaw-Daemon :8088 (Persona, 18 Lean-Tools, Autonomie full)
→ Normalisierungs-Proxy :9009 (sort tools + strip timestamp) ← der Cache-Fix
→ MC2-Gateway :9001 → llama-swap :8080 → Qwen3.6 (fast) ← rock-solid Cache
```
## Nächste Schritte (offen)
- **Persistenz:** Proxy + Daemon als systemd-User-Services (vom User freizugeben).
- **Voice/Telegram** an den Daemon hängen (Desktop-Client postet aktuell an MC2 `/api/voice/chat`).
- **ZeroClaw-Tool-Order-Bug** upstream melden (nicht-deterministische Tool-Sortierung killt KV-Cache).
- Optional: Deutsch-Direktive in IDENTITY.md härten gegen Qwens seltene Wackler.
- Persona/System-Prompt (Lucys Charakter) in ZeroClaw übertragen.
- Memory-Migration: mem0/Chroma → ZeroClaws SQLite (oder MCP-Bridge an `mission-control-memory`).
- MCP-Tools anbinden: `hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch` (ZeroClaw als MCP-Client).
- Toolset auf Kern reduzieren → Prompt < 13k drücken (noch schnellerer Prefill).
- Kanäle: Telegram + Voice an ZeroClaw hängen; Gateway-/Daemon-Betrieb (eigener Port, nicht :8642).
- Erst nach grünem Umzug Hermes ablösen.
+4541 -4541
View File
File diff suppressed because it is too large Load Diff
+39 -39
View File
@@ -1,39 +1,39 @@
{
"name": "mission-control-2-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@pixiv/three-vrm": "^3.4.0",
"@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10",
"@tanstack/react-query": "^5.101.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^3.9.0",
"sigma": "^3.0.3",
"tailwind-merge": "^2.5.5",
"three": "^0.169.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
{
"name": "mission-control-2-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@pixiv/three-vrm": "^3.4.0",
"@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10",
"@tanstack/react-query": "^5.101.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^3.9.0",
"sigma": "^3.0.3",
"tailwind-merge": "^2.5.5",
"three": "^0.169.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
+248 -248
View File
@@ -1,248 +1,248 @@
import { useEffect, useState, useCallback } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle"
import { ModelsView } from "@/views/ModelsView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView"
import { TerminalView } from "@/views/TerminalView"
import { KonsoleView } from "@/views/KonsoleView"
import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView"
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
const [view, setView] = useState<ViewId>(() => {
const h = window.location.hash.slice(1) as ViewId
return NAV.some((n) => n.id === h) ? h : "dashboard"
})
const navigate = useCallback((v: ViewId) => {
if (window.location.hash.slice(1) === v) setView(v)
else window.location.hash = v
}, [])
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default).
const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3")
const { data: health } = useHealth()
const { data: sysStatus } = useSystemStatus(20_000)
useEffect(() => {
document.documentElement.classList.add("dark")
}, [])
useEffect(() => {
const handleOpen = (e: Event) => {
const customEvent = e as CustomEvent
setDrawerTab(customEvent.detail?.tab || "maintenance")
setDrawerOpen(true)
}
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
if (v) navigate(v)
}
window.addEventListener("mc-navigate", handleNav)
return () => window.removeEventListener("mc-navigate", handleNav)
}, [navigate])
useEffect(() => {
const onHash = () => {
setIsMc3(window.location.hash === "#mc3")
const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h)
}
window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash)
}, [])
const active = NAV.find((n) => n.id === view)!
// Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche.
if (isMc3) return <Mc3App />
return (
<div className="flex h-full relative">
{/* Background Aurora Ambient Effects */}
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-35 animate-aurora bg-gradient-to-tr from-teal-500/20 via-indigo-500/15 to-purple-500/20 blur-[130px]" />
<div className="absolute top-[-10%] left-[-10%] h-[50%] w-[50%] rounded-full bg-teal-500/15 blur-[160px]" />
<div className="absolute bottom-[-10%] right-[-10%] h-[50%] w-[50%] rounded-full bg-purple-500/15 blur-[160px]" />
<div className="absolute top-[30%] right-[20%] h-[40%] w-[40%] rounded-full bg-indigo-500/10 blur-[140px]" />
</div>
<CommandPalette onNavigate={navigate} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar */}
<aside className={cn(
"flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
sidebarCollapsed ? "w-16" : "w-60"
)}>
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", sidebarCollapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
<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" />
{!sidebarCollapsed && (
<div className="leading-tight">
<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>
<button
onClick={() => {
setSidebarCollapsed(c => {
const next = !c
localStorage.setItem("mc_sidebar_collapsed", next.toString())
return next
})
}}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label={sidebarCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
title={sidebarCollapsed ? "Maximieren" : "Minimieren"}
>
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{NAV.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
aria-current={view === item.id ? "page" : undefined}
className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id
? "bg-primary/15 text-primary shadow-sm shadow-primary/5"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
title={sidebarCollapsed ? item.label : undefined}
aria-label={sidebarCollapsed ? item.label : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!sidebarCollapsed && <span className="truncate">{item.label}</span>}
</a>
))}
</nav>
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", sidebarCollapsed ? "px-2 text-center" : "px-5")}>
{sidebarCollapsed ? (
<div className="flex justify-center">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
health
? (!health.engine_reachable ? "bg-amber-500"
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
: "bg-red-500"
)} title={health
? (!health.engine_reachable ? "Engine offline"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
: "Backend offline"} />
</div>
) : (
<div className="space-y-2 text-left">
{health ? (
<>
<span className="flex items-center gap-2">
<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>
{health.brain && !health.brain.ready && (
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
<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>
)}
{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"}>
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</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"}>
<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 className="truncate" title={sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.branch}-${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""} (${sysStatus.versions.hermes_ui.date})` : "nicht gefunden"}>
<strong>Hermes UI:</strong> {sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""}` : "—"}
</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"}>
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div>
</div>
)}
</div>
)}
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2">
<ExpertToggle />
<a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank"
rel="noopener"
className="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"
title="Bedien-Anleitung"
>
Hilfe
</a>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev)
}}
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"
>
<CommandIcon className="h-3.5 w-3.5" />
<span>Suchen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">K</kbd>
</button>
</div>
</header>
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
</div>
</div>
)
}
import { useEffect, useState, useCallback } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle"
import { ModelsView } from "@/views/ModelsView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView"
import { TerminalView } from "@/views/TerminalView"
import { KonsoleView } from "@/views/KonsoleView"
import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView"
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
const [view, setView] = useState<ViewId>(() => {
const h = window.location.hash.slice(1) as ViewId
return NAV.some((n) => n.id === h) ? h : "dashboard"
})
const navigate = useCallback((v: ViewId) => {
if (window.location.hash.slice(1) === v) setView(v)
else window.location.hash = v
}, [])
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default).
const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3")
const { data: health } = useHealth()
const { data: sysStatus } = useSystemStatus(20_000)
useEffect(() => {
document.documentElement.classList.add("dark")
}, [])
useEffect(() => {
const handleOpen = (e: Event) => {
const customEvent = e as CustomEvent
setDrawerTab(customEvent.detail?.tab || "maintenance")
setDrawerOpen(true)
}
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
if (v) navigate(v)
}
window.addEventListener("mc-navigate", handleNav)
return () => window.removeEventListener("mc-navigate", handleNav)
}, [navigate])
useEffect(() => {
const onHash = () => {
setIsMc3(window.location.hash === "#mc3")
const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h)
}
window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash)
}, [])
const active = NAV.find((n) => n.id === view)!
// Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche.
if (isMc3) return <Mc3App />
return (
<div className="flex h-full relative">
{/* Background Aurora Ambient Effects */}
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-35 animate-aurora bg-gradient-to-tr from-teal-500/20 via-indigo-500/15 to-purple-500/20 blur-[130px]" />
<div className="absolute top-[-10%] left-[-10%] h-[50%] w-[50%] rounded-full bg-teal-500/15 blur-[160px]" />
<div className="absolute bottom-[-10%] right-[-10%] h-[50%] w-[50%] rounded-full bg-purple-500/15 blur-[160px]" />
<div className="absolute top-[30%] right-[20%] h-[40%] w-[40%] rounded-full bg-indigo-500/10 blur-[140px]" />
</div>
<CommandPalette onNavigate={navigate} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar */}
<aside className={cn(
"flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
sidebarCollapsed ? "w-16" : "w-60"
)}>
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", sidebarCollapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
<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" />
{!sidebarCollapsed && (
<div className="leading-tight">
<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>
<button
onClick={() => {
setSidebarCollapsed(c => {
const next = !c
localStorage.setItem("mc_sidebar_collapsed", next.toString())
return next
})
}}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label={sidebarCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
title={sidebarCollapsed ? "Maximieren" : "Minimieren"}
>
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{NAV.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
aria-current={view === item.id ? "page" : undefined}
className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id
? "bg-primary/15 text-primary shadow-sm shadow-primary/5"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
title={sidebarCollapsed ? item.label : undefined}
aria-label={sidebarCollapsed ? item.label : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!sidebarCollapsed && <span className="truncate">{item.label}</span>}
</a>
))}
</nav>
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", sidebarCollapsed ? "px-2 text-center" : "px-5")}>
{sidebarCollapsed ? (
<div className="flex justify-center">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
health
? (!health.engine_reachable ? "bg-amber-500"
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
: "bg-red-500"
)} title={health
? (!health.engine_reachable ? "Engine offline"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
: "Backend offline"} />
</div>
) : (
<div className="space-y-2 text-left">
{health ? (
<>
<span className="flex items-center gap-2">
<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>
{health.brain && !health.brain.ready && (
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
<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>
)}
{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"}>
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</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"}>
<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 className="truncate" title={sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.branch}-${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""} (${sysStatus.versions.hermes_ui.date})` : "nicht gefunden"}>
<strong>Hermes UI:</strong> {sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""}` : "—"}
</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"}>
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div>
</div>
)}
</div>
)}
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2">
<ExpertToggle />
<a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank"
rel="noopener"
className="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"
title="Bedien-Anleitung"
>
Hilfe
</a>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev)
}}
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"
>
<CommandIcon className="h-3.5 w-3.5" />
<span>Suchen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">K</kbd>
</button>
</div>
</header>
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
</div>
</div>
)
}
+38 -38
View File
@@ -1,38 +1,38 @@
import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
<div className="max-w-lg space-y-4 text-center">
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
<div className="text-sm text-neutral-400 break-all">
{this.state.error.message}
</div>
<button
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
onClick={() => window.location.reload()}
>
Neu laden
</button>
</div>
</div>
)
}
}
import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
<div className="max-w-lg space-y-4 text-center">
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
<div className="text-sm text-neutral-400 break-all">
{this.state.error.message}
</div>
<button
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
onClick={() => window.location.reload()}
>
Neu laden
</button>
</div>
</div>
)
}
}
+82 -82
View File
@@ -1,82 +1,82 @@
import { useRef } from "react"
import { X } from "lucide-react"
export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={title}>
<div className="w-full max-w-sm rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">{title}</h3>
<button
onClick={onCancel || (() => onConfirm())}
aria-label="Schließen"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{message}</p>
{type === "prompt" && (
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
defaultValue={defaultValue}
aria-label={title}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
onConfirm(inputRef.current?.value)
}
}}
/>
{autoValue !== undefined && (
<button
type="button"
onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }}
title="Setup-bewussten Optimalwert eintragen"
className="h-9 px-3 shrink-0 text-xs font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors cursor-pointer"
>
{autoLabel || "Auto"}
</button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
{(type === "confirm" || type === "prompt") && (
<button
onClick={onCancel}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Abbrechen
</button>
)}
<button
onClick={() => {
const val = type === "prompt" ? inputRef.current?.value : undefined
onConfirm(val)
}}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
>
{type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
</button>
</div>
</div>
</div>
)
}
import { useRef } from "react"
import { X } from "lucide-react"
export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={title}>
<div className="w-full max-w-sm rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">{title}</h3>
<button
onClick={onCancel || (() => onConfirm())}
aria-label="Schließen"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{message}</p>
{type === "prompt" && (
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
defaultValue={defaultValue}
aria-label={title}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
onConfirm(inputRef.current?.value)
}
}}
/>
{autoValue !== undefined && (
<button
type="button"
onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }}
title="Setup-bewussten Optimalwert eintragen"
className="h-9 px-3 shrink-0 text-xs font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors cursor-pointer"
>
{autoLabel || "Auto"}
</button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
{(type === "confirm" || type === "prompt") && (
<button
onClick={onCancel}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Abbrechen
</button>
)}
<button
onClick={() => {
const val = type === "prompt" ? inputRef.current?.value : undefined
onConfirm(val)
}}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
>
{type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
</button>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,172 +1,172 @@
import { useState } from "react"
import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function AgentStatusCard() {
const qc = useQueryClient()
const { data: agent } = useAgentStatus(3_000)
const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const [showBrainSelect, setShowBrainSelect] = useState(false)
const models = modelsData?.models ?? []
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
</div>
{agent?.terminal_url && (
<a
href={resolveExternalUrl(agent.terminal_url)}
target="_blank"
rel="noopener"
className={cn(
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
agent.terminal_reachable
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
: "border border-border text-muted-foreground"
)}
>
<ExternalLink className="h-3 w-3" /> Terminal öffnen
</a>
)}
</div>
{agent ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Terminal</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.terminal_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.terminal_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div
onClick={() => setShowBrainSelect(true)}
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span>
<Cpu className="h-3 w-3 text-primary" />
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary truncate flex items-center gap-1">
<Layers className="h-3 w-3 shrink-0" />
{agent.brain_model || "auto"}
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span>
<Wrench className="h-3 w-3 text-primary" />
</div>
<div className="text-[10px] font-mono mt-1 space-y-0.5 text-muted-foreground">
<div>Config: {agent.has_config ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Skills: {agent.has_skills ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Memory: {agent.has_memories ? <span className="text-emerald-400"></span> : "—"}</div>
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div>
)}
</div>
{agent && (
<div className="mt-3 border-t border-border/30 pt-3 space-y-1.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Telegram</span>
<span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}>
{agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"}
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>MCP-Server</span>
<span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>PC Executor</span>
<span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}>
{agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"}
</span>
</div>
</div>
)}
{agent && showBrainSelect && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m)
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">{m}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
)
})}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
import { useState } from "react"
import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function AgentStatusCard() {
const qc = useQueryClient()
const { data: agent } = useAgentStatus(3_000)
const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const [showBrainSelect, setShowBrainSelect] = useState(false)
const models = modelsData?.models ?? []
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
</div>
{agent?.terminal_url && (
<a
href={resolveExternalUrl(agent.terminal_url)}
target="_blank"
rel="noopener"
className={cn(
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
agent.terminal_reachable
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
: "border border-border text-muted-foreground"
)}
>
<ExternalLink className="h-3 w-3" /> Terminal öffnen
</a>
)}
</div>
{agent ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Terminal</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.terminal_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.terminal_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div
onClick={() => setShowBrainSelect(true)}
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span>
<Cpu className="h-3 w-3 text-primary" />
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary truncate flex items-center gap-1">
<Layers className="h-3 w-3 shrink-0" />
{agent.brain_model || "auto"}
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span>
<Wrench className="h-3 w-3 text-primary" />
</div>
<div className="text-[10px] font-mono mt-1 space-y-0.5 text-muted-foreground">
<div>Config: {agent.has_config ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Skills: {agent.has_skills ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Memory: {agent.has_memories ? <span className="text-emerald-400"></span> : "—"}</div>
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div>
)}
</div>
{agent && (
<div className="mt-3 border-t border-border/30 pt-3 space-y-1.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Telegram</span>
<span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}>
{agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"}
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>MCP-Server</span>
<span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>PC Executor</span>
<span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}>
{agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"}
</span>
</div>
</div>
)}
{agent && showBrainSelect && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m)
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">{m}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
)
})}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
@@ -1,165 +1,165 @@
import { useState } from "react"
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
import { api } from "@/lib/api"
import { useDialog } from "@/lib/useDialog"
import { useQueryClient, qk } from "@/lib/queries"
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
import { cn } from "@/lib/utils"
// Farb-/Text-Ton pro Check-Status.
const DOT: Record<string, string> = {
ok: "bg-emerald-500",
warn: "bg-amber-500",
down: "bg-red-500",
loading: "bg-muted-foreground/40",
}
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
const { verdict, checks, memory, problems } = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) {
showAlert(
"Reparatur nicht ganz geklappt",
(r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""),
)
}
}
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
const T = frame === "box"
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
// Banner-Optik je Gesamt-Verdikt.
const banner = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
}[verdict]
const BannerIcon = banner.icon
const memTone = {
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
}[memory.status]
const memText = {
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
}[memory.status]
return (
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
{/* Gesamt-Verdikt */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
<p className="text-xs text-muted-foreground">{banner.sub}</p>
</div>
</div>
{/* Speicher-Ampel */}
<div className="mt-4 rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memText)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", memTone)} style={{ width: `${memory.pct}%` }} />
</div>
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
</div>
{/* Fähigkeiten-Checkliste */}
<div className="mt-4 space-y-1.5">
{checks.map((c) => (
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
))}
</div>
{dialogElement}
</div>
)
}
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
const Icon = c.icon
const bad = c.status === "down" || c.status === "warn"
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
c.status === "down" ? "border-red-500/25 bg-red-500/5"
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
: "border-border/30 bg-background/15",
)}
>
<div className="flex min-w-0 items-center gap-2.5">
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
<Icon className="h-4 w-4" />
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
{c.label}
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
</div>
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
</div>
</div>
{bad && c.repair && (
<button
onClick={onRepair}
disabled={busy}
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
c.status === "down"
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
)}
title={c.repair.label}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
{bad && !c.repair && (
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
</span>
)}
</div>
)
}
import { useState } from "react"
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
import { api } from "@/lib/api"
import { useDialog } from "@/lib/useDialog"
import { useQueryClient, qk } from "@/lib/queries"
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
import { cn } from "@/lib/utils"
// Farb-/Text-Ton pro Check-Status.
const DOT: Record<string, string> = {
ok: "bg-emerald-500",
warn: "bg-amber-500",
down: "bg-red-500",
loading: "bg-muted-foreground/40",
}
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
const { verdict, checks, memory, problems } = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) {
showAlert(
"Reparatur nicht ganz geklappt",
(r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""),
)
}
}
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
const T = frame === "box"
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
// Banner-Optik je Gesamt-Verdikt.
const banner = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
}[verdict]
const BannerIcon = banner.icon
const memTone = {
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
}[memory.status]
const memText = {
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
}[memory.status]
return (
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
{/* Gesamt-Verdikt */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
<p className="text-xs text-muted-foreground">{banner.sub}</p>
</div>
</div>
{/* Speicher-Ampel */}
<div className="mt-4 rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memText)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", memTone)} style={{ width: `${memory.pct}%` }} />
</div>
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
</div>
{/* Fähigkeiten-Checkliste */}
<div className="mt-4 space-y-1.5">
{checks.map((c) => (
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
))}
</div>
{dialogElement}
</div>
)
}
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
const Icon = c.icon
const bad = c.status === "down" || c.status === "warn"
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
c.status === "down" ? "border-red-500/25 bg-red-500/5"
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
: "border-border/30 bg-background/15",
)}
>
<div className="flex min-w-0 items-center gap-2.5">
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
<Icon className="h-4 w-4" />
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
{c.label}
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
</div>
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
</div>
</div>
{bad && c.repair && (
<button
onClick={onRepair}
disabled={busy}
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
c.status === "down"
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
)}
title={c.repair.label}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
{bad && !c.repair && (
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
</span>
)}
</div>
)
}
@@ -1,94 +1,94 @@
import { useState } from "react"
import { Brain, Plus } from "lucide-react"
import { api } from "@/lib/api"
import { useMemory, useQueryClient } from "@/lib/queries"
export function MemoryInputCard() {
const qc = useQueryClient()
const { data: memories = [] } = useMemory({ limit: 3 })
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
const [savingMem, setSavingMem] = useState(false)
async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return
setSavingMem(true)
try {
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
})
setMemContent("")
qc.invalidateQueries({ queryKey: ["memory"] })
} catch (e) {
console.error(e)
} finally {
setSavingMem(false)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Brain className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
</div>
<div className="space-y-3">
<textarea
value={memContent}
onChange={(e) => setMemContent(e.target.value)}
aria-label="Fakt oder Regel im Gedächtnis speichern"
placeholder="Fakt / Regel im Pool speichern…"
rows={2}
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
/>
<div className="flex items-center gap-2 justify-between">
<select
value={memCat}
onChange={(e) => setMemCat(e.target.value)}
aria-label="Kategorie"
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
>
<option value="stable">🔵 Fakt</option>
<option value="instruction">📋 Regel</option>
<option value="user">👤 User</option>
<option value="versioned">🟡 Version</option>
</select>
<button
onClick={saveQuickMemory}
disabled={!memContent.trim() || savingMem}
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Speichern
</button>
</div>
</div>
<div className="mt-3.5 space-y-1.5">
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
{memories.length === 0 ? (
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
) : (
memories.map((m) => (
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
{m.category}
</span>
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
{m.content}
</span>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Steht allen Clients per MCP zur Verfügung.
</div>
</div>
)
}
import { useState } from "react"
import { Brain, Plus } from "lucide-react"
import { api } from "@/lib/api"
import { useMemory, useQueryClient } from "@/lib/queries"
export function MemoryInputCard() {
const qc = useQueryClient()
const { data: memories = [] } = useMemory({ limit: 3 })
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
const [savingMem, setSavingMem] = useState(false)
async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return
setSavingMem(true)
try {
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
})
setMemContent("")
qc.invalidateQueries({ queryKey: ["memory"] })
} catch (e) {
console.error(e)
} finally {
setSavingMem(false)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Brain className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
</div>
<div className="space-y-3">
<textarea
value={memContent}
onChange={(e) => setMemContent(e.target.value)}
aria-label="Fakt oder Regel im Gedächtnis speichern"
placeholder="Fakt / Regel im Pool speichern…"
rows={2}
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
/>
<div className="flex items-center gap-2 justify-between">
<select
value={memCat}
onChange={(e) => setMemCat(e.target.value)}
aria-label="Kategorie"
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
>
<option value="stable">🔵 Fakt</option>
<option value="instruction">📋 Regel</option>
<option value="user">👤 User</option>
<option value="versioned">🟡 Version</option>
</select>
<button
onClick={saveQuickMemory}
disabled={!memContent.trim() || savingMem}
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Speichern
</button>
</div>
</div>
<div className="mt-3.5 space-y-1.5">
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
{memories.length === 0 ? (
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
) : (
memories.map((m) => (
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
{m.category}
</span>
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
{m.content}
</span>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Steht allen Clients per MCP zur Verfügung.
</div>
</div>
)
}
@@ -1,79 +1,79 @@
import { useState } from "react"
import { ExternalLink, RefreshCw, Server } from "lucide-react"
import { api } from "@/lib/api"
import { useServices } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function ServicesCard() {
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const [restarting, setRestarting] = useState<Record<string, boolean>>({})
async function restart(id: string) {
setRestarting((p) => ({ ...p, [id]: true }))
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: id }),
})
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
} catch (e: any) {
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestarting((p) => ({ ...p, [id]: false }))
}
}
return (
<div className="flex flex-col rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
>
Logs / Pflege
</button>
</div>
{svc ? (
<div className="flex flex-1 flex-col">
<div className="space-y-1.5">
{svc.services.map((x) => (
<div key={x.unit} className="group flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-black/40", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
<div className="min-w-0">
<div className="truncate text-xs font-bold text-foreground">{x.name}</div>
<div className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
</div>
</div>
<button
onClick={() => restart(x.unit)} disabled={restarting[x.unit]}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-primary/5 hover:text-primary group-hover:opacity-100"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} />
</button>
</div>
))}
</div>
<div className="mt-auto flex flex-wrap gap-3 border-t border-border/20 pt-2.5 text-[10px] font-semibold text-muted-foreground">
<a href={resolveExternalUrl(svc.links.engine_ui)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Engine
</a>
<a href={resolveExternalUrl(svc.links.gateway)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Gateway
</a>
</div>
</div>
) : (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste</div>
)}
{dialogElement}
</div>
)
}
import { useState } from "react"
import { ExternalLink, RefreshCw, Server } from "lucide-react"
import { api } from "@/lib/api"
import { useServices } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function ServicesCard() {
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const [restarting, setRestarting] = useState<Record<string, boolean>>({})
async function restart(id: string) {
setRestarting((p) => ({ ...p, [id]: true }))
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: id }),
})
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
} catch (e: any) {
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestarting((p) => ({ ...p, [id]: false }))
}
}
return (
<div className="flex flex-col rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
>
Logs / Pflege
</button>
</div>
{svc ? (
<div className="flex flex-1 flex-col">
<div className="space-y-1.5">
{svc.services.map((x) => (
<div key={x.unit} className="group flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-black/40", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
<div className="min-w-0">
<div className="truncate text-xs font-bold text-foreground">{x.name}</div>
<div className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
</div>
</div>
<button
onClick={() => restart(x.unit)} disabled={restarting[x.unit]}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-primary/5 hover:text-primary group-hover:opacity-100"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} />
</button>
</div>
))}
</div>
<div className="mt-auto flex flex-wrap gap-3 border-t border-border/20 pt-2.5 text-[10px] font-semibold text-muted-foreground">
<a href={resolveExternalUrl(svc.links.engine_ui)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Engine
</a>
<a href={resolveExternalUrl(svc.links.gateway)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Gateway
</a>
</div>
</div>
) : (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste</div>
)}
{dialogElement}
</div>
)
}
+68 -68
View File
@@ -1,68 +1,68 @@
import { api } from "@/lib/api"
import { useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { fmtBytes, fmtEta } from "@/lib/format"
export function JobsBar() {
const qc = useQueryClient()
const { data: jobs = [] } = useJobs(2_000)
const { showAlert, dialogElement } = useDialog()
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
qc.invalidateQueries({ queryKey: qk.jobs })
} catch (e: any) {
showAlert("Fehler", e.message)
}
}
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null
return (
<div className="space-y-3 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => (
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground font-mono">
{j.progress ?? 0}% {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
</span>
<button
onClick={() => cancelJob(j.id)}
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
>
Abbrechen
</button>
</div>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
</div>
</div>
))}
{recent.map((j) => (
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
<span className="truncate">{j.label}</span>
<span className={cn(
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
)}>
{j.state}
</span>
</div>
))}
{dialogElement}
</div>
)
}
import { api } from "@/lib/api"
import { useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { fmtBytes, fmtEta } from "@/lib/format"
export function JobsBar() {
const qc = useQueryClient()
const { data: jobs = [] } = useJobs(2_000)
const { showAlert, dialogElement } = useDialog()
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
qc.invalidateQueries({ queryKey: qk.jobs })
} catch (e: any) {
showAlert("Fehler", e.message)
}
}
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null
return (
<div className="space-y-3 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => (
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground font-mono">
{j.progress ?? 0}% {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
</span>
<button
onClick={() => cancelJob(j.id)}
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
>
Abbrechen
</button>
</div>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
</div>
</div>
))}
{recent.map((j) => (
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
<span className="truncate">{j.label}</span>
<span className={cn(
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
)}>
{j.state}
</span>
</div>
))}
{dialogElement}
</div>
)
}
+60 -60
View File
@@ -1,60 +1,60 @@
import { type Fit } from "@/lib/api"
import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil.
export { ROLES, roleTone, roleMeta }
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
// `dense` = nur Kurzform (für enge Badges).
export function RoleLabel({
role,
dense = false,
className,
}: {
role?: string | null
dense?: boolean
className?: string
}) {
const meta = roleMeta(role)
const Icon = meta.icon
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
meta.tone,
className,
)}
title={`${meta.desc} (${meta.role})`}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{dense ? meta.short : meta.label}
</span>
)
}
export function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
}[fit.level]
return (
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
{fit.text} {fit.req_gb} GB RAM
</span>
)
}
export function getBrandInfo(name: string) {
const low = name.toLowerCase()
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
}
import { type Fit } from "@/lib/api"
import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil.
export { ROLES, roleTone, roleMeta }
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
// `dense` = nur Kurzform (für enge Badges).
export function RoleLabel({
role,
dense = false,
className,
}: {
role?: string | null
dense?: boolean
className?: string
}) {
const meta = roleMeta(role)
const Icon = meta.icon
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
meta.tone,
className,
)}
title={`${meta.desc} (${meta.role})`}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{dense ? meta.short : meta.label}
</span>
)
}
export function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
}[fit.level]
return (
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
{fit.text} {fit.req_gb} GB RAM
</span>
)
}
export function getBrandInfo(name: string) {
const low = name.toLowerCase()
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
}
+203 -203
View File
@@ -1,203 +1,203 @@
import { useState } from "react"
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
import { setGroup, type ModelInfo } from "@/lib/api"
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() {
const qc = useQueryClient()
const { data: modelsResp } = useModels(4_000)
const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain()
const { showAlert, dialogElement } = useDialog()
const [picking, setPicking] = useState(false)
const [busy, setBusy] = useState(false)
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const group = groupsResp?.groups?.brains
const members = group?.members ?? []
const byName = (name: string) => models.find((m) => m.name === name)
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
const B = 1024 ** 3
const bud = brain?.budget
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
const reservedGb = bud?.warm_projected_gb ?? weightsGb
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
async function save(next: string[]) {
setBusy(true)
try {
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups })
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.hermesBrain })
} catch (e: any) {
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
} finally {
setBusy(false)
}
}
function remove(name: string) {
const m = byName(name)
if (m && roleMeta(m.role).protected) {
showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
return
}
void save(members.filter((x) => x !== name))
}
function add(name: string) {
setPicking(false)
void save([...members, name])
}
if (!group) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<Header />
<p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
</p>
{dialogElement}
</div>
)
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<Header />
{/* Mitglieder */}
<div className="space-y-1.5">
{memberModels.length === 0 && (
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
)}
{memberModels.map((m) => {
const isWarm = running.includes(m.name)
const prot = roleMeta(m.role).protected
return (
<div key={m.name} className="flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</div>
{prot ? (
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
) : (
<button
onClick={() => remove(m.name)}
disabled={busy}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</div>
{/* Hinzufügen */}
<button
onClick={() => setPicking(true)}
disabled={busy || eligible.length === 0}
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
</button>
{/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span>
<span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
</div>
{bud && !bud.fits && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
bleibt dabei immer geladen wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
statt das Set zu verdrängen).
</span>
</p>
)}
{bud && bud.fits && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell nichts wird verdrängt.
</p>
)}
</div>
{/* Auswahl-Modal */}
{picking && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">Dauerhaft bereithalten</h3>
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
{eligible.map((m) => (
<button
key={m.name}
onClick={() => add(m.name)}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
>
<span className="flex min-w-0 items-center gap-2">
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</button>
))}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
function Header() {
return (
<div className="flex items-center gap-2">
<Zap className="h-4.5 w-4.5 text-primary" />
<div>
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit sie antworten ohne Ladezeit.</p>
</div>
</div>
)
}
import { useState } from "react"
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
import { setGroup, type ModelInfo } from "@/lib/api"
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() {
const qc = useQueryClient()
const { data: modelsResp } = useModels(4_000)
const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain()
const { showAlert, dialogElement } = useDialog()
const [picking, setPicking] = useState(false)
const [busy, setBusy] = useState(false)
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const group = groupsResp?.groups?.brains
const members = group?.members ?? []
const byName = (name: string) => models.find((m) => m.name === name)
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
const B = 1024 ** 3
const bud = brain?.budget
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
const reservedGb = bud?.warm_projected_gb ?? weightsGb
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
async function save(next: string[]) {
setBusy(true)
try {
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups })
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.hermesBrain })
} catch (e: any) {
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
} finally {
setBusy(false)
}
}
function remove(name: string) {
const m = byName(name)
if (m && roleMeta(m.role).protected) {
showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
return
}
void save(members.filter((x) => x !== name))
}
function add(name: string) {
setPicking(false)
void save([...members, name])
}
if (!group) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<Header />
<p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
</p>
{dialogElement}
</div>
)
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<Header />
{/* Mitglieder */}
<div className="space-y-1.5">
{memberModels.length === 0 && (
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
)}
{memberModels.map((m) => {
const isWarm = running.includes(m.name)
const prot = roleMeta(m.role).protected
return (
<div key={m.name} className="flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</div>
{prot ? (
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
) : (
<button
onClick={() => remove(m.name)}
disabled={busy}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</div>
{/* Hinzufügen */}
<button
onClick={() => setPicking(true)}
disabled={busy || eligible.length === 0}
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
</button>
{/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span>
<span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
</div>
{bud && !bud.fits && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
bleibt dabei immer geladen wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
statt das Set zu verdrängen).
</span>
</p>
)}
{bud && bud.fits && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell nichts wird verdrängt.
</p>
)}
</div>
{/* Auswahl-Modal */}
{picking && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">Dauerhaft bereithalten</h3>
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
{eligible.map((m) => (
<button
key={m.name}
onClick={() => add(m.name)}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
>
<span className="flex min-w-0 items-center gap-2">
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</button>
))}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
function Header() {
return (
<div className="flex items-center gap-2">
<Zap className="h-4.5 w-4.5 text-primary" />
<div>
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit sie antworten ohne Ladezeit.</p>
</div>
</div>
)
}
+123 -123
View File
@@ -1,123 +1,123 @@
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Shield } from "lucide-react"
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
// `showAlert` (gemeinsamer Dialog).
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort"
name="sudo-password"
autoComplete="off"
spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token"
name="hf-token"
autoComplete="off"
spellCheck={false}
placeholder="hf_…"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)
}
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Shield } from "lucide-react"
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
// `showAlert` (gemeinsamer Dialog).
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort"
name="sudo-password"
autoComplete="off"
spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token"
name="hf-token"
autoComplete="off"
spellCheck={false}
placeholder="hf_…"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)
}
@@ -1,196 +1,196 @@
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Job, UpdateDetails } from "@/lib/api"
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
detail: UpdateDetailState
maintenanceJob?: Job
onClose: () => void
onApply: () => void
}) {
const d = detail.data
const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div>
) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
) : (
<>
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
{d?.action_needed != null && (
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
{d.action_needed
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
</div>
{d.action_needed && d.action_text && (
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
)}
</div>
</div>
)}
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
{d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</div>
)}
{detail.kind === "os" ? (
<>
{(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren System ist aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
<div className="space-y-1">
{d!.packages!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
</span>
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
</span>
</div>
))}
</div>
</>
)}
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
{(d?.held_back?.length ?? 0) > 0 && (
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt kein Handeln nötig
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
selbst das ist kein Fehler und nichts hängt fest.
</p>
<div className="space-y-1">
{d!.held_back!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
</span>
<span className="text-[9px] text-muted-foreground shrink-0">
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
</span>
</div>
))}
</div>
</div>
)}
</>
) : detail.kind === "engine" || detail.kind === "swap" ? (
<>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
</div>
{(d?.name || d?.latest_tag) && (
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
)}
{d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<details className="group">
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
<pre className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
</details>
)}
</>
) : (
// hermes
(d?.commits?.length ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
<div className="space-y-1">
{d!.commits!.map((c) => (
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-[11px] truncate">{c.subject}</div>
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
</div>
</div>
))}
</div>
</>
)
)}
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
{detail.kind !== "os" && !nothing && (
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-muted-foreground leading-normal">
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
Stack per echter Anfrage läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
</p>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button>
</div>
</div>
</div>
)
}
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Job, UpdateDetails } from "@/lib/api"
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
detail: UpdateDetailState
maintenanceJob?: Job
onClose: () => void
onApply: () => void
}) {
const d = detail.data
const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div>
) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
) : (
<>
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
{d?.action_needed != null && (
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
{d.action_needed
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
</div>
{d.action_needed && d.action_text && (
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
)}
</div>
</div>
)}
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
{d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</div>
)}
{detail.kind === "os" ? (
<>
{(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren System ist aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
<div className="space-y-1">
{d!.packages!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
</span>
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
</span>
</div>
))}
</div>
</>
)}
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
{(d?.held_back?.length ?? 0) > 0 && (
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt kein Handeln nötig
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
selbst das ist kein Fehler und nichts hängt fest.
</p>
<div className="space-y-1">
{d!.held_back!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
</span>
<span className="text-[9px] text-muted-foreground shrink-0">
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
</span>
</div>
))}
</div>
</div>
)}
</>
) : detail.kind === "engine" || detail.kind === "swap" ? (
<>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
</div>
{(d?.name || d?.latest_tag) && (
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
)}
{d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<details className="group">
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
<pre className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
</details>
)}
</>
) : (
// hermes
(d?.commits?.length ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
<div className="space-y-1">
{d!.commits!.map((c) => (
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-[11px] truncate">{c.subject}</div>
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
</div>
</div>
))}
</div>
</>
)
)}
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
{detail.kind !== "os" && !nothing && (
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-muted-foreground leading-normal">
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
Stack per echter Anfrage läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
</p>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button>
</div>
</div>
</div>
)
}
+58 -58
View File
@@ -1,58 +1,58 @@
import { RefreshCw, FileText } from "lucide-react"
import { cn } from "@/lib/utils"
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
export const SERVICES = [
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "voice-service", label: "Voice (Sprache)", type: "user", reach: "voice" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
]
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
export function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
import { RefreshCw, FileText } from "lucide-react"
import { cn } from "@/lib/utils"
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
export const SERVICES = [
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "voice-service", label: "Voice (Sprache)", type: "user", reach: "voice" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
]
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
export function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
+480 -480
View File
@@ -1,480 +1,480 @@
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const headers = {
"Content-Type": "application/json",
...init?.headers,
} as Record<string, string>
const sudoPassword = localStorage.getItem("mc_sudo_password")
const hfToken = localStorage.getItem("mc_hf_token")
if (sudoPassword) {
headers["X-Sudo-Password"] = sudoPassword
}
let body = init?.body
const method = init?.method?.toUpperCase() || "GET"
if (method === "POST") {
if (typeof body === "string") {
try {
const data = JSON.parse(body)
let changed = false
if (sudoPassword && !("sudo_password" in data)) {
data["sudo_password"] = sudoPassword
changed = true
}
if (hfToken && !("hf_token" in data)) {
data["hf_token"] = hfToken
changed = true
}
if (changed) {
body = JSON.stringify(data)
}
} catch (e) {
// ignore JSON parse errors
}
} else if (!body) {
const data: Record<string, any> = {}
if (sudoPassword) data["sudo_password"] = sudoPassword
if (hfToken) data["hf_token"] = hfToken
if (Object.keys(data).length > 0) {
body = JSON.stringify(data)
}
}
}
const res = await fetch(path, {
...init,
headers,
body,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T>
}
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
export interface GroupSpec {
swap: boolean
persist: boolean
members: string[]
}
export interface GroupsResp {
groups: Record<string, GroupSpec>
}
export const getGroups = () => api<GroupsResp>("/api/groups")
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
export interface Capabilities {
moe: boolean
active_b: number | null
tools: "yes" | "likely" | "no"
vision: boolean
coder: boolean
reasoning: boolean
embedding: boolean
ctx: number | null
params_b: number | null
arch: string | null
}
export interface Fit {
level: "perfect" | "marginal" | "too_tight"
text: string
req_gb: number
tps: number
}
export interface RoleRecModel {
name: string
current_role: string | null
params_b: number
quant: string
fit: Fit
suitable: boolean
incomplete: boolean
score: number
reason: string
recommended: boolean
}
export interface RoleRecResp {
role: string
recommended: string | null
models: RoleRecModel[]
}
export interface FitResp {
params_b: number
fit: Fit
optimal_ctx: number
assigned_ctx: number
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
sys_ram_gb: number
}
export interface ModelInfo {
name: string
role: string | null
aliases: string[]
api_ids: string[]
ctx: number | null
ttl: number | null
cmd: string
gguf_path: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
prompt_cache: boolean
spec_draft_model: string | null
spec_type: string | null
spec_active: boolean
parallel_slots: number
capabilities: Capabilities
}
export interface VocabFingerprint {
model: string | null
pre: string | null
n_vocab: number | null
arch: string | null
}
export interface DraftInfo {
path: string
filename: string
size_bytes: number | null
vocab: VocabFingerprint | null
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
}
export interface DraftsResp {
target_path: string
target_exists: boolean
target_vocab: VocabFingerprint | null
drafts: DraftInfo[]
}
export interface DiscoverModel {
name: string
author: string
repo: string
role: string
params_b: number
quant: string
tags: string[]
downloads: number
fit: Fit
optimal_ctx: number
caps: Capabilities
}
export interface DiscoverCategory {
role: string
title: string
icon: string
models: DiscoverModel[]
recommended: string | null
}
export interface DiscoverResp {
updated: number
categories: DiscoverCategory[]
sys_ram_gb: number
}
export interface RoutingResp {
mode?: string
endpoint?: string
heavy_threshold_chars?: number
routes: { name: string; target: string }[]
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean
}
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
// hinter den Lanes; hot-reload im Backend (kein Restart).
export interface RoutingPolicy {
fast: string
heavy: string
coder: string
coder_lite: string
heavy_chars: number
coding_escalate_chars: number
fast_no_think: boolean
}
export interface RoutingPolicyField {
key: keyof RoutingPolicy
label: string
type: "str" | "int" | "bool"
min?: number
max?: number
}
export interface RoutingPolicyMeta {
policy: RoutingPolicy
defaults: RoutingPolicy
fields: RoutingPolicyField[]
}
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
export interface GitInfo {
hash: string
date: string
subject: string
branch: string
dirty: boolean
path: string
}
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
export interface ComponentVersion {
type: "git" | "binary" | "unknown"
hash?: string
date?: string
subject?: string
branch?: string
dirty?: boolean
path?: string
version_text?: string
}
export interface Versions {
mc2: GitInfo | null
engine: ComponentVersion
hermes_ui: GitInfo | null
hermes_agent: GitInfo | null
}
export interface SystemStatus {
cpu: { percent: number; cores: number | null }
ram: { total: number; used: number; percent: number }
gpu: {
busy_percent: number | null
vram_used: number | null
vram_total: number | null
gtt_used?: number | null
gtt_total?: number | null
} | null
temp: { cpu?: number; gpu?: number } | null
disk: { total: number; used: number; percent: number } | null
versions?: Versions
}
export interface ServicesResp {
services: { name: string; unit: string; url: string; ok: boolean }[]
links: { engine_ui: string; gateway: string; hermes_terminal: string }
}
export interface ComponentUpdate {
key: string // z.B. "hermes_agent"
name: string
current: string | null
latest: string | null
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
reachable: boolean | null
}
export interface UpdatesResp {
os: number
engine: number
swap: number
models: number
model_list: { role: string; title: string; repo: string }[]
last_check?: number | null
components?: ComponentUpdate[]
}
export interface UpdateDetails {
kind: "os" | "engine" | "swap" | "hermes"
error?: string
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
summary?: string
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
action_needed?: boolean | null
action_text?: string
// os
count?: number
packages?: { name: string; current: string; candidate: string }[]
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
// engine
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
name?: string | null
url?: string | null
body?: string | null
// hermes
branch?: string | null
behind?: number
commits?: { hash: string; subject: string; when: string }[]
}
export interface ConnectTool {
label: string
lang: string
snippet: string
note: string
}
export interface ConnectResp {
host: string
gateway_url: string
mc_url: string
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
memory: ConnectTool // Leitung 2 — Gedächtnis (separater MCP-Server)
}
export interface ConnectLine {
ok: boolean
detail: string
}
export interface ConnectHealth {
gateway: ConnectLine
memory: ConnectLine
}
export interface Memory {
id: string
content: string
category: string
source: string
created_at: string
updated_at: string
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
}
export interface MemoryGraphNode {
id: string
content: string
category: string
source: string
}
export interface MemoryGraph {
nodes: MemoryGraphNode[]
edges: { source: string; target: string; weight: number }[]
}
export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number
removed: number
applied: boolean
}
export interface AgentStatus {
gateway_url: string
terminal_url: string
box_console_url?: string
hermes_ui_url?: string
gateway_reachable: boolean
terminal_reachable: boolean
box_console_reachable?: boolean
hermes_ui_reachable?: boolean
home_exists: boolean
brain_model?: string
has_config: boolean
has_skills: boolean
has_memories: boolean
telegram_enabled?: boolean
mcp_server_count?: number
pc_executor_reachable?: boolean
}
export interface Job {
id: string
label: string
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
state: "queued" | "running" | "done" | "failed" | "canceled"
progress?: number
total_bytes?: number
done_bytes?: number
rate_bps?: number
eta_s?: number
}
export interface Health {
status: string
version: string
engine_reachable: boolean
gateway_reachable: boolean
brain?: { role: string; model: string | null; ready: boolean }
}
export interface TokenStats {
prompt_tokens: number
completion_tokens: number
total_tokens: number
saved_usd: number
saved_eur: number
pricing?: Record<string, { in: number; out: number }>
}
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
// stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren).
export interface VoiceTurn {
id: string
ts: number // Epoch-Sekunden
session: string
kind: string
had_images: boolean
stt_ms: number | null
vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT)
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
total_ms: number
error: string | null
}
export interface VoiceTraceResp {
turns: VoiceTurn[]
}
export interface ModelsResp {
models: ModelInfo[]
running?: string[]
}
export interface HermesBrainModel {
name: string
filename?: string
params_b: number | null
quant?: string
size_bytes?: number | null
version?: number | null
gguf_path?: string
incomplete?: boolean
}
export interface HermesBrainCandidate {
repo: string
name: string
version: number
params_b: number
downloads: number
fit: Fit
}
export interface HermesBrainBudget {
gtt_gb: number
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
warm_projected_gb: number // alle persist-Modelle warm (Info)
free_after_gb: number // frei nach Brain + größtem on-demand
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
}
export interface HermesBrainResp {
current: HermesBrainModel | null
recommended: HermesBrainCandidate | null
update_available: boolean
budget?: HermesBrainBudget | null
}
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const headers = {
"Content-Type": "application/json",
...init?.headers,
} as Record<string, string>
const sudoPassword = localStorage.getItem("mc_sudo_password")
const hfToken = localStorage.getItem("mc_hf_token")
if (sudoPassword) {
headers["X-Sudo-Password"] = sudoPassword
}
let body = init?.body
const method = init?.method?.toUpperCase() || "GET"
if (method === "POST") {
if (typeof body === "string") {
try {
const data = JSON.parse(body)
let changed = false
if (sudoPassword && !("sudo_password" in data)) {
data["sudo_password"] = sudoPassword
changed = true
}
if (hfToken && !("hf_token" in data)) {
data["hf_token"] = hfToken
changed = true
}
if (changed) {
body = JSON.stringify(data)
}
} catch (e) {
// ignore JSON parse errors
}
} else if (!body) {
const data: Record<string, any> = {}
if (sudoPassword) data["sudo_password"] = sudoPassword
if (hfToken) data["hf_token"] = hfToken
if (Object.keys(data).length > 0) {
body = JSON.stringify(data)
}
}
}
const res = await fetch(path, {
...init,
headers,
body,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T>
}
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
export interface GroupSpec {
swap: boolean
persist: boolean
members: string[]
}
export interface GroupsResp {
groups: Record<string, GroupSpec>
}
export const getGroups = () => api<GroupsResp>("/api/groups")
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
export interface Capabilities {
moe: boolean
active_b: number | null
tools: "yes" | "likely" | "no"
vision: boolean
coder: boolean
reasoning: boolean
embedding: boolean
ctx: number | null
params_b: number | null
arch: string | null
}
export interface Fit {
level: "perfect" | "marginal" | "too_tight"
text: string
req_gb: number
tps: number
}
export interface RoleRecModel {
name: string
current_role: string | null
params_b: number
quant: string
fit: Fit
suitable: boolean
incomplete: boolean
score: number
reason: string
recommended: boolean
}
export interface RoleRecResp {
role: string
recommended: string | null
models: RoleRecModel[]
}
export interface FitResp {
params_b: number
fit: Fit
optimal_ctx: number
assigned_ctx: number
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
sys_ram_gb: number
}
export interface ModelInfo {
name: string
role: string | null
aliases: string[]
api_ids: string[]
ctx: number | null
ttl: number | null
cmd: string
gguf_path: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
prompt_cache: boolean
spec_draft_model: string | null
spec_type: string | null
spec_active: boolean
parallel_slots: number
capabilities: Capabilities
}
export interface VocabFingerprint {
model: string | null
pre: string | null
n_vocab: number | null
arch: string | null
}
export interface DraftInfo {
path: string
filename: string
size_bytes: number | null
vocab: VocabFingerprint | null
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
}
export interface DraftsResp {
target_path: string
target_exists: boolean
target_vocab: VocabFingerprint | null
drafts: DraftInfo[]
}
export interface DiscoverModel {
name: string
author: string
repo: string
role: string
params_b: number
quant: string
tags: string[]
downloads: number
fit: Fit
optimal_ctx: number
caps: Capabilities
}
export interface DiscoverCategory {
role: string
title: string
icon: string
models: DiscoverModel[]
recommended: string | null
}
export interface DiscoverResp {
updated: number
categories: DiscoverCategory[]
sys_ram_gb: number
}
export interface RoutingResp {
mode?: string
endpoint?: string
heavy_threshold_chars?: number
routes: { name: string; target: string }[]
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean
}
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
// hinter den Lanes; hot-reload im Backend (kein Restart).
export interface RoutingPolicy {
fast: string
heavy: string
coder: string
coder_lite: string
heavy_chars: number
coding_escalate_chars: number
fast_no_think: boolean
}
export interface RoutingPolicyField {
key: keyof RoutingPolicy
label: string
type: "str" | "int" | "bool"
min?: number
max?: number
}
export interface RoutingPolicyMeta {
policy: RoutingPolicy
defaults: RoutingPolicy
fields: RoutingPolicyField[]
}
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
export interface GitInfo {
hash: string
date: string
subject: string
branch: string
dirty: boolean
path: string
}
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
export interface ComponentVersion {
type: "git" | "binary" | "unknown"
hash?: string
date?: string
subject?: string
branch?: string
dirty?: boolean
path?: string
version_text?: string
}
export interface Versions {
mc2: GitInfo | null
engine: ComponentVersion
hermes_ui: GitInfo | null
hermes_agent: GitInfo | null
}
export interface SystemStatus {
cpu: { percent: number; cores: number | null }
ram: { total: number; used: number; percent: number }
gpu: {
busy_percent: number | null
vram_used: number | null
vram_total: number | null
gtt_used?: number | null
gtt_total?: number | null
} | null
temp: { cpu?: number; gpu?: number } | null
disk: { total: number; used: number; percent: number } | null
versions?: Versions
}
export interface ServicesResp {
services: { name: string; unit: string; url: string; ok: boolean }[]
links: { engine_ui: string; gateway: string; hermes_terminal: string }
}
export interface ComponentUpdate {
key: string // z.B. "hermes_agent"
name: string
current: string | null
latest: string | null
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
reachable: boolean | null
}
export interface UpdatesResp {
os: number
engine: number
swap: number
models: number
model_list: { role: string; title: string; repo: string }[]
last_check?: number | null
components?: ComponentUpdate[]
}
export interface UpdateDetails {
kind: "os" | "engine" | "swap" | "hermes"
error?: string
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
summary?: string
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
action_needed?: boolean | null
action_text?: string
// os
count?: number
packages?: { name: string; current: string; candidate: string }[]
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
// engine
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
name?: string | null
url?: string | null
body?: string | null
// hermes
branch?: string | null
behind?: number
commits?: { hash: string; subject: string; when: string }[]
}
export interface ConnectTool {
label: string
lang: string
snippet: string
note: string
}
export interface ConnectResp {
host: string
gateway_url: string
mc_url: string
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
memory: ConnectTool // Leitung 2 — Gedächtnis (separater MCP-Server)
}
export interface ConnectLine {
ok: boolean
detail: string
}
export interface ConnectHealth {
gateway: ConnectLine
memory: ConnectLine
}
export interface Memory {
id: string
content: string
category: string
source: string
created_at: string
updated_at: string
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
}
export interface MemoryGraphNode {
id: string
content: string
category: string
source: string
}
export interface MemoryGraph {
nodes: MemoryGraphNode[]
edges: { source: string; target: string; weight: number }[]
}
export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number
removed: number
applied: boolean
}
export interface AgentStatus {
gateway_url: string
terminal_url: string
box_console_url?: string
hermes_ui_url?: string
gateway_reachable: boolean
terminal_reachable: boolean
box_console_reachable?: boolean
hermes_ui_reachable?: boolean
home_exists: boolean
brain_model?: string
has_config: boolean
has_skills: boolean
has_memories: boolean
telegram_enabled?: boolean
mcp_server_count?: number
pc_executor_reachable?: boolean
}
export interface Job {
id: string
label: string
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
state: "queued" | "running" | "done" | "failed" | "canceled"
progress?: number
total_bytes?: number
done_bytes?: number
rate_bps?: number
eta_s?: number
}
export interface Health {
status: string
version: string
engine_reachable: boolean
gateway_reachable: boolean
brain?: { role: string; model: string | null; ready: boolean }
}
export interface TokenStats {
prompt_tokens: number
completion_tokens: number
total_tokens: number
saved_usd: number
saved_eur: number
pricing?: Record<string, { in: number; out: number }>
}
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
// stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren).
export interface VoiceTurn {
id: string
ts: number // Epoch-Sekunden
session: string
kind: string
had_images: boolean
stt_ms: number | null
vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT)
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
total_ms: number
error: string | null
}
export interface VoiceTraceResp {
turns: VoiceTurn[]
}
export interface ModelsResp {
models: ModelInfo[]
running?: string[]
}
export interface HermesBrainModel {
name: string
filename?: string
params_b: number | null
quant?: string
size_bytes?: number | null
version?: number | null
gguf_path?: string
incomplete?: boolean
}
export interface HermesBrainCandidate {
repo: string
name: string
version: number
params_b: number
downloads: number
fit: Fit
}
export interface HermesBrainBudget {
gtt_gb: number
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
warm_projected_gb: number // alle persist-Modelle warm (Info)
free_after_gb: number // frei nach Brain + größtem on-demand
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
}
export interface HermesBrainResp {
current: HermesBrainModel | null
recommended: HermesBrainCandidate | null
update_available: boolean
budget?: HermesBrainBudget | null
}
+32 -32
View File
@@ -1,32 +1,32 @@
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
export function gb(b: number): string {
return (b / 1024 ** 3).toFixed(1)
}
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
export function fmtBytes(b?: number): string {
if (!b) return ""
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
return `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
export function fmtSize(b?: number | null): string {
if (!b) return "—"
const g = b / 1024 ** 3
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
export function fmtEta(s?: number): string {
if (!s) return ""
const m = Math.floor(s / 60)
return m > 0 ? `${m} min` : `${s} s`
}
/** Kontextlänge → "32k"; "—" bei null. */
export function fmtCtx(c: number | null): string {
return c ? `${Math.round(c / 1024)}k` : "—"
}
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
export function gb(b: number): string {
return (b / 1024 ** 3).toFixed(1)
}
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
export function fmtBytes(b?: number): string {
if (!b) return ""
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
return `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
export function fmtSize(b?: number | null): string {
if (!b) return "—"
const g = b / 1024 ** 3
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
export function fmtEta(s?: number): string {
if (!s) return ""
const m = Math.floor(s / 60)
return m > 0 ? `${m} min` : `${s} s`
}
/** Kontextlänge → "32k"; "—" bei null. */
export function fmtCtx(c: number | null): string {
return c ? `${Math.round(c / 1024)}k` : "—"
}
+152 -152
View File
@@ -1,152 +1,152 @@
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import {
api,
type AgentStatus,
type ConnectResp,
type ConnectHealth,
type DiscoverResp,
type DraftsResp,
type GroupsResp,
type HermesBrainResp,
type Health,
type Job,
type Memory,
type MemoryGraph,
type ModelsResp,
type RoutingResp,
type RoutingPolicyMeta,
type ServicesResp,
type SystemStatus,
type TokenStats,
type UpdatesResp,
type VoiceTraceResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = {
health: ["health"] as const,
systemStatus: ["system-status"] as const,
services: ["services"] as const,
models: ["models"] as const,
groups: ["groups"] as const,
routing: ["routing"] as const,
routingPolicy: ["routing-policy"] as const,
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const,
connectHealth: ["connect-health"] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const,
}
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
useQuery({
queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
refetchInterval,
select: (d) => d.turns ?? [],
})
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/memory/graph"),
enabled,
})
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
export const useSystemStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
export const useServices = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
export const useModels = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useGroups = (refetchInterval = 8_000) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
export const useJobs = (refetchInterval = 2_000) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
refetchInterval,
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
export const useAgentStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval = 60_000) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
export const useUpdates = (refetchInterval?: number) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
export const useDrafts = (target?: string) =>
useQuery({
queryKey: qk.drafts(target),
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
enabled: !!target,
})
export const useConnect = (params?: string) =>
useQuery({
queryKey: qk.connect(params),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
})
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useConnectHealth = () =>
useQuery({
queryKey: qk.connectHealth,
queryFn: () => api<ConnectHealth>("/api/connect/health"),
refetchInterval: 15000,
})
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
useQuery({
queryKey: qk.memory(opts?.q, opts?.category),
queryFn: () => {
const p = new URLSearchParams()
if (opts?.q) p.set("q", opts.q)
if (opts?.category) p.set("category", opts.category)
return api<Memory[]>(`/api/memory?${p}`)
},
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
})
/** 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 }
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import {
api,
type AgentStatus,
type ConnectResp,
type ConnectHealth,
type DiscoverResp,
type DraftsResp,
type GroupsResp,
type HermesBrainResp,
type Health,
type Job,
type Memory,
type MemoryGraph,
type ModelsResp,
type RoutingResp,
type RoutingPolicyMeta,
type ServicesResp,
type SystemStatus,
type TokenStats,
type UpdatesResp,
type VoiceTraceResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = {
health: ["health"] as const,
systemStatus: ["system-status"] as const,
services: ["services"] as const,
models: ["models"] as const,
groups: ["groups"] as const,
routing: ["routing"] as const,
routingPolicy: ["routing-policy"] as const,
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const,
connectHealth: ["connect-health"] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const,
}
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
useQuery({
queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
refetchInterval,
select: (d) => d.turns ?? [],
})
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/memory/graph"),
enabled,
})
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
export const useSystemStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
export const useServices = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
export const useModels = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useGroups = (refetchInterval = 8_000) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
export const useJobs = (refetchInterval = 2_000) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
refetchInterval,
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
export const useAgentStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval = 60_000) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
export const useUpdates = (refetchInterval?: number) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
export const useDrafts = (target?: string) =>
useQuery({
queryKey: qk.drafts(target),
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
enabled: !!target,
})
export const useConnect = (params?: string) =>
useQuery({
queryKey: qk.connect(params),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
})
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useConnectHealth = () =>
useQuery({
queryKey: qk.connectHealth,
queryFn: () => api<ConnectHealth>("/api/connect/health"),
refetchInterval: 15000,
})
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
useQuery({
queryKey: qk.memory(opts?.q, opts?.category),
queryFn: () => {
const p = new URLSearchParams()
if (opts?.q) p.set("q", opts.q)
if (opts?.category) p.set("category", opts.category)
return api<Memory[]>(`/api/memory?${p}`)
},
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
})
/** 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 }
+59 -59
View File
@@ -1,59 +1,59 @@
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
//
// Nutzung:
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
// ...
// showConfirm("Titel", "Wirklich?", () => doIt())
// return (<>{dialogElement}...</>)
import { useCallback, useState } from "react"
import { CustomDialog } from "@/components/CustomDialog"
interface DialogState {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function useDialog() {
const [dialog, setDialog] = useState<DialogState | null>(null)
const close = useCallback(() => setDialog(null), [])
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
setDialog({
type: "alert", title, message,
onConfirm: () => { setDialog(null); onConfirm?.() },
})
}, [])
const showConfirm = useCallback(
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setDialog({
type: "confirm", title, message,
onConfirm: () => { setDialog(null); onConfirm() },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const showPrompt = useCallback(
(title: string, message: string, defaultValue: string,
onConfirm: (val?: string) => void, onCancel?: () => void,
opts?: { autoValue?: string; autoLabel?: string }) => {
setDialog({
type: "prompt", title, message, defaultValue,
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
onConfirm: (val) => { setDialog(null); onConfirm(val) },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
return { showAlert, showConfirm, showPrompt, close, dialogElement }
}
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
//
// Nutzung:
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
// ...
// showConfirm("Titel", "Wirklich?", () => doIt())
// return (<>{dialogElement}...</>)
import { useCallback, useState } from "react"
import { CustomDialog } from "@/components/CustomDialog"
interface DialogState {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function useDialog() {
const [dialog, setDialog] = useState<DialogState | null>(null)
const close = useCallback(() => setDialog(null), [])
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
setDialog({
type: "alert", title, message,
onConfirm: () => { setDialog(null); onConfirm?.() },
})
}, [])
const showConfirm = useCallback(
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setDialog({
type: "confirm", title, message,
onConfirm: () => { setDialog(null); onConfirm() },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const showPrompt = useCallback(
(title: string, message: string, defaultValue: string,
onConfirm: (val?: string) => void, onCancel?: () => void,
opts?: { autoValue?: string; autoLabel?: string }) => {
setDialog({
type: "prompt", title, message, defaultValue,
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
onConfirm: (val) => { setDialog(null); onConfirm(val) },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
return { showAlert, showConfirm, showPrompt, close, dialogElement }
}
+27 -27
View File
@@ -1,27 +1,27 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import "./index.css"
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5_000,
},
},
})
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>,
)
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import "./index.css"
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5_000,
},
},
})
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>,
)
+90 -90
View File
@@ -1,90 +1,90 @@
import { useEffect, useState } from "react"
import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { Mc3Start } from "./Mc3Start"
import { Mc3Maschinenraum } from "./Mc3Maschinenraum"
// MC3-Prototyp-Schale. Läuft nicht-destruktiv unter #mc3, das produktive MC2 bleibt
// unangetastet. Vier Türen; Innereien sind bewusst die bestehenden, bewährten Views.
type Door = "start" | "vibe" | "konstitution" | "maschinenraum"
const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [
{ id: "start", label: "Start", icon: Activity },
{ id: "vibe", label: "Vibe-Coding", icon: Code },
{ id: "konstitution", label: "Konstitution", icon: Bookmark },
{ id: "maschinenraum", label: "Maschinenraum", icon: Wrench },
]
export function Mc3App() {
const [door, setDoor] = useState<Door>("start")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: health } = useHealth()
useEffect(() => {
const onOpen = (e: Event) => {
setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance")
setDrawerOpen(true)
}
window.addEventListener("open-system-drawer", onOpen)
return () => window.removeEventListener("open-system-drawer", onOpen)
}, [])
const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true)
return (
<div className="flex h-full relative">
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-30 animate-aurora bg-gradient-to-tr from-teal-500/15 via-indigo-500/10 to-purple-500/15 blur-[130px]" />
</div>
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar — 4 Türen */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border/40">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20" />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-[10px] text-muted-foreground">3 · Prototyp</div>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{DOORS.map((d) => (
<button key={d.id} onClick={() => setDoor(d.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-all cursor-pointer",
door === d.id ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}>
<d.icon className="h-4.5 w-4.5 shrink-0" /> {d.label}
</button>
))}
</nav>
<div className="px-3 py-3 border-t border-border/40 space-y-2">
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", boxOk ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{boxOk ? "Box läuft" : "prüfen"}
</div>
<a href="#dashboard"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-3.5 w-3.5" /> zurück zu MC2
</a>
</div>
</aside>
{/* Main */}
<main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin">
{door === "start" && <Mc3Start />}
{door === "vibe" && <ConnectView />}
{door === "konstitution" && <MemoryView />}
{door === "maschinenraum" && <Mc3Maschinenraum />}
</main>
</div>
)
}
import { useEffect, useState } from "react"
import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { Mc3Start } from "./Mc3Start"
import { Mc3Maschinenraum } from "./Mc3Maschinenraum"
// MC3-Prototyp-Schale. Läuft nicht-destruktiv unter #mc3, das produktive MC2 bleibt
// unangetastet. Vier Türen; Innereien sind bewusst die bestehenden, bewährten Views.
type Door = "start" | "vibe" | "konstitution" | "maschinenraum"
const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [
{ id: "start", label: "Start", icon: Activity },
{ id: "vibe", label: "Vibe-Coding", icon: Code },
{ id: "konstitution", label: "Konstitution", icon: Bookmark },
{ id: "maschinenraum", label: "Maschinenraum", icon: Wrench },
]
export function Mc3App() {
const [door, setDoor] = useState<Door>("start")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: health } = useHealth()
useEffect(() => {
const onOpen = (e: Event) => {
setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance")
setDrawerOpen(true)
}
window.addEventListener("open-system-drawer", onOpen)
return () => window.removeEventListener("open-system-drawer", onOpen)
}, [])
const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true)
return (
<div className="flex h-full relative">
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-30 animate-aurora bg-gradient-to-tr from-teal-500/15 via-indigo-500/10 to-purple-500/15 blur-[130px]" />
</div>
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar — 4 Türen */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border/40">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20" />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-[10px] text-muted-foreground">3 · Prototyp</div>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{DOORS.map((d) => (
<button key={d.id} onClick={() => setDoor(d.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-all cursor-pointer",
door === d.id ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}>
<d.icon className="h-4.5 w-4.5 shrink-0" /> {d.label}
</button>
))}
</nav>
<div className="px-3 py-3 border-t border-border/40 space-y-2">
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", boxOk ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{boxOk ? "Box läuft" : "prüfen"}
</div>
<a href="#dashboard"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-3.5 w-3.5" /> zurück zu MC2
</a>
</div>
</aside>
{/* Main */}
<main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin">
{door === "start" && <Mc3Start />}
{door === "vibe" && <ConnectView />}
{door === "konstitution" && <MemoryView />}
{door === "maschinenraum" && <Mc3Maschinenraum />}
</main>
</div>
)
}
+60 -60
View File
@@ -1,60 +1,60 @@
import { useState } from "react"
import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react"
import { Cockpit } from "@/views/models/Cockpit"
import { AgentView } from "@/views/AgentView"
import { ExpertToggle } from "@/components/ExpertToggle"
// Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes
// zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder.
function SectionCard({ icon: Icon, title, sub, onClick }: {
icon: any; title: string; sub: string; onClick: () => void
}) {
return (
<button onClick={onClick}
className="w-full flex items-center gap-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-left transition-all hover:border-primary/40 cursor-pointer">
<Icon className="h-6 w-6 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{sub}</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" />
</button>
)
}
export function Mc3Maschinenraum() {
const [section, setSection] = useState<null | "modelle" | "hermes">(null)
if (section) {
return (
<div className="space-y-4">
<button onClick={() => setSection(null)}
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="h-4 w-4" /> Maschinenraum
</button>
{section === "modelle" ? <Cockpit /> : <AgentView />}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Maschinenraum
</h1>
<p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p>
</div>
<ExpertToggle />
</div>
<SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek"
onClick={() => setSection("modelle")} />
<SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten"
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} />
<SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung"
onClick={() => setSection("hermes")} />
</div>
)
}
import { useState } from "react"
import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react"
import { Cockpit } from "@/views/models/Cockpit"
import { AgentView } from "@/views/AgentView"
import { ExpertToggle } from "@/components/ExpertToggle"
// Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes
// zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder.
function SectionCard({ icon: Icon, title, sub, onClick }: {
icon: any; title: string; sub: string; onClick: () => void
}) {
return (
<button onClick={onClick}
className="w-full flex items-center gap-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-left transition-all hover:border-primary/40 cursor-pointer">
<Icon className="h-6 w-6 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{sub}</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" />
</button>
)
}
export function Mc3Maschinenraum() {
const [section, setSection] = useState<null | "modelle" | "hermes">(null)
if (section) {
return (
<div className="space-y-4">
<button onClick={() => setSection(null)}
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="h-4 w-4" /> Maschinenraum
</button>
{section === "modelle" ? <Cockpit /> : <AgentView />}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Maschinenraum
</h1>
<p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p>
</div>
<ExpertToggle />
</div>
<SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek"
onClick={() => setSection("modelle")} />
<SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten"
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} />
<SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung"
onClick={() => setSection("hermes")} />
</div>
)
}
+93 -93
View File
@@ -1,93 +1,93 @@
import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { useUpdates, useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
// MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates
// (klickbar) + was gerade läuft + die geschätzte Telemetrie (System/Token/Sprach-Latenz).
// Bewusst KEINE Wärme/Platte/Speicher-Kacheln — die stehen schon im System-Status.
export function Mc3Start() {
const { data: updates } = useUpdates(30_000)
const { data: modelsResp } = useModels()
const upCount = (updates?.os || 0) + (updates?.engine || 0) + (updates?.swap || 0) + (updates?.models || 0)
const openUpdates = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const brain = models.find((m) => m.role === "hermes")
const coder = models.find((m) => m.role === "coder-lite") || models.find((m) => m.role === "coder")
const isWarm = (name?: string) => (name ? running.includes(name) : false)
const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—"
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Basisstation
</h1>
<p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p>
</div>
{/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */}
<LucyHealthCard frame="box" />
{/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */}
<button
onClick={openUpdates}
className={cn(
"w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer",
upCount > 0
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10"
: "border-border/60 bg-card/45 hover:border-primary/40",
)}
>
{upCount > 0
? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" />
: <Check className="h-6 w-6 shrink-0 text-emerald-400" />}
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-semibold", upCount > 0 ? "text-amber-400" : "text-foreground")}>
{upCount > 0 ? `${upCount} Update${upCount === 1 ? "" : "s"} verfügbar` : "Alles aktuell"}
</div>
<div className="text-xs text-muted-foreground">
{upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."}
</div>
</div>
{upCount > 0 && (
<span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400">
Ansehen <ChevronRight className="h-4 w-4" />
</span>
)}
</button>
{/* Was gerade läuft — beide Lanes */}
<div>
<div className="text-xs text-muted-foreground mb-2">Was gerade läuft</div>
<div className="rounded-2xl border border-border/60 overflow-hidden">
{[
{ icon: Cpu, label: "Lucys Hirn", model: brain?.name, warm: isWarm(brain?.name) },
{ icon: Code, label: "Coder (Vibe-Coding)", model: coder?.name, warm: isWarm(coder?.name) },
].map((r) => (
<div key={r.label} className="flex items-center gap-3 px-4 py-3 border-b border-border/40 last:border-0">
<span className={cn("h-2 w-2 shrink-0 rounded-full", r.warm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")} />
<r.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground w-40 shrink-0">{r.label}</span>
<span className="text-xs text-muted-foreground flex-1 truncate font-mono">{shortName(r.model)}</span>
<span className={cn("text-xs shrink-0", r.warm ? "text-emerald-400" : "text-muted-foreground")}>
{r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"}
</span>
</div>
))}
</div>
</div>
{/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */}
<div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard />
<TokenPerformanceCard />
</div>
</div>
)
}
import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { useUpdates, useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
// MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates
// (klickbar) + was gerade läuft + die geschätzte Telemetrie (System/Token/Sprach-Latenz).
// Bewusst KEINE Wärme/Platte/Speicher-Kacheln — die stehen schon im System-Status.
export function Mc3Start() {
const { data: updates } = useUpdates(30_000)
const { data: modelsResp } = useModels()
const upCount = (updates?.os || 0) + (updates?.engine || 0) + (updates?.swap || 0) + (updates?.models || 0)
const openUpdates = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const brain = models.find((m) => m.role === "hermes")
const coder = models.find((m) => m.role === "coder-lite") || models.find((m) => m.role === "coder")
const isWarm = (name?: string) => (name ? running.includes(name) : false)
const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—"
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Basisstation
</h1>
<p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p>
</div>
{/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */}
<LucyHealthCard frame="box" />
{/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */}
<button
onClick={openUpdates}
className={cn(
"w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer",
upCount > 0
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10"
: "border-border/60 bg-card/45 hover:border-primary/40",
)}
>
{upCount > 0
? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" />
: <Check className="h-6 w-6 shrink-0 text-emerald-400" />}
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-semibold", upCount > 0 ? "text-amber-400" : "text-foreground")}>
{upCount > 0 ? `${upCount} Update${upCount === 1 ? "" : "s"} verfügbar` : "Alles aktuell"}
</div>
<div className="text-xs text-muted-foreground">
{upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."}
</div>
</div>
{upCount > 0 && (
<span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400">
Ansehen <ChevronRight className="h-4 w-4" />
</span>
)}
</button>
{/* Was gerade läuft — beide Lanes */}
<div>
<div className="text-xs text-muted-foreground mb-2">Was gerade läuft</div>
<div className="rounded-2xl border border-border/60 overflow-hidden">
{[
{ icon: Cpu, label: "Lucys Hirn", model: brain?.name, warm: isWarm(brain?.name) },
{ icon: Code, label: "Coder (Vibe-Coding)", model: coder?.name, warm: isWarm(coder?.name) },
].map((r) => (
<div key={r.label} className="flex items-center gap-3 px-4 py-3 border-b border-border/40 last:border-0">
<span className={cn("h-2 w-2 shrink-0 rounded-full", r.warm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")} />
<r.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground w-40 shrink-0">{r.label}</span>
<span className="text-xs text-muted-foreground flex-1 truncate font-mono">{shortName(r.model)}</span>
<span className={cn("text-xs shrink-0", r.warm ? "text-emerald-400" : "text-muted-foreground")}>
{r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"}
</span>
</div>
))}
</div>
</div>
{/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */}
<div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard />
<TokenPerformanceCard />
</div>
</div>
)
}
+34 -34
View File
@@ -1,34 +1,34 @@
import {
LayoutDashboard,
Boxes,
Brain,
Plug,
Bot,
AppWindow,
SquareTerminal,
HelpCircle,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide"
export interface NavItem {
id: ViewId
label: string
hint: string
icon: LucideIcon
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
{ id: "terminal", label: "Hermes GUI", hint: "Eingebaute Hermes-Weboberfläche (Threads & Tool-Calls)", icon: AppWindow },
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
]
import {
LayoutDashboard,
Boxes,
Brain,
Plug,
Bot,
AppWindow,
SquareTerminal,
HelpCircle,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide"
export interface NavItem {
id: ViewId
label: string
hint: string
icon: LucideIcon
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
{ id: "terminal", label: "Hermes GUI", hint: "Eingebaute Hermes-Weboberfläche (Threads & Tool-Calls)", icon: AppWindow },
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
]
+46 -46
View File
@@ -1,46 +1,46 @@
import { useState } from "react"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover"
export function ModelsView() {
const [tab, setTab] = useState<"cockpit" | "discover">("cockpit")
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Modell-Manager
</h1>
<p className="text-sm text-muted-foreground">
Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an.
</p>
</div>
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
{(["cockpit", "discover"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "cockpit" ? "Werkbank" : "Modelle finden"}
</button>
))}
</div>
</div>
<JobsBar />
<div className="transition-all duration-300">
{tab === "cockpit" ? <ModelsWorkbench /> : <Discover />}
</div>
</div>
)
}
import { useState } from "react"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover"
export function ModelsView() {
const [tab, setTab] = useState<"cockpit" | "discover">("cockpit")
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Modell-Manager
</h1>
<p className="text-sm text-muted-foreground">
Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an.
</p>
</div>
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
{(["cockpit", "discover"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "cockpit" ? "Werkbank" : "Modelle finden"}
</button>
))}
</div>
</div>
<JobsBar />
<div className="transition-all duration-300">
{tab === "cockpit" ? <ModelsWorkbench /> : <Discover />}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+295 -295
View File
@@ -1,295 +1,295 @@
import { useState } from "react"
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { api } from "@/lib/api"
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { fmtBytes } from "@/lib/format"
import { FitBadge } from "@/components/models/ModelBadges"
import { ModelBrowse } from "./ModelBrowse"
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
fast: {
title: "Schnelles Alltags-Hirn",
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
icon: Zap
},
heavy: {
title: "Schweres Reasoning",
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
icon: Brain
},
coder: {
title: "Coden & Entwicklung",
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
icon: Code
},
vision: {
title: "Bilder & Vision",
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
icon: Eye
},
hermes: {
title: "Lucys Hirn (Agent)",
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
icon: BrainCircuit
},
scout: {
title: "Multimodal-Allrounder",
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
icon: Compass
}
}
export function Discover() {
const { data, isLoading: loading, error: loadErr } = useDiscover()
const { data: modelsResp } = useModels()
const { data: updates } = useUpdates()
const models = modelsResp?.models ?? []
const error = loadErr ? String(loadErr) : ""
const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [mode, setMode] = useState<"recommended" | "browse">("recommended")
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
} catch (e) {
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
}
}
return (
<div className="space-y-6">
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 w-fit">
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
<button
key={m}
onClick={() => setMode(m)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
{mode === "browse" ? (
<ModelBrowse />
) : loading ? (
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
) : (error || !data) ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Empfehlungsdienst temporär nicht erreichbar ({error}).
</div>
) : (
<div className="space-y-8">
{/* Informational Header */}
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
<div>
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
</div>
<div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
</div>
</div>
{/* Sockets/Slots Grid */}
<div className="grid gap-6 md:grid-cols-2">
{data.categories.map((cat) => {
const meta = ROLE_METADATA[cat.role] || {
title: cat.title || cat.role,
desc: "Spezifisches Modell für diese Systemrolle.",
icon: Layers
}
const IconComponent = meta.icon
// Check if a model is installed for this role — per Rolle ODER Alias:
// ein Modell kann eine Kategorie über einen Alias bedienen (z.B. Qwen3.6 hat
// role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
const installedModel = models.find(
(m) => m.role === cat.role || (m.aliases || []).includes(cat.role),
)
// Check if an upgrade is available for this role
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
// Get the primary recommended model
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
if (!recommendedModel) return null
const isInstallingRecommended = installing[recommendedModel.repo]
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
const isExpanded = !!expandedAlternatives[cat.role]
return (
<div
key={cat.role}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
)}
>
<div className="space-y-4">
{/* Socket Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
<IconComponent className="h-5.5 w-5.5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
<span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
Rolle: {cat.role}
</span>
</div>
</div>
{/* Status Badges */}
{installedModel ? (
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aktiviert
</span>
) : (
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
Frei
</span>
)}
</div>
{/* Role Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{meta.desc}
</p>
{/* Current vs Recommended model card */}
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
{installedModel ? (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
{installedModel.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
<span></span>
<span>Quant: {installedModel.quant || "GGUF"}</span>
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
{recommendedModel.name}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
<span>Ersteller: {recommendedModel.author}</span>
<span></span>
<span>Quant: {recommendedModel.quant}</span>
</div>
<div className="flex items-center gap-1.5 pt-0.5">
<FitBadge fit={recommendedModel.fit} />
</div>
</div>
)}
</div>
{/* Main Action Button */}
<div className="pt-1">
{installedModel ? (
hasUpgrade ? (
<div className="space-y-2">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!installing[hasUpgrade.repo]}
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
>
<Download className="h-3.5 w-3.5" />
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
</button>
</div>
) : (
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
<Check className="h-4 w-4" /> Auf neuestem Stand
</div>
)
) : (
<button
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!isInstallingRecommended}
className={cn(
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
isInstallingRecommended
? "border-primary/40 bg-primary/5 text-primary"
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
)}
>
<Download className="h-3.5 w-3.5" />
{isInstallingRecommended || "Optimales Modell einsetzen"}
</button>
)}
</div>
</div>
{/* Collapsible alternatives list */}
{alternativeModels.length > 0 && (
<div className="border-t border-border/20 pt-3">
<button
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
</button>
{isExpanded && (
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
{alternativeModels.map((alt) => (
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
{alt.name}
</div>
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
<span>Quant: {alt.quant}</span>
<span></span>
<span>{alt.fit.text}</span>
</div>
</div>
<button
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
disabled={!!installing[alt.repo]}
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
>
{installing[alt.repo] || "Installieren"}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
import { useState } from "react"
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { api } from "@/lib/api"
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { fmtBytes } from "@/lib/format"
import { FitBadge } from "@/components/models/ModelBadges"
import { ModelBrowse } from "./ModelBrowse"
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
fast: {
title: "Schnelles Alltags-Hirn",
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
icon: Zap
},
heavy: {
title: "Schweres Reasoning",
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
icon: Brain
},
coder: {
title: "Coden & Entwicklung",
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
icon: Code
},
vision: {
title: "Bilder & Vision",
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
icon: Eye
},
hermes: {
title: "Lucys Hirn (Agent)",
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
icon: BrainCircuit
},
scout: {
title: "Multimodal-Allrounder",
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
icon: Compass
}
}
export function Discover() {
const { data, isLoading: loading, error: loadErr } = useDiscover()
const { data: modelsResp } = useModels()
const { data: updates } = useUpdates()
const models = modelsResp?.models ?? []
const error = loadErr ? String(loadErr) : ""
const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [mode, setMode] = useState<"recommended" | "browse">("recommended")
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
} catch (e) {
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
}
}
return (
<div className="space-y-6">
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 w-fit">
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
<button
key={m}
onClick={() => setMode(m)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
{mode === "browse" ? (
<ModelBrowse />
) : loading ? (
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
) : (error || !data) ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Empfehlungsdienst temporär nicht erreichbar ({error}).
</div>
) : (
<div className="space-y-8">
{/* Informational Header */}
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
<div>
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
</div>
<div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
</div>
</div>
{/* Sockets/Slots Grid */}
<div className="grid gap-6 md:grid-cols-2">
{data.categories.map((cat) => {
const meta = ROLE_METADATA[cat.role] || {
title: cat.title || cat.role,
desc: "Spezifisches Modell für diese Systemrolle.",
icon: Layers
}
const IconComponent = meta.icon
// Check if a model is installed for this role — per Rolle ODER Alias:
// ein Modell kann eine Kategorie über einen Alias bedienen (z.B. Qwen3.6 hat
// role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
const installedModel = models.find(
(m) => m.role === cat.role || (m.aliases || []).includes(cat.role),
)
// Check if an upgrade is available for this role
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
// Get the primary recommended model
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
if (!recommendedModel) return null
const isInstallingRecommended = installing[recommendedModel.repo]
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
const isExpanded = !!expandedAlternatives[cat.role]
return (
<div
key={cat.role}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
)}
>
<div className="space-y-4">
{/* Socket Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
<IconComponent className="h-5.5 w-5.5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
<span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
Rolle: {cat.role}
</span>
</div>
</div>
{/* Status Badges */}
{installedModel ? (
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aktiviert
</span>
) : (
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
Frei
</span>
)}
</div>
{/* Role Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{meta.desc}
</p>
{/* Current vs Recommended model card */}
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
{installedModel ? (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
{installedModel.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
<span></span>
<span>Quant: {installedModel.quant || "GGUF"}</span>
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
{recommendedModel.name}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
<span>Ersteller: {recommendedModel.author}</span>
<span></span>
<span>Quant: {recommendedModel.quant}</span>
</div>
<div className="flex items-center gap-1.5 pt-0.5">
<FitBadge fit={recommendedModel.fit} />
</div>
</div>
)}
</div>
{/* Main Action Button */}
<div className="pt-1">
{installedModel ? (
hasUpgrade ? (
<div className="space-y-2">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!installing[hasUpgrade.repo]}
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
>
<Download className="h-3.5 w-3.5" />
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
</button>
</div>
) : (
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
<Check className="h-4 w-4" /> Auf neuestem Stand
</div>
)
) : (
<button
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!isInstallingRecommended}
className={cn(
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
isInstallingRecommended
? "border-primary/40 bg-primary/5 text-primary"
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
)}
>
<Download className="h-3.5 w-3.5" />
{isInstallingRecommended || "Optimales Modell einsetzen"}
</button>
)}
</div>
</div>
{/* Collapsible alternatives list */}
{alternativeModels.length > 0 && (
<div className="border-t border-border/20 pt-3">
<button
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
</button>
{isExpanded && (
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
{alternativeModels.map((alt) => (
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
{alt.name}
</div>
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
<span>Quant: {alt.quant}</span>
<span></span>
<span>{alt.fit.text}</span>
</div>
</div>
<button
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
disabled={!!installing[alt.repo]}
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
>
{installing[alt.repo] || "Installieren"}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -1,104 +1,104 @@
import { Check, X, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtSize } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { RoleLabel } from "@/components/models/ModelBadges"
import type { ModelInfo, RoleRecResp } from "@/lib/api"
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
const isProtected = (role?: string | null) => !!roleMeta(role).protected
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
role: string
roleRec: RoleRecResp | null
models: ModelInfo[]
onAssign: (modelName: string) => void
onClose: () => void
}) {
const rec = roleRec && roleRec.role === role ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(role).label} konfigurieren`}>
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={role} /> festlegen
</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
</p>
{rec?.recommended && (
<button
onClick={() => onAssign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(role) ? (
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben wähle stattdessen ein anderes Modell.
</div>
) : (
<button
onClick={() => onAssign("")}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
)}
{ordered.map((m) => {
const r = recByName[m.name]
const isCur = m.role === role
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => onAssign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
)
})}
</div>
</div>
</div>
)
}
import { Check, X, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtSize } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { RoleLabel } from "@/components/models/ModelBadges"
import type { ModelInfo, RoleRecResp } from "@/lib/api"
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
const isProtected = (role?: string | null) => !!roleMeta(role).protected
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
role: string
roleRec: RoleRecResp | null
models: ModelInfo[]
onAssign: (modelName: string) => void
onClose: () => void
}) {
const rec = roleRec && roleRec.role === role ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(role).label} konfigurieren`}>
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={role} /> festlegen
</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
</p>
{rec?.recommended && (
<button
onClick={() => onAssign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(role) ? (
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben wähle stattdessen ein anderes Modell.
</div>
) : (
<button
onClick={() => onAssign("")}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
)}
{ordered.map((m) => {
const r = recByName[m.name]
const isCur = m.role === role
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => onAssign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
)
})}
</div>
</div>
</div>
)
}
+218 -218
View File
@@ -1,218 +1,218 @@
"""
MC2 Memory Hermes-Memory-Provider, der ans geteilte **Mem0-Gedächtnis** hängt (über MC2 :9001,
das den Mem0-Sidecar :8765 proxyt). Macht das Gedächtnis für Hermes **hands-off**:
- `sync_turn` nach jedem Turn werden die Turn-Messages an `/api/memory/learn` geschickt;
Mem0 EXTRAHIERT dauerhafte Fakten selbst (infer=True), dedupliziert semantisch. Nicht-blockierend
(Hintergrund-Worker), damit der Agent nie auf die LLM-Extraktion wartet.
- `prefetch` vor jedem Turn semantische Suche gegen die Nutzer-Message; relevante Fakten werden
als Kontext eingeblendet (automatischer Recall).
**Context-only:** `get_tool_schemas()` liefert `[]` der Agent bekommt KEINE Memory-Tools. Damit
entfällt das Tool-Loop-Risiko, wegen dem das native Memory-Toolset abgeschaltet ist
(`agent.disabled_toolsets: [..., memory]` bleibt). Single Source of Truth bleibt der MC2-Sidecar
(NoThink-Fix + alleiniger Chroma-Besitzer) dieser Provider ist nur ein dünner HTTP-Client.
Aktivierung (in ~/.hermes/config.yaml):
memory:
memory_enabled: true
provider: mc2-memory
"""
from __future__ import annotations
import logging
import os
import queue
import re
import threading
import time
from typing import Any, Dict, List, Optional
import httpx
from agent.memory_provider import MemoryProvider
log = logging.getLogger(__name__)
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
RECALL_LIMIT = int(os.environ.get("MC2_MEMORY_RECALL_LIMIT", "5"))
# 0.5 statt 0.3: nur starke Treffer einblenden → weniger Rausch-Fakten UND ein stabilerer
# Prompt-Prefix (oft leer), damit --cache-reuse über Turns greift.
RECALL_MIN_SCORE = float(os.environ.get("MC2_MEMORY_RECALL_MIN_SCORE", "0.5"))
# 25 statt 12: triviale Kurz-Turns (Begrüßung/Quittung) gar nicht erst zum Lernen schicken.
MIN_USER_LEN = int(os.environ.get("MC2_MEMORY_MIN_USER_LEN", "25")) # zu Kurzes nicht lernen
# Anti-Memory-Poisoning (Stufe 0 Security): aus Turns mit untrusted Bildschirm-/Web-Inhalt NICHT
# auto-lernen. Lucys Bildschirm-Sicht prependet diesen Marker an die User-Message (voice.py); auf dem
# Schirm sichtbarer Text könnte Injection-Anweisungen tragen, die sonst DAUERHAFT ins Gedächtnis
# wandern und Tage später ungefragt wiederverwendet werden.
SKIP_UNTRUSTED = os.environ.get("MC2_MEMORY_SKIP_UNTRUSTED", "1") not in ("0", "false", "False", "no")
UNTRUSTED_MARKERS = ("[Bildschirm-Sicht",)
# Triviale Turns, die keinen dauerhaften Fakt tragen (reine Begrüßung/Quittung, Aufwärm-/Test-
# Kommandos) → gar nicht erst zum Auto-Lernen schicken. Spart Extraktions-Läufe und verhindert
# Müll-Fakten aus Meta-/Ablauf-Turns.
_TRIVIAL_TURN_RE = re.compile(
r"^\s*(?:(?:hi|hallo|hey|moin|servus|guten\s+(?:morgen|tag|abend)|danke\w*|"
r"ok(?:ay)?|alles\s+klar|passt|super|top|cool|nice|ja|nein|jo|jup|jap)[\s.,!?]*)+$"
r"|^\s*(?:aufw(?:ä|ae)rmen|/?test)\b",
re.IGNORECASE,
)
def _is_trivial_turn(text: str) -> bool:
return bool(_TRIVIAL_TURN_RE.search(text or ""))
def _headers() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
class MC2MemoryProvider(MemoryProvider):
def __init__(self) -> None:
self._session_id = ""
self._agent_context = "primary"
self._q: "queue.Queue[list]" = queue.Queue(maxsize=200)
self._worker: Optional[threading.Thread] = None
self._stop = threading.Event()
self._recall_cache: Dict[str, str] = {}
# -- Identität / Verfügbarkeit -------------------------------------------
def name(self) -> str:
return "mc2-memory"
def is_available(self) -> bool:
try:
r = httpx.get(f"{MC_URL}/api/health", timeout=3.0)
return r.status_code == 200
except Exception:
return False
def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id
# Nur im primären Agent-Kontext lernen (cron/subagent-Systemprompts nicht einlernen).
self._agent_context = kwargs.get("agent_context", "primary")
if self._worker is None or not self._worker.is_alive():
self._stop.clear()
self._worker = threading.Thread(target=self._run, name="mc2-memory-learn", daemon=True)
self._worker.start()
def system_prompt_block(self) -> str:
return (
"Du hast ein dauerhaftes, geteiltes Langzeitgedächtnis (MC2/Mem0). Relevante Fakten "
"werden dir vor einem Turn automatisch eingeblendet; neue dauerhafte Fakten über den "
"Nutzer und das Projekt werden nach dem Turn automatisch gelernt — du musst dafür "
"nichts tun."
)
# -- Recall (vor dem Turn) -----------------------------------------------
def prefetch(self, query: str, *, session_id: str = "") -> str:
q = (query or "").strip()
if len(q) < 3:
return ""
if q in self._recall_cache:
return self._recall_cache[q]
try:
r = httpx.get(f"{MC_URL}/api/memory", params={"q": q}, headers=_headers(), timeout=5.0)
r.raise_for_status()
items = r.json()
except Exception as exc: # nie den Turn blockieren/abbrechen
log.debug("mc2-memory prefetch failed: %s", exc)
return ""
facts = [
i for i in items
if i.get("score") is None or float(i.get("score", 0)) >= RECALL_MIN_SCORE
][:RECALL_LIMIT]
out = ""
if facts:
lines = "\n".join(f"- {f.get('content', '')}" for f in facts)
out = f"Relevante Fakten aus dem Langzeitgedächtnis:\n{lines}"
# Cache klein halten
if len(self._recall_cache) > 64:
self._recall_cache.clear()
self._recall_cache[q] = out
return out
# -- Lernen (nach dem Turn) ----------------------------------------------
def sync_turn(self, user_content: str, assistant_content: str, *,
session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None) -> None:
if self._agent_context != "primary":
return
u = (user_content or "").strip()
if len(u) < MIN_USER_LEN:
return
if _is_trivial_turn(u):
log.debug("mc2-memory: trivialer Turn (Begrüßung/Quittung/Aufwärmen) — Auto-Lernen übersprungen")
return
if SKIP_UNTRUSTED and any(m in u for m in UNTRUSTED_MARKERS):
log.debug("mc2-memory: Turn mit untrusted Bildschirm-/Web-Inhalt — Auto-Lernen übersprungen")
return
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
a = (assistant_content or "").strip()
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls).
# Bewusst NUR die Tool-NAMEN mitlernen ("hat X per Tool Y geprüft") — Tool-ERGEBNISSE
# sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
if messages:
tools = []
for m in messages:
for tc in (m.get("tool_calls") or []):
name = ((tc.get("function") or {}).get("name") or "").strip()
if name and name not in tools:
tools.append(name)
if tools:
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
if a:
msgs.append({"role": "assistant", "content": a[:4000]})
try:
self._q.put_nowait(msgs)
except queue.Full:
log.debug("mc2-memory learn queue full — Turn übersprungen")
# Neue Fakten können gelandet sein → Recall-Cache invalidieren.
self._recall_cache.clear()
def _post_learn(self, msgs: list) -> None:
try:
httpx.post(f"{MC_URL}/api/memory/learn",
json={"messages": msgs, "source": "hermes"},
headers=_headers(), timeout=90.0)
except Exception as exc:
log.debug("mc2-memory learn POST failed: %s", exc)
def _run(self) -> None:
while not self._stop.is_set():
try:
msgs = self._q.get(timeout=1.0)
except queue.Empty:
continue
try:
self._post_learn(msgs)
finally:
self._q.task_done()
def _flush(self) -> None:
"""Offene Turns garantiert rausschreiben — bei Session-Ende/CLI-Exit, wo der
Prozess sofort beendet wird (daemon-Worker würde sonst mitten im POST sterben)."""
self._stop.set()
if self._worker and self._worker.is_alive():
self._worker.join(timeout=95.0) # laufenden Worker-POST zu Ende lassen
while True: # vom Worker nicht mehr abgeholte Turns
try:
msgs = self._q.get_nowait()
except queue.Empty:
break
self._post_learn(msgs)
# -- Context-only: keine Agent-Tools → kein Tool-Loop --------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return []
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
self._flush()
def shutdown(self) -> None:
self._flush()
def register(ctx) -> None:
ctx.register_memory_provider(MC2MemoryProvider())
"""
MC2 Memory Hermes-Memory-Provider, der ans geteilte **Mem0-Gedächtnis** hängt (über MC2 :9001,
das den Mem0-Sidecar :8765 proxyt). Macht das Gedächtnis für Hermes **hands-off**:
- `sync_turn` nach jedem Turn werden die Turn-Messages an `/api/memory/learn` geschickt;
Mem0 EXTRAHIERT dauerhafte Fakten selbst (infer=True), dedupliziert semantisch. Nicht-blockierend
(Hintergrund-Worker), damit der Agent nie auf die LLM-Extraktion wartet.
- `prefetch` vor jedem Turn semantische Suche gegen die Nutzer-Message; relevante Fakten werden
als Kontext eingeblendet (automatischer Recall).
**Context-only:** `get_tool_schemas()` liefert `[]` der Agent bekommt KEINE Memory-Tools. Damit
entfällt das Tool-Loop-Risiko, wegen dem das native Memory-Toolset abgeschaltet ist
(`agent.disabled_toolsets: [..., memory]` bleibt). Single Source of Truth bleibt der MC2-Sidecar
(NoThink-Fix + alleiniger Chroma-Besitzer) dieser Provider ist nur ein dünner HTTP-Client.
Aktivierung (in ~/.hermes/config.yaml):
memory:
memory_enabled: true
provider: mc2-memory
"""
from __future__ import annotations
import logging
import os
import queue
import re
import threading
import time
from typing import Any, Dict, List, Optional
import httpx
from agent.memory_provider import MemoryProvider
log = logging.getLogger(__name__)
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
RECALL_LIMIT = int(os.environ.get("MC2_MEMORY_RECALL_LIMIT", "5"))
# 0.5 statt 0.3: nur starke Treffer einblenden → weniger Rausch-Fakten UND ein stabilerer
# Prompt-Prefix (oft leer), damit --cache-reuse über Turns greift.
RECALL_MIN_SCORE = float(os.environ.get("MC2_MEMORY_RECALL_MIN_SCORE", "0.5"))
# 25 statt 12: triviale Kurz-Turns (Begrüßung/Quittung) gar nicht erst zum Lernen schicken.
MIN_USER_LEN = int(os.environ.get("MC2_MEMORY_MIN_USER_LEN", "25")) # zu Kurzes nicht lernen
# Anti-Memory-Poisoning (Stufe 0 Security): aus Turns mit untrusted Bildschirm-/Web-Inhalt NICHT
# auto-lernen. Lucys Bildschirm-Sicht prependet diesen Marker an die User-Message (voice.py); auf dem
# Schirm sichtbarer Text könnte Injection-Anweisungen tragen, die sonst DAUERHAFT ins Gedächtnis
# wandern und Tage später ungefragt wiederverwendet werden.
SKIP_UNTRUSTED = os.environ.get("MC2_MEMORY_SKIP_UNTRUSTED", "1") not in ("0", "false", "False", "no")
UNTRUSTED_MARKERS = ("[Bildschirm-Sicht",)
# Triviale Turns, die keinen dauerhaften Fakt tragen (reine Begrüßung/Quittung, Aufwärm-/Test-
# Kommandos) → gar nicht erst zum Auto-Lernen schicken. Spart Extraktions-Läufe und verhindert
# Müll-Fakten aus Meta-/Ablauf-Turns.
_TRIVIAL_TURN_RE = re.compile(
r"^\s*(?:(?:hi|hallo|hey|moin|servus|guten\s+(?:morgen|tag|abend)|danke\w*|"
r"ok(?:ay)?|alles\s+klar|passt|super|top|cool|nice|ja|nein|jo|jup|jap)[\s.,!?]*)+$"
r"|^\s*(?:aufw(?:ä|ae)rmen|/?test)\b",
re.IGNORECASE,
)
def _is_trivial_turn(text: str) -> bool:
return bool(_TRIVIAL_TURN_RE.search(text or ""))
def _headers() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
class MC2MemoryProvider(MemoryProvider):
def __init__(self) -> None:
self._session_id = ""
self._agent_context = "primary"
self._q: "queue.Queue[list]" = queue.Queue(maxsize=200)
self._worker: Optional[threading.Thread] = None
self._stop = threading.Event()
self._recall_cache: Dict[str, str] = {}
# -- Identität / Verfügbarkeit -------------------------------------------
def name(self) -> str:
return "mc2-memory"
def is_available(self) -> bool:
try:
r = httpx.get(f"{MC_URL}/api/health", timeout=3.0)
return r.status_code == 200
except Exception:
return False
def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id
# Nur im primären Agent-Kontext lernen (cron/subagent-Systemprompts nicht einlernen).
self._agent_context = kwargs.get("agent_context", "primary")
if self._worker is None or not self._worker.is_alive():
self._stop.clear()
self._worker = threading.Thread(target=self._run, name="mc2-memory-learn", daemon=True)
self._worker.start()
def system_prompt_block(self) -> str:
return (
"Du hast ein dauerhaftes, geteiltes Langzeitgedächtnis (MC2/Mem0). Relevante Fakten "
"werden dir vor einem Turn automatisch eingeblendet; neue dauerhafte Fakten über den "
"Nutzer und das Projekt werden nach dem Turn automatisch gelernt — du musst dafür "
"nichts tun."
)
# -- Recall (vor dem Turn) -----------------------------------------------
def prefetch(self, query: str, *, session_id: str = "") -> str:
q = (query or "").strip()
if len(q) < 3:
return ""
if q in self._recall_cache:
return self._recall_cache[q]
try:
r = httpx.get(f"{MC_URL}/api/memory", params={"q": q}, headers=_headers(), timeout=5.0)
r.raise_for_status()
items = r.json()
except Exception as exc: # nie den Turn blockieren/abbrechen
log.debug("mc2-memory prefetch failed: %s", exc)
return ""
facts = [
i for i in items
if i.get("score") is None or float(i.get("score", 0)) >= RECALL_MIN_SCORE
][:RECALL_LIMIT]
out = ""
if facts:
lines = "\n".join(f"- {f.get('content', '')}" for f in facts)
out = f"Relevante Fakten aus dem Langzeitgedächtnis:\n{lines}"
# Cache klein halten
if len(self._recall_cache) > 64:
self._recall_cache.clear()
self._recall_cache[q] = out
return out
# -- Lernen (nach dem Turn) ----------------------------------------------
def sync_turn(self, user_content: str, assistant_content: str, *,
session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None) -> None:
if self._agent_context != "primary":
return
u = (user_content or "").strip()
if len(u) < MIN_USER_LEN:
return
if _is_trivial_turn(u):
log.debug("mc2-memory: trivialer Turn (Begrüßung/Quittung/Aufwärmen) — Auto-Lernen übersprungen")
return
if SKIP_UNTRUSTED and any(m in u for m in UNTRUSTED_MARKERS):
log.debug("mc2-memory: Turn mit untrusted Bildschirm-/Web-Inhalt — Auto-Lernen übersprungen")
return
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
a = (assistant_content or "").strip()
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls).
# Bewusst NUR die Tool-NAMEN mitlernen ("hat X per Tool Y geprüft") — Tool-ERGEBNISSE
# sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
if messages:
tools = []
for m in messages:
for tc in (m.get("tool_calls") or []):
name = ((tc.get("function") or {}).get("name") or "").strip()
if name and name not in tools:
tools.append(name)
if tools:
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
if a:
msgs.append({"role": "assistant", "content": a[:4000]})
try:
self._q.put_nowait(msgs)
except queue.Full:
log.debug("mc2-memory learn queue full — Turn übersprungen")
# Neue Fakten können gelandet sein → Recall-Cache invalidieren.
self._recall_cache.clear()
def _post_learn(self, msgs: list) -> None:
try:
httpx.post(f"{MC_URL}/api/memory/learn",
json={"messages": msgs, "source": "hermes"},
headers=_headers(), timeout=90.0)
except Exception as exc:
log.debug("mc2-memory learn POST failed: %s", exc)
def _run(self) -> None:
while not self._stop.is_set():
try:
msgs = self._q.get(timeout=1.0)
except queue.Empty:
continue
try:
self._post_learn(msgs)
finally:
self._q.task_done()
def _flush(self) -> None:
"""Offene Turns garantiert rausschreiben — bei Session-Ende/CLI-Exit, wo der
Prozess sofort beendet wird (daemon-Worker würde sonst mitten im POST sterben)."""
self._stop.set()
if self._worker and self._worker.is_alive():
self._worker.join(timeout=95.0) # laufenden Worker-POST zu Ende lassen
while True: # vom Worker nicht mehr abgeholte Turns
try:
msgs = self._q.get_nowait()
except queue.Empty:
break
self._post_learn(msgs)
# -- Context-only: keine Agent-Tools → kein Tool-Loop --------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return []
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
self._flush()
def shutdown(self) -> None:
self._flush()
def register(ctx) -> None:
ctx.register_memory_provider(MC2MemoryProvider())
+326 -326
View File
@@ -1,326 +1,326 @@
#!/usr/bin/env python3
"""
Mission Control 2.0 Stack-Management MCP Server (für Hermes).
Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern:
Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle
laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden,
System-Status lesen. Spricht die MC-2-REST-API (/api/*).
Damit ist Hermes echte Control-Plane des Stacks (Plan: MC-Mgmt-MCP, damit Hermes
den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes.
Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ),
damit kleine Modelle nicht in Aufruf-Schleifen laufen.
Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional).
Install: pip install mcp httpx
"""
import json
import os
import httpx
from mcp.server.fastmcp import FastMCP
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
mcp = FastMCP("mission-control-stack")
def _h() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
def _get(path: str, **params):
r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict | None = None):
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict):
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20)
r.raise_for_status()
return r.json()
def _delete(path: str):
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30)
r.raise_for_status()
return r.json()
# ── Modelle: ansehen / entdecken ────────────────────────────────────────────
@mcp.tool()
def list_models() -> str:
"""Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten)
und welche gerade geladen sind."""
data = _get("/api/models")
running = set(data.get("running") or [])
lines = []
for m in data.get("models", []):
warm = " [LÄUFT]" if m["name"] in running else ""
lines.append(
f"- {m['name']}{warm} (Rolle: {m.get('role') or ''}, ctx: {m.get('ctx')}, "
f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})"
)
return "\n".join(lines) or "Keine Modelle konfiguriert."
@mcp.tool()
def discover_models() -> str:
"""Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit).
Nutze dies, bevor du ein neues Modell vorschlägst/installierst."""
data = _get("/api/discover")
out = [f"System-RAM: {data.get('sys_ram_gb')} GB"]
for c in data.get("categories", []):
rec = c.get("recommended") or ""
out.append(f"\n## {c['title']} (beste Wahl: {rec})")
for m in c["models"][:3]:
out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)")
return "\n".join(out)
@mcp.tool()
def hf_search(q: str) -> str:
"""Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes
Modell finden willst, das nicht in discover_models auftaucht."""
res = _get("/api/hf/search", q=q).get("results", [])
if not res:
return f"Keine HF-Treffer für '{q}'."
return json.dumps(res[:15], ensure_ascii=False, indent=2)
@mcp.tool()
def hf_quants(repo: str) -> str:
"""Listet die verfügbaren Quantisierungen (Q4_K_M, Q8_0, …) eines HF-Repos.
Nutze dies VOR install_model, um die passende Quant-Stufe zu wählen."""
d = _get("/api/hf/quants", repo=repo)
return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden."
# ── Modelle: installieren / löschen / laden ─────────────────────────────────
@mcp.tool()
def install_model(repo: str, role: str = "", quant: str = "Q4_K_M",
ctx: int = 0, jinja: bool = False) -> str:
"""Lädt ein GGUF-Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein. Akzeptiert volle HF-URL ODER org/repo. ctx=0 setup-bewusst automatisch.
jinja=True für Tool-Calling-Modelle (Agent-Hirne). role z.B. fast/heavy/coder/vision.
Gibt eine job_id zurück Fortschritt via list_jobs prüfen."""
body = {"repo": repo, "quant": quant, "jinja": jinja}
if role:
body["role"] = role
if ctx > 0:
body["ctx"] = ctx
r = _post("/api/models/install", body)
return (f"Installation gestartet: model_id={r.get('model_id')}, job_id={r.get('job_id')}, "
f"{r.get('files')} Datei(en), ~{round((r.get('total_bytes') or 0)/1e9, 1)} GB. "
f"Fortschritt mit list_jobs prüfen.")
@mcp.tool()
def list_jobs() -> str:
"""Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt."""
jobs = _get("/api/jobs").get("jobs", [])
if not jobs:
return "Keine Jobs."
out = []
for j in jobs:
prog = j.get("progress")
p = f" {round(prog*100)}%" if isinstance(prog, (int, float)) else ""
out.append(f"- [{j.get('id', '?')[:8]}] {j.get('label', '?')}{j.get('status', '?')}{p}")
return "\n".join(out)
@mcp.tool()
def cancel_job(job_id: str) -> str:
"""Bricht einen laufenden Job (z.B. Download) ab."""
ok = _post(f"/api/jobs/{job_id}/cancel").get("ok")
return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar."
@mcp.tool()
def delete_model(model_id: str) -> str:
"""Entfernt ein Modell aus llama-swap (Eintrag + Dateien). NUR wenn der Nutzer es
will oder es eindeutig veraltet/ersetzt ist vorher list_models prüfen."""
_delete(f"/api/models/{model_id}")
return f"Modell '{model_id}' gelöscht."
@mcp.tool()
def load_model(model_id: str) -> str:
"""Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten
echten Request zu vermeiden."""
return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen."
@mcp.tool()
def unload_model(model_id: str = "") -> str:
"""Entlädt ein Modell aus dem Speicher (gibt RAM/GTT frei). Leer = ALLE entladen."""
path = f"/api/models/{model_id}/unload" if model_id else "/api/models/unload"
return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen."
# ── Rollen & Routing ────────────────────────────────────────────────────────
@mcp.tool()
def set_model_role(model_id: str, role: str = "") -> str:
"""Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…).
Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist."""
_post(f"/api/models/{model_id}/role", {"role": role or None})
return f"Rolle für '{model_id}': {role or '— (entfernt)'}"
@mcp.tool()
def set_route(name: str, target_alias: str) -> str:
"""Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias."""
_put("/api/routing/route", {"name": name, "target_alias": target_alias})
return f"Routing gesetzt: {name}{target_alias}"
@mcp.tool()
def routing_overview() -> str:
"""Zeigt das aktuelle Gateway-Routing + Fallbacks."""
return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2)
@mcp.tool()
def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str:
"""Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias).
role z.B. fast/heavy/coder/vision/hermes. jinja=True für Tool-Calling (Agent-Hirn).
Für Download+Eintrag in einem Schritt nutze install_model."""
res = _post("/api/models/register",
{"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja})
return f"Eingetragen als '{res.get('model_id')}'."
# ── System / Status / Wartung ───────────────────────────────────────────────
@mcp.tool()
def system_status() -> str:
"""Live-Auslastung der Box (CPU/RAM/GPU/Disk)."""
s = _get("/api/system/status")
g = s.get("gpu") or {}
return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · "
f"GPU {g.get('busy_percent', '')}% · Disk {(s.get('disk') or {}).get('percent', '')}%")
@mcp.tool()
def list_services() -> str:
"""Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice)."""
data = _get("/api/system/services")
return "\n".join(
f"{'' if s['ok'] else ''} {s['name']} ({s['url']})"
for s in data.get("services", [])
) or "Keine Dienste."
@mcp.tool()
def restart_service(service: str) -> str:
"""Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 |
hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart
(llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist."""
res = _post("/api/system/restart", {"service": service})
return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}"
@mcp.tool()
def backup_now() -> str:
"""Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten
Änderungen oder wenn der Nutzer ein Backup will."""
r = _post("/api/system/backup")
return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}"
@mcp.tool()
def list_backups() -> str:
"""Listet vorhandene Backups (Datei, Zeit, Größe)."""
bks = _get("/api/system/backups").get("backups", [])
if not bks:
return "Keine Backups."
return json.dumps(bks, ensure_ascii=False, indent=2)
@mcp.tool()
def token_stats() -> str:
"""Token-Verbrauch + Cloud-Ersparnis (was die lokale Nutzung gegenüber Cloud-APIs spart)."""
return json.dumps(_get("/api/system/token-stats"), ensure_ascii=False, indent=2)
@mcp.tool()
def check_updates() -> str:
"""Prüft, ob Updates für OS / Engine (llama.cpp) / Router (llama-swap) / Hermes anstehen."""
return json.dumps(_get("/api/maintenance/updates"), ensure_ascii=False, indent=2)
@mcp.tool()
def apply_update(kind: str) -> str:
"""Wendet ein Update an. kind: os | engine | swap | hermes. NUR wenn der Nutzer es will
oder check_updates ein anstehendes Update zeigt. Kann den jeweiligen Dienst kurz neu starten."""
kind = kind.lower().strip()
routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update",
"swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"}
if kind not in routes:
return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes."
return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2)
# ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ─────────
@mcp.tool()
def reminder_create(text: str, when: str, repeat: str = "") -> str:
"""Legt eine Erinnerung an: zur angegebenen Zeit sagt die Box dem Commander den Text AKTIV
an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden
will ('erinner mich', 'sag mir um', 'jeden Morgen').
when = ISO-8601 mit Datum UND Uhrzeit in der Lokalzeit des Commanders, z.B. 2026-07-04T09:00.
Relative Angaben ('in 20 Minuten', 'morgen früh') selbst aus der aktuellen Zeit im Kontext
umrechnen. repeat: '' einmalig | daily (täglich) | weekdays (MoFr) | weekly (wöchentlich).
text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor das ergänzt die Box)."""
try:
item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"]
except httpx.HTTPStatusError as e:
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "")
return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}"
@mcp.tool()
def reminder_list() -> str:
"""Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen
hiermit die Nummer ermitteln."""
items = _get("/api/reminders")["items"]
if not items:
return "Keine Erinnerungen angelegt."
rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"}
return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')}{i['text']}" for i in items)
@mcp.tool()
def reminder_delete(reminder_id: int) -> str:
"""Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf
ausdrücklichen Wunsch des Commanders."""
try:
gone = _delete(f"/api/reminders/{reminder_id}")["deleted"]
except httpx.HTTPStatusError as e:
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)."
@mcp.tool()
def service_logs(service: str, lines: int = 100) -> str:
"""Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway,
mission-control-2, voice-service."""
return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines),
ensure_ascii=False, indent=2)
if __name__ == "__main__":
mcp.run()
#!/usr/bin/env python3
"""
Mission Control 2.0 Stack-Management MCP Server (für Hermes).
Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern:
Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle
laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden,
System-Status lesen. Spricht die MC-2-REST-API (/api/*).
Damit ist Hermes echte Control-Plane des Stacks (Plan: MC-Mgmt-MCP, damit Hermes
den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes.
Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ),
damit kleine Modelle nicht in Aufruf-Schleifen laufen.
Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional).
Install: pip install mcp httpx
"""
import json
import os
import httpx
from mcp.server.fastmcp import FastMCP
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
mcp = FastMCP("mission-control-stack")
def _h() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
def _get(path: str, **params):
r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict | None = None):
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict):
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20)
r.raise_for_status()
return r.json()
def _delete(path: str):
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30)
r.raise_for_status()
return r.json()
# ── Modelle: ansehen / entdecken ────────────────────────────────────────────
@mcp.tool()
def list_models() -> str:
"""Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten)
und welche gerade geladen sind."""
data = _get("/api/models")
running = set(data.get("running") or [])
lines = []
for m in data.get("models", []):
warm = " [LÄUFT]" if m["name"] in running else ""
lines.append(
f"- {m['name']}{warm} (Rolle: {m.get('role') or ''}, ctx: {m.get('ctx')}, "
f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})"
)
return "\n".join(lines) or "Keine Modelle konfiguriert."
@mcp.tool()
def discover_models() -> str:
"""Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit).
Nutze dies, bevor du ein neues Modell vorschlägst/installierst."""
data = _get("/api/discover")
out = [f"System-RAM: {data.get('sys_ram_gb')} GB"]
for c in data.get("categories", []):
rec = c.get("recommended") or ""
out.append(f"\n## {c['title']} (beste Wahl: {rec})")
for m in c["models"][:3]:
out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)")
return "\n".join(out)
@mcp.tool()
def hf_search(q: str) -> str:
"""Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes
Modell finden willst, das nicht in discover_models auftaucht."""
res = _get("/api/hf/search", q=q).get("results", [])
if not res:
return f"Keine HF-Treffer für '{q}'."
return json.dumps(res[:15], ensure_ascii=False, indent=2)
@mcp.tool()
def hf_quants(repo: str) -> str:
"""Listet die verfügbaren Quantisierungen (Q4_K_M, Q8_0, …) eines HF-Repos.
Nutze dies VOR install_model, um die passende Quant-Stufe zu wählen."""
d = _get("/api/hf/quants", repo=repo)
return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden."
# ── Modelle: installieren / löschen / laden ─────────────────────────────────
@mcp.tool()
def install_model(repo: str, role: str = "", quant: str = "Q4_K_M",
ctx: int = 0, jinja: bool = False) -> str:
"""Lädt ein GGUF-Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein. Akzeptiert volle HF-URL ODER org/repo. ctx=0 setup-bewusst automatisch.
jinja=True für Tool-Calling-Modelle (Agent-Hirne). role z.B. fast/heavy/coder/vision.
Gibt eine job_id zurück Fortschritt via list_jobs prüfen."""
body = {"repo": repo, "quant": quant, "jinja": jinja}
if role:
body["role"] = role
if ctx > 0:
body["ctx"] = ctx
r = _post("/api/models/install", body)
return (f"Installation gestartet: model_id={r.get('model_id')}, job_id={r.get('job_id')}, "
f"{r.get('files')} Datei(en), ~{round((r.get('total_bytes') or 0)/1e9, 1)} GB. "
f"Fortschritt mit list_jobs prüfen.")
@mcp.tool()
def list_jobs() -> str:
"""Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt."""
jobs = _get("/api/jobs").get("jobs", [])
if not jobs:
return "Keine Jobs."
out = []
for j in jobs:
prog = j.get("progress")
p = f" {round(prog*100)}%" if isinstance(prog, (int, float)) else ""
out.append(f"- [{j.get('id', '?')[:8]}] {j.get('label', '?')}{j.get('status', '?')}{p}")
return "\n".join(out)
@mcp.tool()
def cancel_job(job_id: str) -> str:
"""Bricht einen laufenden Job (z.B. Download) ab."""
ok = _post(f"/api/jobs/{job_id}/cancel").get("ok")
return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar."
@mcp.tool()
def delete_model(model_id: str) -> str:
"""Entfernt ein Modell aus llama-swap (Eintrag + Dateien). NUR wenn der Nutzer es
will oder es eindeutig veraltet/ersetzt ist vorher list_models prüfen."""
_delete(f"/api/models/{model_id}")
return f"Modell '{model_id}' gelöscht."
@mcp.tool()
def load_model(model_id: str) -> str:
"""Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten
echten Request zu vermeiden."""
return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen."
@mcp.tool()
def unload_model(model_id: str = "") -> str:
"""Entlädt ein Modell aus dem Speicher (gibt RAM/GTT frei). Leer = ALLE entladen."""
path = f"/api/models/{model_id}/unload" if model_id else "/api/models/unload"
return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen."
# ── Rollen & Routing ────────────────────────────────────────────────────────
@mcp.tool()
def set_model_role(model_id: str, role: str = "") -> str:
"""Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…).
Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist."""
_post(f"/api/models/{model_id}/role", {"role": role or None})
return f"Rolle für '{model_id}': {role or '— (entfernt)'}"
@mcp.tool()
def set_route(name: str, target_alias: str) -> str:
"""Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias."""
_put("/api/routing/route", {"name": name, "target_alias": target_alias})
return f"Routing gesetzt: {name}{target_alias}"
@mcp.tool()
def routing_overview() -> str:
"""Zeigt das aktuelle Gateway-Routing + Fallbacks."""
return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2)
@mcp.tool()
def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str:
"""Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias).
role z.B. fast/heavy/coder/vision/hermes. jinja=True für Tool-Calling (Agent-Hirn).
Für Download+Eintrag in einem Schritt nutze install_model."""
res = _post("/api/models/register",
{"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja})
return f"Eingetragen als '{res.get('model_id')}'."
# ── System / Status / Wartung ───────────────────────────────────────────────
@mcp.tool()
def system_status() -> str:
"""Live-Auslastung der Box (CPU/RAM/GPU/Disk)."""
s = _get("/api/system/status")
g = s.get("gpu") or {}
return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · "
f"GPU {g.get('busy_percent', '')}% · Disk {(s.get('disk') or {}).get('percent', '')}%")
@mcp.tool()
def list_services() -> str:
"""Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice)."""
data = _get("/api/system/services")
return "\n".join(
f"{'' if s['ok'] else ''} {s['name']} ({s['url']})"
for s in data.get("services", [])
) or "Keine Dienste."
@mcp.tool()
def restart_service(service: str) -> str:
"""Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 |
hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart
(llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist."""
res = _post("/api/system/restart", {"service": service})
return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}"
@mcp.tool()
def backup_now() -> str:
"""Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten
Änderungen oder wenn der Nutzer ein Backup will."""
r = _post("/api/system/backup")
return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}"
@mcp.tool()
def list_backups() -> str:
"""Listet vorhandene Backups (Datei, Zeit, Größe)."""
bks = _get("/api/system/backups").get("backups", [])
if not bks:
return "Keine Backups."
return json.dumps(bks, ensure_ascii=False, indent=2)
@mcp.tool()
def token_stats() -> str:
"""Token-Verbrauch + Cloud-Ersparnis (was die lokale Nutzung gegenüber Cloud-APIs spart)."""
return json.dumps(_get("/api/system/token-stats"), ensure_ascii=False, indent=2)
@mcp.tool()
def check_updates() -> str:
"""Prüft, ob Updates für OS / Engine (llama.cpp) / Router (llama-swap) / Hermes anstehen."""
return json.dumps(_get("/api/maintenance/updates"), ensure_ascii=False, indent=2)
@mcp.tool()
def apply_update(kind: str) -> str:
"""Wendet ein Update an. kind: os | engine | swap | hermes. NUR wenn der Nutzer es will
oder check_updates ein anstehendes Update zeigt. Kann den jeweiligen Dienst kurz neu starten."""
kind = kind.lower().strip()
routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update",
"swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"}
if kind not in routes:
return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes."
return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2)
# ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ─────────
@mcp.tool()
def reminder_create(text: str, when: str, repeat: str = "") -> str:
"""Legt eine Erinnerung an: zur angegebenen Zeit sagt die Box dem Commander den Text AKTIV
an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden
will ('erinner mich', 'sag mir um', 'jeden Morgen').
when = ISO-8601 mit Datum UND Uhrzeit in der Lokalzeit des Commanders, z.B. 2026-07-04T09:00.
Relative Angaben ('in 20 Minuten', 'morgen früh') selbst aus der aktuellen Zeit im Kontext
umrechnen. repeat: '' einmalig | daily (täglich) | weekdays (MoFr) | weekly (wöchentlich).
text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor das ergänzt die Box)."""
try:
item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"]
except httpx.HTTPStatusError as e:
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "")
return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}"
@mcp.tool()
def reminder_list() -> str:
"""Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen
hiermit die Nummer ermitteln."""
items = _get("/api/reminders")["items"]
if not items:
return "Keine Erinnerungen angelegt."
rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"}
return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')}{i['text']}" for i in items)
@mcp.tool()
def reminder_delete(reminder_id: int) -> str:
"""Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf
ausdrücklichen Wunsch des Commanders."""
try:
gone = _delete(f"/api/reminders/{reminder_id}")["deleted"]
except httpx.HTTPStatusError as e:
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)."
@mcp.tool()
def service_logs(service: str, lines: int = 100) -> str:
"""Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway,
mission-control-2, voice-service."""
return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines),
ensure_ascii=False, indent=2)
if __name__ == "__main__":
mcp.run()
+628 -628
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -1,15 +1,15 @@
# Kern (immer nötig — STT + Server). Piper-TTS läuft über das offizielle Binary (install.sh lädt
# es), nicht über das PyPI-Paket (piper-phonemize hat auf neueren Linux keine Wheels). Chatterbox
# + torch zieht install.sh best-effort nach (CPU-Wheel), damit der Loop auch ohne Premium läuft.
fastapi
uvicorn
python-multipart
soundfile
numpy
faster-whisper
edge-tts
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller
# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
onnx-asr[cpu,hub]
# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features)
transformers
# Kern (immer nötig — STT + Server). Piper-TTS läuft über das offizielle Binary (install.sh lädt
# es), nicht über das PyPI-Paket (piper-phonemize hat auf neueren Linux keine Wheels). Chatterbox
# + torch zieht install.sh best-effort nach (CPU-Wheel), damit der Loop auch ohne Premium läuft.
fastapi
uvicorn
python-multipart
soundfile
numpy
faster-whisper
edge-tts
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller
# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
onnx-asr[cpu,hub]
# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features)
transformers