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. # .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten, # Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien. # Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel) # Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/ backend/.venv/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
.git/ .git/
# Große / binäre / sensible Dateien # Große / binäre / sensible Dateien
*.vrm *.vrm
*.gguf *.gguf
*.onnx *.onnx
*.safetensors *.safetensors
*.wav *.wav
*.env *.env
.env .env
credentials* credentials*
*.key *.key
*.pem *.pem
# Lokale Scratch-/Recon-Skripte # Lokale Scratch-/Recon-Skripte
box_recon* box_recon*
gemma_swap* gemma_swap*
+28 -28
View File
@@ -1,28 +1,28 @@
{ {
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "configurations": [
{ {
"name": "mc2", "name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe", "runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [ "runtimeArgs": [
"-m", "-m",
"uvicorn", "uvicorn",
"app:app", "app:app",
"--app-dir", "--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend", "F:\\Coding Stuff\\mission-control-2\\backend",
"--port", "--port",
"9000" "9000"
], ],
"port": 9000 "port": 9000
}, },
{ {
"name": "frontend", "name": "frontend",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"], "runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" }, "env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false, "autoPort": false,
"port": 5180 "port": 5180
} }
] ]
} }
+22 -22
View File
@@ -1,22 +1,22 @@
# Python # Python
backend/.venv/ backend/.venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# Node / Vite # Node / Vite
frontend/node_modules/ frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ # 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. # 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. # Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm frontend/public/avatar.vrm
frontend/dist/avatar.vrm frontend/dist/avatar.vrm
# Env / local # Env / local
*.env *.env
.DS_Store .DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo) # Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon* box_recon*
gemma_swap* gemma_swap*
+48 -48
View File
@@ -1,48 +1,48 @@
# AGENTS.md — Mission Control 2.0 # AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Zed, Kilo Code, Claude Code lesen diese Datei). 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/`. Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist ## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten: 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) · 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. Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
## Sprache (nicht verhandelbar) ## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen, - **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare. Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht). - **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. Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle) ## Build & Deploy (die häufigste Falle)
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die - **`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 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. **das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.) (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 - **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). 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. - **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Zeit & Umgebung ## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. - **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 Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
(siehe `backend/services/reminders.py`). (siehe `backend/services/reminders.py`).
## Backend-Konventionen ## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/` - 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 (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`); `services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren. keine zweite Allowlist in einem Router duplizieren.
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen. - **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 = - 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. 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. - Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten) ## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein. - **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.** - **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- Alles reversibel halten: Branch + Backup + Pin. - Alles reversibel halten: Branch + Backup + Pin.
## Gitea ## Gitea
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub. PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
+50 -50
View File
@@ -1,50 +1,50 @@
# Savepoint — Mission Control 2.0 (MC2) # Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst _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/`._ `AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
## Was das ist (in einem Satz) ## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD 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, 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- Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
Begleiterin „Lucy". Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/) ## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei). - **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth). Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git** - **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets aus). (Box hat kein Node; Backend liefert die gebauten Assets aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV). - **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), - **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). `mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
## Aktueller Zustand ## Aktueller Zustand
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird - **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
sauber gehalten und eingefroren, nicht neu erfunden. sauber gehalten und eingefroren, nicht neu erfunden.
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung, - **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate. 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 - **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` (`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. 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 · - **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. 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) ## Nordstern & Randbedingungen (WICHTIG für den Review)
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber - **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein. 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. - **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.** - **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern) ## 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 - Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
`Europe/Berlin` umgestellt. `Europe/Berlin` umgestellt.
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen). - 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. - Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
## Was NICHT empfohlen werden soll (bereits entschieden) ## Was NICHT empfohlen werden soll (bereits entschieden)
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in 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 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.** **Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
## Letzter Sicherungspunkt ## Letzter Sicherungspunkt
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live). - 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. Mission Control 2.0 — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen. Server (proxyt /api hierher), daher CORS für localhost offen.
""" """
import asyncio import asyncio
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable from typing import Any, AsyncGenerator, Awaitable, Callable
import httpx import httpx
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.requests import Request from starlette.requests import Request
from config import FRONTEND_DIST, VERSION from config import FRONTEND_DIST, VERSION
from routers import ( from routers import (
agent, agent,
connect, connect,
console, console,
gateway_proxy, gateway_proxy,
health, health,
hermes_ui, hermes_ui,
maintenance, maintenance,
memory, memory,
models, models,
routing, routing,
system, system,
voice, voice,
) )
from routers import reminders as reminders_router from routers import reminders as reminders_router
from services import memory as memory_svc from services import memory as memory_svc
from services import reminders, sentry, warmer from services import reminders, sentry, warmer
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # für alle Module (logging.getLogger(__name__)).
logging.basicConfig( logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn """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).""" + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
tasks: list[asyncio.Task[Any]] = [] tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED: if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop())) tasks.append(asyncio.create_task(warmer.rewarm_loop()))
log.info( log.info(
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
warmer.INTERVAL, warmer.INTERVAL,
) )
if sentry.ENABLED: if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop())) tasks.append(asyncio.create_task(reminders.reminders_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED: if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info( log.info(
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", "Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_INTERVAL,
memory_svc.AUTO_DEDUPE_THRESHOLD, memory_svc.AUTO_DEDUPE_THRESHOLD,
) )
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # 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). # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient( app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0), timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200), limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
) )
try: try:
yield yield
finally: finally:
for task in tasks: for task in tasks:
_ = task.cancel() _ = task.cancel()
await app.state.gw_client.aclose() await app.state.gw_client.aclose()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan) app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@app.middleware("http") @app.middleware("http")
async def no_cache( async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
) -> httpx.Response: ) -> httpx.Response:
resp = await call_next(request) resp = await call_next(request)
if request.url.path.startswith("/api"): if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache" resp.headers["Cache-Control"] = "no-cache"
return resp return resp
app.include_router(health.router) app.include_router(health.router)
app.include_router(models.router) app.include_router(models.router)
app.include_router(routing.router) app.include_router(routing.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(connect.router) app.include_router(connect.router)
app.include_router(memory.router) app.include_router(memory.router)
app.include_router(agent.router) app.include_router(agent.router)
app.include_router( app.include_router(
voice.router voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) ) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router( app.include_router(
reminders_router.router reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten ) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router( app.include_router(
console.router console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all ) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
app.include_router( app.include_router(
hermes_ui.router hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all ) # 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. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists(): if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
_DIST_ROOT = FRONTEND_DIST.resolve() _DIST_ROOT = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
def spa(full_path: str): def spa(full_path: str):
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
try: try:
target = (FRONTEND_DIST / full_path).resolve() target = (FRONTEND_DIST / full_path).resolve()
if target.is_relative_to(_DIST_ROOT) and target.is_file(): if target.is_relative_to(_DIST_ROOT) and target.is_file():
return FileResponse(target) return FileResponse(target)
except (ValueError, OSError): except (ValueError, OSError):
pass pass
index = FRONTEND_DIST / "index.html" index = FRONTEND_DIST / "index.html"
if index.exists(): if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
return FileResponse( return FileResponse(
index, headers={"Cache-Control": "no-cache, must-revalidate"} index, headers={"Cache-Control": "no-cache, must-revalidate"}
) )
return {"detail": "frontend not built"} return {"detail": "frontend not built"}
+136 -136
View File
@@ -1,136 +1,136 @@
""" """
Zentrale Konfiguration für Mission Control 2.0. Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars 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 überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen. Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
""" """
import os import os
from pathlib import Path from pathlib import Path
from ruamel.yaml import YAML from ruamel.yaml import YAML
# --- Engine (llama-swap) ----------------------------------------------------- # --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") 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")) 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")) MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace). # Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster. # 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_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 DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen. # Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis # 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). # 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"))) 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), # 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. # 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("/") 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. # 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 — # 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 # llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse). # macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = ( _DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} " "llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap" "-c {ctx} -ngl 999 -fa on --no-mmap"
) )
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE) CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE: if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines # Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt # 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). # (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"))) 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 # 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, # 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.) # 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", "") SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich # Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv). # zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple") SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen # 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 # 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 # `--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-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4")) 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). # Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"} HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) --------------------------- # --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer # 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. # 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("/") 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) ------------------------------- # --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`). # 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("/") 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 # 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 # /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP. # (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str: def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env). """Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht.""" Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
try: try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env" env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines(): for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip() line = line.strip()
if line.startswith(f"{key}="): if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'") return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError: except OSError:
pass pass
return "" return ""
HERMES_API_KEY = ( HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY") os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY") or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY") or _read_hermes_env("API_SERVER_KEY")
) )
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn, # 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. # 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") HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) --------------- # --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen. # 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("/") 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 # 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 # 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). # 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_UPSTREAM = os.environ.get("MC_HERMES_TERMINAL_UPSTREAM", "http://127.0.0.1:7681").rstrip("/")
HERMES_TERMINAL_PATH = "/hermes-terminal/" HERMES_TERMINAL_PATH = "/hermes-terminal/"
# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback # 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 # (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 # 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. # 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. # 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("/") 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). # Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/" BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit # 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 # 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 # (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 # /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 # 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. # 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_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/" HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks. # GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") 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"))) 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 — 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("/") PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# --- Server ------------------------------------------------------------------ # --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0") HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000")) PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus; # 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. # 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"))) FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton). # Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8" VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml). # Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML() yaml = YAML()
yaml.preserve_quotes = True yaml.preserve_quotes = True
+56 -56
View File
@@ -1,56 +1,56 @@
import sys import sys
from pathlib import Path from pathlib import Path
# Add backend directory to sys.path so we can import services # Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent)) sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH from config import CONFIG_PATH
def migrate(): def migrate():
print(f"Reading config from {CONFIG_PATH}...") print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists(): if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.") print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return return
cfg = read_config() cfg = read_config()
models = cfg.get("models", {}) models = cfg.get("models", {})
for name, spec in models.items(): for name, spec in models.items():
if not isinstance(spec, dict): if not isinstance(spec, dict):
continue continue
cmd = spec.get("cmd", "") cmd = spec.get("cmd", "")
if not cmd: if not cmd:
continue continue
print(f"Migrating model: {name}") print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags, # 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server # die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot. # automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "") cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role # 2. Extract aliases/role
aliases = spec.get("aliases", []) aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder. # 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; # 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. # ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"): if role in ("fast", "coder"):
if "--parallel" not in cmd: if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2" cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd: if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else "" target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target) cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd # Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n") spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...") print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg) write_config(cfg)
print("Migration completed successfully!") print("Migration completed successfully!")
if __name__ == "__main__": if __name__ == "__main__":
migrate() 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.", "_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", "version": "2026-07-03",
"models": [ "models": [
{ {
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF", "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, "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 "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", "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, "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 "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", "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, "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 "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", "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, "family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false "moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
}, },
{ {
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF", "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, "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 "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", "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, "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 "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", "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, "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 "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", "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, "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 "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", "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, "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 "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", "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, "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 "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
} }
] ]
} }
+121 -121
View File
@@ -1,121 +1,121 @@
import os import os
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage from services.gateway_stream import record_stream_chunk, record_usage
from services.router_logic import LANES, choose_for_lane from services.router_logic import LANES, choose_for_lane
from services.routing_policy import load_policy from services.routing_policy import load_policy
router = APIRouter(prefix="/v1") router = APIRouter(prefix="/v1")
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch # 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. # (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
_LANG_DIRECTIVE = os.environ.get( _LANG_DIRECTIVE = os.environ.get(
"MC_GATEWAY_LANG_DIRECTIVE", "MC_GATEWAY_LANG_DIRECTIVE",
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, " "Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. " "Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
def _inject_language(body: dict, alias: str) -> None: def _inject_language(body: dict, alias: str) -> None:
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
Persona (SOUL.md) regelt die Sprache selbst.""" Persona (SOUL.md) regelt die Sprache selbst."""
if not _LANG_DIRECTIVE or alias == "hermes": if not _LANG_DIRECTIVE or alias == "hermes":
return return
msgs = body.get("messages") msgs = body.get("messages")
if not isinstance(msgs, list): if not isinstance(msgs, list):
return return
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None) first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
if first_sys is None: if first_sys is None:
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE}) msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
elif isinstance(first_sys.get("content"), str): elif isinstance(first_sys.get("content"), str):
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
elif isinstance(first_sys.get("content"), list): elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet. # 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)"} _LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
@router.get("/models") @router.get("/models")
async def models(request: Request): async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) 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) r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
data = r.json() data = r.json()
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können. # Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router", lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES] "description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). # 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, # 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. # Clients fragen aber genau damit an → als eigene Einträge einblenden.
ctx_map: dict[str, int] = {} ctx_map: dict[str, int] = {}
alias_entries: list[dict] = [] alias_entries: list[dict] = []
try: try:
from services import llamaswap from services import llamaswap
for m in llamaswap.list_models(): for m in llamaswap.list_models():
ctx = m.get("ctx") ctx = m.get("ctx")
if ctx: if ctx:
for api_id in m.get("api_ids", []): for api_id in m.get("api_ids", []):
ctx_map[api_id] = ctx ctx_map[api_id] = ctx
for alias in m.get("aliases", []): for alias in m.get("aliases", []):
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
"description": f"Alias für {m['name']}"} "description": f"Alias für {m['name']}"}
if ctx: if ctx:
entry["context_length"] = ctx entry["context_length"] = ctx
alias_entries.append(entry) alias_entries.append(entry)
except Exception: except Exception:
pass pass
if isinstance(data, dict) and isinstance(data.get("data"), list): if isinstance(data, dict) and isinstance(data.get("data"), list):
for entry in data["data"]: for entry in data["data"]:
if (ctx := ctx_map.get(entry.get("id"))): if (ctx := ctx_map.get(entry.get("id"))):
entry.setdefault("context_length", ctx) entry.setdefault("context_length", ctx)
data["data"] = lanes + alias_entries + data["data"] data["data"] = lanes + alias_entries + data["data"]
return JSONResponse(data, status_code=r.status_code) return JSONResponse(data, status_code=r.status_code)
async def _proxy(path: str, request: Request): async def _proxy(path: str, request: Request):
body = await request.json() body = await request.json()
requested = str(body.get("model") or "auto") requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"): if requested.lower() in ("auto", "chat", "coding"):
lane = requested.lower() lane = requested.lower()
alias, reason = choose_for_lane(lane, body) alias, reason = choose_for_lane(lane, body)
body["model"] = alias body["model"] = alias
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
else: else:
alias = requested alias = requested
routed = {"x-mc-routed-to": requested} routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt). # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
pol = load_policy() pol = load_policy()
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body: if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias) _inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}" url = f"{LLAMA_SWAP_URL}{path}"
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if body.get("stream"): if body.get("stream"):
async def gen(): async def gen():
async with client.stream("POST", url, json=body, timeout=None) as r: async with client.stream("POST", url, json=body, timeout=None) as r:
async for chunk in r.aiter_raw(): async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias) record_stream_chunk(chunk, alias)
yield chunk yield chunk
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
r = await client.post(url, json=body, timeout=600.0) r = await client.post(url, json=body, timeout=600.0)
resp_json = r.json() resp_json = r.json()
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias) 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) return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
@router.post("/chat/completions") @router.post("/chat/completions")
async def chat_completions(request: Request): async def chat_completions(request: Request):
return await _proxy("/v1/chat/completions", request) return await _proxy("/v1/chat/completions", request)
@router.post("/completions") @router.post("/completions")
async def completions(request: Request): async def completions(request: Request):
return await _proxy("/v1/completions", 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). """System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
zurückgegeben statt zu crashen. zurückgegeben statt zu crashen.
""" """
import logging import logging
import os import os
import subprocess import subprocess
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
import httpx import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL 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 backup as backup_svc
from services import maintenance from services import maintenance
from services.agent import agent_status from services.agent import agent_status
from services.gateway import gateway_reachable from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models from services.llamaswap import engine_reachable, list_models
from services.pricing import compute_savings from services.pricing import compute_savings
from services.system import system_status from services.system import system_status
from services.token_stats import get_stats from services.token_stats import get_stats
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2). # 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")) SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status") @router.get("/system/status")
def status() -> dict: def status() -> dict:
return system_status() return system_status()
def _mem0_reachable() -> bool: def _mem0_reachable() -> bool:
try: try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception: except Exception:
return False return False
def _voice_reachable() -> bool: def _voice_reachable() -> bool:
try: try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception: except Exception:
return False return False
@router.get("/system/services") @router.get("/system/services")
def services() -> dict: def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige).""" """Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status() a = agent_status()
gw_url = f"{GATEWAY_URL}/v1" gw_url = f"{GATEWAY_URL}/v1"
return { return {
"services": [ "services": [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()}, {"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": "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-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": "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": "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()}, {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
], ],
"links": { "links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui", "engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url, "gateway": gw_url,
"hermes_terminal": a["terminal_url"], "hermes_terminal": a["terminal_url"],
}, },
} }
@router.post("/system/backup") @router.post("/system/backup")
def backup() -> dict: def backup() -> dict:
return backup_svc.backup_now() return backup_svc.backup_now()
@router.get("/system/backups") @router.get("/system/backups")
def backups() -> dict: def backups() -> dict:
return {"backups": backup_svc.list_backups()} return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict: def _run(cmd: list[str], cwd: str | None = None) -> dict:
try: try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode, return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)} return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel): class RestartReq(BaseModel):
service: str service: str
@router.post("/system/restart") @router.post("/system/restart")
def restart(req: RestartReq) -> dict: def restart(req: RestartReq) -> dict:
"""Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit): """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 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 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 (ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
Sprache/MCP schlug fehl.""" Sprache/MCP schlug fehl."""
return maintenance.restart_service(req.service) return maintenance.restart_service(req.service)
@router.post("/system/self-update") @router.post("/system/self-update")
def self_update() -> dict: def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler.""" """git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR) pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR) reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"]) restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res} return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats") @router.get("/system/token-stats")
def token_stats() -> dict: def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen. # Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
role_map: dict[str, str | None] = {} role_map: dict[str, str | None] = {}
try: try:
for m in list_models(): for m in list_models():
role_map[m["name"].lower()] = m.get("role") role_map[m["name"].lower()] = m.get("role")
for alias in m.get("aliases", []): for alias in m.get("aliases", []):
role_map[alias.lower()] = m.get("role") role_map[alias.lower()] = m.get("role")
except Exception: except Exception:
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True) log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
return compute_savings(get_stats(), role_map) 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). 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 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 + 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 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. 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. LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
""" """
import logging import logging
import os import os
import time import time
import httpx import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern) from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer, Timer,
TurnTrace, TurnTrace,
get_metrics, get_metrics,
get_trace, get_trace,
park, park,
record_stage, record_stage,
) )
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). # Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys import sys as _sys
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") _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: if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR) _sys.path.insert(0, _GUARD_DIR)
try: try:
from guard import wrap_untrusted from guard import wrap_untrusted
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
def wrap_untrusted(text: str, label: str = "") -> str: def wrap_untrusted(text: str, label: str = "") -> str:
return text return text
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung # 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 # 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. # (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2). # Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
async def _describe_images(image_urls: list[str], hint: str) -> str: async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). """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.""" Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
multi = len(image_urls) > 1 multi = len(image_urls) > 1
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " 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). " "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
"Keine Einleitung, keine Wiederholung der Frage. " "Keine Einleitung, keine Wiederholung der Frage. "
if multi else if multi else
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
for u in image_urls: for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}}) content.append({"type": "image_url", "image_url": {"url": u}})
try: try:
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas # 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. # 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: 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={ r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
"messages": [{"role": "user", "content": content}], "messages": [{"role": "user", "content": content}],
}) })
r.raise_for_status() r.raise_for_status()
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
except Exception as exc: except Exception as exc:
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return "" return ""
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern _TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
class TTSIn(BaseModel): class TTSIn(BaseModel):
text: str text: str
engine: str = "piper" engine: str = "piper"
voice: str = "" voice: str = ""
language: str = "" language: str = ""
ref_path: str = "" ref_path: str = ""
class ChatIn(BaseModel): class ChatIn(BaseModel):
text: str # die neue User-Äußerung (STT-Ergebnis) text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Voice-Faden → server-seitiger Transcript session_id: str # stabiler Voice-Faden → server-seitiger Transcript
session_key: str = "" # optional: Langzeit-Memory-Scope session_key: str = "" # optional: Langzeit-Memory-Scope
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
model: str = "" model: str = ""
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
class AnnounceIn(BaseModel): class AnnounceIn(BaseModel):
text: str # die Meldung (wird von Lucy gesprochen) text: str # die Meldung (wird von Lucy gesprochen)
subject: str = "" # kurze Betreffzeile (z.B. "[Update]") subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
class AlarmIn(BaseModel): class AlarmIn(BaseModel):
text: str # die Alarm-Meldung text: str # die Alarm-Meldung
subject: str = "[Alarm]" # Betreff (Telegram-Präfix) subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog") source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
@router.post("/alarm") @router.post("/alarm")
def alarm(body: AlarmIn) -> dict: def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den """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 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 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 nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints.""" wie alle MC2-Endpoints."""
text = (body.text or "").strip() text = (body.text or "").strip()
if not text: if not text:
raise HTTPException(400, "Leere Meldung.") raise HTTPException(400, "Leere Meldung.")
subject = (body.subject or "[Alarm]").strip() subject = (body.subject or "[Alarm]").strip()
try: try:
item = announce.add(text, subject, body.source or "alarm", "normal") item = announce.add(text, subject, body.source or "alarm", "normal")
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
return {"ok": True, "item": item} return {"ok": True, "item": item}
@router.post("/voice/announce") @router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict: def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter, """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.""" notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
try: try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
@router.get("/voice/announcements") @router.get("/voice/announcements")
def voice_announcements(after: int | None = None, limit: int = 20) -> dict: def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
return announce.list_after(after, limit) return announce.list_after(after, limit)
@router.get("/voice/health") @router.get("/voice/health")
def voice_health() -> dict: def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist.""" """Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)} out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
try: try:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0)) r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200 out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
out["error"] = str(exc) out["error"] = str(exc)
return out return out
@router.get("/voice/metrics") @router.get("/voice/metrics")
def voice_metrics() -> dict: def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh.""" """Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics() return get_metrics()
@router.get("/voice/trace") @router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict: def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn · """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 — 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.""" damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)} return {"turns": get_trace(limit)}
@router.get("/voice/voices") @router.get("/voice/voices")
def voice_voices() -> dict: def voice_voices() -> dict:
try: try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0)) r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}") raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@router.post("/voice/stt") @router.post("/voice/stt")
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt).""" """Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
data = await audio.read() data = await audio.read()
if not data: if not data:
raise HTTPException(400, "Leeres Audio.") raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
_t0 = time.perf_counter() _t0 = time.perf_counter()
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0 _ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms) record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise HTTPException(502, f"STT fehlgeschlagen: {exc}") raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
@router.post("/voice/turn") @router.post("/voice/turn")
async def voice_turn(audio: UploadFile = File(...)) -> dict: async def voice_turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar.""" """Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
data = await audio.read() data = await audio.read()
if not data: if not data:
raise HTTPException(400, "Leeres Audio.") raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"): with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen. # Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc) log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"} return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference") @router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict: async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
data = await audio.read() data = await audio.read()
if not data: if not data:
raise HTTPException(400, "Leeres Audio.") raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")} files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files) r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}") raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
@router.get("/voice/reference") @router.get("/voice/reference")
def voice_get_reference() -> dict: def voice_get_reference() -> dict:
try: try:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return {"active": False, "error": str(exc)} return {"active": False, "error": str(exc)}
@router.delete("/voice/reference") @router.delete("/voice/reference")
def voice_clear_reference() -> dict: def voice_clear_reference() -> dict:
try: try:
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}") raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
@router.post("/voice/tts") @router.post("/voice/tts")
async def voice_tts(body: TTSIn) -> Response: async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes.""" """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("tts"): with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status() r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav")) return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}") raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
@router.post("/voice/chat") @router.post("/voice/chat")
async def voice_chat(body: ChatIn) -> StreamingResponse: async def voice_chat(body: ChatIn) -> StreamingResponse:
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. """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. 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.""" Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
if not HERMES_API_KEY: if not HERMES_API_KEY:
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
headers = { headers = {
"Authorization": f"Bearer {HERMES_API_KEY}", "Authorization": f"Bearer {HERMES_API_KEY}",
"X-Hermes-Session-Id": body.session_id, "X-Hermes-Session-Id": body.session_id,
} }
if body.session_key: if body.session_key:
headers["X-Hermes-Session-Key"] = body.session_key headers["X-Hermes-Session-Key"] = body.session_key
async def gen(): async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0 # 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. # (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice") trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True first = True
first_content = True first_content = True
committed = False committed = False
def _commit() -> None: def _commit() -> None:
nonlocal committed nonlocal committed
if not committed: if not committed:
committed = True committed = True
trace.commit() trace.commit()
try: try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und # 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 # 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. # dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text user_text = body.text
imgs = [u for u in (body.images or []) if u] imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs) trace.had_images = bool(imgs)
if imgs: if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter() _tv = time.perf_counter()
desc = await _describe_images(imgs, body.text) desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0) trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc: if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM") 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}" user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = [] messages = []
if body.system: if body.system:
messages.append({"role": "system", "content": body.system}) messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text}) messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen) 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} 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, # 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). # 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. # 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"): if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False} payload["chat_template_kwargs"] = {"enable_thinking": False}
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream( async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, "POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r: ) as r:
if r.status_code != 200: if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500] detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}" trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return return
async for chunk in r.aiter_raw(): async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead) if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb() trace.note_ttfb()
first = False first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) — # 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. # chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk: if first_content and b'"content"' in chunk:
trace.note_first_content() trace.note_first_content()
first_content = False first_content = False
yield chunk yield chunk
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
trace.error = "verbindung" trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally: finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch) _commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream") 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 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 Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md). Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
""" """
import logging import logging
import os import os
import re import re
import httpx import httpx
import psutil import psutil
from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM, from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH, BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL) HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None: def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0.""" """Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower() low = (name or "").lower()
if "hermes" not in low: if "hermes" not in low:
return None return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low) m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None return float(m.group(1)) if m else None
def _active_brain_name() -> str: def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model).""" """Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try: try:
from ruamel.yaml import YAML from ruamel.yaml import YAML
p = HERMES_HOME / "config.yaml" p = HERMES_HOME / "config.yaml"
if p.exists(): if p.exists():
with p.open(encoding="utf-8") as f: with p.open(encoding="utf-8") as f:
cfg = YAML().load(f) or {} cfg = YAML().load(f) or {}
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {} m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
return str(m.get("default") or m.get("model") or "auto") return str(m.get("default") or m.get("model") or "auto")
except Exception: except Exception:
log.debug("_active_brain_name: Lesefehler", exc_info=True) log.debug("_active_brain_name: Lesefehler", exc_info=True)
return "auto" return "auto"
def hermes_brain_info() -> dict: def hermes_brain_info() -> dict:
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default), """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.""" plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
from services import llamaswap from services import llamaswap
models = llamaswap.list_models() models = llamaswap.list_models()
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
bl = brain.lower() bl = brain.lower()
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \ 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) 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 cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
current = None current = None
if cur: if cur:
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"), current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
"params_b": cur_params, "quant": cur.get("quant"), "params_b": cur_params, "quant": cur.get("quant"),
"size_bytes": cur.get("size_bytes"), "size_bytes": cur.get("size_bytes"),
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")} "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? # Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
budget = None budget = None
try: try:
from services.budget import footprint_gb, gtt_budget_gb from services.budget import footprint_gb, gtt_budget_gb
groups = llamaswap.list_groups() groups = llamaswap.list_groups()
persist = set() persist = set()
for g in groups.values(): for g in groups.values():
if isinstance(g, dict) and (g.get("persist") or g.get("persistent")): if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
persist.update(g.get("members") or []) persist.update(g.get("members") or [])
cur_name = cur["name"] if cur else None cur_name = cur["name"] if cur else None
brain_gb = footprint_gb(cur) if cur else 0.0 brain_gb = footprint_gb(cur) if cur else 0.0
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info # voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
warm = brain_gb + sum(footprint_gb(m) for m in models warm = brain_gb + sum(footprint_gb(m) for m in models
if m["name"] in persist and m["name"] != cur_name) 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) largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
gtt = gtt_budget_gb() gtt = gtt_budget_gb()
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision) # 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 + # 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. # größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
budget = { budget = {
"gtt_gb": gtt, "gtt_gb": gtt,
"brain_gb": round(brain_gb, 1), "brain_gb": round(brain_gb, 1),
"warm_projected_gb": round(warm, 1), "warm_projected_gb": round(warm, 1),
"largest_ondemand_gb": round(largest_od, 1), "largest_ondemand_gb": round(largest_od, 1),
"fits": (warm + largest_od) <= gtt, "fits": (warm + largest_od) <= gtt,
"free_after_gb": round(gtt - warm - largest_od, 1), "free_after_gb": round(gtt - warm - largest_od, 1),
} }
except Exception: except Exception:
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True) log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
return {"current": current, "recommended": None, "update_available": False, "budget": budget} return {"current": current, "recommended": None, "update_available": False, "budget": budget}
def _reach(url: str, path: str = "") -> bool: def _reach(url: str, path: str = "") -> bool:
try: try:
with httpx.Client(timeout=3.0) as c: with httpx.Client(timeout=3.0) as c:
return c.get(f"{url}{path}").status_code < 500 return c.get(f"{url}{path}").status_code < 500
except httpx.HTTPError: except httpx.HTTPError:
return False return False
def _count_enabled_mcp_servers() -> int: def _count_enabled_mcp_servers() -> int:
config_path = HERMES_HOME / "config.yaml" config_path = HERMES_HOME / "config.yaml"
if not config_path.exists(): if not config_path.exists():
return 0 return 0
try: try:
from ruamel.yaml import YAML from ruamel.yaml import YAML
r_yaml = YAML() r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f: with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {} cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {} mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict): if not isinstance(mcp_servers, dict):
return 0 return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True)) return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception: except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True) log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0 return 0
def agent_status() -> dict: def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise.""" """Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME home = HERMES_HOME
brain_model = "auto" brain_model = "auto"
config_path = home / "config.yaml" config_path = home / "config.yaml"
if config_path.exists(): if config_path.exists():
try: try:
from ruamel.yaml import YAML from ruamel.yaml import YAML
r_yaml = YAML() r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f: with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {} cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict): if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param). # Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {} m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto" brain_model = m.get("default") or m.get("model") or "auto"
except Exception: except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True) log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
return { return {
"gateway_url": HERMES_API_URL, "gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt. # Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt.
"terminal_url": HERMES_TERMINAL_PATH, "terminal_url": HERMES_TERMINAL_PATH,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/). # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH, "box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/). # Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
"hermes_ui_url": HERMES_BUILTIN_UI_PATH, "hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"), "gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path). # Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path).
"terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"), "terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"),
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"), "box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI (Loopback :9119). # Erreichbarkeit der eingebauten Hermes-GUI (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"), "hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(), "home_exists": home.exists(),
"brain_model": brain_model, "brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(), "has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(), "has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(), "has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit. # Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")), "telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(), "mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"), "pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
} }
def set_agent_brain(model_id: str) -> dict: def set_agent_brain(model_id: str) -> dict:
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst: """Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot), 1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben), 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. 3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
So bleibt das neue Hirn warm und der Agent nutzt es sofort.""" So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
from services import llamaswap from services import llamaswap
models = {m["name"]: m for m in llamaswap.list_models()} models = {m["name"]: m for m in llamaswap.list_models()}
if model_id not in models: if model_id not in models:
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."} 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) old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
if model_id == old: if model_id == old:
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen. # Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
try: try:
from services.llamaswap import set_ttl from services.llamaswap import set_ttl
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
if model_id not in brains: if model_id not in brains:
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True) llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
set_ttl(model_id, 0) set_ttl(model_id, 0)
except PermissionError as exc: except PermissionError as exc:
return {"ok": False, "reason": str(exc)} return {"ok": False, "reason": str(exc)}
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"} return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
try: try:
llamaswap.set_role(model_id, "hermes") # 1) Alias llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] 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] 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 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. # 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL from services.llamaswap import set_ttl, DEFAULT_TTL
set_ttl(model_id, 0) set_ttl(model_id, 0)
if old: if old:
set_ttl(old, DEFAULT_TTL) set_ttl(old, DEFAULT_TTL)
except PermissionError as exc: except PermissionError as exc:
return {"ok": False, "reason": str(exc)} return {"ok": False, "reason": str(exc)}
update_brain_model("hermes") # 3) Config + Restart update_brain_model("hermes") # 3) Config + Restart
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT? # Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
warning = None warning = None
try: try:
b = hermes_brain_info().get("budget") or {} b = hermes_brain_info().get("budget") or {}
if b and not b.get("fits", True): if b and not b.get("fits", True):
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-" 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"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"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).") f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
except Exception: except Exception:
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True) log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
return {"ok": True, "old": old, "new": model_id, "warning": warning} return {"ok": True, "old": old, "new": model_id, "warning": warning}
def update_brain_model(new_model: str) -> bool: def update_brain_model(new_model: str) -> bool:
from config import HERMES_HOME from config import HERMES_HOME
home = HERMES_HOME home = HERMES_HOME
config_path = home / "config.yaml" config_path = home / "config.yaml"
# Ensure home directory exists # Ensure home directory exists
home.mkdir(parents=True, exist_ok=True) home.mkdir(parents=True, exist_ok=True)
cfg = {} cfg = {}
if config_path.exists(): if config_path.exists():
try: try:
from ruamel.yaml import YAML from ruamel.yaml import YAML
r_yaml = YAML() r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f: with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {} cfg = r_yaml.load(f) or {}
except Exception: except Exception:
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True) log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
cfg = {} cfg = {}
if not isinstance(cfg, dict): if not isinstance(cfg, dict):
cfg = {} cfg = {}
if "model" not in cfg or not isinstance(cfg["model"], dict): if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {} cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. # 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). # Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
cfg["model"]["default"] = new_model cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model cfg["model"]["model"] = new_model
try: try:
from ruamel.yaml import YAML from ruamel.yaml import YAML
r_yaml = YAML() r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f: with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f) r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes # Restart the user-space service to apply changes
try: try:
import services.maintenance as maintenance import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway") maintenance.restart_service("hermes-gateway")
except Exception: except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True return True
except Exception: except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True) log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
return False return False
+205 -205
View File
@@ -1,205 +1,205 @@
""" """
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit. Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB): Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB):
• Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident — • Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident —
seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist` 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 in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last
verdrängte damals die ganze Gruppe). verdrängte damals die ganze Gruppe).
• Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss • Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss
deren Footprint mit einplanen. deren Footprint mit einplanen.
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss — 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). 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 Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine
ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides. ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides.
""" """
import re import re
import psutil import psutil
from services.fit import ( from services.fit import (
QUANT_BYTES_PER_PARAM, QUANT_BYTES_PER_PARAM,
estimate_memory_gb, estimate_memory_gb,
extract_params_b, extract_params_b,
max_ctx_in_budget, max_ctx_in_budget,
) )
HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung
def gtt_budget_gb() -> float: def gtt_budget_gb() -> float:
"""GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest """GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest
amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve.""" amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve."""
try: try:
with open("/proc/cmdline") as f: with open("/proc/cmdline") as f:
m = re.search(r"amdgpu\.gttsize=(\d+)", f.read()) m = re.search(r"amdgpu\.gttsize=(\d+)", f.read())
if m: if m:
return round(int(m.group(1)) / 1024.0, 1) return round(int(m.group(1)) / 1024.0, 1)
except Exception: except Exception:
pass pass
return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1) return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1)
def params_of_model(model: dict) -> float: def params_of_model(model: dict) -> float:
"""Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und """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 Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs
(size_bytes = nur erster Teil → ignoriert) ab.""" (size_bytes = nur erster Teil → ignoriert) ab."""
caps = model.get("capabilities") or {} caps = model.get("capabilities") or {}
quant = model.get("quant") or "Q4_K_M" quant = model.get("quant") or "Q4_K_M"
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 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) 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+)") _CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)") _CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
def _cache_types(cmd: str) -> tuple[str | None, str | None]: def _cache_types(cmd: str) -> tuple[str | None, str | None]:
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → 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 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 cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
return ck, cv return ck, cv
def _real_kv_gb(model: dict, ctx: int) -> float | None: def _real_kv_gb(model: dict, ctx: int) -> float | None:
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads × """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 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.""" → Aufrufer fällt auf die params-basierte Heuristik zurück."""
from services import gguf_meta from services import gguf_meta
path = model.get("gguf_path") path = model.get("gguf_path")
if not path: if not path:
return None return None
meta = gguf_meta.arch_meta(path) meta = gguf_meta.arch_meta(path)
if not meta: if not meta:
return None return None
ck, cv = _cache_types(model.get("cmd") or "") ck, cv = _cache_types(model.get("cmd") or "")
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv) return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
def footprint_gb(model: dict) -> float: def footprint_gb(model: dict) -> float:
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx). """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) — 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 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.""" grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
quant = model.get("quant") or "Q4_K_M" quant = model.get("quant") or "Q4_K_M"
ctx = int(model.get("ctx") or 32768) ctx = int(model.get("ctx") or 32768)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb = params_of_model(model) pb = params_of_model(model)
weights = max(pb * bpp, size_gb) weights = max(pb * bpp, size_gb)
kv = _real_kv_gb(model, ctx) kv = _real_kv_gb(model, ctx)
if kv is None: if kv is None:
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
return weights + max(kv, 0.0) return weights + max(kv, 0.0)
def params_b_for(name: str) -> float: def params_b_for(name: str) -> float:
"""Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst, """Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst,
sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe.""" sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe."""
from services import catalog from services import catalog
meta = catalog.meta_for_name(name) if name else None meta = catalog.meta_for_name(name) if name else None
if meta and meta.get("total_params_b"): if meta and meta.get("total_params_b"):
return float(meta["total_params_b"]) return float(meta["total_params_b"])
return extract_params_b(name) return extract_params_b(name)
def _coresident_members(groups: dict) -> set: def _coresident_members(groups: dict) -> set:
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen """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) (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 ü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 Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere
Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key.""" Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key."""
out: set = set() out: set = set()
for g in (groups or {}).values(): for g in (groups or {}).values():
if isinstance(g, dict) and g.get("swap") is False: if isinstance(g, dict) and g.get("swap") is False:
out.update(g.get("members") or []) out.update(g.get("members") or [])
return out return out
def reserved_gb(role: str | None) -> dict: def reserved_gb(role: str | None) -> dict:
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der """Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente
`swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben. `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 - Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
Gruppen-Mitgliedern → reserviert deren Summe. Gruppen-Mitgliedern → reserviert deren Summe.
- Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe → - 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 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). Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte).
""" """
from services import llamaswap from services import llamaswap
models = llamaswap.list_models() models = llamaswap.list_models()
groups = llamaswap.list_groups() groups = llamaswap.list_groups()
cores = _coresident_members(groups) cores = _coresident_members(groups)
brain = next((m for m in models if (m.get("role") == "hermes")), None) brain = next((m for m in models if (m.get("role") == "hermes")), None)
brain_gb = footprint_gb(brain) if brain else 0.0 brain_gb = footprint_gb(brain) if brain else 0.0
role = (role or "").strip().lower() role = (role or "").strip().lower()
holder = next((m for m in models if (m.get("role") == role)), None) if role else None 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 holder_name = holder["name"] if holder else None
# Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen. # 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) in_group = role == "hermes" or bool(holder_name and holder_name in cores)
if in_group: if in_group:
others = sum(footprint_gb(m) for m in models others = sum(footprint_gb(m) for m in models
if m["name"] in cores and m["name"] != holder_name) if m["name"] in cores and m["name"] != holder_name)
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb} return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
# on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren. # 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) 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} 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: 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 """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).""" bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz)."""
gtt = gtt_budget_gb() gtt = gtt_budget_gb()
r = reserved_gb(role) r = reserved_gb(role)
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
ctx = max_ctx_in_budget(params_b, quant, budget) ctx = max_ctx_in_budget(params_b, quant, budget)
return { return {
"ctx": ctx, "ctx": ctx,
"gtt_gb": gtt, "gtt_gb": gtt,
"reserved_gb": round(r["reserved_gb"], 1), "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "budget_gb": round(budget, 1),
"mode": r["mode"], "mode": r["mode"],
} }
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int: 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).""" """Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX from services.fit import _NICE_CTX
if cap: if cap:
raw_ctx = min(raw_ctx, cap) raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0] best = _NICE_CTX[0]
for c in _NICE_CTX: for c in _NICE_CTX:
if c <= raw_ctx: if c <= raw_ctx:
best = c best = c
return best return best
def setup_aware_ctx_for_model(model: dict) -> dict: 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 """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) — 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.""" Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta from services import gguf_meta
quant = model.get("quant") or "Q4_K_M" quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path") path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None meta = gguf_meta.arch_meta(path) if path else None
if not meta: if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role")) return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb() gtt = gtt_budget_gb()
r = reserved_gb(model.get("role")) r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb) weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "") ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv) 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 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), return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]} "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 Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover). und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable` Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen. spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
""" """
import json import json
import math import math
import os import os
import time import time
from datetime import datetime from datetime import datetime
import httpx import httpx
import logging import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog from services import catalog
from services.caps import capabilities from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} _FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str: def _categorize(repo_id: str) -> str:
low = repo_id.lower() low = repo_id.lower()
for cat in CATEGORIES: for cat in CATEGORIES:
if any(k in low for k in cat["kw"]): if any(k in low for k in cat["kw"]):
return cat["role"] return cat["role"]
return "scout" return "scout"
def _fetch_author_models(author: str) -> list: def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}" url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40") f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try: try:
with httpx.Client(timeout=12.0) as c: with httpx.Client(timeout=12.0) as c:
data = c.get(url).json() data = c.get(url).json()
return data if isinstance(data, list) else [] return data if isinstance(data, list) else []
except Exception: except Exception:
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True) log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
return [] return []
def _age_days(last_modified, now_ts: float) -> float: def _age_days(last_modified, now_ts: float) -> float:
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre.""" """Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
if not last_modified: if not last_modified:
return 540.0 return 540.0
try: try:
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00")) dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
return max((now_ts - dt.timestamp()) / 86400.0, 0.0) return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
except Exception: except Exception:
return 540.0 return 540.0
def _score(m: dict, now_ts: float) -> float: def _score(m: dict, now_ts: float) -> float:
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert: """Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst), - Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified), - Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
- Capability: mehr Parameter (log-skaliert), - Capability: mehr Parameter (log-skaliert),
- Popularity: Downloads (log-skaliert). - Popularity: Downloads (log-skaliert).
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor 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.""" 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 fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.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 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 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 return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
def rank_runnable(models: list[dict]) -> list[dict]: def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware. """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 Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden" + Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance).""" schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
now_ts = time.time() now_ts = time.time()
return sorted( return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"], [m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: -_score(m, now_ts), key=lambda m: -_score(m, now_ts),
) )
def refresh_discover(ram_gb: float) -> dict: def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE """Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
Quelle erreichbar war.""" Quelle erreichbar war."""
raw, seen, ok = [], set(), 0 raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS: for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author) models = _fetch_author_models(author)
if models: if models:
ok += 1 ok += 1
for m in models: for m in models:
rid = m.get("id") rid = m.get("id")
if not rid or rid in seen: if not rid or rid in seen:
continue continue
seen.add(rid) seen.add(rid)
raw.append(m) raw.append(m)
if ok == 0 and not catalog.entries(): if ok == 0 and not catalog.entries():
raise RuntimeError("Keine Quelle erreichbar.") raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES} by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
for m in raw: for m in raw:
rid = m["id"] rid = m["id"]
low = rid.lower() low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS): if any(tok in low for tok in SKIP_TOKENS):
continue continue
role = _categorize(rid) role = _categorize(rid)
params_b = extract_params_b(rid) params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid) fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
tags = [str(t) for t in (m.get("tags") or [])] tags = [str(t) for t in (m.get("tags") or [])]
by_cat[role].append({ by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid, "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "tags": tags, "role": role, "params_b": params_b, "quant": quant, "tags": tags,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0), "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"), "lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"caps": capabilities(name=rid, hf={"tags": tags}), "caps": capabilities(name=rid, hf={"tags": tags}),
}) })
cats = [] cats = []
for c in CATEGORIES: for c in CATEGORIES:
role = c["role"] role = c["role"]
# 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) — # 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
# macht die Empfehlung präzise statt Namens-Raterei. # macht die Empfehlung präzise statt Namens-Raterei.
cat_entries = sorted(catalog.entries_for_role(role), cat_entries = sorted(catalog.entries_for_role(role),
key=lambda e: -catalog.stack_score(e, ram_gb)) 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) cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
if m["fit"]["level"] != "too_tight"] if m["fit"]["level"] != "too_tight"]
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert. # 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
hf_ranked = rank_runnable(by_cat[role]) hf_ranked = rank_runnable(by_cat[role])
seen = {catalog._norm(m["repo"]) for m in cat_models} 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] extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
combined = cat_models + extra combined = cat_models + extra
if combined: if combined:
cats.append({ cats.append({
"role": role, "title": c["title"], "icon": c["icon"], "role": role, "title": c["title"], "icon": c["icon"],
"models": combined[:6], "models": combined[:6],
# Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle. # Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
"recommended": (cat_models[0]["repo"] if cat_models "recommended": (cat_models[0]["repo"] if cat_models
else (hf_ranked[0]["repo"] if hf_ranked else None)), else (hf_ranked[0]["repo"] if hf_ranked else None)),
}) })
data = {"updated": time.time(), "categories": cats} data = {"updated": time.time(), "categories": cats}
try: try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp") 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") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH) os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception: except Exception:
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True) log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
return data return data
def load_discover() -> dict | None: def load_discover() -> dict | None:
try: try:
if DISCOVER_CACHE_PATH.exists(): if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8")) return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception: except Exception:
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True) log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
return None return None
def safe_discover(ram_gb: float) -> dict | None: def safe_discover(ram_gb: float) -> dict | None:
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da.""" """Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
cached = load_discover() cached = load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL): if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached return cached
try: try:
return refresh_discover(ram_gb) return refresh_discover(ram_gb)
except Exception: except Exception:
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True) log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
return cached return cached
+41 -41
View File
@@ -1,41 +1,41 @@
"""Token-Erfassung für den Builtin-Gateway. """Token-Erfassung für den Builtin-Gateway.
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet 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 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. verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
""" """
import json import json
import logging import logging
from services.token_stats import increment_tokens from services.token_stats import increment_tokens
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def record_usage(usage: dict | None, model: str) -> None: def record_usage(usage: dict | None, model: str) -> None:
"""Ein usage-Objekt verbuchen (no-op bei None/leer).""" """Ein usage-Objekt verbuchen (no-op bei None/leer)."""
if not usage: if not usage:
return return
prompt = usage.get("prompt_tokens", 0) prompt = usage.get("prompt_tokens", 0)
completion = usage.get("completion_tokens", 0) completion = usage.get("completion_tokens", 0)
if prompt or completion: if prompt or completion:
increment_tokens(prompt, completion, model=model) increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None: def record_stream_chunk(chunk: bytes, model: str) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden """Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht.""" geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"usage"' not in chunk: if b'"usage"' not in chunk:
return return
text = chunk.decode("utf-8", errors="ignore") text = chunk.decode("utf-8", errors="ignore")
for line in text.splitlines(): for line in text.splitlines():
if not line.startswith("data:"): if not line.startswith("data:"):
continue continue
data_str = line[5:].strip() data_str = line[5:].strip()
if not data_str or data_str == "[DONE]": if not data_str or data_str == "[DONE]":
continue continue
try: try:
record_usage(json.loads(data_str).get("usage"), model) record_usage(json.loads(data_str).get("usage"), model)
except json.JSONDecodeError: except json.JSONDecodeError:
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120]) 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-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem
GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell 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 **vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative
Decoding in llama.cpp — sonst: "draft model vocab type must match target"). 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 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 `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 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 Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung
beim Laden bleibt der letzte Schiedsrichter. beim Laden bleibt der letzte Schiedsrichter.
""" """
import hashlib import hashlib
import struct import struct
from functools import lru_cache from functools import lru_cache
# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md) # 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_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) _T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13)
_SCALAR_FMT = { _SCALAR_FMT = {
_T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h", _T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?", _T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d", _T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
} }
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()} _SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"} _WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
class _Reader: class _Reader:
def __init__(self, f): def __init__(self, f):
self.f = f self.f = f
def read(self, n: int) -> bytes: def read(self, n: int) -> bytes:
b = self.f.read(n) b = self.f.read(n)
if len(b) != n: if len(b) != n:
raise EOFError("unerwartetes Dateiende beim GGUF-Parsen") raise EOFError("unerwartetes Dateiende beim GGUF-Parsen")
return b return b
def u32(self) -> int: def u32(self) -> int:
return struct.unpack("<I", self.read(4))[0] return struct.unpack("<I", self.read(4))[0]
def u64(self) -> int: def u64(self) -> int:
return struct.unpack("<Q", self.read(8))[0] return struct.unpack("<Q", self.read(8))[0]
def gstr(self) -> str: def gstr(self) -> str:
n = self.u64() n = self.u64()
return self.read(n).decode("utf-8", "replace") return self.read(n).decode("utf-8", "replace")
def scalar(self, vtype: int): def scalar(self, vtype: int):
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar.""" """Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
fmt = _SCALAR_FMT.get(vtype) fmt = _SCALAR_FMT.get(vtype)
if not fmt: if not fmt:
self.skip_value(vtype) self.skip_value(vtype)
return None return None
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0] return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
def skip_value(self, vtype: int) -> None: def skip_value(self, vtype: int) -> None:
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt """Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
weiterzuschieben). Arrays werden elementweise konsumiert.""" weiterzuschieben). Arrays werden elementweise konsumiert."""
if vtype == _T_STRING: if vtype == _T_STRING:
self.f.seek(self.u64(), 1) self.f.seek(self.u64(), 1)
elif vtype in _SCALAR_SIZE: elif vtype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[vtype], 1) self.f.seek(_SCALAR_SIZE[vtype], 1)
elif vtype == _T_ARRAY: elif vtype == _T_ARRAY:
etype = self.u32() etype = self.u32()
count = self.u64() count = self.u64()
if etype == _T_STRING: if etype == _T_STRING:
for _ in range(count): for _ in range(count):
self.f.seek(self.u64(), 1) self.f.seek(self.u64(), 1)
elif etype in _SCALAR_SIZE: elif etype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[etype] * count, 1) self.f.seek(_SCALAR_SIZE[etype] * count, 1)
else: else:
raise ValueError(f"unbekannter Array-Elementtyp {etype}") raise ValueError(f"unbekannter Array-Elementtyp {etype}")
else: else:
raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}") raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}")
def _read_fingerprint(path: str) -> dict | None: def _read_fingerprint(path: str) -> dict | None:
"""Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF.""" """Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF."""
try: try:
with open(path, "rb") as fh: with open(path, "rb") as fh:
r = _Reader(fh) r = _Reader(fh)
if r.read(4) != b"GGUF": if r.read(4) != b"GGUF":
return None return None
r.u32() # version r.u32() # version
r.u64() # tensor_count r.u64() # tensor_count
kv_count = r.u64() kv_count = r.u64()
fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None, fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None,
"tokens_sha": None} "tokens_sha": None}
for _ in range(kv_count): for _ in range(kv_count):
key = r.gstr() key = r.gstr()
vtype = r.u32() vtype = r.u32()
if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY: if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY:
etype = r.u32() etype = r.u32()
count = r.u64() count = r.u64()
fp["n_vocab"] = count fp["n_vocab"] = count
if etype != _T_STRING: if etype != _T_STRING:
return None return None
# ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste # ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste
# (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …). # (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …).
h = hashlib.sha256() h = hashlib.sha256()
h.update(count.to_bytes(8, "little")) h.update(count.to_bytes(8, "little"))
for _ in range(count): for _ in range(count):
n = r.u64() n = r.u64()
h.update(r.read(n)) h.update(r.read(n))
fp["tokens_sha"] = h.hexdigest() fp["tokens_sha"] = h.hexdigest()
# model/pre kommen vor tokens → wir haben alles. Abbrechen. # model/pre kommen vor tokens → wir haben alles. Abbrechen.
break break
if key in _WANT_STRINGS and vtype == _T_STRING: if key in _WANT_STRINGS and vtype == _T_STRING:
val = r.gstr() val = r.gstr()
if key == "tokenizer.ggml.model": if key == "tokenizer.ggml.model":
fp["model"] = val fp["model"] = val
elif key == "tokenizer.ggml.pre": elif key == "tokenizer.ggml.pre":
fp["pre"] = val fp["pre"] = val
else: else:
fp["arch"] = val fp["arch"] = val
else: else:
r.skip_value(vtype) r.skip_value(vtype)
if fp["model"] is None and fp["n_vocab"] is None: if fp["model"] is None and fp["n_vocab"] is None:
return None return None
return fp return fp
except (OSError, EOFError, ValueError, struct.error): except (OSError, EOFError, ValueError, struct.error):
return None return None
@lru_cache(maxsize=256) @lru_cache(maxsize=256)
def _cached(path: str, mtime: float, size: int) -> tuple | None: def _cached(path: str, mtime: float, size: int) -> tuple | None:
fp = _read_fingerprint(path) fp = _read_fingerprint(path)
if fp is None: if fp is None:
return None return None
return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha")) return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha"))
def fingerprint(path: str) -> dict | None: def fingerprint(path: str) -> dict | None:
"""Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size). """Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size).
Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar.""" Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar."""
import os import os
try: try:
st = os.stat(path) st = os.stat(path)
except OSError: except OSError:
return None return None
t = _cached(path, st.st_mtime, st.st_size) t = _cached(path, st.st_mtime, st.st_size)
if t is None: if t is None:
return None return None
return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]} 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: def vocab_key(path: str) -> tuple | None:
"""ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256 """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 der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht
nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität
verlangt llama.cpp für Speculative Decoding.""" verlangt llama.cpp für Speculative Decoding."""
fp = fingerprint(path) fp = fingerprint(path)
if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"): if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"):
return None return None
return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"]) return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"])
def compatible(target_path: str, draft_path: str) -> bool | None: def compatible(target_path: str, draft_path: str) -> bool | None:
"""True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar """True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar
(eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'.""" (eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'."""
a = vocab_key(target_path) a = vocab_key(target_path)
b = vocab_key(draft_path) b = vocab_key(draft_path)
if a is None or b is None: if a is None or b is None:
return None return None
return a == b return a == b
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ────────────────────────────── # ── 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. # 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 # 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. # ü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' …), # 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. # gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
_ARCH_WANT = ( _ARCH_WANT = (
".block_count", ".attention.head_count_kv", ".attention.head_count", ".block_count", ".attention.head_count_kv", ".attention.head_count",
".attention.key_length", ".attention.value_length", ".embedding_length", ".attention.key_length", ".attention.value_length", ".embedding_length",
".context_length", ".context_length",
) )
def _read_arch_meta(path: str) -> dict | None: def _read_arch_meta(path: str) -> dict | None:
try: try:
with open(path, "rb") as fh: with open(path, "rb") as fh:
r = _Reader(fh) r = _Reader(fh)
if r.read(4) != b"GGUF": if r.read(4) != b"GGUF":
return None return None
r.u32() # version r.u32() # version
r.u64() # tensor_count r.u64() # tensor_count
kv_count = r.u64() kv_count = r.u64()
raw: dict = {} raw: dict = {}
arch = None arch = None
for _ in range(kv_count): for _ in range(kv_count):
key = r.gstr() key = r.gstr()
vtype = r.u32() vtype = r.u32()
if key == "general.architecture" and vtype == _T_STRING: if key == "general.architecture" and vtype == _T_STRING:
arch = r.gstr() arch = r.gstr()
continue continue
if key == "tokenizer.ggml.tokens": if key == "tokenizer.ggml.tokens":
break # Arch-Metadaten stehen davor → fertig, Rest überspringen break # Arch-Metadaten stehen davor → fertig, Rest überspringen
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None) suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
if suf is not None and vtype in _SCALAR_SIZE: if suf is not None and vtype in _SCALAR_SIZE:
raw[suf] = r.scalar(vtype) raw[suf] = r.scalar(vtype)
else: else:
r.skip_value(vtype) r.skip_value(vtype)
n_layers = raw.get(".block_count") n_layers = raw.get(".block_count")
n_head = raw.get(".attention.head_count") n_head = raw.get(".attention.head_count")
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
n_embd = raw.get(".embedding_length") n_embd = raw.get(".embedding_length")
hd_k = raw.get(".attention.key_length") \ hd_k = raw.get(".attention.key_length") \
or (int(n_embd / n_head) if (n_embd and n_head) else None) or (int(n_embd / n_head) if (n_embd and n_head) else None)
hd_v = raw.get(".attention.value_length") or hd_k hd_v = raw.get(".attention.value_length") or hd_k
if not (n_layers and n_head_kv and hd_k and hd_v): if not (n_layers and n_head_kv and hd_k and hd_v):
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv), 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), "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} "n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
except (OSError, EOFError, ValueError, struct.error): except (OSError, EOFError, ValueError, struct.error):
return None return None
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def _arch_cached(path: str, mtime: float, size: int) -> dict | None: def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
return _read_arch_meta(path) return _read_arch_meta(path)
def arch_meta(path: str) -> dict | None: def arch_meta(path: str) -> dict | None:
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size): """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 {arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
oder unvollständig.""" oder unvollständig."""
import os import os
try: try:
st = os.stat(path) st = os.stat(path)
except OSError: except OSError:
return None return None
return _arch_cached(path, st.st_mtime, st.st_size) return _arch_cached(path, st.st_mtime, st.st_size)
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants). # Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
_KV_BPE = { _KV_BPE = {
"f32": 4.0, "f16": 2.0, "bf16": 2.0, "f32": 4.0, "f16": 2.0, "bf16": 2.0,
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875, "q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625, "q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
} }
_GIB = 1024 ** 3 _GIB = 1024 ** 3
def _bpe(cache_type: str | None) -> float: def _bpe(cache_type: str | None) -> float:
return _KV_BPE.get((cache_type or "f16").lower(), 2.0) 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: 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: """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.""" 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 per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
k = per_tok * meta["head_dim_k"] * _bpe(ck) k = per_tok * meta["head_dim_k"] * _bpe(ck)
v = per_tok * meta["head_dim_v"] * _bpe(cv) v = per_tok * meta["head_dim_v"] * _bpe(cv)
return (k + v) / _GIB return (k + v) / _GIB
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float: 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).""" """KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
return kv_cache_gb(meta, 1, ck, cv) 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_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_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_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: 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). """Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
eingesparte Summe. Wird vom System-Router dünn aufgerufen. eingesparte Summe. Wird vom System-Router dünn aufgerufen.
""" """
import os import os
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M). # Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
PRICING: dict[str, tuple[float, float]] = { PRICING: dict[str, tuple[float, float]] = {
"heavy": (15.0, 75.0), "heavy": (15.0, 75.0),
"coder": (3.0, 15.0), "coder": (3.0, 15.0),
"hermes": (1.0, 5.0), "hermes": (1.0, 5.0),
"fast": (0.15, 0.60), "fast": (0.15, 0.60),
"scout": (0.15, 0.60), "scout": (0.15, 0.60),
"vision": (0.15, 0.60), "vision": (0.15, 0.60),
"reasoning": (0.15, 0.60), "reasoning": (0.15, 0.60),
} }
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse). # Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60) DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten, # Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
# damit historische Ersparnis erhalten bleibt. # damit historische Ersparnis erhalten bleibt.
BASELINE_RATE: tuple[float, float] = PRICING["heavy"] BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92")) 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: def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis. """Aggregiert Tokens und berechnet die Cloud-Ersparnis.
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung. role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
""" """
prompt = stats.get("prompt_tokens", 0) prompt = stats.get("prompt_tokens", 0)
completion = stats.get("completion_tokens", 0) completion = stats.get("completion_tokens", 0)
modeled_p = modeled_c = 0 modeled_p = modeled_c = 0
saved_usd = 0.0 saved_usd = 0.0
for m_name, m_tokens in (stats.get("models") or {}).items(): for m_name, m_tokens in (stats.get("models") or {}).items():
mp = m_tokens.get("prompt", 0) mp = m_tokens.get("prompt", 0)
mc = m_tokens.get("completion", 0) mc = m_tokens.get("completion", 0)
modeled_p += mp modeled_p += mp
modeled_c += mc modeled_c += mc
role = role_map.get(m_name, m_name) role = role_map.get(m_name, m_name)
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE) rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0 saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
baseline_p = max(0, prompt - modeled_p) baseline_p = max(0, prompt - modeled_p)
baseline_c = max(0, completion - modeled_c) baseline_c = max(0, completion - modeled_c)
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0 saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
return { return {
"prompt_tokens": prompt, "prompt_tokens": prompt,
"completion_tokens": completion, "completion_tokens": completion,
"total_tokens": prompt + completion, "total_tokens": prompt + completion,
"saved_usd": round(saved_usd, 2), "saved_usd": round(saved_usd, 2),
"saved_eur": round(saved_usd * USD_TO_EUR, 2), "saved_eur": round(saved_usd * USD_TO_EUR, 2),
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()}, "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). 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 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 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 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. 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 Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) die
bleiben in router_logic.py im Code. bleiben in router_logic.py im Code.
""" """
import json import json
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from config import MODELS_DIR from config import MODELS_DIR
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json"))) POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
def _env_bool(name: str, default: str) -> bool: def _env_bool(name: str, default: str) -> bool:
return os.environ.get(name, default) not in ("0", "false", "") return os.environ.get(name, default) not in ("0", "false", "")
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert. # Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
DEFAULTS: dict = { DEFAULTS: dict = {
"fast": os.environ.get("MC_ROUTE_FAST", "fast"), "fast": os.environ.get("MC_ROUTE_FAST", "fast"),
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"), "heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"), "coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""), "coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")), "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
} }
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung. # Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
FIELDS: list[dict] = [ FIELDS: list[dict] = [
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"}, {"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"}, {"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "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": "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": "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": "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"}, {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
] ]
_LOCK = threading.Lock() _LOCK = threading.Lock()
_CACHE: dict = {"mtime": None, "policy": None} _CACHE: dict = {"mtime": None, "policy": None}
def _read_file() -> dict: def _read_file() -> dict:
try: try:
with open(POLICY_PATH, "r", encoding="utf-8") as f: with open(POLICY_PATH, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError): except (FileNotFoundError, json.JSONDecodeError, OSError):
return {} return {}
def _coerce(patch: dict) -> dict: def _coerce(patch: dict) -> dict:
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten.""" """Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
spec = {f["key"]: f for f in FIELDS} spec = {f["key"]: f for f in FIELDS}
out: dict = {} out: dict = {}
for k, v in (patch or {}).items(): for k, v in (patch or {}).items():
f = spec.get(k) f = spec.get(k)
if not f: if not f:
continue # unbekannte Keys still verwerfen continue # unbekannte Keys still verwerfen
if f["type"] == "int": if f["type"] == "int":
iv = int(v) iv = int(v)
lo, hi = f.get("min", 1), f.get("max", 10**9) lo, hi = f.get("min", 1), f.get("max", 10**9)
if not (lo <= iv <= hi): if not (lo <= iv <= hi):
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]") raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
out[k] = iv out[k] = iv
elif f["type"] == "bool": elif f["type"] == "bool":
out[k] = bool(v) out[k] = bool(v)
else: # str else: # str
sv = str(v).strip() sv = str(v).strip()
if k != "coder_lite" and not sv: if k != "coder_lite" and not sv:
raise ValueError(f"{k} darf nicht leer sein") raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv out[k] = sv
return out return out
def _coerce_safe(patch: dict) -> dict: def _coerce_safe(patch: dict) -> dict:
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen.""" """Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
try: try:
return _coerce(patch) return _coerce(patch)
except (ValueError, TypeError): except (ValueError, TypeError):
return {} return {}
def load_policy() -> dict: def load_policy() -> dict:
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig.""" """Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
try: try:
mtime = POLICY_PATH.stat().st_mtime mtime = POLICY_PATH.stat().st_mtime
except OSError: except OSError:
mtime = None mtime = None
with _LOCK: with _LOCK:
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime: if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
merged = {**DEFAULTS} merged = {**DEFAULTS}
if mtime is not None: if mtime is not None:
merged.update(_coerce_safe(_read_file())) merged.update(_coerce_safe(_read_file()))
_CACHE["mtime"] = mtime _CACHE["mtime"] = mtime
_CACHE["policy"] = merged _CACHE["policy"] = merged
return dict(_CACHE["policy"]) return dict(_CACHE["policy"])
def save_policy(patch: dict) -> dict: def save_policy(patch: dict) -> dict:
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück.""" """Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK: with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean} current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp") tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2) json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH) os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch _CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None _CACHE["policy"] = None
return current return current
def policy_meta() -> dict: def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation.""" """Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS} 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. """Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben 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 (Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand. beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
""" """
import atexit import atexit
import json import json
import logging import logging
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from config import HERMES_HOME from config import HERMES_HOME
STATS_FILE = HERMES_HOME / "token_stats.json" STATS_FILE = HERMES_HOME / "token_stats.json"
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging). # Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}} _BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_lock = threading.Lock() _lock = threading.Lock()
_stats: dict | None = None _stats: dict | None = None
_dirty = False _dirty = False
_last_flush = 0.0 _last_flush = 0.0
def _load_from_disk() -> dict: def _load_from_disk() -> dict:
if not STATS_FILE.exists(): if not STATS_FILE.exists():
return dict(_BASELINE) return dict(_BASELINE)
try: try:
with open(STATS_FILE, "r", encoding="utf-8") as f: with open(STATS_FILE, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
data.setdefault("prompt_tokens", 0) data.setdefault("prompt_tokens", 0)
data.setdefault("completion_tokens", 0) data.setdefault("completion_tokens", 0)
data.setdefault("models", {}) data.setdefault("models", {})
return data return data
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True) log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
return dict(_BASELINE) return dict(_BASELINE)
def _ensure_loaded() -> dict: def _ensure_loaded() -> dict:
global _stats global _stats
if _stats is None: if _stats is None:
_stats = _load_from_disk() _stats = _load_from_disk()
return _stats return _stats
def _write(stats: dict) -> None: def _write(stats: dict) -> None:
try: try:
STATS_FILE.parent.mkdir(parents=True, exist_ok=True) STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATS_FILE.with_suffix(".tmp") tmp = STATS_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
json.dump(stats, f) json.dump(stats, f)
tmp.replace(STATS_FILE) tmp.replace(STATS_FILE)
except OSError: except OSError:
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True) log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
def get_stats() -> dict: def get_stats() -> dict:
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.""" """Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
with _lock: with _lock:
return json.loads(json.dumps(_ensure_loaded())) return json.loads(json.dumps(_ensure_loaded()))
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None: def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren.""" """Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
global _dirty, _last_flush global _dirty, _last_flush
with _lock: with _lock:
stats = _ensure_loaded() stats = _ensure_loaded()
stats["prompt_tokens"] += prompt stats["prompt_tokens"] += prompt
stats["completion_tokens"] += completion stats["completion_tokens"] += completion
if model: if model:
m = stats.setdefault("models", {}).setdefault( m = stats.setdefault("models", {}).setdefault(
model.lower(), {"prompt": 0, "completion": 0}) model.lower(), {"prompt": 0, "completion": 0})
m["prompt"] += prompt m["prompt"] += prompt
m["completion"] += completion m["completion"] += completion
_dirty = True _dirty = True
now = time.monotonic() now = time.monotonic()
if now - _last_flush >= FLUSH_INTERVAL: if now - _last_flush >= FLUSH_INTERVAL:
_write(stats) _write(stats)
_dirty = False _dirty = False
_last_flush = now _last_flush = now
def flush() -> None: def flush() -> None:
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown).""" """Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
global _dirty global _dirty
with _lock: with _lock:
if _dirty and _stats is not None: if _dirty and _stats is not None:
_write(_stats) _write(_stats)
_dirty = False _dirty = False
atexit.register(flush) 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. 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 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 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- 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. Ä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 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 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 vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
raus, verdrängt also nie ein gerade genutztes Modell. raus, verdrängt also nie ein gerade genutztes Modell.
warmup.sh deckt korrekt ab: fast+vision über /v1/chat/completions, embed über /v1/embeddings 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. (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 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'). warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast vision') + MC_WARMUP_EMBED (Default 'embed').
""" """
import asyncio import asyncio
import logging import logging
import os import os
import subprocess import subprocess
import httpx import httpx
from config import LLAMA_SWAP_URL from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0" ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25")) 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 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 # 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. # 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")) _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). # 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 # 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 # 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. # Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
_loop: asyncio.AbstractEventLoop | None = None _loop: asyncio.AbstractEventLoop | None = None
_wake: asyncio.Event | None = None _wake: asyncio.Event | None = None
def nudge() -> None: def nudge() -> None:
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op, """Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
solange der Wächter (noch) nicht läuft.""" solange der Wächter (noch) nicht läuft."""
if _loop is not None and _wake is not None and not _loop.is_closed(): if _loop is not None and _wake is not None and not _loop.is_closed():
try: try:
_loop.call_soon_threadsafe(_wake.set) _loop.call_soon_threadsafe(_wake.set)
except RuntimeError: except RuntimeError:
pass pass
def _run_warmup() -> bool: def _run_warmup() -> bool:
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions, """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. embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
False, wenn das Skript fehlt.""" False, wenn das Skript fehlt."""
if not os.path.exists(_WARMUP_SH): if not os.path.exists(_WARMUP_SH):
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH) log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
return False return False
try: try:
subprocess.Popen(["bash", _WARMUP_SH], subprocess.Popen(["bash", _WARMUP_SH],
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL}, env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True return True
except Exception: except Exception:
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True) log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
return False return False
def _warmset_members() -> set[str]: def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent).""" """Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
try: try:
from services import llamaswap from services import llamaswap
groups = llamaswap.list_groups() or {} groups = llamaswap.list_groups() or {}
except Exception: except Exception:
return set() return set()
want: set[str] = set() want: set[str] = set()
for g in groups.values(): for g in groups.values():
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")): if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
want.update(g.get("members") or []) want.update(g.get("members") or [])
return want return want
async def _warmset_missing() -> list[str] | None: async def _warmset_missing() -> list[str] | None:
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running """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 nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) genau der
Fall, der nach einem watch-config-Reload/Deploy auftritt.""" Fall, der nach einem watch-config-Reload/Deploy auftritt."""
try: try:
async with httpx.AsyncClient(timeout=8.0) as c: async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running") r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {} data = r.json() or {}
except Exception: except Exception:
return None return None
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"} ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
want = _warmset_members() want = _warmset_members()
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
return [] if ready else ["<leer>"] return [] if ready else ["<leer>"]
return [m for m in want if m not in ready] return [m for m in want if m not in ready]
async def rewarm_loop() -> None: async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein """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.""" Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge lädt via warmup.sh nach."""
global _loop, _wake global _loop, _wake
_loop = asyncio.get_running_loop() _loop = asyncio.get_running_loop()
_wake = asyncio.Event() _wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True: while True:
try: try:
missing = await _warmset_missing() missing = await _warmset_missing()
if missing: if missing:
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing)) log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
_run_warmup() _run_warmup()
except Exception: except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True) log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren. # Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
try: try:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL) await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear() _wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)") log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
_run_warmup() _run_warmup()
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
+115 -115
View File
@@ -1,115 +1,115 @@
# Auto-generiert von provision.sh - per Mission Control erweiterbar # Auto-generiert von provision.sh - per Mission Control erweiterbar
healthCheckTimeout: 300 healthCheckTimeout: 300
globalTTL: 0 globalTTL: 0
models: models:
Qwen3.6-35B-A3B: Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns # 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: # 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. # Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
cmd: | 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 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 ttl: 0
aliases: aliases:
- hermes - hermes
- fast - fast
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots). # context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
capabilities: capabilities:
in: [text] in: [text]
out: [text] out: [text]
tools: true tools: true
context: 65536 context: 65536
Qwen3.5-122B-A10B: Qwen3.5-122B-A10B:
cmd: | 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 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 ttl: 600
aliases: aliases:
- heavy - heavy
capabilities: capabilities:
in: [text] in: [text]
out: [text] out: [text]
tools: true tools: true
context: 32768 context: 32768
Qwen3-Coder-Next: Qwen3-Coder-Next:
cmd: | 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 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 ttl: 600
aliases: aliases:
- coder - coder
capabilities: capabilities:
in: [text] in: [text]
out: [text] out: [text]
tools: true tools: true
context: 131072 context: 131072
Qwen3-VL-8B-Instruct: Qwen3-VL-8B-Instruct:
cmd: | 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 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 (03.07.): Mitglied des Immer-bereit-Sets — ein Selbstauslöser widerspräche dem UI-Versprechen.
ttl: 0 ttl: 0
aliases: aliases:
- vision - vision
capabilities: capabilities:
in: [text, image] in: [text, image]
out: [text] out: [text]
context: 32768 context: 32768
Qwen3-Embedding-0.6B: Qwen3-Embedding-0.6B:
cmd: | 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 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 ttl: 0
aliases: aliases:
- embed - embed
Qwen3-Coder-30B-A3B-Instruct: Qwen3-Coder-30B-A3B-Instruct:
cmd: | 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 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 ttl: 300
aliases: aliases:
- coder-lite - coder-lite
capabilities: capabilities:
in: [text] in: [text]
out: [text] out: [text]
tools: true tools: true
context: 131072 context: 131072
gpt-oss-120b: 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 # 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. # 73 GB). Noch OHNE heavy-Alias — erst Qualitaets-Check (deutsch/Reasoning), dann Entscheid.
cmd: | 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 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 ttl: 600
capabilities: capabilities:
in: [text] in: [text]
out: [text] out: [text]
tools: true tools: true
context: 32768 context: 32768
Qwen3-VL-30B-A3B-Instruct: Qwen3-VL-30B-A3B-Instruct:
# Lucys AUGEN (vision, seit 03.07. live via set_role/set_group): MoE 3B aktiv, versteht die # 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 # 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). # gegen Verdraengung, NICHT gegen ttl-Selbstentladen (sonst faellt es alle 5 Min raus).
cmd: | 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 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 ttl: 0
capabilities: capabilities:
in: [text, image] in: [text, image]
out: [text] out: [text]
context: 32768 context: 32768
GLM-4.6V-Flash: GLM-4.6V-Flash:
cmd: | 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 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 ttl: 300
aliases: aliases:
- scout - scout
capabilities: capabilities:
in: [text, image] in: [text, image]
out: [text] out: [text]
tools: true tools: true
context: 131072 context: 131072
groups: groups:
brains: brains:
swap: false swap: false
# llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der # llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der
# MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen! # MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen!
persist: true persist: true
persistent: true persistent: true
members: members:
- Qwen3-Embedding-0.6B - Qwen3-Embedding-0.6B
- Qwen3-VL-8B-Instruct - Qwen3-VL-8B-Instruct
- Qwen3.6-35B-A3B - Qwen3.6-35B-A3B
+186 -186
View File
@@ -1,186 +1,186 @@
--- ---
name: orchestrator 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." 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 version: 1.0.0
author: MC2 (Autonomie — Orchestrator) author: MC2 (Autonomie — Orchestrator)
platforms: [linux] platforms: [linux]
metadata: metadata:
hermes: hermes:
tags: [orchestrator, delegation, werkstatt, mc2, fliessband] tags: [orchestrator, delegation, werkstatt, mc2, fliessband]
--- ---
# Orchestrator — du managst, du fuehrst nicht alles selbst aus # 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 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 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 zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse
zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet. zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet.
Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt 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 (Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst
die Integration + das Gate. die Integration + das Gate.
## Warum seriell (und warum das ok ist) ## Warum seriell (und warum das ok ist)
Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein 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.** 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. Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal.
## Scope-Check zuerst (klein anfangen) ## Scope-Check zuerst (klein anfangen)
Passt: ein Auftrag, der in **24 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 12-Datei-Bau-Schritt 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 (neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in
wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung): 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 NUR einen **Plan** als Text im Telegram-Vorschlag liefern, KEINEN Code — der Commander schneidet
dann kleiner. dann kleiner.
## Leitplanken (nicht verhandelbar) ## Leitplanken (nicht verhandelbar)
- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf - **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 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 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 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 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.) faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.)
- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh` - **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`). 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 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. 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. - **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. NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten.
- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt. - **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt.
- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen. - **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 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), 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 auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen
Hintergrund-Prozess gewollt. Hintergrund-Prozess gewollt.
- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes - **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 Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind
zusammenklebt, produziert leisen Muell. zusammenklebt, produziert leisen Muell.
- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen. - **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. - Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen.
## Ablauf ## Ablauf
### 1. Beratung & Zerlegen (Manager plant mit dem Chef) ### 1. Beratung & Zerlegen (Manager plant mit dem Chef)
Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan: 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: 1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer:
```bash ```bash
printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt 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. 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. 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) ### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags)
``` ```
cd ~/mission-control-v2 && git fetch -q origin \ cd ~/mission-control-v2 && git fetch -q origin \
&& git worktree add /tmp/orch-<slug> -b orchestrator/<slug> origin/main && git worktree add /tmp/orch-<slug> -b orchestrator/<slug> origin/main
mkdir -p /tmp/orch-<slug>-work # Ablage fuer rohe Worker-Ausgaben mkdir -p /tmp/orch-<slug>-work # Ablage fuer rohe Worker-Ausgaben
``` ```
### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell) ### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell)
Fuer jeden Bau-Teil-Task: Fuer jeden Bau-Teil-Task:
1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits 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. fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht.
2. Ruf den Worker auf und fang die Ausgabe roh ab: 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 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>`.) (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 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 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 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-Tools an seinen Platz im Worktree (`/tmp/orch-<slug>/...`). Der Worker hat KEINE
Datei-Haende — das Schreiben machst DU. Datei-Haende — das Schreiben machst DU.
### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende) ### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende)
Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung, Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung,
Namen). Dann das Selbst-Gate, was zutrifft: Namen). Dann das Selbst-Gate, was zutrifft:
- Python geaendert → `python3 -m py_compile <dateien>` (und wenn ein Test dabei ist und ohne - Python geaendert → `python3 -m py_compile <dateien>` (und wenn ein Test dabei ist und ohne
schwere Deps laeuft: den Test im Worktree ausfuehren). schwere Deps laeuft: den Test im Worktree ausfuehren).
- Shell geaendert → `bash -n <dateien>` - Shell geaendert → `bash -n <dateien>`
- Frontend (`frontend/src/...`) → auf der Box gibt es KEIN Node. Im Vorschlag ausweisen: - Frontend (`frontend/src/...`) → auf der Box gibt es KEIN Node. Im Vorschlag ausweisen:
„Gate eingeschraenkt: tsc/Build laeuft erst beim Merge auf dem PC." „Gate eingeschraenkt: tsc/Build laeuft erst beim Merge auf dem PC."
- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):** - **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):**
- `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du - `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 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 der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht
bemerkt). bemerkt).
- `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben. - `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 - 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 `git -C ~/mission-control-v2 checkout -- <datei>` zuruecknehmen (deine Arbeit liegt sicher im
Worktree) und im Vorschlag ehrlich erwaehnen. Worktree) und im Vorschlag ehrlich erwaehnen.
### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht) ### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht)
Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben 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 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). `delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision).
**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert **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): untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen):
``` ```
git -C /tmp/orch-<slug> add -A git -C /tmp/orch-<slug> add -A
``` ```
Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind): Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind):
``` ```
EINGABE="AUFTRAG (im Wortlaut): EINGABE="AUFTRAG (im Wortlaut):
<der urspruengliche Auftrag> <der urspruengliche Auftrag>
ZERLEGUNG (deine Teil-Tasks, je 1 Zeile): ZERLEGUNG (deine Teil-Tasks, je 1 Zeile):
<1..n> <1..n>
GIT-DIFF des Worktrees: GIT-DIFF des Worktrees:
$(git -C /tmp/orch-<slug> diff --cached origin/main)" $(git -C /tmp/orch-<slug> diff --cached origin/main)"
``` ```
Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben. 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): **Kritik A — Kompetenz** (`Qwen3-Coder-Next`, starker Coder, vom Bauen noch geladen → KEIN Swap):
``` ```
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh
``` ```
**Kritik B — Fremd-Blick** (`GLM-4.6V-Flash`, andere Modellfamilie, andere blinde Flecken): **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 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 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.** `URTEIL: <ABGELEHNT | FREIGABE-MIT-VORBEHALT | FREIGABE>`. **Warte auf beide.**
**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den **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` 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 → 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 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 ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X
fiel aus" vermerken (NICHT als Freigabe zaehlen). fiel aus" vermerken (NICHT als Freigabe zaehlen).
> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB > 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 > 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. > Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind.
- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen - `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 Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den
Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander. Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander.
- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich - `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich
„⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln). „⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln).
### 6. Telegram-Vorschlag (in LUCYS Stimme) ### 6. Telegram-Vorschlag (in LUCYS Stimme)
`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" "<text>"` — an den Commander, nicht `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: 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), - die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging),
- geaenderte/neue Dateien · Kern des Diffs, - geaenderte/neue Dateien · Kern des Diffs,
- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"), - Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"),
- Branch-Name · Frage „merge oder verwerfen?" - Branch-Name · Frage „merge oder verwerfen?"
### 7. Nichts loeschen ### 7. Nichts loeschen
Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat. Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat.
## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief ## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief
Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein 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: 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), - 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 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. 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 - 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". 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 - 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. den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht.
## Nach dem Commander-Entscheid (kommt als neuer Auftrag) ## Nach dem Commander-Entscheid (kommt als neuer Auftrag)
- „verwerfen" → `git worktree remove /tmp/orch-<slug> --force && git branch -D orchestrator/<slug>` - „verwerfen" → `git worktree remove /tmp/orch-<slug> --force && git branch -D orchestrator/<slug>`
(+ `rm -rf /tmp/orch-<slug>-work`; Remote-Branch loeschen, falls gepusht). (+ `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). - „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort).
Du pushst NIE nach main. Du pushst NIE nach main.
+52 -52
View File
@@ -1,52 +1,52 @@
# Antigravity-Review-Prompt — Mission Control 2.0 # Antigravity-Review-Prompt — Mission Control 2.0
Prompt zum Einfügen in Antigravity 2.0 (Modell: **Gemini 3.1 Pro, High-Modus**). Projektordner = 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. 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 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. Architektur-Review** dieses Projekts (Mission Control 2.0, kurz MC2) durch.
**Zuerst lesen — das ist der Rahmen, weiche nicht davon ab:** **Zuerst lesen — das ist der Rahmen, weiche nicht davon ab:**
1. `AGENTS.md` — die verbindlichen Projekt-Regeln. 1. `AGENTS.md` — die verbindlichen Projekt-Regeln.
2. `SAVEPOINT.md` — wo das Projekt gerade steht, Nordstern und Randbedingungen. 2. `SAVEPOINT.md` — wo das Projekt gerade steht, Nordstern und Randbedingungen.
3. `README.md` und `docs/` — Kontext. 3. `README.md` und `docs/` — Kontext.
**Was MC2 ist:** die Web-Admin-Konsole einer **100 % lokalen** KI-Appliance (AMD Strix Halo, 128 GB, **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 llama.cpp-Vulkan). Backend = FastAPI (`backend/`, Dienst auf :9001), Frontend = React/Vite/Tailwind
(`frontend/`). Sidecars: `mem0_service/`, `voice_service/`, `mcp/`, `hermes/`, `client/`. (`frontend/`). Sidecars: `mem0_service/`, `voice_service/`, `mcp/`, `hermes/`, `client/`.
**Nordstern:** Das System muss **über Jahre ohne Wartung** laufen, betrieben von einem **Nordstern:** Das System muss **über Jahre ohne Wartung** laufen, betrieben von einem
**Nicht-Entwickler**. Robustheit, Einfachheit und Selbstheilung schlagen Features. **Nicht-Entwickler**. Robustheit, Einfachheit und Selbstheilung schlagen Features.
## Dein Auftrag ## Dein Auftrag
Prüfe das gesamte Repo (außer dem, was `.aiexclude` ausschließt). Suche gezielt nach: 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. - **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 - **Betriebs-Fragilität:** Was bricht bei **Reboot**, **Update** oder **Teil-Ausfall**? Was ist nicht
reboot-/idempotenz-fest? (Der Betreiber kann es nicht selbst debuggen.) reboot-/idempotenz-fest? (Der Betreiber kann es nicht selbst debuggen.)
- **Sicherheitslücken** — Auth/Token-Handling, Command-Allowlists, Prompt-Injection-Guards, - **Sicherheitslücken** — Auth/Token-Handling, Command-Allowlists, Prompt-Injection-Guards,
Pfad-/Eingabe-Validierung, `sudo -n`-Nutzung. (Nur MELDEN, nichts an der Security-Config ändern.) Pfad-/Eingabe-Validierung, `sudo -n`-Nutzung. (Nur MELDEN, nichts an der Security-Config ändern.)
- **Ressourcen-Lecks & Fehlerbehandlung** — offene Clients/Dateien, verschluckte Exceptions, - **Ressourcen-Lecks & Fehlerbehandlung** — offene Clients/Dateien, verschluckte Exceptions,
fehlende Timeouts. fehlende Timeouts.
- **Toter/redundanter Code, DRY-/KISS-Verstöße, Über-Komplexität** — Vereinfachungs-Chancen. - **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 - **Doku-/Realitäts-Drift** — Widersprüche zwischen `AGENTS.md`/Kommentaren und dem Code
(z. B. Zeitzone UTC vs. Europe/Berlin). (z. B. Zeitzone UTC vs. Europe/Berlin).
- **Wartbarkeit** — würde ein Fremder das in einem Jahr noch verstehen und sicher anfassen können? - **Wartbarkeit** — würde ein Fremder das in einem Jahr noch verstehen und sicher anfassen können?
## Harte Leitplanken (nicht empfehlen) ## Harte Leitplanken (nicht empfehlen)
- KEINE Cloud-Migration, KEIN Voll-Rewrite, KEINE schweren neuen Frameworks. - KEINE Cloud-Migration, KEIN Voll-Rewrite, KEINE schweren neuen Frameworks.
- KEINE Modell-/Engine-Wechsel, die nicht in ~124 GB passen; keine Cloud-LLMs. - 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. - `frontend/dist` bleibt bewusst im git (Box hat kein Node) — nicht als Fehler werten.
- Security-Config nur MELDEN, nie „einfach ändern". - Security-Config nur MELDEN, nie „einfach ändern".
- Bevorzuge **Vereinfachung und Härtung** vor neuen Features. - Bevorzuge **Vereinfachung und Härtung** vor neuen Features.
## Ausgabeformat (auf DEUTSCH) ## Ausgabeformat (auf DEUTSCH)
1. **Gesamturteil** (35 Sätze): Wie gesund ist das Projekt? Trägt es den Nordstern „Jahre ohne Wartung"? 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: 2. **Befunde, nach Schwere sortiert** — je Befund:
- Schweregrad **P0** (bricht/Sicherheit) · **P1** (echter Bug/Fragilität) · **P2** (Wartbarkeit) · **P3** (nice-to-have) - 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** - `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). 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). 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. 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 # Kickoff: Voll-Audit MC2 (Front+Backend) + Box-SSH + Optimierungsplan
> Für eine **frische Claude-Code-Session mit echtem Terminal** (direkter SSH-Zugang zur Box). > 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 > 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 > (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**. > weitergeht — inkl. Optimierungen für ALLE LLM-Rollen und einem **neuen `scout`-Modell**.
> Erst auditieren/planen — keine destruktiven Änderungen ohne Plan-Freigabe. > Erst auditieren/planen — keine destruktiven Änderungen ohne Plan-Freigabe.
## Zugänge ## Zugänge
- **Repo (Dev-PC):** `F:\Coding Stuff\mission-control-2` (Windows). Auf der Box: `~/mission-control-v2`. - **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 - **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. `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`), - **Wichtige Box-Pfade:** `/etc/llama-swap/config.yaml` (Engine, `-watch-config`),
`~/.hermes/config.yaml` (Hermes-Agent = Lucys Hirn), `/srv/models/` (GGUFs), `~/.hermes/config.yaml` (Hermes-Agent = Lucys Hirn), `/srv/models/` (GGUFs),
`/opt/llamacpp-vulkan/llama-server` (Engine v9843). `/opt/llamacpp-vulkan/llama-server` (Engine v9843).
- **Dienste (systemd --user):** `hermes-gateway` (:8642), `hermes-webui` (:8787), llama-swap (:8080), - **Dienste (systemd --user):** `hermes-gateway` (:8642), `hermes-webui` (:8787), llama-swap (:8080),
MC2-Gateway (:9001). Live-Logs: `journalctl --user -u hermes-gateway -f`. MC2-Gateway (:9001). Live-Logs: `journalctl --user -u hermes-gateway -f`.
## Architektur (verifiziert 30.06.2026) ## Architektur (verifiziert 30.06.2026)
- **llama-swap** (:8080) lädt GGUFs; Rollen = llama-swap **`aliases`**; Warm-Bleiben über - **llama-swap** (:8080) lädt GGUFs; Rollen = llama-swap **`aliases`**; Warm-Bleiben über
`groups: { brains: { swap:false } }` + `ttl`. `groups: { brains: { swap:false } }` + `ttl`.
- **MC2-Gateway** (:9001/v1, FastAPI) mit virtuellen Lanes `chat`(→fast/heavy) & `coding`(→coder_lite/coder). - **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`, 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`, `fit.py`; Router `backend/routers/{models,routing,voice}.py`. Frontend: `frontend/src/views/ModelsView.tsx`,
`components/models/*`, `SystemDrawer.tsx`. `components/models/*`, `SystemDrawer.tsx`.
- **Hermes-Agent** (:8642) = Lucys Hirn (`~/.hermes/config.yaml: model.default`), Delegation `heavy`. - **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 - **Lucy-Client** (`client/lucy-desktop`, Electron) spawnt lokal pocket-tts (:8130); Persona+Umlaut-Logik
im Client (`renderer/src/config.ts`, `lib/voice/useVoiceAgent.ts`). 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`), - **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. Sprechen=pocket-tts, Embedding=`Qwen3-Embedding-0.6B`(ttl 0, immer warm), Gedächtnis=Mem0/MCP.
## Modelle / Rollen (Stand jetzt) ## Modelle / Rollen (Stand jetzt)
| Rolle (alias) | Modell | aktiv | ctt/ttl | Notiz | | Rolle (alias) | Modell | aktiv | ctt/ttl | Notiz |
|---|---|---|---|---| |---|---|---|---|---|
| fast | Qwen3.6-35B-A3B | 3B | ttl 0, --parallel 2, mmproj | chat-Lane Default | | fast | Qwen3.6-35B-A3B | 3B | ttl 0, --parallel 2, mmproj | chat-Lane Default |
| heavy | Qwen3.5-122B-A10B | 10B | ttl 600 | Delegation-Ziel | | heavy | Qwen3.5-122B-A10B | 10B | ttl 600 | Delegation-Ziel |
| coder | Qwen3-Coder-Next | — | ttl 600, spec draft-simple (Qwen3-0.6B) | | | coder | Qwen3-Coder-Next | — | ttl 600, spec draft-simple (Qwen3-0.6B) | |
| coder_lite | Qwen3-Coder-30B-A3B | 3B | ttl 300 | coding-Lane Default | | coder_lite | Qwen3-Coder-30B-A3B | 3B | ttl 300 | coding-Lane Default |
| vision | Qwen3-VL-8B-Instruct | — | ttl 300 | Screen-Beschreibung | | vision | Qwen3-VL-8B-Instruct | — | ttl 300 | Screen-Beschreibung |
| (embedding) | Qwen3-Embedding-0.6B | — | ttl 0 | immer warm | | (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. | | **brain (NEU, Lucy)** | **gemma-4-26B-A4B-it** | 4B | ttl 0, **noch Alias `scout`** | Hermes default seit 30.06. |
## Bereits gemacht (30.06.) ## Bereits gemacht (30.06.)
- Lucys Hirn `fast`**`gemma-4-26B-A4B-it`** (`~/.hermes/config.yaml model.default`, Backup - 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). `~/.hermes/config.yaml.gemma-swap.bak`, hermes-gateway neu gestartet, verifiziert).
- Umlaut-Fix: Client-Stammliste erweitert (`useVoiceAgent.ts`) **und** Server-Netz `_fix_umlauts` - 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). (`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, - 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`. FAST_FIRST (kurzer 1. Chunk, TTFB ~1,3s), seriell (WORKERS=0) als Default. Details: `docs/LUCY_TTS_PLAN.md`.
## Offene Probleme / Aufgaben ## Offene Probleme / Aufgaben
1. **Brain-Rolle fehlt als Konzept.** gemma läuft als Alias `scout`, ist NICHT in `brains: swap:false` 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`** → 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). (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:** 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 multimodaler Allrounder, klein/MoE (schnell, KO-Residenz-freundlich), Tools wünschenswert. Tagesaktuell
recherchieren + Empfehlung. recherchieren + Empfehlung.
3. **MTP-Speculative-Decoding für gemma** (1,52× Durchsatz, 0 Qualitätsverlust): Engine kann `draft-mtp` 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 ✓; 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` (`-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.) 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 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 (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 auf Strix Halo (bandbreitenlimitiert → MoE bevorzugt; dichte Modelle langsam). KO-Residenz-Set
sinnvoll definieren (was muss gleichzeitig warm sein für Lucy + IDE-Coding?). sinnvoll definieren (was muss gleichzeitig warm sein für Lucy + IDE-Coding?).
## Deliverable ## Deliverable
Ein **strukturierter Plan** (z.B. `docs/OPTIMIZATION_PLAN.md`): IST-Zustand → konkrete Maßnahmen je Rolle 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 (mit Begründung/Zahlen) → Reihenfolge/Risiko/Revert → offene Entscheidungen. Erst Plan, dann Umsetzung
nach Freigabe. Alles reversibel (Config-Backups vor jeder Änderung). nach Freigabe. Alles reversibel (Config-Backups vor jeder Änderung).
## Leitplanken ## Leitplanken
- Features (Sehen/Hören/Sprechen/Embedding/Gedächtnis) + IDE-Lanes dürfen NICHT brechen. - 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`. - Vor jeder Box-Config-Änderung Backup; llama-swap reloadt per `-watch-config`.
- Keine destruktiven Aktionen ohne Plan-Freigabe des Nutzers. - Keine destruktiven Aktionen ohne Plan-Freigabe des Nutzers.
+62 -62
View File
@@ -1,62 +1,62 @@
# Backup & Restore # Backup & Restore
Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git, Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git,
Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre. Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre.
## Was im Backup ist ## Was im Backup ist
- **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`) - **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`)
- **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/` - **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/`
- **Engine:** `/etc/llama-swap/config.yaml` - **Engine:** `/etc/llama-swap/config.yaml`
Nicht enthalten (bewusst): GGUF-Modelle, MC2-Code (Git), venvs, systemd-Units (aus `deploy.sh`). 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/`, 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. `chmod 600` (enthält `.env`). Es werden die letzten **14** behalten.
## Off-Box-Spiegel (C12) — zweite Kopie WEG von der Box ## 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** 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 (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, 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 `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). lokale Backup trotzdem als erfolgreich (Warnung im Log).
- **Auth:** eigener SSH-Key `~/.ssh/mc2_offsite` (nur für dieses Backup), im Proxmox in - **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-/ `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. 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`. - **Ziel/Key überschreibbar:** `MC_BACKUP_OFFSITE` (leer = Off-Box aus) und `MC_BACKUP_OFFSITE_KEY`.
## Backup erstellen ## Backup erstellen
- **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status: - **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status:
`systemctl --user list-timers mc2-backup.timer` `systemctl --user list-timers mc2-backup.timer`
- **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh` - **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh`
- **UI:** Wartungs-Drawer → „Snapshot erstellen" - **UI:** Wartungs-Drawer → „Snapshot erstellen"
## Wiederherstellen (Restore) ## Wiederherstellen (Restore)
Restore läuft **nur per CLI auf der Box** (bewusst — er stoppt Dienste und überschreibt Configs). 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. Vor dem Zurückspielen macht das Skript automatisch ein Sicherheits-Backup des aktuellen Zustands.
```bash ```bash
cd ~/mission-control-v2 cd ~/mission-control-v2
bash deploy/restore.sh --list # vorhandene Backups anzeigen bash deploy/restore.sh --list # vorhandene Backups anzeigen
bash deploy/restore.sh --dry-run latest # zeigen, was passieren würde 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 latest # neuestes wiederherstellen (mit Rückfrage)
bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup
``` ```
Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`, Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`,
`hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) → `hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) →
Dienste starten → Health-Check. Dienste starten → Health-Check.
### Wenn die Box-Platte tot ist (Off-Box-Restore) ### 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 `restore.sh` kennt den Off-Box-Spiegel: fehlt ein Backup lokal, holt es sich das Skript
automatisch vom Proxmox. Kein manuelles Kopieren nötig. automatisch vom Proxmox. Kein manuelles Kopieren nötig.
```bash ```bash
bash deploy/restore.sh --list # zeigt lokal UND Off-Box (Proxmox) 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 --pull-offsite # ganzen Off-Box-Bestand nach lokal spiegeln
bash deploy/restore.sh latest # zieht das jüngste — vom Proxmox, falls lokal leer bash deploy/restore.sh latest # zieht das jüngste — vom Proxmox, falls lokal leer
``` ```
## Erledigt ## Erledigt
- **Off-Box-Spiegel (C12):** rsync auf den Proxmox-Host, live E2E verifiziert (2026-07-03). - **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. 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) # 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 > 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/ > 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. > Sprechen/Embedding) dürfen NICHT verloren gehen, KO-Residenz + Budget müssen sauber bleiben.
## Kontext / Architektur (Ist-Zustand, verifiziert 30.06.) ## Kontext / Architektur (Ist-Zustand, verifiziert 30.06.)
- **Engine:** llama-swap (config: `/etc/llama-swap/config.yaml` auf der Box, läuft mit `-watch-config`). - **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 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), `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.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-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). `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 - **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`. `coding` (→ coder_lite/coder). Code: `backend/services/router_logic.py`, `routing_policy.py`.
**Lucy nutzt die Lanes NICHT** — sie ist der Hermes-Agent. **Lucy nutzt die Lanes NICHT** — sie ist der Hermes-Agent.
- **Lucy-Hirn:** Hermes-Agent (`:8642`), Brain = `~/.hermes/config.yaml``model.default` - **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** (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. (`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 → - **Hardware:** Strix Halo (Ryzen AI Max+ 395), 122 GB, GTT ~124 GB, bandbreitenlimitiert →
MoE mit wenig aktiven Params ist schnell, dichte Modelle langsam. MoE mit wenig aktiven Params ist schnell, dichte Modelle langsam.
## Root-Cause des Bugs ## Root-Cause des Bugs
1. Kanonische Rollen `ROLE_IDS = {fast, heavy, coder, vision, scout}`**keine `brain`/Agent-Rolle**. 1. Kanonische Rollen `ROLE_IDS = {fast, heavy, coder, vision, scout}`**keine `brain`/Agent-Rolle**.
(Kommentar im Code: „kein agent/reasoning mehr".) (Kommentar im Code: „kein agent/reasoning mehr".)
2. Es gibt keine Verknüpfung „Hirn-Modell ⇒ muss warm bleiben". gemma trägt den Alias `scout`, 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). 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. 3. `roles.py` referenziert noch eine `hermes`-Rolle, die in `ROLE_IDS` nicht existiert → Inkonsistenz.
## Aufgabe ## Aufgabe
### Backend (`backend/`) ### Backend (`backend/`)
1. **Neue erstklassige Rolle `brain` einführen** (oder `hermes` reaktivieren) und in ALLEN 1. **Neue erstklassige Rolle `brain` einführen** (oder `hermes` reaktivieren) und in ALLEN
Quellen synchronisieren (heute dupliziert!): Quellen synchronisieren (heute dupliziert!):
- `services/llamaswap.py``ROLE_IDS` - `services/llamaswap.py``ROLE_IDS`
- `services/sources.py``ROLE_IDS` + Titel/Icon (analog `scout: {title, icon}`) - `services/sources.py``ROLE_IDS` + Titel/Icon (analog `scout: {title, icon}`)
- `services/maintenance.py` (Modell-Upgrade-Rollenliste) - `services/maintenance.py` (Modell-Upgrade-Rollenliste)
- `services/roles.py``_capability_suit`/`_pref`/`_reason` für `brain` (Tools Pflicht, - `services/roles.py``_capability_suit`/`_pref`/`_reason` für `brain` (Tools Pflicht,
mittlere Größe + MoE bevorzugt, niedrige Latenz). `hermes`-Altlast bereinigen. mittlere Größe + MoE bevorzugt, niedrige Latenz). `hermes`-Altlast bereinigen.
2. **Rolle `brain` ⇒ automatisch warm + ko-resident.** Beim Zuweisen der `brain`-Rolle 2. **Rolle `brain` ⇒ automatisch warm + ko-resident.** Beim Zuweisen der `brain`-Rolle
(`POST /api/models/{model_id}/role`, `routers/models.py`): (`POST /api/models/{model_id}/role`, `routers/models.py`):
- Modell via `set_group("brains", members=[...], swap=False, persist=True)` in die Warm-Gruppe - Modell via `set_group("brains", members=[...], swap=False, persist=True)` in die Warm-Gruppe
aufnehmen (bestehender Helper in `llamaswap.py`). aufnehmen (bestehender Helper in `llamaswap.py`).
- `ttl: 0` setzen (oder hoch), vorheriges Brain-Modell aus `brains` entfernen + ttl entspannen. - `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. - 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' 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/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. 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 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). 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; 5. **Lanes/IDEs unangetastet lassen:** chat/coding-Routing (`router_logic.py`) bleibt wie es ist;
`fast` bleibt für die chat-Lane warm. `fast` bleibt für die chat-Lane warm.
### Frontend (`frontend/src/`) ### Frontend (`frontend/src/`)
1. **Rollen-Taxonomie** um `brain` erweitern (Badges/Titel/Icon) — Duplikat zur Backend-Liste 1. **Rollen-Taxonomie** um `brain` erweitern (Badges/Titel/Icon) — Duplikat zur Backend-Liste
finden & angleichen (`components/models/*`, `views/ModelsView.tsx`, evtl. `ModelBadges.ROLES`). finden & angleichen (`components/models/*`, `views/ModelsView.tsx`, evtl. `ModelBadges.ROLES`).
gemma darf NICHT mehr als `scout` erscheinen. gemma darf NICHT mehr als `scout` erscheinen.
2. **Rollen-Zuweisungs-Modal:** `brain` auswählbar; Warm-/KO-Residenz-Status anzeigen 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`. (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, 3. **Warm-Set / GTT-Budget sichtbar machen** (Dashboard/Models): was ist gerade ko-resident,
passt es ins Budget? (nutzt `/api/models` `running` + budget-Infos). passt es ins Budget? (nutzt `/api/models` `running` + budget-Infos).
4. Optional: „Lucy-Hirn"-Selector, der die `brain`-Rolle setzt (treibt Backend #2 + #3). 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) ### 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 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. 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 …`. ✓ - **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, - **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 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). 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` / - **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 `--spec-draft-n-min`. Drafter laden via `-md <assistant.gguf> --spec-type draft-mtp`. Exakte Flags
des Builds mit `llama-server --help` gegenprüfen. des Builds mit `llama-server --help` gegenprüfen.
- **MC2-Bug:** die Spec-Draft-Auswahl (UI + Backend) kennt **nur klassische Drafts** (vergleicht stur - **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 `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`- (`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 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. llama-swap-cmd schreiben. Co-Residenz: Drafter ~0,4B → vernachlässigbar.
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `gemma-4-26B-A4B-it` wird in der UI als **`brain`** geführt (nicht `scout`). - [ ] `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 - [ ] 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`). (Verifikation: `curl :8080/running` nach >5 Min Idle zeigt das Modell weiterhin `ready`).
- [ ] Lucy antwortet ohne Kalt-Nachladen nach Leerlauf. - [ ] Lucy antwortet ohne Kalt-Nachladen nach Leerlauf.
- [ ] Vision (`Qwen3-VL-8B`), Embedding (`Qwen3-Embedding-0.6B`), Coder bleiben funktionsfähig & - [ ] Vision (`Qwen3-VL-8B`), Embedding (`Qwen3-Embedding-0.6B`), Coder bleiben funktionsfähig &
ko-resident; **kein OOM**; IDE-Lanes (chat/coding) unverändert. ko-resident; **kein OOM**; IDE-Lanes (chat/coding) unverändert.
- [ ] Brain-Wechsel in der UI aktualisiert llama-swap (Warm-Gruppe) UND Hermes `model.default` - [ ] Brain-Wechsel in der UI aktualisiert llama-swap (Warm-Gruppe) UND Hermes `model.default`
(+ Restart), reversibel (Backup). (+ Restart), reversibel (Backup).
## Sofort-Hotfix (unabhängig vom Umbau — macht Lucy JETZT warm) ## Sofort-Hotfix (unabhängig vom Umbau — macht Lucy JETZT warm)
Auf der Box, bis der saubere Umbau steht: Auf der Box, bis der saubere Umbau steht:
```bash ```bash
# gemma als immer-warm + in die brains-Gruppe (Backup zuerst!) # gemma als immer-warm + in die brains-Gruppe (Backup zuerst!)
cp /etc/llama-swap/config.yaml /etc/llama-swap/config.yaml.bak 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 # gemma ttl 180 -> 0 und groups.brains.members um gemma-4-26B-A4B-it ergänzen
# (manuell editieren oder via MC2 set_group), dann: # (manuell editieren oder via MC2 set_group), dann:
# llama-swap reloadt per -watch-config automatisch. # llama-swap reloadt per -watch-config automatisch.
``` ```
## Wichtige Dateien (Einstieg) ## Wichtige Dateien (Einstieg)
- Backend: `services/llamaswap.py` (Rollen=aliases, `set_role_alias`, `set_group`, `register_model`), - Backend: `services/llamaswap.py` (Rollen=aliases, `set_role_alias`, `set_group`, `register_model`),
`services/roles.py`, `services/sources.py`, `services/routing_policy.py`, `services/roles.py`, `services/sources.py`, `services/routing_policy.py`,
`services/router_logic.py`, `services/budget.py`, `services/fit.py`, `routers/models.py`, `services/router_logic.py`, `services/budget.py`, `services/fit.py`, `routers/models.py`,
`routers/routing.py`. `routers/routing.py`.
- Frontend: `views/ModelsView.tsx`, `components/models/*`, `components/SystemDrawer.tsx`. - Frontend: `views/ModelsView.tsx`, `components/models/*`, `components/SystemDrawer.tsx`.
- Box (nicht im Repo): `/etc/llama-swap/config.yaml`, `~/.hermes/config.yaml`. - 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) # Konzept: Bare-Metal-Wiederaufbau + First-Run-Wizard (VOLLSTÄNDIG)
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04, > **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. > 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, > **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: > Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
> **1:1 zurück** · **neu/Default** · **weglassen**. > **1:1 zurück** · **neu/Default** · **weglassen**.
> >
> Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut > Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut
> noch nichts. Stand: 2026-06-28. > noch nichts. Stand: 2026-06-28.
--- ---
## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt ## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt
Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**: Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
| Modus | Bedeutung | | Modus | Bedeutung |
|---|---| |---|---|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). | | 📦 **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). | | 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). |
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. | | ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer 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. entweder den exakten alten Stand oder kann gezielt entrümpeln.
--- ---
## 2. Was es schon gibt (wiederverwenden) ## 2. Was es schon gibt (wiederverwenden)
| Asset | Datei | Deckt ab | | Asset | Datei | Deckt ab |
|---|---|---| |---|---|---|
| Code-Deploy | `deploy/deploy.sh` | git pull + venvs + Units + Restart. **Setzt Engine/llama-swap/Dirs/venvs voraus.** | | 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). | | 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). | | Restore | `deploy/restore.sh` | Spielt Tarball zurück (mit Pre-Restore-Sicherung). |
| Engine | `deploy/provision-engine.sh` | llama.cpp Vulkan/RADV-Build (root). | | 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). | | 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. | | Updates | `deploy/update-engine.sh`, `update-swap.sh` | Laufende Engine/Router-Updates. |
| Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. | | Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. |
--- ---
## 3. VOLLSTÄNDIGER Komponenten-Katalog ## 3. VOLLSTÄNDIGER Komponenten-Katalog
> **Scan-verifiziert (2026-06-28)** gegen `backend/config.py`, alle `backend/services/*` & `routers/*`, > **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/*`. > `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. > Alle persistenten Schreibpfade, Dienste, Secrets und Env-Vars sind unten erfasst.
Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git · Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git ·
🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt. 🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt.
„Im Backup?" = deckt der **aktuelle** `backup.sh` es ab. „Im Backup?" = deckt der **aktuelle** `backup.sh` es ab.
### A · OS & System (L0, root/sudo, einmalig) ### A · OS & System (L0, root/sudo, einmalig)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a | | Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a |
| System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein | | System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein |
| Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein | | Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein |
| Chrome-Libs (Browser): `agent-browser install --with-deps` | — | ⬇️ 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 | | Verzeichnis-Layout: `/srv/models/{,mem0,mc2-backups,drafts}`, `/etc/llama-swap/` | — | ⬇️ mkdir+chown | nein |
### B · Inferenz-Engine + Router (L1, root) ### B · Inferenz-Engine + Router (L1, root)
| Komponente | Ort | Quelle | Im Backup? | | 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.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** 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 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** | | llama-swap-Config (Modelle/Rollen) | `/etc/llama-swap/config.yaml` | 📦 | **ja** |
| Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein | | Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein |
### C · MC2-App (L2, userspace) ### C · MC2-App (L2, userspace)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a | | MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) | | 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 | | 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 | | systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
### D · 3D-Avatar (Sprechen-Tab) ### D · 3D-Avatar (Sprechen-Tab)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a | | Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a |
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 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)** | | **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 > 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. > Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht.
### E · Voice-Sidecar (STT + TTS + Klonstimme) ### E · Voice-Sidecar (STT + TTS + Klonstimme)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) | | Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) |
| STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein | | STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein |
| Piper-Binary + dt. Stimmen (thorsten/kerstin) | `~/.voice/piper`, `~/.voice/voices` | ⬇️ install.sh | 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 | | 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** | | **Klonstimme / Voice-Referenz-Audio** (Nutzer) | `~/.voice/refs/ref.wav` (`VOICE_REFS_DIR`, `/api/voice/reference`) | 📦 | **nein → Anhang B** |
| ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja | | ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja |
| Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a | | Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a |
| Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein | | Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein |
### F · Mem0 (Langzeitgedächtnis) ### F · Mem0 (Langzeitgedächtnis)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) | | Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) |
| **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** | | **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** |
| Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja | | Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja |
| Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein | | Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein |
### G · Hermes-Agent (das Herz) ### G · Hermes-Agent (das Herz)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) | | Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) |
| Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein | | Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein |
| Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein | | Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein |
| **`config.yaml` 1:1** (Toolsets, MCP, Engine, Browser, Personalities, alles) | `~/.hermes/config.yaml` | 📦 | **ja** | | **`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** | | **`.env` (Secrets: TELEGRAM_BOT_TOKEN, API_SERVER_KEY, ElevenLabs …)** | `~/.hermes/.env` | 📦/🔑 | **ja** |
| Plugins | `~/.hermes/plugins/` | 📦 | ja | | Plugins | `~/.hermes/plugins/` | 📦 | ja |
| **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** | | **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** |
| Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** | | Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** |
| Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | ⏭️ skip | nein | | Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | ⏭️ skip | nein |
| **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** | | **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** |
| Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein | | Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein |
| Hermes-Terminal (ttyd → `hermes chat`, `:7681`) | systemd-User `hermes-terminal` | ⬇️ deploy.sh (+ttyd apt) | nein | | Hermes-Terminal (ttyd → `hermes chat`, `:7681`) | systemd-User `hermes-terminal` | ⬇️ deploy.sh (+ttyd apt) | nein |
### H · MCP-Server (4) ### H · MCP-Server (4)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| `mission-control-memory`, `-stack` (MC2-venv) | `~/mission-control-v2/mcp/*.py` | 🌐 Git | über config.yaml | | `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 | | `hermes-pc-control`, `hermes-web-fetch` (Hermes-venv) | dito | 🌐 Git | über config.yaml |
| MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja | | MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja |
### I · Browser-Stack ### I · Browser-Stack
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| agent-browser (npm global) | `~/.local/...`, symlink `~/.hermes/node/bin` | ⬇️ npm i -g | nein | | 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 | | Chrome (engine) + System-Libs | `~/.agent-browser/browsers/` + apt-Libs | ⬇️ install --with-deps | nein |
| lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein | | lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein |
| Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja | | Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja |
### J · Modelle (GGUF — der große Brocken) ### J · Modelle (GGUF — der große Brocken)
| Komponente | Ort | Quelle | Im Backup? | | 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)** | | GGUF-Modelle (Brain, fast, heavy, coder, vision, embedding …) | `/srv/models/` | ⬇️ Re-Download aus llama-swap-Manifest | **nein (zu groß, bewusst)** |
### K · Extern (anderer Rechner) ### K · Extern (anderer Rechner)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) | | PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) |
| Hermes-WebUI (nesquena, optional, `:8787`) | `~/hermes-webui` | ⬇️ git clone | nein | | Hermes-WebUI (nesquena, optional, `:8787`) | `~/hermes-webui` | ⬇️ git clone | nein |
--- ---
## 4. Ziel-Architektur ## 4. Ziel-Architektur
### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf ### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf
- **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`). - **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`).
- **Getrennt nach sudo-Bedarf**: `bootstrap-root.sh` (L0+L1, bewusst mit sudo) + `bootstrap.sh` - **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". (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. - **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) 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) → 4 venvs(backend/mem0/voice/hermes) → 5 Browser → 6 deploy.sh(Units/enable/restart)
→ 7 Restore(optional) → 8 Wizard hoch + postcheck`. → 7 Restore(optional) → 8 Wizard hoch + postcheck`.
### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert) ### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert)
MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte: MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte:
1. **Systemcheck** — Live-Ampel je Komponente aus §3 (`GET /api/setup/status` + `…/system/services`). 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". 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 3. **Komponenten-Auswahl (Kernstück)** — pro Katalog-Eintrag aus §3 eine Kachel mit
📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden. 📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden.
4. **Netzwerk & Identität** — Box-IP (→ `HERMES_TERMINAL_URL`, `PC_EXECUTOR_URL`), Hostname. 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), 5. **Secrets** 🔑 — `TELEGRAM_BOT_TOKEN`, erlaubte User-ID, `API_SERVER_KEY` (oder generieren),
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup. ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser 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`. 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 7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen). (📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`, 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. Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
9. **Externe Checkliste** — PC-Executor (Windows-Task), Hermes-WebUI als Haken. 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. 10. **Verifikation & Abschluss**`stack-postcheck.sh` + `hermes-postcheck.sh` → grün/rot-Liste → Dashboard.
**Technik:** neuer Router `backend/routers/setup.py` **Technik:** neuer Router `backend/routers/setup.py`
(`GET /api/setup/status`, `POST /api/setup/{secrets,network,hermes,components,finish}`). (`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`. 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) ## 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 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: liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarball um:
- **Voice-Klonstimme**`~/.voice/refs/` (`ref.wav`) - **Voice-Klonstimme**`~/.voice/refs/` (`ref.wav`)
- **Token-/Ersparnis-Statistik**`~/.hermes/token_stats.json` - **Token-/Ersparnis-Statistik**`~/.hermes/token_stats.json`
- **Hermes-Skills**`~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen) - **Hermes-Skills**`~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen)
- **Hermes-Sessions/History**`~/.hermes/sessions/` - **Hermes-Sessions/History**`~/.hermes/sessions/`
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service` Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
mit neu starten. mit neu starten.
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D). **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, **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. `/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 → 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. „opt-in für große/optionale Teile"-Logik), und den MANIFEST-Inhalt entsprechend.
--- ---
## 6. Secrets-Strategie ## 6. Secrets-Strategie
- Single Source: `~/.hermes/.env` (chmod 600), im Tarball (selbst 600). - 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. - 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]]). - Nie ins Git, nie in Logs, nie in die MC2-DB. **Off-Box-Spiegelung des Tarballs noch offen** ([[mc2-backup-restore]]).
## 7. Modell-Strategie ## 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." - **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`. - **B:** geführte Discover-Neuauswahl je Rolle. Reihenfolge Brain → heavy → Rest, danach `warmup.sh`.
--- ---
## 8. Implementierungs-Reihenfolge (neue Session) ## 8. Implementierungs-Reihenfolge (neue Session)
1. ✅ **Box-Verifikation erledigt (2026-06-28, nur gelesen — nichts verändert):** llama-swap.service-Inhalt 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 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. 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). 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. 3. `deploy/bootstrap.sh` + `bootstrap-root.sh` (Phasen, State-Marker); llama-swap-Install skripten.
4. `backend/routers/setup.py` + First-Run-Gate. 4. `backend/routers/setup.py` + First-Run-Gate.
5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views 5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views
(Cockpit/AgentView/Sprechen) wiederverwenden. (Cockpit/AgentView/Sprechen) wiederverwenden.
6. Modell-Restore-aus-Manifest. 6. Modell-Restore-aus-Manifest.
7. **Abnahme in frischer VM** (§9). 7. **Abnahme in frischer VM** (§9).
Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR. Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
## 9. Abnahmekriterien ## 9. Abnahmekriterien
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen. - 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. - 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. - „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem. - Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
- Idempotenz: zweiter Lauf = No-op. - Idempotenz: zweiter Lauf = No-op.
## 10. Offene Entscheidungen (vor dem Bau) ## 10. Offene Entscheidungen (vor dem Bau)
1. ~~llama-swap systemd-Unit-Inhalt~~**geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen). 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). 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). 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]]. 4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?). 5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo. 6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
## 11. Referenzen ## 11. Referenzen
- `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]] - `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]]
- [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor) - [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor)
- [[voice-sprechen-feature]] (Voice + 3D-Avatar), [[mem0-memory-architecture]], [[mc2-backup-restore]] - [[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` - `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) ## 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 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 (hitonabi, gfx1151 → HSA 11.5.1) — auf anderer Hardware anpassen. Die `.d/`-Drop-ins
(`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an. (`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an.
```ini ```ini
[Unit] [Unit]
Description=llama-swap (lokaler LLM Router) Description=llama-swap (lokaler LLM Router)
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=hitonabi User=hitonabi
Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1 Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1
Environment=PATH=/usr/local/bin:/usr/bin:/bin 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 ExecStart=/usr/local/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:8080 --watch-config
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
```ini ```ini
# /etc/systemd/system/llama-swap.service.d/vulkan.conf # /etc/systemd/system/llama-swap.service.d/vulkan.conf
[Service] [Service]
Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan
``` ```
```ini ```ini
# /etc/systemd/system/llama-swap.service.d/warmup.conf # /etc/systemd/system/llama-swap.service.d/warmup.conf
[Service] [Service]
ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh
``` ```
**Erstinstall-Reihenfolge:** `bash deploy/update-swap.sh` (Binary) → Unit kopieren → **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`. `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) ## 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}"`, In `deploy/backup.sh` ergänzen (Variablen oben: `VOICE="${VOICE_HOME:-$HOME/.voice}"`,
Stage zusätzlich `"$STAGE/voice"`): Stage zusätzlich `"$STAGE/voice"`):
```bash ```bash
# 1:1-Erweiterung (scan-verifiziert 2026-06-28): # 1:1-Erweiterung (scan-verifiziert 2026-06-28):
[ -f "$HERMES/token_stats.json" ] && cp -a "$HERMES/token_stats.json" "$STAGE/hermes/" || true [ -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/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true
[ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true [ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true
[ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true [ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true
``` ```
tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern: tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern:
```bash ```bash
tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" . tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" .
``` ```
In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und
`voice-service` mit neustarten: `voice-service` mit neustarten:
```bash ```bash
VOICE="${VOICE_HOME:-$HOME/.voice}" VOICE="${VOICE_HOME:-$HOME/.voice}"
SERVICES="mem0-service voice-service mission-control-2 hermes-gateway" SERVICES="mem0-service voice-service mission-control-2 hermes-gateway"
[ -f "$STAGE/hermes/token_stats.json" ] && cp -a "$STAGE/hermes/token_stats.json" "$HERMES/" [ -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/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/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/"; } [ -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 # Lucy TTS — Optimierungs- & Strategieplan
> Ziel: **Lucys Stimme läuft 100 % lokal & on-device** (kein Box-Zwang, keine Cloud). > 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). > 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`. > Stand: 2026-06-30. Resume-fähig im Stil von `docs/STATUS.md`.
## Leitentscheidung: Hardware ## Leitentscheidung: Hardware
- **pocket-tts ist CPU-gebunden** — Kyutai bestätigt: *kein* GPU-Speedup (Batch 1, 100M Params). - **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. **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** - **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. 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**. - **Konsequenz:** `oute n_gpu_layers=999` und „pocket auf ROCm/Vulkan" werden **eingestellt**.
--- ---
## Phase A — Sofort-Wins (risikoarm, pocket_server.py) ## Phase A — Sofort-Wins (risikoarm, pocket_server.py)
**A1 — Voice → safetensors exportieren (einmalig)** **A1 — Voice → safetensors exportieren (einmalig)**
- Statt `get_state_for_audio_prompt(ref)` bei jedem Boot: einmal `export_model_state(...)``lucy_voice.safetensors`. - 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. - 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. - Fallback behalten: wenn safetensors fehlt → aus `ref.mp3` klonen + direkt exportieren.
**A2 — Referenz säubern** **A2 — Referenz säubern**
- Kyutai: Sample-Qualität wird *mitreproduziert*. Sauber entrauschte/normalisierte `ref` → weniger Artefakte. - Kyutai: Sample-Qualität wird *mitreproduziert*. Sauber entrauschte/normalisierte `ref` → weniger Artefakte.
- Schritt in `_prep_ref()` ergänzen (Denoise/Highpass/Lautheit), Ergebnis cachen. - Schritt in `_prep_ref()` ergänzen (Denoise/Highpass/Lautheit), Ergebnis cachen.
**A3 — temp-Sweep gegen Kollaps** **A3 — temp-Sweep gegen Kollaps**
- Hypothese: `temp=0.9` treibt die best-of-N-Regenerationen. Niedriger testen (0.70.85). - 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). - Akzeptanz: gleiche/bessere Natürlichkeit bei **messbar weniger Regenerationen** (= weniger Latenz).
*Gate A:* A1A3 live, Kollaps-Rate & TTFB protokolliert. *Gate A:* A1A3 live, Kollaps-Rate & TTFB protokolliert.
--- ---
## Phase B — Benchmark-Harness (datenbasiert tunen) ## Phase B — Benchmark-Harness (datenbasiert tunen)
**B1 — `bench_lucy.py`** (lokal, CPU) **B1 — `bench_lucy.py`** (lokal, CPU)
- Misst je Konfig: **RTF**, **TTFB**, **Kollaps-/Regenerations-Rate**, Whisper-Rücktranskription (Verständlichkeit). - 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`. - 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`). - Feste Testsätze (kurz/mittel/lang, wie in `ptts_test.py`).
**B2 — CPU-Parallelität** **B2 — CPU-Parallelität**
- pocket nutzt nur **2 Kerne** → mehrere **Satz-Worker parallel** statt globalem `LOCK`. - 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. - 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`. *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) ## Phase C — Runtime-Eval (lokal schneller + Python-3.14-frei)
**C1 — ONNX-Pfade testen** **C1 — ONNX-Pfade testen**
- Kandidaten: **PocketTTS.cpp** (Single-File C++/ONNX, CLI+HTTP+FFI) und **sherpa-onnx** (Windows, viele Bindings). - 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. - Ziel: torch-CPU schlagen **und** das Python-3.14-Packaging-Problem umgehen.
- Vergleich gegen Phase-B-Baseline (gleicher Benchmark). - Vergleich gegen Phase-B-Baseline (gleicher Benchmark).
**C2 — Server-Vertrag prüfen** **C2 — Server-Vertrag prüfen**
- Fertige **OpenAI-kompatible Streaming-Server** (teddybear082 / ai-joe-git) gegen den aktuellen Custom-Proxy halten. - 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. - Nur übernehmen, wenn Stimm-Wächter (F0 + Fingerabdruck) erhalten/abbildbar bleiben.
*Gate C:* Entscheidung „torch-CPU behalten" vs. „auf ONNX-Runtime wechseln" (mit Zahlen). *Gate C:* Entscheidung „torch-CPU behalten" vs. „auf ONNX-Runtime wechseln" (mit Zahlen).
--- ---
## Phase D — Strategische Weiche: pocket vs. F5 ## Phase D — Strategische Weiche: pocket vs. F5
**D1 — F5 fair gegen pocket messen** **D1 — F5 fair gegen pocket messen**
- `lucy-f5/` (F5-TTS DE, ONNX + DirectML, **RDNA4/9070 XT**) ist **non-autoregressiv → strukturell kein - `lucy-f5/` (F5-TTS DE, ONNX + DirectML, **RDNA4/9070 XT**) ist **non-autoregressiv → strukturell kein
Kollaps/Männerstimme/Wiederholung** (genau pockets Schmerz). Kollaps/Männerstimme/Wiederholung** (genau pockets Schmerz).
- Gleicher Benchmark wie Phase B: RTF, TTFB, Natürlichkeit, Stabilität. - Gleicher Benchmark wie Phase B: RTF, TTFB, Natürlichkeit, Stabilität.
**D2 — Entscheidung** **D2 — Entscheidung**
- **pocket gewinnt** (gut genug stabil, CPU, „nur lokal"-Ideal): pocket = Lucy-Stimme, F5 verworfen/geparkt. - **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, - **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). **pocket bleibt schneller CPU-Fallback** (z. B. wenn keine GPU verfügbar).
**Beobachtungsposten (extern):** distilliertes **`german`** (statt `german_24l`) ist noch nicht released **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), (Kyutai fixt Distillations-Datenqualität). Sobald da → größter Einzel-Win (Tempo + Stabilität),
dann `german_24l → german` swappen. Release-Feed im Blick behalten. dann `german_24l → german` swappen. Release-Feed im Blick behalten.
--- ---
## Reihenfolge & Quick-Start ## Reihenfolge & Quick-Start
1. **A1 + A2 + A3** (heute) — sofort spürbar, kein Risiko. 1. **A1 + A2 + A3** (heute) — sofort spürbar, kein Risiko.
2. **B1 + B2** — Zahlen sammeln, Defaults härten. 2. **B1 + B2** — Zahlen sammeln, Defaults härten.
3. **C1/C2** — Runtime-Wechsel nur wenn Benchmark es trägt. 3. **C1/C2** — Runtime-Wechsel nur wenn Benchmark es trägt.
4. **D1/D2** — finale Stimm-Architektur entscheiden. 4. **D1/D2** — finale Stimm-Architektur entscheiden.
## Mess-Ergebnisse & finale Defaults (30.06., 9700X, german_24l) ## 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`). Alles auf der lokalen Maschine gemessen (Logs: `sweep_b2.log`, `ttfb_test.log`, `sweep_b2_result.json`).
**B2-Sweep (6-Satz-Antwort, ~21s Audio):** **B2-Sweep (6-Satz-Antwort, ~21s Audio):**
| Konfig | Wall | TTFB | RTF | | Konfig | Wall | TTFB | RTF |
|---|---|---|---| |---|---|---|---|
| seriell (1×alle Kerne) | 15,8s | **3,6s** | 0,74 | | seriell (1×alle Kerne) | 15,8s | **3,6s** | 0,74 |
| 2w×3t | 12,9s | 6,2s | 0,62 | | 2w×3t | 12,9s | 6,2s | 0,62 |
| 3w×2t | 10,3s | 6,8s | 0,50 | | 3w×2t | 10,3s | 6,8s | 0,50 |
| 4w×2t | 9,7s | 8,2s | **0,44** | | 4w×2t | 9,7s | 8,2s | **0,44** |
Erkenntnis: pocket ist **speicherbandbreiten-gebunden**. Der Pool verbessert nur die Gesamt-Wall-Clock 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 (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 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**. 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:** **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- 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- 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): Männerstimmen-Wächter bleibt immer aktiv. Effekt (Drift-Warnungen pro Lauf: ~6 → 1):
| | TTFB | Wall | | | TTFB | Wall |
|---|---|---| |---|---|---|
| kurzer 1. Chunk, ohne B3 | 4,71s | 19,9s | | kurzer 1. Chunk, ohne B3 | 4,71s | 19,9s |
| gebündelt (alt) | 3,74s | 15,8s | | gebündelt (alt) | 3,74s | 15,8s |
| **kurzer 1. Chunk, mit B3** | **1,31s** | 16,6s | | **kurzer 1. Chunk, mit B3** | **1,31s** | 16,6s |
→ Lucy spricht nach **1,3s** statt 3,7s, Wall ~gleich, weiter lückenlos. → Lucy spricht nach **1,3s** statt 3,7s, Wall ~gleich, weiter lückenlos.
**Finale Defaults in `pocket_server.py`:** **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_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_REF_CLEAN=1` (A2) · Voice aus `lucy_voice.safetensors` (A1). Pool-Opt-in für Batch:
`LUCY_WORKERS=4 LUCY_WORKER_THREADS=2`. `LUCY_WORKERS=4 LUCY_WORKER_THREADS=2`.
**Offen / nächster Hebel:** distilliertes `german`-Modell (statt `german_24l`) abwarten — größter **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 Qualität/Tempo-Sprung. Optional: TTFB weiter drücken über kürzeres erstes Wort / `lsd`-Tuning nur
für den ersten Chunk. für den ersten Chunk.
## Umlaute (ae/oe/ue) — Fix (30.06.) ## Umlaute (ae/oe/ue) — Fix (30.06.)
Symptom: Lucy spricht manchmal „u-e" statt „ü". Ursache: das LLM (Hermes/Qwen) gibt gelegentlich 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 ASCII-Ersatzschreibweisen (ueber/schoen/maerz) aus; pocket liest sie wörtlich. (ß vs ss ist
akustisch identisch -> ignoriert.) akustisch identisch -> ignoriert.)
**Wurzel-Fix (empfohlen, auf der Box im Hermes-System-Prompt/Persona ergänzen):** **Wurzel-Fix (empfohlen, auf der Box im Hermes-System-Prompt/Persona ergänzen):**
> „Schreibe ausschließlich korrektes Deutsch mit echten Umlauten (ä, ö, ü) und ß. Verwende niemals > „Schreibe ausschließlich korrektes Deutsch mit echten Umlauten (ä, ö, ü) und ß. Verwende niemals
> die Ersatzschreibweisen ae, oe, ue oder ss anstelle von Umlauten." > 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 **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). 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 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 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 nicht jedes seltene Wort — erweiterbar via `LUCY_UMLAUT_EXTRA="wort1,wort2"`. Der Wurzel-Fix bleibt
die zuverlässige Lösung. die zuverlässige Lösung.
## Referenzen ## Referenzen
- pocket-tts README (GPU-kein-Speedup, export-voice, Sprachen): github.com/kyutai-labs/pocket-tts - 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 - 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 - 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 # 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. **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 ## 1. Management-Summary
| Bereich | Zustand | Kernaussage | | 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) | | 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 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 | | 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 | | 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 | | Ö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 | | Verdikte | ✅ alle bestätigt | Pocket TTS, Electron, Qwen3.6-Hirn, kein ZeroClaw, Mem0 — alle bestehen die Re-Prüfung |
**Die zwei größten Hebel:** **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 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 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. Zusammen: Voice-to-Voice von ~3,55 s auf realistisch **≤1,2 s**, ohne neue Hardware.
--- ---
## 2. IST-Zustand (live verifiziert) ## 2. IST-Zustand (live verifiziert)
### 2.1 Box (192.168.178.151 — Strix Halo, gfx1151, 122 GB, Vulkan) ### 2.1 Box (192.168.178.151 — Strix Halo, gfx1151, 122 GB, Vulkan)
| Komponente | Installiert | Aktuell (02.07.2026) | Bewertung | | Komponente | Installiert | Aktuell (02.07.2026) | Bewertung |
|---|---|---|---| |---|---|---|---|
| llama.cpp | b9843 (86b94708f) | b9859 (01.07.) | ✅ nur ~16 Builds dahinter | | llama.cpp | b9843 (86b94708f) | b9859 (01.07.) | ✅ nur ~16 Builds dahinter |
| llama-swap | v233 (29.06.) | v233 | ✅ aktuell | | 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) | | 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 | | mem0ai / chromadb | 2.0.8 / 1.5.9 | 2.0.8 | ✅ aktuell |
| Kernel | 7.0.0-27-generic | — | ✅ | | 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): **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 | | 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 | | 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 | | heavy | Qwen3.5-122B-A10B | 32768 | cache-reuse 256, ttl 600 |
| coder | Qwen3-Coder-Next | 131072 | 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 | | coder-lite | Qwen3-Coder-30B-A3B | 131072 | cache-reuse 256, ttl 300 |
| vision | Qwen3-VL-8B | 32768 | mmproj, in brains | | vision | Qwen3-VL-8B | 32768 | mmproj, in brains |
| embed | Qwen3-Embedding-0.6B | 8192 | ttl 0, in brains | | embed | Qwen3-Embedding-0.6B | 8192 | ttl 0, in brains |
| scout | GLM-4.6V-Flash | 131072 | mmproj, ttl 300 | | 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). 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. **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) ### 2.2 🔴 Live-Bugs (selbst reproduziert)
**B1 — ~~MC2-Backend ohne Autostart~~ FEHLALARM (korrigiert 02.07., nachverifiziert).** **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). :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).** **B2 — chat-Lane kaputt (live reproduziert).**
``` ```
POST /v1/chat/completions {"model":"chat",...} POST /v1/chat/completions {"model":"chat",...}
→ {"error":"no router for requested model","src":"llama-swap"} → {"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. 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.** **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. `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 ### 2.3 Gemessene Latenzkette Lucy
Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap. Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap.
| Stufe | Messwert | Anteil am Problem | | 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ß | | 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** | | 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 | | 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 | | 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** | | **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. **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) ### 2.4 Lokaler PC (RX 9070 XT / RDNA4)
- Lucy-App zum Prüfzeitpunkt nicht gestartet (kein Electron-Prozess, :8130 frei) - 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 - `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 - 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-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** - [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 - 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) ### 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: **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 - 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)) - 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 - C3: Thinking-Deaktivierung 3× dupliziert (voice.py, gateway.py, mem0_service/app.py) — DRY
- C4: Junk-Fact-Regex im Mem0-Sidecar hartcodiert (Wartungspunkt) - C4: Junk-Fact-Regex im Mem0-Sidecar hartcodiert (Wartungspunkt)
- Dead Code: `backend/services/pricing.py`, vermutlich `token_stats.py` - 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 - 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):** **Frontend (React 18/Vite 6/Tailwind 4/TanStack 5):**
- Voice-Umzug in die Desktop-App sauber (keine toten Imports/Nav-Reste) ✅ - 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 - 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 - 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 - 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 - F4: keine globale Error Boundary
- MC3-Prototyp ([frontend/src/mc3/](../frontend/src/mc3/)) non-destruktiv hinter `#mc3`, untracked - MC3-Prototyp ([frontend/src/mc3/](../frontend/src/mc3/)) non-destruktiv hinter `#mc3`, untracked
**Lucy-Desktop:** **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) - 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) - L2: kein Retry/Backoff beim TTS-Warmup (Crash → 2-min-Spinner)
- L3: VAD = reines RMS mit 900-ms-Stille-Regel - L3: VAD = reines RMS mit 900-ms-Stille-Regel
- L4: Echo-Schutz = 450-ms-Cooldown-Workaround (kein echtes Barge-in) - 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 ✅ - 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 - H1: ~23 Dateien uncommitted, darunter die neue voice-core-Architektur → Verlustrisiko
--- ---
## 3. SOLL-Zustand (Recherche, Stand 02.07.2026) ## 3. SOLL-Zustand (Recherche, Stand 02.07.2026)
### 3.1 Voice-Latenz — der Stand der Technik ### 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) - 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. - **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. - **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). - **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). - **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 ### 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. - **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. - **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. - **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/)). - **„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)). - **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. - 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 ### 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)). - **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. - **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. - **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. - **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/). - **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 ### 3.4 Verdikte — Ergebnis der Re-Prüfung
| Verdikt | Ergebnis | Begründung | | 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 | | 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 | | 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 | | 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 | | 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 | | 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) ## 4. Roadmap (Aufwand/Nutzen, Voice-Speed-first)
### P0 — Live-Bugs & Betriebssicherheit (Stunden) ### P0 — Live-Bugs & Betriebssicherheit (Stunden)
| # | Maßnahme | Nutzen | | # | 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 | | 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 | | 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 | | 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 | | 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 | | 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) ### P1 — Voice-Latenz: 3,55 s → Ziel ≤1,2 s (Tage)
| # | Maßnahme | Erwartung | | # | 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** | | 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** | | 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 | | 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 | | 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 | | 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 | | 10b | KV Q8_0 für coder@131k/heavy erwägen | Warm-Set-Reserve |
### P2 — Lucy v2 Kern & Code-Gesundheit (12 Wochen) ### P2 — Lucy v2 Kern & Code-Gesundheit (12 Wochen)
| # | Maßnahme | | # | Maßnahme |
|---|---| |---|---|
| 11 | Semantische Turn-Detection (Smart Turn V2 / Easy Turn) auf Parakeet aufsetzen; Barge-in-Vorstufe statt 450-ms-Cooldown | | 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) | | 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 | | 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 | | 14 | Backend C2 (Vision async) + C3 (DRY) + Dead Code; Frontend Cockpit/SystemDrawer-Split + globale Error Boundary |
### P3 — Strategisch (nach Signal) ### P3 — Strategisch (nach Signal)
| # | Maßnahme | | # | Maßnahme |
|---|---| |---|---|
| 15 | Mem0-v3-Status final verifizieren; Junk-Regex durch v3-Retrieval entschärfen; mc2-memory auf `sync_turn(messages)` heben | | 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) | | 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 | | 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 | | 18 | Lokaler GPU-Klon: AMD Lemonade auf der 9070 XT evaluieren |
--- ---
## 5. Findings-Katalog (Referenz) ## 5. Findings-Katalog (Referenz)
| ID | Schwere | Fund | Ort | | 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) | | 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 | | 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 | | B3 | 🟡 | deploy/llama-swap.config.yaml untracked + veraltet | Repo |
| C1 | 🟡 | Install-Template ohne Produktions-Tunings | backend/config.py:33 | | C1 | 🟡 | Install-Template ohne Produktions-Tunings | backend/config.py:33 |
| C2 | 🟡 | Vision-Beschreibung blockiert Chat bis 120 s | backend/routers/voice.py | | 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 | | C3 | 🟢 | Thinking-Disable 3× dupliziert | voice.py / gateway.py / mem0_service/app.py |
| C4 | 🟢 | Junk-Fact-Regex hartcodiert | 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/ | | 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 | | 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/ | | 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 | | F4 | 🟢 | Keine globale Error Boundary | frontend/src/App.tsx |
| L1 | 🟡 | useVoiceAgent monolithisch (377 Z) | client/lucy-desktop/.../lib/voice/useVoiceAgent.ts | | L1 | 🟡 | useVoiceAgent monolithisch (377 Z) | client/lucy-desktop/.../lib/voice/useVoiceAgent.ts |
| L2 | 🟡 | Kein TTS-Warmup-Retry | ebd. + main/index.ts | | L2 | 🟡 | Kein TTS-Warmup-Retry | ebd. + main/index.ts |
| L3 | 🔴 | RMS-VAD mit 900-ms-Timer | client/lucy-desktop/.../lib/voice/useVAD.ts | | L3 | 🔴 | RMS-VAD mit 900-ms-Timer | client/lucy-desktop/.../lib/voice/useVAD.ts |
| L4 | 🟡 | Echo-Cooldown statt Barge-in | ebd. | | L4 | 🟡 | Echo-Cooldown statt Barge-in | ebd. |
| H1 | 🟡 | ~23 Dateien uncommitted (inkl. voice-core, MC3) | lucy-v2 Working Tree | | H1 | 🟡 | ~23 Dateien uncommitted (inkl. voice-core, MC3) | lucy-v2 Working Tree |
--- ---
## 6. Nachtrag: P0/P1-Umsetzung (02.07.2026) ## 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.). **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-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-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): **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 | | 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 3, KV f16 (live) | **78,6 t/s** | — (Probe-EOS) | 55 % |
| MTP n-max 4 | 63,7 | 100,9* | 39 % | | MTP n-max 4 | 63,7 | 100,9* | 39 % |
| ohne Spec | 62,4 | — (Probe-EOS) | — | | ohne Spec | 62,4 | — (Probe-EOS) | — |
| **MTP n-max 3 + KV Q8_0** | **78,6** | 83,7 | 56 % | | **MTP n-max 3 + KV Q8_0** | **78,6** | 83,7 | 56 % |
| ohne Spec + KV Q8_0 | 62,1 | 57,0 | — | | ohne Spec + KV Q8_0 | 62,1 | 57,0 | — |
*\*repetitiver Test-Text → unrealistisch hohe Draft-Akzeptanz (95 %); die Kurz-Spalte ist maßgeblich.* *\*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). **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. **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.):** **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). - **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). - **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. - **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). - **C5 war Fehlalarm** (s. Findings-Tabelle).
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme). - **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) ## 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. **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 | | 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-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-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-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-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 | | **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-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-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-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 | | **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) | | **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) ## 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. **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). **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. **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.* *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) # 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 > 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 + > 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. > Latenz). Hermes bleibt **unangetastet** parallel laufen — null Risiko für die laufende Lucy.
## Warum (die ganze Vorgeschichte in Kürze) ## 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). 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: 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 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 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.) 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: 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.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 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** CPU-Fallback). gemma-GENERIERUNG ist ok (~93 t/s), gecachter Prompt **0,3 s** — nur **Cache-Misses**
(Prefill, ~1127 s) sind tödlich. (Prefill, ~1127 s) sind tödlich.
3. **gemmas Thinking ist per Default AN** (~90 Extra-Tokens/Antwort). Abschaltbar NUR via 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). `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** 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 (~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. 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; **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, 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' 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 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 verlässlich niedrige Latenz.** Einziger gemma-Vorteil: **deutlich besseres Deutsch** (Qwen leakt englische
Wörter, z.B. „biggest"; gemma = natürlich + echte Umlaute). Wörter, z.B. „biggest"; gemma = natürlich + echte Umlaute).
## Die These des PoC ## Die These des PoC
**Hermes' Schwergewicht (Skills + ChromaDB + Auto-Injektion) baut den fetten, instabilen Prompt.** **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 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 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 kauen → **Lucy schnell mit gemma UND Qwen** → gemmas Deutsch gerettet + Lucy entschlackt. Wurzel-Fix statt
Modell-Pflaster. Modell-Pflaster.
## PoC-Aufgabe (risikoarm, Hermes bleibt parallel) ## PoC-Aufgabe (risikoarm, Hermes bleibt parallel)
1. **ZeroClaw** auf die Box holen (Rust-Binary; Repos: github.com/zeroclaw-labs/zeroclaw bzw. 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** github.com/elev8tion/zeroclaw — beides prüfen, das gepflegtere/passende nehmen). Eigener Port, **NICHT**
:8642 (Hermes) anfassen. :8642 (Hermes) anfassen.
2. **An euer MC2-Gateway** hängen (OpenAI-kompatibel, `http://127.0.0.1:9001/v1`). Erst mit Modell `fast` 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. (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. 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`, Eure bestehenden MCP-Server (`hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch`) kann ZeroClaw als MCP-Client mitnutzen. `hermes-web-fetch`) kann ZeroClaw als MCP-Client mitnutzen.
4. **Benchmark gegen Hermes (Zahlen, nicht Gefühl):** 4. **Benchmark gegen Hermes (Zahlen, nicht Gefühl):**
- **Prompt-Größe** (prompt_tokens) eines simplen Turns: ZeroClaw vs. Hermes (Hermes ≈ 20.000). - **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? - **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` + - Messmethode wie in der Saga: `curl` an den Agent + `/v1/chat/completions`-`usage.prompt_tokens` +
llama-swap-POST-Dauer (`journalctl -u llama-swap`). llama-swap-POST-Dauer (`journalctl -u llama-swap`).
5. **Entscheidung:** Wird Lucy mit ZeroClaw schnell (idealerweise mit gemma) → voller Umzug planen 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: (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. Lucys Hirn auf Qwen3.6-35B-A3B** (schnell+verlässlich) + harte Deutsch-Direktive im System-Prompt.
## Box-Fakten (für den PoC) ## Box-Fakten (für den PoC)
- **Box:** `ssh hitonabi@192.168.178.151` (key-based). Strix Halo, 122 GB, GTT ~124 GB, Vulkan/RADV. - **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, - **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. `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 - **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. 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` - **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.** (`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-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`. `MEM0_EMBED_MODEL=embed`. Systemd-User-Unit `~/.config/systemd/user/mem0-service.service`.
- **brains-Gruppe** (immer warm, ko-resident): gemma + fast + embed + vision. - **brains-Gruppe** (immer warm, ko-resident): gemma + fast + embed + vision.
## Modell-Kandidaten (falls Fallback/Vergleich) ## Modell-Kandidaten (falls Fallback/Vergleich)
| Modell | Profil | Speed (Strix Halo) | Deutsch | | Modell | Profil | Speed (Strix Halo) | Deutsch |
|---|---|---|---| |---|---|---|---|
| gemma-4-26B-A4B | head_dim 512, Thinking, multimodal | langsam (Prefill) | **exzellent** | | 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.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 | | 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 | | LFM2-8B-A1B (Liquid) | 8B/1B aktiv, Hybrid Conv+MoE | **~170 t/s** | klein → unsicher Deutsch |
## Leitplanken ## Leitplanken
- **Hermes/Lucy bleibt live** — PoC läuft isoliert (eigener Port, eigene Memory-Datei). Null Risiko. - **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`. - Vor Box-Config-Änderungen Backup; llama-swap reloadt per `-watch-config`.
- Erst messen (Prompt-Größe + Latenz), dann über vollen Umzug entscheiden. - Erst messen (Prompt-Größe + Latenz), dann über vollen Umzug entscheiden.
</content> </content>
+118 -118
View File
@@ -1,118 +1,118 @@
# ZeroClaw-PoC — Ergebnis & Verdikt (2026-06-30) # ZeroClaw-PoC — Ergebnis & Verdikt (2026-06-30)
> Durchführung des Plans aus `docs/ZEROCLAW_POC_BRIEF.md`. Hermes blieb die ganze Zeit > 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 > **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. > PoC lief als kurzlebiger CLI-Prozess). Null Risiko für die laufende Lucy.
## Setup (reproduzierbar, alles auf der Box unter `~/zeroclaw-poc/`) ## Setup (reproduzierbar, alles auf der Box unter `~/zeroclaw-poc/`)
- **Binary:** ZeroClaw **v0.8.2** (`zeroclaw-labs/zeroclaw`, 32k ⭐, gepflegt die andere Repo - **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 `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. gegen `SHA256SUMS` verifiziert (`6b9f7e9d…`). Kein Rust-Toolchain auf der Box nötig.
- **Config:** isoliert via `--config-dir ~/zeroclaw-poc/config`. Provider = nativer `openai` - **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` 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). 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` - **Agent:** `[agents.lucy]`, `model_provider = openai.default`, `risk_profile = default`
(supervised), Memory-Backend = **sqlite**. Modellwahl pro Turn via `--model fast|hermes`. (supervised), Memory-Backend = **sqlite**. Modellwahl pro Turn via `--model fast|hermes`.
- Stolpersteine gelöst: quickstart braucht TTY → headless via `config set --no-interactive`; - Stolpersteine gelöst: quickstart braucht TTY → headless via `config set --no-interactive`;
Secrets (`api_key`) brauchen `--no-interactive` + Wert; `risk_profiles.<key>` ist Map → Secrets (`api_key`) brauchen `--no-interactive` + Wert; `risk_profiles.<key>` ist Map →
per `config set` nicht adressierbar, Block direkt in `config.toml` angehängt. per `config set` nicht adressierbar, Block direkt in `config.toml` angehängt.
## Messungen (4 Folge-Turns, je frischer CLI-Prozess, identischer 13k-Prefix) ## Messungen (4 Folge-Turns, je frischer CLI-Prozess, identischer 13k-Prefix)
| Turn | FAST (Qwen3.6-35B) | HERMES (gemma-4-26B) | input_tokens | | Turn | FAST (Qwen3.6-35B) | HERMES (gemma-4-26B) | input_tokens |
|---|---|---|---| |---|---|---|---|
| 1 | 429 ms | **937 ms** | ~13.059 | | 1 | 429 ms | **937 ms** | ~13.059 |
| 2 | 534 ms | **493 ms** | ~13.056 | | 2 | 534 ms | **493 ms** | ~13.056 |
| 3 | 337 ms | **340 ms** | ~13.060 | | 3 | 337 ms | **340 ms** | ~13.060 |
| 4 | 465 ms | **465 ms** | ~13.061 | | 4 | 465 ms | **465 ms** | ~13.061 |
`hermes`-Alias → verifiziert echtes `gemma-4-26B-A4B-it-…Q4_K_M.gguf` (kein Fallback auf fast); `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`). gemma ist warm in der brains-Gruppe (llama-swap `/running`: gemma + Qwen3.6 + embed alle `ready`).
## Kernbefunde ## Kernbefunde
1. **Prompt-Größe:** ZeroClaw baut **~13.05513.061 Tokens** pro simplem Turn — vs. Hermes **≈ 20.000**. 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.) ~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 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 um wenige Tokens). Genau das Gegenteil von Hermes' pro-Turn-wechselnder Auto-Injektion, die
gemmas Cache jeden Turn brach. gemmas Cache jeden Turn brach.
3. **Latenz (das Verdikt):** gemma antwortet unter ZeroClaw in **0,30,9 s/Turn** — gegenüber 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** **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**. (stabiler Prefix → cache-reuse greift). gemma ist hier sogar **gleichauf mit Qwen**.
## Verdikt ## Verdikt
**Die PoC-These ist bestätigt.** Lucys Latenz-Problem war **nicht gemma**, sondern Hermes' **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 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 mit **kleinem, stabilem** Prompt macht **gemma sub-sekündlich** — und rettet damit gemmas
**exzellentes Deutsch** (kein Umstieg auf Qwen mit English-Leaks nötig). **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. **Empfehlung: voller Umzug auf ZeroClaw planen** (mit gemma als Hirn). Kein Fallback auf Qwen nötig.
## Umzug umgesetzt (2026-06-30, Etappe 2) ## 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`: 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`, - **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 = `USER.md`, `AGENTS.md`) — Lucy spricht „Commander", echte Umlaute, `<emo:>`-Tags. (Workspace-Pfad =
`<config>/agents/<alias>/workspace/`, NICHT `config/data`!) `<config>/agents/<alias>/workspace/`, NICHT `config/data`!)
- **MCP-Tools:** 4 Server / 37 Tools (`[[mcp.servers]]` als **Array** mit `name`, nicht Tabelle!) + - **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. Bundle `lucy_tools` an `agents.lucy.mcp_bundles`. Alle verbunden, autonom nutzbar.
- **Hirn:** gemma (`hermes`), Autonomie `level="full"`, Thinking aus via - **Hirn:** gemma (`hermes`), Autonomie `level="full"`, Thinking aus via
`providers.models.openai.default.chat_template_kwargs.enable_thinking=false`. `providers.models.openai.default.chat_template_kwargs.enable_thinking=false`.
- **Aux:** `classifier_provider`/`summary_provider``openai.fastaux` (Qwen); Auto-Hydrate/Save aus. - **Aux:** `classifier_provider`/`summary_provider``openai.fastaux` (Qwen); Auto-Hydrate/Save aus.
## 🔴 KV-Cache-Killer gefunden & gefixt (der eigentliche Latenz-Showstopper) ## 🔴 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 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 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**: (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 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 `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. ä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]` 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). (variiert jeden Turn; steht am Ende → kleiner).
3. **Ausgeschlossen:** Classifier (feuert gar nicht, 1 Call/Turn), mem0/Memory (feuert bei Chat nicht). 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** **Fix (Binary nicht kompilierbar → Wire-Ebene):** schlanker Python-**Normalisierungs-Proxy**
(`~/zeroclaw-poc/normalizing-proxy.py`, Port 9009) zwischen ZeroClaw und Gateway: **sortiert `tools` (`~/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 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** 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. (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-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 ## 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, Nach dem Proxy blieben Rest-Spitzen (26 s). Tagesaktuelle llama.cpp-Recherche bestätigt: **bekannter,
Gemma-4-spezifischer Bug.** Gemma-4-spezifischer Bug.**
- **Gemma 4 nutzt „Shared KV Cache"** (letzte Layer teilen K/V mit früheren) → llama.cpp loggt wörtlich - **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 *„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)) 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 - **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 **`--swa-full`** (hält ganze KV-History, kein Sliding-Window-Pruning). Caveat: cache-reuse ↔ mmproj
inkompatibel. inkompatibel.
- **Getestet an der live gemma:** `--swa-full` testweise gesetzt → mehr saubere 400-ms-Hits, ABER weiter - **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 ~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**. `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 - **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; 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). `--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 **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 **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 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`. Wahl bestätigt.** Lucy-Daemon läuft auf `model=fast`.
## Finaler Lucy-Stack (läuft, manuell/nohup) ## Finaler Lucy-Stack (läuft, manuell/nohup)
``` ```
Desktop/Webhook → ZeroClaw-Daemon :8088 (Persona, 18 Lean-Tools, Autonomie full) Desktop/Webhook → ZeroClaw-Daemon :8088 (Persona, 18 Lean-Tools, Autonomie full)
→ Normalisierungs-Proxy :9009 (sort tools + strip timestamp) ← der Cache-Fix → Normalisierungs-Proxy :9009 (sort tools + strip timestamp) ← der Cache-Fix
→ MC2-Gateway :9001 → llama-swap :8080 → Qwen3.6 (fast) ← rock-solid Cache → MC2-Gateway :9001 → llama-swap :8080 → Qwen3.6 (fast) ← rock-solid Cache
``` ```
## Nächste Schritte (offen) ## Nächste Schritte (offen)
- **Persistenz:** Proxy + Daemon als systemd-User-Services (vom User freizugeben). - **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`). - **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). - **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. - Optional: Deutsch-Direktive in IDENTITY.md härten gegen Qwens seltene Wackler.
- Persona/System-Prompt (Lucys Charakter) in ZeroClaw übertragen. - Persona/System-Prompt (Lucys Charakter) in ZeroClaw übertragen.
- Memory-Migration: mem0/Chroma → ZeroClaws SQLite (oder MCP-Bridge an `mission-control-memory`). - 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`, - MCP-Tools anbinden: `hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch` (ZeroClaw als MCP-Client). `hermes-web-fetch` (ZeroClaw als MCP-Client).
- Toolset auf Kern reduzieren → Prompt < 13k drücken (noch schnellerer Prefill). - 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). - Kanäle: Telegram + Voice an ZeroClaw hängen; Gateway-/Daemon-Betrieb (eigener Port, nicht :8642).
- Erst nach grünem Umzug Hermes ablösen. - 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", "name": "mission-control-2-frontend",
"private": true, "private": true,
"version": "2.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@pixiv/three-vrm": "^3.4.0", "@pixiv/three-vrm": "^3.4.0",
"@react-three/drei": "^9.114.0", "@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10", "@react-three/fiber": "^8.17.10",
"@tanstack/react-query": "^5.101.1", "@tanstack/react-query": "^5.101.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-forceatlas2": "^0.10.1",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^3.9.0", "recharts": "^3.9.0",
"sigma": "^3.0.3", "sigma": "^3.0.3",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"three": "^0.169.0" "three": "^0.169.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/three": "^0.169.0", "@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^6.0.3" "vite": "^6.0.3"
} }
} }
+248 -248
View File
@@ -1,248 +1,248 @@
import { useEffect, useState, useCallback } from "react" import { useEffect, useState, useCallback } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react" import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { NAV, type ViewId } from "@/nav" import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette" import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle" import { ExpertToggle } from "@/components/ExpertToggle"
import { ModelsView } from "@/views/ModelsView" import { ModelsView } from "@/views/ModelsView"
import { ConnectView } from "@/views/ConnectView" import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView" import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView" import { AgentView } from "@/views/AgentView"
import { TerminalView } from "@/views/TerminalView" import { TerminalView } from "@/views/TerminalView"
import { KonsoleView } from "@/views/KonsoleView" import { KonsoleView } from "@/views/KonsoleView"
import { GuideView } from "@/views/GuideView" import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer" import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries" import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore" import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App" import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView" import { CockpitView } from "@/views/cockpit/CockpitView"
export default function App() { export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren. // View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
const [view, setView] = useState<ViewId>(() => { const [view, setView] = useState<ViewId>(() => {
const h = window.location.hash.slice(1) as ViewId const h = window.location.hash.slice(1) as ViewId
return NAV.some((n) => n.id === h) ? h : "dashboard" return NAV.some((n) => n.id === h) ? h : "dashboard"
}) })
const navigate = useCallback((v: ViewId) => { const navigate = useCallback((v: ViewId) => {
if (window.location.hash.slice(1) === v) setView(v) if (window.location.hash.slice(1) === v) setView(v)
else window.location.hash = v else window.location.hash = v
}, []) }, [])
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance") const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default). // MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default).
const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3") const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3")
const { data: health } = useHealth() const { data: health } = useHealth()
const { data: sysStatus } = useSystemStatus(20_000) const { data: sysStatus } = useSystemStatus(20_000)
useEffect(() => { useEffect(() => {
document.documentElement.classList.add("dark") document.documentElement.classList.add("dark")
}, []) }, [])
useEffect(() => { useEffect(() => {
const handleOpen = (e: Event) => { const handleOpen = (e: Event) => {
const customEvent = e as CustomEvent const customEvent = e as CustomEvent
setDrawerTab(customEvent.detail?.tab || "maintenance") setDrawerTab(customEvent.detail?.tab || "maintenance")
setDrawerOpen(true) setDrawerOpen(true)
} }
window.addEventListener("open-system-drawer", handleOpen) window.addEventListener("open-system-drawer", handleOpen)
return () => window.removeEventListener("open-system-drawer", handleOpen) return () => window.removeEventListener("open-system-drawer", handleOpen)
}, []) }, [])
useEffect(() => { useEffect(() => {
const handleNav = (e: Event) => { const handleNav = (e: Event) => {
const v = (e as CustomEvent).detail?.view as ViewId | undefined const v = (e as CustomEvent).detail?.view as ViewId | undefined
if (v) navigate(v) if (v) navigate(v)
} }
window.addEventListener("mc-navigate", handleNav) window.addEventListener("mc-navigate", handleNav)
return () => window.removeEventListener("mc-navigate", handleNav) return () => window.removeEventListener("mc-navigate", handleNav)
}, [navigate]) }, [navigate])
useEffect(() => { useEffect(() => {
const onHash = () => { const onHash = () => {
setIsMc3(window.location.hash === "#mc3") setIsMc3(window.location.hash === "#mc3")
const h = window.location.hash.slice(1) as ViewId const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h) if (NAV.some((n) => n.id === h)) setView(h)
} }
window.addEventListener("hashchange", onHash) window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash) return () => window.removeEventListener("hashchange", onHash)
}, []) }, [])
const active = NAV.find((n) => n.id === view)! const active = NAV.find((n) => n.id === view)!
// Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche. // Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche.
if (isMc3) return <Mc3App /> if (isMc3) return <Mc3App />
return ( return (
<div className="flex h-full relative"> <div className="flex h-full relative">
{/* Background Aurora Ambient Effects */} {/* Background Aurora Ambient Effects */}
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]"> <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 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 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 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 className="absolute top-[30%] right-[20%] h-[40%] w-[40%] rounded-full bg-indigo-500/10 blur-[140px]" />
</div> </div>
<CommandPalette onNavigate={navigate} /> <CommandPalette onNavigate={navigate} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} /> <SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar */} {/* Sidebar */}
<aside className={cn( <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", "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" 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={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="flex items-center gap-2 overflow-hidden">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" /> <div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" />
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="leading-tight"> <div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div> <div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-xs text-muted-foreground">2.0</div> <div className="text-xs text-muted-foreground">2.0</div>
</div> </div>
)} )}
</div> </div>
<button <button
onClick={() => { onClick={() => {
setSidebarCollapsed(c => { setSidebarCollapsed(c => {
const next = !c const next = !c
localStorage.setItem("mc_sidebar_collapsed", next.toString()) localStorage.setItem("mc_sidebar_collapsed", next.toString())
return next return next
}) })
}} }}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer" className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label={sidebarCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"} aria-label={sidebarCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
title={sidebarCollapsed ? "Maximieren" : "Minimieren"} title={sidebarCollapsed ? "Maximieren" : "Minimieren"}
> >
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />} {sidebarCollapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
</button> </button>
</div> </div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto"> <nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{NAV.map((item) => ( {NAV.map((item) => (
<a <a
key={item.id} key={item.id}
href={`#${item.id}`} href={`#${item.id}`}
aria-current={view === item.id ? "page" : undefined} aria-current={view === item.id ? "page" : undefined}
className={cn( className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer", "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", sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id view === item.id
? "bg-primary/15 text-primary shadow-sm shadow-primary/5" ? "bg-primary/15 text-primary shadow-sm shadow-primary/5"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)} )}
title={sidebarCollapsed ? item.label : undefined} title={sidebarCollapsed ? item.label : undefined}
aria-label={sidebarCollapsed ? item.label : undefined} aria-label={sidebarCollapsed ? item.label : undefined}
> >
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" /> <item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!sidebarCollapsed && <span className="truncate">{item.label}</span>} {!sidebarCollapsed && <span className="truncate">{item.label}</span>}
</a> </a>
))} ))}
</nav> </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")}> <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 ? ( {sidebarCollapsed ? (
<div className="flex justify-center"> <div className="flex justify-center">
<span className={cn( <span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40", "h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
health health
? (!health.engine_reachable ? "bg-amber-500" ? (!health.engine_reachable ? "bg-amber-500"
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse")) : (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
: "bg-red-500" : "bg-red-500"
)} title={health )} title={health
? (!health.engine_reachable ? "Engine offline" ? (!health.engine_reachable ? "Engine offline"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online")) : (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
: "Backend offline"} /> : "Backend offline"} />
</div> </div>
) : ( ) : (
<div className="space-y-2 text-left"> <div className="space-y-2 text-left">
{health ? ( {health ? (
<> <>
<span className="flex items-center gap-2"> <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={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span> <span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
</span> </span>
{health.brain && !health.brain.ready && ( {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="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="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</span> <span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</span>
</span> </span>
)} )}
</> </>
) : ( ) : (
<span className="flex items-center gap-2"> <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 className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span>
</span> </span>
)} )}
{sysStatus?.versions && ( {sysStatus?.versions && (
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30"> <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"}> <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 ? "*" : ""}` : "—"} <strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</div> </div>
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}> <div className="truncate" title={sysStatus.versions.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() || "—")} <strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
</div> </div>
<div className="truncate" title={sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.branch}-${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""} (${sysStatus.versions.hermes_ui.date})` : "nicht gefunden"}> <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 ? "*" : ""}` : "—"} <strong>Hermes UI:</strong> {sysStatus.versions.hermes_ui ? `${sysStatus.versions.hermes_ui.hash}${sysStatus.versions.hermes_ui.dirty ? "*" : ""}` : "—"}
</div> </div>
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}> <div 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 ? "*" : ""}` : "—"} <strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
</aside> </aside>
{/* Main */} {/* Main */}
<div className="flex min-w-0 flex-1 flex-col"> <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"> <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="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ExpertToggle /> <ExpertToggle />
<a <a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md" href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank" target="_blank"
rel="noopener" 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" 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" title="Bedien-Anleitung"
> >
Hilfe Hilfe
</a> </a>
<button <button
onClick={() => { onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true }) const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev) 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" 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" /> <CommandIcon className="h-3.5 w-3.5" />
<span>Suchen</span> <span>Suchen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">K</kbd> <kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">K</kbd>
</button> </button>
</div> </div>
</header> </header>
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin"> <main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />} {view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "models" && <ModelsView />} {view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />} {view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />} {view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />} {view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />} {view === "terminal" && <TerminalView />}
{view === "konsole" && <KonsoleView />} {view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />} {view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && ( {!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} /> <Placeholder title={active.label} hint={active.hint} />
)} )}
</main> </main>
</div> </div>
</div> </div>
) )
} }
+38 -38
View File
@@ -1,38 +1,38 @@
import React from "react" import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette // 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 // 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.) // nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
interface State { error: Error | null } interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> { export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null } state: State = { error: null }
static getDerivedStateFromError(error: Error): State { static getDerivedStateFromError(error: Error): State {
return { error } return { error }
} }
componentDidCatch(error: Error, info: React.ErrorInfo) { componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Unbehandelter UI-Fehler:", error, info.componentStack) console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
} }
render() { render() {
if (!this.state.error) return this.props.children if (!this.state.error) return this.props.children
return ( return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8"> <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="max-w-lg space-y-4 text-center">
<div className="text-2xl">Da ist etwas schiefgelaufen.</div> <div className="text-2xl">Da ist etwas schiefgelaufen.</div>
<div className="text-sm text-neutral-400 break-all"> <div className="text-sm text-neutral-400 break-all">
{this.state.error.message} {this.state.error.message}
</div> </div>
<button <button
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700" className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
> >
Neu laden Neu laden
</button> </button>
</div> </div>
</div> </div>
) )
} }
} }
+82 -82
View File
@@ -1,82 +1,82 @@
import { useRef } from "react" import { useRef } from "react"
import { X } from "lucide-react" import { X } from "lucide-react"
export interface CustomDialogProps { export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt" type: "alert" | "confirm" | "prompt"
title: string title: string
message: string message: string
defaultValue?: string defaultValue?: string
autoValue?: string autoValue?: string
autoLabel?: string autoLabel?: string
onConfirm: (val?: string) => void onConfirm: (val?: string) => void
onCancel?: () => void onCancel?: () => void
} }
export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) { export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
return ( 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="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="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"> <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> <h3 className="text-xs font-bold uppercase tracking-wider text-primary">{title}</h3>
<button <button
onClick={onCancel || (() => onConfirm())} onClick={onCancel || (() => onConfirm())}
aria-label="Schließen" aria-label="Schließen"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer" className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
> >
<X className="h-4 w-4" aria-hidden="true" /> <X className="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </div>
<p className="text-xs text-muted-foreground leading-relaxed">{message}</p> <p className="text-xs text-muted-foreground leading-relaxed">{message}</p>
{type === "prompt" && ( {type === "prompt" && (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
defaultValue={defaultValue} defaultValue={defaultValue}
aria-label={title} 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" 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 autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
onConfirm(inputRef.current?.value) onConfirm(inputRef.current?.value)
} }
}} }}
/> />
{autoValue !== undefined && ( {autoValue !== undefined && (
<button <button
type="button" type="button"
onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }} onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }}
title="Setup-bewussten Optimalwert eintragen" 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" 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"} {autoLabel || "Auto"}
</button> </button>
)} )}
</div> </div>
)} )}
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
{(type === "confirm" || type === "prompt") && ( {(type === "confirm" || type === "prompt") && (
<button <button
onClick={onCancel} 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" 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 Abbrechen
</button> </button>
)} )}
<button <button
onClick={() => { onClick={() => {
const val = type === "prompt" ? inputRef.current?.value : undefined const val = type === "prompt" ? inputRef.current?.value : undefined
onConfirm(val) 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" 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"} {type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
File diff suppressed because it is too large Load Diff
@@ -1,172 +1,172 @@
import { useState } from "react" import { useState } from "react"
import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react" import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries" import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
export function AgentStatusCard() { export function AgentStatusCard() {
const qc = useQueryClient() const qc = useQueryClient()
const { data: agent } = useAgentStatus(3_000) const { data: agent } = useAgentStatus(3_000)
const { data: modelsData } = useModels() const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog() const { showAlert, dialogElement } = useDialog()
const [showBrainSelect, setShowBrainSelect] = useState(false) const [showBrainSelect, setShowBrainSelect] = useState(false)
const models = modelsData?.models ?? [] const models = modelsData?.models ?? []
async function changeBrainModel(model: string) { async function changeBrainModel(model: string) {
try { try {
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) }) 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.`) showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus }) qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false) setShowBrainSelect(false)
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`) showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
} }
} }
return ( 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 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>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" /> <Bot className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2> <h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
</div> </div>
{agent?.terminal_url && ( {agent?.terminal_url && (
<a <a
href={resolveExternalUrl(agent.terminal_url)} href={resolveExternalUrl(agent.terminal_url)}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
className={cn( className={cn(
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all", "flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
agent.terminal_reachable agent.terminal_reachable
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20" ? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
: "border border-border text-muted-foreground" : "border border-border text-muted-foreground"
)} )}
> >
<ExternalLink className="h-3 w-3" /> Terminal öffnen <ExternalLink className="h-3 w-3" /> Terminal öffnen
</a> </a>
)} )}
</div> </div>
{agent ? ( {agent ? (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-background/20 rounded-xl border border-border/40"> <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="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
<div className="flex items-center gap-2 mt-1"> <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={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> <span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
</div> </div>
</div> </div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40"> <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="text-[10px] text-muted-foreground uppercase font-semibold">Terminal</div>
<div className="flex items-center gap-2 mt-1"> <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={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> <span className="text-xs font-medium">{agent.terminal_reachable ? "Online" : "Offline"}</span>
</div> </div>
</div> </div>
<div <div
onClick={() => setShowBrainSelect(true)} 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" 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"> <div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span> <span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span>
<Cpu className="h-3 w-3 text-primary" /> <Cpu className="h-3 w-3 text-primary" />
</div> </div>
<div className="text-xs font-medium mt-1 font-mono text-primary truncate flex items-center gap-1"> <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" /> <Layers className="h-3 w-3 shrink-0" />
{agent.brain_model || "auto"} {agent.brain_model || "auto"}
</div> </div>
</div> </div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40"> <div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span> <span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span>
<Wrench className="h-3 w-3 text-primary" /> <Wrench className="h-3 w-3 text-primary" />
</div> </div>
<div className="text-[10px] font-mono mt-1 space-y-0.5 text-muted-foreground"> <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>Config: {agent.has_config ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Skills: {agent.has_skills ? <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>Memory: {agent.has_memories ? <span className="text-emerald-400"></span> : "—"}</div>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div> <div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div>
)} )}
</div> </div>
{agent && ( {agent && (
<div className="mt-3 border-t border-border/30 pt-3 space-y-1.5"> <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"> <div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Telegram</span> <span>Telegram</span>
<span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}> <span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}>
{agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"} {agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground"> <div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>MCP-Server</span> <span>MCP-Server</span>
<span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span> <span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span>
</div> </div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground"> <div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>PC Executor</span> <span>PC Executor</span>
<span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}> <span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}>
{agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"} {agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"}
</span> </span>
</div> </div>
</div> </div>
)} )}
{agent && showBrainSelect && ( {agent && showBrainSelect && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"> <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="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"> <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"> <h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<Cpu className="h-4 w-4" /> <Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span> <span>Hermes-Gehirn konfigurieren</span>
</h3> </h3>
<button <button
onClick={() => setShowBrainSelect(false)} onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer" className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<p className="text-xs text-muted-foreground leading-relaxed"> <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>): 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> </p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1"> <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) => { {(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m) const isAlias = ["auto", "fast", "heavy"].includes(m)
return ( return (
<button <button
key={m} key={m}
onClick={() => changeBrainModel(m)} onClick={() => changeBrainModel(m)}
className={cn( 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", "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") agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30" ? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20" : "text-foreground bg-background/20"
)} )}
> >
<div className="flex flex-col text-left"> <div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">{m}</span> <span className="font-semibold truncate max-w-[280px]">{m}</span>
<span className="text-[9px] text-muted-foreground mt-0.5"> <span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"} {isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span> </span>
</div> </div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && ( {(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" /> <Check className="h-4 w-4 shrink-0 text-primary" />
)} )}
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
</div> </div>
)} )}
{dialogElement} {dialogElement}
</div> </div>
) )
} }
@@ -1,165 +1,165 @@
import { useState } from "react" import { useState } from "react"
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react" import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { useQueryClient, qk } from "@/lib/queries" import { useQueryClient, qk } from "@/lib/queries"
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth" import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Farb-/Text-Ton pro Check-Status. // Farb-/Text-Ton pro Check-Status.
const DOT: Record<string, string> = { const DOT: Record<string, string> = {
ok: "bg-emerald-500", ok: "bg-emerald-500",
warn: "bg-amber-500", warn: "bg-amber-500",
down: "bg-red-500", down: "bg-red-500",
loading: "bg-muted-foreground/40", loading: "bg-muted-foreground/40",
} }
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation). // `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) { export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
const { verdict, checks, memory, problems } = useLucyHealth() const { verdict, checks, memory, problems } = useLucyHealth()
const { showAlert, dialogElement } = useDialog() const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient() const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({}) const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) { async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true })) setBusy((b) => ({ ...b, [id]: true }))
try { try {
if (repair.kind === "loadModel") { if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" }) await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else { } else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", { const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", method: "POST",
body: JSON.stringify({ service: repair.service }), body: JSON.stringify({ service: repair.service }),
}) })
if (!r.ok) { if (!r.ok) {
showAlert( showAlert(
"Reparatur nicht ganz geklappt", "Reparatur nicht ganz geklappt",
(r.err || "Unbekannter Fehler") + (r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""), (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. // Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
qc.invalidateQueries({ queryKey: qk.health }) qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services }) qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models }) qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) { } catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e)) showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally { } finally {
setBusy((b) => ({ ...b, [id]: false })) setBusy((b) => ({ ...b, [id]: false }))
} }
} }
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard). // Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
const T = frame === "box" const T = frame === "box"
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" } ? { 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" } : { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
// Banner-Optik je Gesamt-Verdikt. // Banner-Optik je Gesamt-Verdikt.
const banner = { 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." }, 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." }, 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." }, 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.` }, 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] }[verdict]
const BannerIcon = banner.icon const BannerIcon = banner.icon
const memTone = { const memTone = {
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40", ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
}[memory.status] }[memory.status]
const memText = { const memText = {
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground", ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
}[memory.status] }[memory.status]
return ( return (
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}> <div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
{/* Gesamt-Verdikt */} {/* Gesamt-Verdikt */}
<div className="flex items-center gap-3"> <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"> <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)} /> <BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2> <h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
<p className="text-xs text-muted-foreground">{banner.sub}</p> <p className="text-xs text-muted-foreground">{banner.sub}</p>
</div> </div>
</div> </div>
{/* Speicher-Ampel */} {/* Speicher-Ampel */}
<div className="mt-4 rounded-xl border border-border/30 bg-background/20 p-3"> <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]"> <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"> <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 <HardDrive className="h-3.5 w-3.5" /> Speicher
</span> </span>
<span className={cn("font-mono font-bold", memText)}> <span className={cn("font-mono font-bold", memText)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`} {memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span> </span>
</div> </div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50"> <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 className={cn("h-full rounded-full transition-all duration-500", memTone)} style={{ width: `${memory.pct}%` }} />
</div> </div>
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p> <p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
</div> </div>
{/* Fähigkeiten-Checkliste */} {/* Fähigkeiten-Checkliste */}
<div className="mt-4 space-y-1.5"> <div className="mt-4 space-y-1.5">
{checks.map((c) => ( {checks.map((c) => (
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} /> <CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
))} ))}
</div> </div>
{dialogElement} {dialogElement}
</div> </div>
) )
} }
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) { function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
const Icon = c.icon const Icon = c.icon
const bad = c.status === "down" || c.status === "warn" const bad = c.status === "down" || c.status === "warn"
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors", "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 === "down" ? "border-red-500/25 bg-red-500/5"
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5" : c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
: "border-border/30 bg-background/15", : "border-border/30 bg-background/15",
)} )}
> >
<div className="flex min-w-0 items-center gap-2.5"> <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"> <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" /> <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 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> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground"> <div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
{c.label} {c.label}
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />} {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>} {!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>
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div> <div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
</div> </div>
</div> </div>
{bad && c.repair && ( {bad && c.repair && (
<button <button
onClick={onRepair} onClick={onRepair}
disabled={busy} disabled={busy}
className={cn( 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", "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" c.status === "down"
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20" ? "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", : "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
)} )}
title={c.repair.label} title={c.repair.label}
> >
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />} {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label} {c.repair.label}
</button> </button>
)} )}
{bad && !c.repair && ( {bad && !c.repair && (
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt"> <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 <ShieldQuestion className="h-3.5 w-3.5" /> manuell
</span> </span>
)} )}
</div> </div>
) )
} }
@@ -1,94 +1,94 @@
import { useState } from "react" import { useState } from "react"
import { Brain, Plus } from "lucide-react" import { Brain, Plus } from "lucide-react"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useMemory, useQueryClient } from "@/lib/queries" import { useMemory, useQueryClient } from "@/lib/queries"
export function MemoryInputCard() { export function MemoryInputCard() {
const qc = useQueryClient() const qc = useQueryClient()
const { data: memories = [] } = useMemory({ limit: 3 }) const { data: memories = [] } = useMemory({ limit: 3 })
const [memContent, setMemContent] = useState("") const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable") const [memCat, setMemCat] = useState("stable")
const [savingMem, setSavingMem] = useState(false) const [savingMem, setSavingMem] = useState(false)
async function saveQuickMemory() { async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return if (!memContent.trim() || savingMem) return
setSavingMem(true) setSavingMem(true)
try { try {
await api("/api/memory", { await api("/api/memory", {
method: "POST", method: "POST",
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }), body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
}) })
setMemContent("") setMemContent("")
qc.invalidateQueries({ queryKey: ["memory"] }) qc.invalidateQueries({ queryKey: ["memory"] })
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
setSavingMem(false) setSavingMem(false)
} }
} }
return ( 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 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>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Brain className="h-4.5 w-4.5 text-primary" /> <Brain className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2> <h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<textarea <textarea
value={memContent} value={memContent}
onChange={(e) => setMemContent(e.target.value)} onChange={(e) => setMemContent(e.target.value)}
aria-label="Fakt oder Regel im Gedächtnis speichern" aria-label="Fakt oder Regel im Gedächtnis speichern"
placeholder="Fakt / Regel im Pool speichern…" placeholder="Fakt / Regel im Pool speichern…"
rows={2} 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" 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"> <div className="flex items-center gap-2 justify-between">
<select <select
value={memCat} value={memCat}
onChange={(e) => setMemCat(e.target.value)} onChange={(e) => setMemCat(e.target.value)}
aria-label="Kategorie" aria-label="Kategorie"
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer" 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="stable">🔵 Fakt</option>
<option value="instruction">📋 Regel</option> <option value="instruction">📋 Regel</option>
<option value="user">👤 User</option> <option value="user">👤 User</option>
<option value="versioned">🟡 Version</option> <option value="versioned">🟡 Version</option>
</select> </select>
<button <button
onClick={saveQuickMemory} onClick={saveQuickMemory}
disabled={!memContent.trim() || savingMem} 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" 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 <Plus className="h-3.5 w-3.5" /> Speichern
</button> </button>
</div> </div>
</div> </div>
<div className="mt-3.5 space-y-1.5"> <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="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"> <div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
{memories.length === 0 ? ( {memories.length === 0 ? (
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div> <div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
) : ( ) : (
memories.map((m) => ( 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"> <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"> <span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
{m.category} {m.category}
</span> </span>
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}> <span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
{m.content} {m.content}
</span> </span>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3"> <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. Steht allen Clients per MCP zur Verfügung.
</div> </div>
</div> </div>
) )
} }
@@ -1,79 +1,79 @@
import { useState } from "react" import { useState } from "react"
import { ExternalLink, RefreshCw, Server } from "lucide-react" import { ExternalLink, RefreshCw, Server } from "lucide-react"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useServices } from "@/lib/queries" import { useServices } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
export function ServicesCard() { export function ServicesCard() {
const { data: svc } = useServices(3_000) const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog() const { showAlert, dialogElement } = useDialog()
const [restarting, setRestarting] = useState<Record<string, boolean>>({}) const [restarting, setRestarting] = useState<Record<string, boolean>>({})
async function restart(id: string) { async function restart(id: string) {
setRestarting((p) => ({ ...p, [id]: true })) setRestarting((p) => ({ ...p, [id]: true }))
try { try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", { const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: id }), method: "POST", body: JSON.stringify({ service: id }),
}) })
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`) if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler: ${e.message}`) showAlert("Fehler", `Fehler: ${e.message}`)
} finally { } finally {
setRestarting((p) => ({ ...p, [id]: false })) setRestarting((p) => ({ ...p, [id]: false }))
} }
} }
return ( 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="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="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Server className="h-4.5 w-4.5 text-primary" /> <Server className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
</div> </div>
<button <button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))} 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" className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
> >
Logs / Pflege Logs / Pflege
</button> </button>
</div> </div>
{svc ? ( {svc ? (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="space-y-1.5"> <div className="space-y-1.5">
{svc.services.map((x) => ( {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 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"> <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")} /> <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="min-w-0">
<div className="truncate text-xs font-bold text-foreground">{x.name}</div> <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 className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
</div> </div>
</div> </div>
<button <button
onClick={() => restart(x.unit)} disabled={restarting[x.unit]} 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" 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" title="Dienst neu starten"
> >
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} /> <RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} />
</button> </button>
</div> </div>
))} ))}
</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"> <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"> <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 <ExternalLink className="h-3 w-3" /> Engine
</a> </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"> <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 <ExternalLink className="h-3 w-3" /> Gateway
</a> </a>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste</div> <div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste</div>
)} )}
{dialogElement} {dialogElement}
</div> </div>
) )
} }
+68 -68
View File
@@ -1,68 +1,68 @@
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useJobs, useQueryClient, qk } from "@/lib/queries" import { useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { fmtBytes, fmtEta } from "@/lib/format" import { fmtBytes, fmtEta } from "@/lib/format"
export function JobsBar() { export function JobsBar() {
const qc = useQueryClient() const qc = useQueryClient()
const { data: jobs = [] } = useJobs(2_000) const { data: jobs = [] } = useJobs(2_000)
const { showAlert, dialogElement } = useDialog() const { showAlert, dialogElement } = useDialog()
async function cancelJob(jobId: string) { async function cancelJob(jobId: string) {
try { try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" }) await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
qc.invalidateQueries({ queryKey: qk.jobs }) qc.invalidateQueries({ queryKey: qk.jobs })
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", e.message) showAlert("Fehler", e.message)
} }
} }
const active = jobs.filter((j) => j.state === "running" || j.state === "queued") const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3) const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null if (active.length === 0 && recent.length === 0) return null
return ( 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="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> <div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => ( {active.map((j) => (
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40"> <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"> <div className="flex justify-between items-center text-xs">
<span className="font-semibold truncate max-w-[250px]">{j.label}</span> <span className="font-semibold truncate max-w-[250px]">{j.label}</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-muted-foreground font-mono"> <span className="text-muted-foreground font-mono">
{j.progress ?? 0}% {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)} {j.progress ?? 0}% {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""} {j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
</span> </span>
<button <button
onClick={() => cancelJob(j.id)} 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" 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 Abbrechen
</button> </button>
</div> </div>
</div> </div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted"> <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 className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
</div> </div>
</div> </div>
))} ))}
{recent.map((j) => ( {recent.map((j) => (
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1"> <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="truncate">{j.label}</span>
<span className={cn( <span className={cn(
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono", "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 === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
)}> )}>
{j.state} {j.state}
</span> </span>
</div> </div>
))} ))}
{dialogElement} {dialogElement}
</div> </div>
) )
} }
+60 -60
View File
@@ -1,60 +1,60 @@
import { type Fit } from "@/lib/api" import { type Fit } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta" import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts. // Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil. // Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil.
export { ROLES, roleTone, roleMeta } export { ROLES, roleTone, roleMeta }
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip. // Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
// `dense` = nur Kurzform (für enge Badges). // `dense` = nur Kurzform (für enge Badges).
export function RoleLabel({ export function RoleLabel({
role, role,
dense = false, dense = false,
className, className,
}: { }: {
role?: string | null role?: string | null
dense?: boolean dense?: boolean
className?: string className?: string
}) { }) {
const meta = roleMeta(role) const meta = roleMeta(role)
const Icon = meta.icon const Icon = meta.icon
return ( return (
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide", "inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
meta.tone, meta.tone,
className, className,
)} )}
title={`${meta.desc} (${meta.role})`} title={`${meta.desc} (${meta.role})`}
> >
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" /> <Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{dense ? meta.short : meta.label} {dense ? meta.short : meta.label}
</span> </span>
) )
} }
export function FitBadge({ fit }: { fit: Fit }) { export function FitBadge({ fit }: { fit: Fit }) {
const tone = { const tone = {
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20", 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", 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", too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
}[fit.level] }[fit.level]
return ( return (
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}> <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 {fit.text} {fit.req_gb} GB RAM
</span> </span>
) )
} }
export function getBrandInfo(name: string) { export function getBrandInfo(name: string) {
const low = name.toLowerCase() 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("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("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("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("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("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("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: "Φ" } 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" } 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 { useState } from "react"
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react" import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
import { setGroup, type ModelInfo } from "@/lib/api" import { setGroup, type ModelInfo } from "@/lib/api"
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries" import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges" import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { fmtSize } from "@/lib/format" import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder // „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar, // bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar). // mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() { export function WarmSetManager() {
const qc = useQueryClient() const qc = useQueryClient()
const { data: modelsResp } = useModels(4_000) const { data: modelsResp } = useModels(4_000)
const { data: groupsResp } = useGroups() const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus() const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain() const { data: brain } = useHermesBrain()
const { showAlert, dialogElement } = useDialog() const { showAlert, dialogElement } = useDialog()
const [picking, setPicking] = useState(false) const [picking, setPicking] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const models = modelsResp?.models ?? [] const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? [] const running = modelsResp?.running ?? []
const group = groupsResp?.groups?.brains const group = groupsResp?.groups?.brains
const members = group?.members ?? [] const members = group?.members ?? []
const byName = (name: string) => models.find((m) => m.name === name) const byName = (name: string) => models.find((m) => m.name === name)
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[] const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
const B = 1024 ** 3 const B = 1024 ** 3
const bud = brain?.budget const bud = brain?.budget
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B 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 gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0) const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte. // Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
const reservedGb = bud?.warm_projected_gb ?? weightsGb const reservedGb = bud?.warm_projected_gb ?? weightsGb
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0 const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete) const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
async function save(next: string[]) { async function save(next: string[]) {
setBusy(true) setBusy(true)
try { try {
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true) await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups }) qc.invalidateQueries({ queryKey: qk.groups })
qc.invalidateQueries({ queryKey: qk.models }) qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.hermesBrain }) qc.invalidateQueries({ queryKey: qk.hermesBrain })
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`) showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
} finally { } finally {
setBusy(false) setBusy(false)
} }
} }
function remove(name: string) { function remove(name: string) {
const m = byName(name) const m = byName(name)
if (m && roleMeta(m.role).protected) { if (m && roleMeta(m.role).protected) {
showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`) showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
return return
} }
void save(members.filter((x) => x !== name)) void save(members.filter((x) => x !== name))
} }
function add(name: string) { function add(name: string) {
setPicking(false) setPicking(false)
void save([...members, name]) void save([...members, name])
} }
if (!group) { if (!group) {
return ( return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<Header /> <Header />
<p className="mt-3 text-xs text-muted-foreground"> <p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist. Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
</p> </p>
{dialogElement} {dialogElement}
</div> </div>
) )
} }
return ( 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"> <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 /> <Header />
{/* Mitglieder */} {/* Mitglieder */}
<div className="space-y-1.5"> <div className="space-y-1.5">
{memberModels.length === 0 && ( {memberModels.length === 0 && (
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div> <div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
)} )}
{memberModels.map((m) => { {memberModels.map((m) => {
const isWarm = running.includes(m.name) const isWarm = running.includes(m.name)
const prot = roleMeta(m.role).protected const prot = roleMeta(m.role).protected
return ( 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 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"> <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")} <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"} /> title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />} {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="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> <span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</div> </div>
{prot ? ( {prot ? (
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span> <span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
) : ( ) : (
<button <button
onClick={() => remove(m.name)} onClick={() => remove(m.name)}
disabled={busy} 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" 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)" title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
)} )}
</div> </div>
) )
})} })}
</div> </div>
{/* Hinzufügen */} {/* Hinzufügen */}
<button <button
onClick={() => setPicking(true)} onClick={() => setPicking(true)}
disabled={busy || eligible.length === 0} 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" 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 <Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
</button> </button>
{/* Speicher-Budget */} {/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3"> <div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]"> <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."> <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 <HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span> </span>
<span className="font-mono font-bold text-foreground"> <span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB {reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
</span> </span>
</div> </div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50"> <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 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> </div>
{bud && !bud.fits && ( {bud && !bud.fits && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400"> <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" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span> <span>
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set 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, 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). statt das Set zu verdrängen).
</span> </span>
</p> </p>
)} )}
{bud && bud.fits && ( {bud && bud.fits && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400"> <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. <Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell nichts wird verdrängt.
</p> </p>
)} )}
</div> </div>
{/* Auswahl-Modal */} {/* Auswahl-Modal */}
{picking && ( {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="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="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"> <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> <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> <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> </div>
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p> <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"> <div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
{eligible.map((m) => ( {eligible.map((m) => (
<button <button
key={m.name} key={m.name}
onClick={() => add(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" 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"> <span className="flex min-w-0 items-center gap-2">
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />} {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 className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
</span> </span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span> <span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</button> </button>
))} ))}
</div> </div>
</div> </div>
</div> </div>
)} )}
{dialogElement} {dialogElement}
</div> </div>
) )
} }
function Header() { function Header() {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="h-4.5 w-4.5 text-primary" /> <Zap className="h-4.5 w-4.5 text-primary" />
<div> <div>
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2> <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> <p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit sie antworten ohne Ladezeit.</p>
</div> </div>
</div> </div>
) )
} }
+123 -123
View File
@@ -1,123 +1,123 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Shield } from "lucide-react" import { Eye, EyeOff, Key, Shield } from "lucide-react"
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett // 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 // selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
// `showAlert` (gemeinsamer Dialog). // `showAlert` (gemeinsamer Dialog).
export function SettingsTab({ open, showAlert }: { export function SettingsTab({ open, showAlert }: {
open: boolean open: boolean
showAlert: (title: string, message: string) => void showAlert: (title: string, message: string) => void
}) { }) {
const [sudoPasswordInput, setSudoPasswordInput] = useState("") const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("") const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false) const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false) const [showHf, setShowHf] = useState(false)
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "") setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "") setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
} }
}, [open]) }, [open])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3> <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"> <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. Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p> </p>
</div> </div>
{/* Sudo Passwort */} {/* Sudo Passwort */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5"> <label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" /> <Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort Host Sudo-Passwort
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type={showSudo ? "text" : "password"} type={showSudo ? "text" : "password"}
value={sudoPasswordInput} value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)} onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort" aria-label="Host Sudo-Passwort"
name="sudo-password" name="sudo-password"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen" 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" 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 <button
type="button" type="button"
onClick={() => setShowSudo(!showSudo)} onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"} 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" 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" />} {showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button> </button>
</div> </div>
<p className="text-[9px] text-muted-foreground leading-normal"> <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. Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p> </p>
</div> </div>
{/* HuggingFace Token */} {/* HuggingFace Token */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5"> <label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" /> <Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token HuggingFace API Token
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type={showHf ? "text" : "password"} type={showHf ? "text" : "password"}
value={hfTokenInput} value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)} onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token" aria-label="HuggingFace API Token"
name="hf-token" name="hf-token"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
placeholder="hf_…" 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" 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 <button
type="button" type="button"
onClick={() => setShowHf(!showHf)} onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"} 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" 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" />} {showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button> </button>
</div> </div>
<p className="text-[9px] text-muted-foreground leading-normal"> <p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control. Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p> </p>
</div> </div>
{/* Buttons */} {/* Buttons */}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button
onClick={() => { onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput); localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput); localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert."); 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" 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 Speichern
</button> </button>
<button <button
onClick={() => { onClick={() => {
setSudoPasswordInput(""); setSudoPasswordInput("");
setHfTokenInput(""); setHfTokenInput("");
localStorage.removeItem("mc_sudo_password"); localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token"); localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht."); 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" 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 Zurücksetzen
</button> </button>
</div> </div>
</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 { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { Job, UpdateDetails } from "@/lib/api" import type { Job, UpdateDetails } from "@/lib/api"
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null } 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 // Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits). // Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: { export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
detail: UpdateDetailState detail: UpdateDetailState
maintenanceJob?: Job maintenanceJob?: Job
onClose: () => void onClose: () => void
onApply: () => void onApply: () => void
}) { }) {
const d = detail.data const d = detail.data
const meta = { const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" }, os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" }, engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" }, swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" }, hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind] }[detail.kind]
const Icon = meta.icon const Icon = meta.icon
const nothing = !d ? true const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0 : detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0 : detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build) : (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4"> <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="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"> <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 */} {/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5"> <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"> <div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} /> <Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3> <h3 className="text-sm font-semibold">{meta.title}</h3>
</div> </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"> <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" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin"> <div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? ( {detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground"> <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 <RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div> </div>
) : d?.error ? ( ) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div> <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) */} {/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
{d?.action_needed != null && ( {d?.action_needed != null && (
<div className={cn("flex items-start gap-2 rounded-lg border p-3", <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 ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
{d.action_needed {d.action_needed
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" /> ? <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" />} : <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
<div className="min-w-0"> <div className="min-w-0">
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}> <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)"} Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
</div> </div>
{d.action_needed && d.action_text && ( {d.action_needed && d.action_text && (
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div> <div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
)} )}
</div> </div>
</div> </div>
)} )}
{/* Zusammenfassung der Box (Breaking Changes zuerst) */} {/* Zusammenfassung der Box (Breaking Changes zuerst) */}
{d?.summary && ( {d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1"> <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> <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> <pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</div> </div>
)} )}
{detail.kind === "os" ? ( {detail.kind === "os" ? (
<> <>
{(d?.count ?? 0) === 0 ? ( {(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren System ist aktuell.</div> <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="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
<div className="space-y-1"> <div className="space-y-1">
{d!.packages!.map((p) => ( {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"> <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"> <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} <Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
</span> </span>
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0"> <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>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
</span> </span>
</div> </div>
))} ))}
</div> </div>
</> </>
)} )}
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */} {/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
{(d?.held_back?.length ?? 0) > 0 && ( {(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="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"> <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 <Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt kein Handeln nötig
</div> </div>
<p className="text-[10px] text-muted-foreground leading-normal"> <p className="text-[10px] text-muted-foreground leading-normal">
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout) 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 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. selbst das ist kein Fehler und nichts hängt fest.
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{d!.held_back!.map((p) => ( {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"> <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"> <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} <Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
</span> </span>
<span className="text-[9px] text-muted-foreground shrink-0"> <span className="text-[9px] text-muted-foreground shrink-0">
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"} {p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
</> </>
) : detail.kind === "engine" || detail.kind === "swap" ? ( ) : detail.kind === "engine" || detail.kind === "swap" ? (
<> <>
<div className="flex items-center gap-2 font-mono text-[11px]"> <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> <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" /> <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> <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> </div>
{(d?.name || d?.latest_tag) && ( {(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> <div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
)} )}
{d?.url && ( {d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline"> <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" /> Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a> </a>
)} )}
{d?.body && ( {d?.body && (
<details className="group"> <details className="group">
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary> <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> <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> </details>
)} )}
</> </>
) : ( ) : (
// hermes // hermes
(d?.commits?.length ?? 0) === 0 ? ( (d?.commits?.length ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div> <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="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"> <div className="space-y-1">
{d!.commits!.map((c) => ( {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"> <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" /> <GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] truncate">{c.subject}</div> <div className="text-[11px] truncate">{c.subject}</div>
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div> <div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</> </>
) )
)} )}
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */} {/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
{detail.kind !== "os" && !nothing && ( {detail.kind !== "os" && !nothing && (
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5"> <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" /> <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"> <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 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" : ""}. Stack per echter Anfrage läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
</p> </p>
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0"> <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"> <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 Schließen
</button> </button>
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob} <button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined} 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"> 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"} {maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
+58 -58
View File
@@ -1,58 +1,58 @@
import { RefreshCw, FileText } from "lucide-react" import { RefreshCw, FileText } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert). // 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. // systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
export const SERVICES = [ export const SERVICES = [
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" }, { id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" }, { id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" }, { id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "voice-service", label: "Voice (Sprache)", type: "user", reach: "voice" }, { id: "voice-service", label: "Voice (Sprache)", type: "user", reach: "voice" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" }, { id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" }, { id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
] ]
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: { 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 icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) { }) {
return ( return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2", <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")}> 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)} /> <Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div> <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 className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div> </div>
<button onClick={onAction} disabled={!available || busy} <button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0", 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")}> available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel} {busy ? "…" : actionLabel}
</button> </button>
</div> </div>
) )
} }
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: { export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) { }) {
return ( return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2"> <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", <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")} /> 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> <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"> <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")} /> <RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button> </button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors"> <button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
) )
} }
export function formatBytes(bytes?: number) { export function formatBytes(bytes?: number) {
if (bytes == null) return "" if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB` if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB` return `${(bytes / 1024 ** 2).toFixed(1)} MB`
} }
+480 -480
View File
@@ -1,480 +1,480 @@
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*). // Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> { export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...init?.headers, ...init?.headers,
} as Record<string, string> } as Record<string, string>
const sudoPassword = localStorage.getItem("mc_sudo_password") const sudoPassword = localStorage.getItem("mc_sudo_password")
const hfToken = localStorage.getItem("mc_hf_token") const hfToken = localStorage.getItem("mc_hf_token")
if (sudoPassword) { if (sudoPassword) {
headers["X-Sudo-Password"] = sudoPassword headers["X-Sudo-Password"] = sudoPassword
} }
let body = init?.body let body = init?.body
const method = init?.method?.toUpperCase() || "GET" const method = init?.method?.toUpperCase() || "GET"
if (method === "POST") { if (method === "POST") {
if (typeof body === "string") { if (typeof body === "string") {
try { try {
const data = JSON.parse(body) const data = JSON.parse(body)
let changed = false let changed = false
if (sudoPassword && !("sudo_password" in data)) { if (sudoPassword && !("sudo_password" in data)) {
data["sudo_password"] = sudoPassword data["sudo_password"] = sudoPassword
changed = true changed = true
} }
if (hfToken && !("hf_token" in data)) { if (hfToken && !("hf_token" in data)) {
data["hf_token"] = hfToken data["hf_token"] = hfToken
changed = true changed = true
} }
if (changed) { if (changed) {
body = JSON.stringify(data) body = JSON.stringify(data)
} }
} catch (e) { } catch (e) {
// ignore JSON parse errors // ignore JSON parse errors
} }
} else if (!body) { } else if (!body) {
const data: Record<string, any> = {} const data: Record<string, any> = {}
if (sudoPassword) data["sudo_password"] = sudoPassword if (sudoPassword) data["sudo_password"] = sudoPassword
if (hfToken) data["hf_token"] = hfToken if (hfToken) data["hf_token"] = hfToken
if (Object.keys(data).length > 0) { if (Object.keys(data).length > 0) {
body = JSON.stringify(data) body = JSON.stringify(data)
} }
} }
} }
const res = await fetch(path, { const res = await fetch(path, {
...init, ...init,
headers, headers,
body, body,
}) })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T> return res.json() as Promise<T>
} }
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz). // 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. // Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
export interface GroupSpec { export interface GroupSpec {
swap: boolean swap: boolean
persist: boolean persist: boolean
members: string[] members: string[]
} }
export interface GroupsResp { export interface GroupsResp {
groups: Record<string, GroupSpec> groups: Record<string, GroupSpec>
} }
export const getGroups = () => api<GroupsResp>("/api/groups") export const getGroups = () => api<GroupsResp>("/api/groups")
export const setGroup = (group: string, members: string[], swap = false, persist = true) => export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) }) api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
export interface Capabilities { export interface Capabilities {
moe: boolean moe: boolean
active_b: number | null active_b: number | null
tools: "yes" | "likely" | "no" tools: "yes" | "likely" | "no"
vision: boolean vision: boolean
coder: boolean coder: boolean
reasoning: boolean reasoning: boolean
embedding: boolean embedding: boolean
ctx: number | null ctx: number | null
params_b: number | null params_b: number | null
arch: string | null arch: string | null
} }
export interface Fit { export interface Fit {
level: "perfect" | "marginal" | "too_tight" level: "perfect" | "marginal" | "too_tight"
text: string text: string
req_gb: number req_gb: number
tps: number tps: number
} }
export interface RoleRecModel { export interface RoleRecModel {
name: string name: string
current_role: string | null current_role: string | null
params_b: number params_b: number
quant: string quant: string
fit: Fit fit: Fit
suitable: boolean suitable: boolean
incomplete: boolean incomplete: boolean
score: number score: number
reason: string reason: string
recommended: boolean recommended: boolean
} }
export interface RoleRecResp { export interface RoleRecResp {
role: string role: string
recommended: string | null recommended: string | null
models: RoleRecModel[] models: RoleRecModel[]
} }
export interface FitResp { export interface FitResp {
params_b: number params_b: number
fit: Fit fit: Fit
optimal_ctx: number optimal_ctx: number
assigned_ctx: number assigned_ctx: number
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string } budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
sys_ram_gb: number sys_ram_gb: number
} }
export interface ModelInfo { export interface ModelInfo {
name: string name: string
role: string | null role: string | null
aliases: string[] aliases: string[]
api_ids: string[] api_ids: string[]
ctx: number | null ctx: number | null
ttl: number | null ttl: number | null
cmd: string cmd: string
gguf_path: string gguf_path: string
filename: string filename: string
quant: string quant: string
size_bytes: number | null size_bytes: number | null
incomplete: boolean incomplete: boolean
prompt_cache: boolean prompt_cache: boolean
spec_draft_model: string | null spec_draft_model: string | null
spec_type: string | null spec_type: string | null
spec_active: boolean spec_active: boolean
parallel_slots: number parallel_slots: number
capabilities: Capabilities capabilities: Capabilities
} }
export interface VocabFingerprint { export interface VocabFingerprint {
model: string | null model: string | null
pre: string | null pre: string | null
n_vocab: number | null n_vocab: number | null
arch: string | null arch: string | null
} }
export interface DraftInfo { export interface DraftInfo {
path: string path: string
filename: string filename: string
size_bytes: number | null size_bytes: number | null
vocab: VocabFingerprint | null vocab: VocabFingerprint | null
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt) compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
} }
export interface DraftsResp { export interface DraftsResp {
target_path: string target_path: string
target_exists: boolean target_exists: boolean
target_vocab: VocabFingerprint | null target_vocab: VocabFingerprint | null
drafts: DraftInfo[] drafts: DraftInfo[]
} }
export interface DiscoverModel { export interface DiscoverModel {
name: string name: string
author: string author: string
repo: string repo: string
role: string role: string
params_b: number params_b: number
quant: string quant: string
tags: string[] tags: string[]
downloads: number downloads: number
fit: Fit fit: Fit
optimal_ctx: number optimal_ctx: number
caps: Capabilities caps: Capabilities
} }
export interface DiscoverCategory { export interface DiscoverCategory {
role: string role: string
title: string title: string
icon: string icon: string
models: DiscoverModel[] models: DiscoverModel[]
recommended: string | null recommended: string | null
} }
export interface DiscoverResp { export interface DiscoverResp {
updated: number updated: number
categories: DiscoverCategory[] categories: DiscoverCategory[]
sys_ram_gb: number sys_ram_gb: number
} }
export interface RoutingResp { export interface RoutingResp {
mode?: string mode?: string
endpoint?: string endpoint?: string
heavy_threshold_chars?: number heavy_threshold_chars?: number
routes: { name: string; target: string }[] routes: { name: string; target: string }[]
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[] lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
fallbacks: Record<string, string[]>[] fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[] context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean gateway_reachable: boolean
} }
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen // UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
// hinter den Lanes; hot-reload im Backend (kein Restart). // hinter den Lanes; hot-reload im Backend (kein Restart).
export interface RoutingPolicy { export interface RoutingPolicy {
fast: string fast: string
heavy: string heavy: string
coder: string coder: string
coder_lite: string coder_lite: string
heavy_chars: number heavy_chars: number
coding_escalate_chars: number coding_escalate_chars: number
fast_no_think: boolean fast_no_think: boolean
} }
export interface RoutingPolicyField { export interface RoutingPolicyField {
key: keyof RoutingPolicy key: keyof RoutingPolicy
label: string label: string
type: "str" | "int" | "bool" type: "str" | "int" | "bool"
min?: number min?: number
max?: number max?: number
} }
export interface RoutingPolicyMeta { export interface RoutingPolicyMeta {
policy: RoutingPolicy policy: RoutingPolicy
defaults: RoutingPolicy defaults: RoutingPolicy
fields: RoutingPolicyField[] fields: RoutingPolicyField[]
} }
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy") export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) => export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) }) api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
export interface GitInfo { export interface GitInfo {
hash: string hash: string
date: string date: string
subject: string subject: string
branch: string branch: string
dirty: boolean dirty: boolean
path: string path: string
} }
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`. // Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
export interface ComponentVersion { export interface ComponentVersion {
type: "git" | "binary" | "unknown" type: "git" | "binary" | "unknown"
hash?: string hash?: string
date?: string date?: string
subject?: string subject?: string
branch?: string branch?: string
dirty?: boolean dirty?: boolean
path?: string path?: string
version_text?: string version_text?: string
} }
export interface Versions { export interface Versions {
mc2: GitInfo | null mc2: GitInfo | null
engine: ComponentVersion engine: ComponentVersion
hermes_ui: GitInfo | null hermes_ui: GitInfo | null
hermes_agent: GitInfo | null hermes_agent: GitInfo | null
} }
export interface SystemStatus { export interface SystemStatus {
cpu: { percent: number; cores: number | null } cpu: { percent: number; cores: number | null }
ram: { total: number; used: number; percent: number } ram: { total: number; used: number; percent: number }
gpu: { gpu: {
busy_percent: number | null busy_percent: number | null
vram_used: number | null vram_used: number | null
vram_total: number | null vram_total: number | null
gtt_used?: number | null gtt_used?: number | null
gtt_total?: number | null gtt_total?: number | null
} | null } | null
temp: { cpu?: number; gpu?: number } | null temp: { cpu?: number; gpu?: number } | null
disk: { total: number; used: number; percent: number } | null disk: { total: number; used: number; percent: number } | null
versions?: Versions versions?: Versions
} }
export interface ServicesResp { export interface ServicesResp {
services: { name: string; unit: string; url: string; ok: boolean }[] services: { name: string; unit: string; url: string; ok: boolean }[]
links: { engine_ui: string; gateway: string; hermes_terminal: string } links: { engine_ui: string; gateway: string; hermes_terminal: string }
} }
export interface ComponentUpdate { export interface ComponentUpdate {
key: string // z.B. "hermes_agent" key: string // z.B. "hermes_agent"
name: string name: string
current: string | null current: string | null
latest: string | null latest: string | null
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar) update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
reachable: boolean | null reachable: boolean | null
} }
export interface UpdatesResp { export interface UpdatesResp {
os: number os: number
engine: number engine: number
swap: number swap: number
models: number models: number
model_list: { role: string; title: string; repo: string }[] model_list: { role: string; title: string; repo: string }[]
last_check?: number | null last_check?: number | null
components?: ComponentUpdate[] components?: ComponentUpdate[]
} }
export interface UpdateDetails { export interface UpdateDetails {
kind: "os" | "engine" | "swap" | "hermes" kind: "os" | "engine" | "swap" | "hermes"
error?: string error?: string
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst) // LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
summary?: string summary?: string
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary) // Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
action_needed?: boolean | null action_needed?: boolean | null
action_text?: string action_text?: string
// os // os
count?: number count?: number
packages?: { name: string; current: string; candidate: string }[] packages?: { name: string; current: string; candidate: string }[]
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt" // os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
held_back?: { name: string; reason: "phasing" | "kept_back" }[] held_back?: { name: string; reason: "phasing" | "kept_back" }[]
// engine // engine
installed_build?: number | null installed_build?: number | null
latest_build?: number | null latest_build?: number | null
latest_tag?: string | null latest_tag?: string | null
name?: string | null name?: string | null
url?: string | null url?: string | null
body?: string | null body?: string | null
// hermes // hermes
branch?: string | null branch?: string | null
behind?: number behind?: number
commits?: { hash: string; subject: string; when: string }[] commits?: { hash: string; subject: string; when: string }[]
} }
export interface ConnectTool { export interface ConnectTool {
label: string label: string
lang: string lang: string
snippet: string snippet: string
note: string note: string
} }
export interface ConnectResp { export interface ConnectResp {
host: string host: string
gateway_url: string gateway_url: string
mc_url: string mc_url: string
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway) tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
memory: ConnectTool // Leitung 2 — Gedächtnis (separater MCP-Server) memory: ConnectTool // Leitung 2 — Gedächtnis (separater MCP-Server)
} }
export interface ConnectLine { export interface ConnectLine {
ok: boolean ok: boolean
detail: string detail: string
} }
export interface ConnectHealth { export interface ConnectHealth {
gateway: ConnectLine gateway: ConnectLine
memory: ConnectLine memory: ConnectLine
} }
export interface Memory { export interface Memory {
id: string id: string
content: string content: string
category: string category: string
source: string source: string
created_at: string created_at: string
updated_at: string updated_at: string
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
} }
export interface MemoryGraphNode { export interface MemoryGraphNode {
id: string id: string
content: string content: string
category: string category: string
source: string source: string
} }
export interface MemoryGraph { export interface MemoryGraph {
nodes: MemoryGraphNode[] nodes: MemoryGraphNode[]
edges: { source: string; target: string; weight: number }[] edges: { source: string; target: string; weight: number }[]
} }
export interface DedupeResult { export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[] groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number duplicate_count: number
removed: number removed: number
applied: boolean applied: boolean
} }
export interface AgentStatus { export interface AgentStatus {
gateway_url: string gateway_url: string
terminal_url: string terminal_url: string
box_console_url?: string box_console_url?: string
hermes_ui_url?: string hermes_ui_url?: string
gateway_reachable: boolean gateway_reachable: boolean
terminal_reachable: boolean terminal_reachable: boolean
box_console_reachable?: boolean box_console_reachable?: boolean
hermes_ui_reachable?: boolean hermes_ui_reachable?: boolean
home_exists: boolean home_exists: boolean
brain_model?: string brain_model?: string
has_config: boolean has_config: boolean
has_skills: boolean has_skills: boolean
has_memories: boolean has_memories: boolean
telegram_enabled?: boolean telegram_enabled?: boolean
mcp_server_count?: number mcp_server_count?: number
pc_executor_reachable?: boolean pc_executor_reachable?: boolean
} }
export interface Job { export interface Job {
id: string id: string
label: string label: string
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel) group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
state: "queued" | "running" | "done" | "failed" | "canceled" state: "queued" | "running" | "done" | "failed" | "canceled"
progress?: number progress?: number
total_bytes?: number total_bytes?: number
done_bytes?: number done_bytes?: number
rate_bps?: number rate_bps?: number
eta_s?: number eta_s?: number
} }
export interface Health { export interface Health {
status: string status: string
version: string version: string
engine_reachable: boolean engine_reachable: boolean
gateway_reachable: boolean gateway_reachable: boolean
brain?: { role: string; model: string | null; ready: boolean } brain?: { role: string; model: string | null; ready: boolean }
} }
export interface TokenStats { export interface TokenStats {
prompt_tokens: number prompt_tokens: number
completion_tokens: number completion_tokens: number
total_tokens: number total_tokens: number
saved_usd: number saved_usd: number
saved_eur: number saved_eur: number
pricing?: Record<string, { in: number; out: number }> pricing?: Record<string, { in: number; out: number }>
} }
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt): // 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). // stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren).
export interface VoiceTurn { export interface VoiceTurn {
id: string id: string
ts: number // Epoch-Sekunden ts: number // Epoch-Sekunden
session: string session: string
kind: string kind: string
had_images: boolean had_images: boolean
stt_ms: number | null stt_ms: number | null
vision_ms: number | null vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT) 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 gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
total_ms: number total_ms: number
error: string | null error: string | null
} }
export interface VoiceTraceResp { export interface VoiceTraceResp {
turns: VoiceTurn[] turns: VoiceTurn[]
} }
export interface ModelsResp { export interface ModelsResp {
models: ModelInfo[] models: ModelInfo[]
running?: string[] running?: string[]
} }
export interface HermesBrainModel { export interface HermesBrainModel {
name: string name: string
filename?: string filename?: string
params_b: number | null params_b: number | null
quant?: string quant?: string
size_bytes?: number | null size_bytes?: number | null
version?: number | null version?: number | null
gguf_path?: string gguf_path?: string
incomplete?: boolean incomplete?: boolean
} }
export interface HermesBrainCandidate { export interface HermesBrainCandidate {
repo: string repo: string
name: string name: string
version: number version: number
params_b: number params_b: number
downloads: number downloads: number
fit: Fit fit: Fit
} }
export interface HermesBrainBudget { export interface HermesBrainBudget {
gtt_gb: number gtt_gb: number
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
warm_projected_gb: number // alle persist-Modelle warm (Info) warm_projected_gb: number // alle persist-Modelle warm (Info)
free_after_gb: number // frei nach Brain + größtem on-demand free_after_gb: number // frei nach Brain + größtem on-demand
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder) largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget? fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
} }
export interface HermesBrainResp { export interface HermesBrainResp {
current: HermesBrainModel | null current: HermesBrainModel | null
recommended: HermesBrainCandidate | null recommended: HermesBrainCandidate | null
update_available: boolean update_available: boolean
budget?: HermesBrainBudget | null budget?: HermesBrainBudget | null
} }
+32 -32
View File
@@ -1,32 +1,32 @@
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert). // Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */ /** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
export function gb(b: number): string { export function gb(b: number): string {
return (b / 1024 ** 3).toFixed(1) return (b / 1024 ** 3).toFixed(1)
} }
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */ /** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
export function fmtBytes(b?: number): string { export function fmtBytes(b?: number): string {
if (!b) return "" if (!b) return ""
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB` if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
return `${(b / 1024 ** 2).toFixed(0)} MB` return `${(b / 1024 ** 2).toFixed(0)} MB`
} }
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */ /** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
export function fmtSize(b?: number | null): string { export function fmtSize(b?: number | null): string {
if (!b) return "—" if (!b) return "—"
const g = b / 1024 ** 3 const g = b / 1024 ** 3
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB` return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
} }
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */ /** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
export function fmtEta(s?: number): string { export function fmtEta(s?: number): string {
if (!s) return "" if (!s) return ""
const m = Math.floor(s / 60) const m = Math.floor(s / 60)
return m > 0 ? `${m} min` : `${s} s` return m > 0 ? `${m} min` : `${s} s`
} }
/** Kontextlänge → "32k"; "—" bei null. */ /** Kontextlänge → "32k"; "—" bei null. */
export function fmtCtx(c: number | null): string { export function fmtCtx(c: number | null): string {
return c ? `${Math.round(c / 1024)}k` : "—" 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, // Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling. // Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden. // Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import { import {
api, api,
type AgentStatus, type AgentStatus,
type ConnectResp, type ConnectResp,
type ConnectHealth, type ConnectHealth,
type DiscoverResp, type DiscoverResp,
type DraftsResp, type DraftsResp,
type GroupsResp, type GroupsResp,
type HermesBrainResp, type HermesBrainResp,
type Health, type Health,
type Job, type Job,
type Memory, type Memory,
type MemoryGraph, type MemoryGraph,
type ModelsResp, type ModelsResp,
type RoutingResp, type RoutingResp,
type RoutingPolicyMeta, type RoutingPolicyMeta,
type ServicesResp, type ServicesResp,
type SystemStatus, type SystemStatus,
type TokenStats, type TokenStats,
type UpdatesResp, type UpdatesResp,
type VoiceTraceResp, type VoiceTraceResp,
} from "./api" } from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate). // Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = { export const qk = {
health: ["health"] as const, health: ["health"] as const,
systemStatus: ["system-status"] as const, systemStatus: ["system-status"] as const,
services: ["services"] as const, services: ["services"] as const,
models: ["models"] as const, models: ["models"] as const,
groups: ["groups"] as const, groups: ["groups"] as const,
routing: ["routing"] as const, routing: ["routing"] as const,
routingPolicy: ["routing-policy"] as const, routingPolicy: ["routing-policy"] as const,
jobs: ["jobs"] as const, jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const, tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const, agentStatus: ["agent-status"] as const,
hermesBrain: ["hermes-brain"] as const, hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const, updates: ["updates"] as const,
discover: ["discover"] as const, discover: ["discover"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const, drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const, connect: (params?: string) => ["connect", params ?? ""] as const,
connectHealth: ["connect-health"] as const, connectHealth: ["connect-health"] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const, memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const, memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const, voiceTrace: ["voice-trace"] as const,
} }
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown). // Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) => export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
useQuery({ useQuery({
queryKey: qk.voiceTrace, queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`), queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
refetchInterval, refetchInterval,
select: (d) => d.turns ?? [], select: (d) => d.turns ?? [],
}) })
export const useMemoryGraph = (enabled = true) => export const useMemoryGraph = (enabled = true) =>
useQuery({ useQuery({
queryKey: qk.memoryGraph, queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/memory/graph"), queryFn: () => api<MemoryGraph>("/api/memory/graph"),
enabled, enabled,
}) })
export const useHealth = () => export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 }) useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
export const useSystemStatus = (refetchInterval = 5_000) => export const useSystemStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval }) useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
export const useServices = (refetchInterval = 3_000) => export const useServices = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval }) useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
export const useModels = (refetchInterval = 4_000) => export const useModels = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval }) useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useGroups = (refetchInterval = 8_000) => export const useGroups = (refetchInterval = 8_000) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval }) useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval = 4_000) => export const useRouting = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval }) useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useRoutingPolicy = () => export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") }) useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
export const useJobs = (refetchInterval = 2_000) => export const useJobs = (refetchInterval = 2_000) =>
useQuery({ useQuery({
queryKey: qk.jobs, queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"), queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
refetchInterval, refetchInterval,
select: (d) => d.jobs ?? [], select: (d) => d.jobs ?? [],
}) })
export const useTokenStats = (refetchInterval = 3_000) => export const useTokenStats = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval }) useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
export const useAgentStatus = (refetchInterval = 5_000) => export const useAgentStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval }) useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage). // Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval = 60_000) => export const useHermesBrain = (refetchInterval = 60_000) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval }) useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
export const useUpdates = (refetchInterval?: number) => export const useUpdates = (refetchInterval?: number) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval }) useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () => export const useDiscover = () =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") }) useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad). // Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
export const useDrafts = (target?: string) => export const useDrafts = (target?: string) =>
useQuery({ useQuery({
queryKey: qk.drafts(target), queryKey: qk.drafts(target),
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`), queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
enabled: !!target, enabled: !!target,
}) })
export const useConnect = (params?: string) => export const useConnect = (params?: string) =>
useQuery({ useQuery({
queryKey: qk.connect(params), queryKey: qk.connect(params),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"), queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
}) })
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert. // Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useConnectHealth = () => export const useConnectHealth = () =>
useQuery({ useQuery({
queryKey: qk.connectHealth, queryKey: qk.connectHealth,
queryFn: () => api<ConnectHealth>("/api/connect/health"), queryFn: () => api<ConnectHealth>("/api/connect/health"),
refetchInterval: 15000, refetchInterval: 15000,
}) })
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) => export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
useQuery({ useQuery({
queryKey: qk.memory(opts?.q, opts?.category), queryKey: qk.memory(opts?.q, opts?.category),
queryFn: () => { queryFn: () => {
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts?.q) p.set("q", opts.q) if (opts?.q) p.set("q", opts.q)
if (opts?.category) p.set("category", opts.category) if (opts?.category) p.set("category", opts.category)
return api<Memory[]>(`/api/memory?${p}`) return api<Memory[]>(`/api/memory?${p}`)
}, },
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d), select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
}) })
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */ /** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
export function invalidate(qc: QueryClient, ...keys: readonly unknown[][]) { export function invalidate(qc: QueryClient, ...keys: readonly unknown[][]) {
for (const key of keys) qc.invalidateQueries({ queryKey: key }) for (const key of keys) qc.invalidateQueries({ queryKey: key })
} }
export { useQueryClient } export { useQueryClient }
+59 -59
View File
@@ -1,59 +1,59 @@
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View // Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State. // duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
// //
// Nutzung: // Nutzung:
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog() // const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
// ... // ...
// showConfirm("Titel", "Wirklich?", () => doIt()) // showConfirm("Titel", "Wirklich?", () => doIt())
// return (<>{dialogElement}...</>) // return (<>{dialogElement}...</>)
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
import { CustomDialog } from "@/components/CustomDialog" import { CustomDialog } from "@/components/CustomDialog"
interface DialogState { interface DialogState {
type: "alert" | "confirm" | "prompt" type: "alert" | "confirm" | "prompt"
title: string title: string
message: string message: string
defaultValue?: string defaultValue?: string
autoValue?: string autoValue?: string
autoLabel?: string autoLabel?: string
onConfirm: (val?: string) => void onConfirm: (val?: string) => void
onCancel?: () => void onCancel?: () => void
} }
export function useDialog() { export function useDialog() {
const [dialog, setDialog] = useState<DialogState | null>(null) const [dialog, setDialog] = useState<DialogState | null>(null)
const close = useCallback(() => setDialog(null), []) const close = useCallback(() => setDialog(null), [])
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => { const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
setDialog({ setDialog({
type: "alert", title, message, type: "alert", title, message,
onConfirm: () => { setDialog(null); onConfirm?.() }, onConfirm: () => { setDialog(null); onConfirm?.() },
}) })
}, []) }, [])
const showConfirm = useCallback( const showConfirm = useCallback(
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => { (title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setDialog({ setDialog({
type: "confirm", title, message, type: "confirm", title, message,
onConfirm: () => { setDialog(null); onConfirm() }, onConfirm: () => { setDialog(null); onConfirm() },
onCancel: () => { setDialog(null); onCancel?.() }, onCancel: () => { setDialog(null); onCancel?.() },
}) })
}, []) }, [])
const showPrompt = useCallback( const showPrompt = useCallback(
(title: string, message: string, defaultValue: string, (title: string, message: string, defaultValue: string,
onConfirm: (val?: string) => void, onCancel?: () => void, onConfirm: (val?: string) => void, onCancel?: () => void,
opts?: { autoValue?: string; autoLabel?: string }) => { opts?: { autoValue?: string; autoLabel?: string }) => {
setDialog({ setDialog({
type: "prompt", title, message, defaultValue, type: "prompt", title, message, defaultValue,
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel, autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
onConfirm: (val) => { setDialog(null); onConfirm(val) }, onConfirm: (val) => { setDialog(null); onConfirm(val) },
onCancel: () => { setDialog(null); onCancel?.() }, onCancel: () => { setDialog(null); onCancel?.() },
}) })
}, []) }, [])
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
return { showAlert, showConfirm, showPrompt, close, dialogElement } return { showAlert, showConfirm, showPrompt, close, dialogElement }
} }
+27 -27
View File
@@ -1,27 +1,27 @@
import React from "react" import React from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App" import App from "./App"
import { AppErrorBoundary } from "./components/AppErrorBoundary" import { AppErrorBoundary } from "./components/AppErrorBoundary"
import "./index.css" import "./index.css"
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook. // Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: 1, retry: 1,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: 5_000, staleTime: 5_000,
}, },
}, },
}) })
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<AppErrorBoundary> <AppErrorBoundary>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider> </QueryClientProvider>
</AppErrorBoundary> </AppErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
) )
+90 -90
View File
@@ -1,90 +1,90 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react" import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react"
import { ConnectView } from "@/views/ConnectView" import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView" import { MemoryView } from "@/views/MemoryView"
import { SystemDrawer } from "@/components/SystemDrawer" import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth } from "@/lib/queries" import { useHealth } from "@/lib/queries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Mc3Start } from "./Mc3Start" import { Mc3Start } from "./Mc3Start"
import { Mc3Maschinenraum } from "./Mc3Maschinenraum" import { Mc3Maschinenraum } from "./Mc3Maschinenraum"
// MC3-Prototyp-Schale. Läuft nicht-destruktiv unter #mc3, das produktive MC2 bleibt // 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. // unangetastet. Vier Türen; Innereien sind bewusst die bestehenden, bewährten Views.
type Door = "start" | "vibe" | "konstitution" | "maschinenraum" type Door = "start" | "vibe" | "konstitution" | "maschinenraum"
const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [ const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [
{ id: "start", label: "Start", icon: Activity }, { id: "start", label: "Start", icon: Activity },
{ id: "vibe", label: "Vibe-Coding", icon: Code }, { id: "vibe", label: "Vibe-Coding", icon: Code },
{ id: "konstitution", label: "Konstitution", icon: Bookmark }, { id: "konstitution", label: "Konstitution", icon: Bookmark },
{ id: "maschinenraum", label: "Maschinenraum", icon: Wrench }, { id: "maschinenraum", label: "Maschinenraum", icon: Wrench },
] ]
export function Mc3App() { export function Mc3App() {
const [door, setDoor] = useState<Door>("start") const [door, setDoor] = useState<Door>("start")
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance") const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: health } = useHealth() const { data: health } = useHealth()
useEffect(() => { useEffect(() => {
const onOpen = (e: Event) => { const onOpen = (e: Event) => {
setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance") setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance")
setDrawerOpen(true) setDrawerOpen(true)
} }
window.addEventListener("open-system-drawer", onOpen) window.addEventListener("open-system-drawer", onOpen)
return () => window.removeEventListener("open-system-drawer", onOpen) return () => window.removeEventListener("open-system-drawer", onOpen)
}, []) }, [])
const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true) const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true)
return ( return (
<div className="flex h-full relative"> <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="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 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> </div>
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} /> <SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar — 4 Türen */} {/* Sidebar — 4 Türen */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md"> <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="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="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20" />
<div className="leading-tight"> <div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div> <div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-[10px] text-muted-foreground">3 · Prototyp</div> <div className="text-[10px] text-muted-foreground">3 · Prototyp</div>
</div> </div>
</div> </div>
<nav className="flex-1 space-y-1 px-3 py-4"> <nav className="flex-1 space-y-1 px-3 py-4">
{DOORS.map((d) => ( {DOORS.map((d) => (
<button key={d.id} onClick={() => setDoor(d.id)} <button key={d.id} onClick={() => setDoor(d.id)}
className={cn( className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-all cursor-pointer", "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", 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} <d.icon className="h-4.5 w-4.5 shrink-0" /> {d.label}
</button> </button>
))} ))}
</nav> </nav>
<div className="px-3 py-3 border-t border-border/40 space-y-2"> <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"> <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")} /> <span className={cn("h-2 w-2 rounded-full", boxOk ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{boxOk ? "Box läuft" : "prüfen"} {boxOk ? "Box läuft" : "prüfen"}
</div> </div>
<a href="#dashboard" <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"> 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 <ArrowLeft className="h-3.5 w-3.5" /> zurück zu MC2
</a> </a>
</div> </div>
</aside> </aside>
{/* Main */} {/* Main */}
<main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin"> <main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin">
{door === "start" && <Mc3Start />} {door === "start" && <Mc3Start />}
{door === "vibe" && <ConnectView />} {door === "vibe" && <ConnectView />}
{door === "konstitution" && <MemoryView />} {door === "konstitution" && <MemoryView />}
{door === "maschinenraum" && <Mc3Maschinenraum />} {door === "maschinenraum" && <Mc3Maschinenraum />}
</main> </main>
</div> </div>
) )
} }
+60 -60
View File
@@ -1,60 +1,60 @@
import { useState } from "react" import { useState } from "react"
import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react" import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react"
import { Cockpit } from "@/views/models/Cockpit" import { Cockpit } from "@/views/models/Cockpit"
import { AgentView } from "@/views/AgentView" import { AgentView } from "@/views/AgentView"
import { ExpertToggle } from "@/components/ExpertToggle" import { ExpertToggle } from "@/components/ExpertToggle"
// Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes // Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes
// zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder. // zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder.
function SectionCard({ icon: Icon, title, sub, onClick }: { function SectionCard({ icon: Icon, title, sub, onClick }: {
icon: any; title: string; sub: string; onClick: () => void icon: any; title: string; sub: string; onClick: () => void
}) { }) {
return ( return (
<button onClick={onClick} <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"> 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" /> <Icon className="h-6 w-6 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-foreground">{title}</div> <div className="text-sm text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{sub}</div> <div className="text-xs text-muted-foreground">{sub}</div>
</div> </div>
<ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" /> <ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" />
</button> </button>
) )
} }
export function Mc3Maschinenraum() { export function Mc3Maschinenraum() {
const [section, setSection] = useState<null | "modelle" | "hermes">(null) const [section, setSection] = useState<null | "modelle" | "hermes">(null)
if (section) { if (section) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<button onClick={() => setSection(null)} <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"> 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 <ArrowLeft className="h-4 w-4" /> Maschinenraum
</button> </button>
{section === "modelle" ? <Cockpit /> : <AgentView />} {section === "modelle" ? <Cockpit /> : <AgentView />}
</div> </div>
) )
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <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"> <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 Maschinenraum
</h1> </h1>
<p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p> <p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p>
</div> </div>
<ExpertToggle /> <ExpertToggle />
</div> </div>
<SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek" <SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek"
onClick={() => setSection("modelle")} /> onClick={() => setSection("modelle")} />
<SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten" <SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten"
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} /> onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} />
<SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung" <SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung"
onClick={() => setSection("hermes")} /> onClick={() => setSection("hermes")} />
</div> </div>
) )
} }
+93 -93
View File
@@ -1,93 +1,93 @@
import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react" import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard" import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard" import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard" import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { useUpdates, useModels } from "@/lib/queries" import { useUpdates, useModels } from "@/lib/queries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates // MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates
// (klickbar) + was gerade läuft + die geschätzte Telemetrie (System/Token/Sprach-Latenz). // (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. // Bewusst KEINE Wärme/Platte/Speicher-Kacheln — die stehen schon im System-Status.
export function Mc3Start() { export function Mc3Start() {
const { data: updates } = useUpdates(30_000) const { data: updates } = useUpdates(30_000)
const { data: modelsResp } = useModels() const { data: modelsResp } = useModels()
const upCount = (updates?.os || 0) + (updates?.engine || 0) + (updates?.swap || 0) + (updates?.models || 0) 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 openUpdates = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
const models = modelsResp?.models ?? [] const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? [] const running = modelsResp?.running ?? []
const brain = models.find((m) => m.role === "hermes") const brain = models.find((m) => m.role === "hermes")
const coder = models.find((m) => m.role === "coder-lite") || models.find((m) => m.role === "coder") 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 isWarm = (name?: string) => (name ? running.includes(name) : false)
const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—" const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—"
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <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"> <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 Basisstation
</h1> </h1>
<p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p> <p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p>
</div> </div>
{/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */} {/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */}
<LucyHealthCard frame="box" /> <LucyHealthCard frame="box" />
{/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */} {/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */}
<button <button
onClick={openUpdates} onClick={openUpdates}
className={cn( className={cn(
"w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer", "w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer",
upCount > 0 upCount > 0
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10" ? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10"
: "border-border/60 bg-card/45 hover:border-primary/40", : "border-border/60 bg-card/45 hover:border-primary/40",
)} )}
> >
{upCount > 0 {upCount > 0
? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" /> ? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" />
: <Check className="h-6 w-6 shrink-0 text-emerald-400" />} : <Check className="h-6 w-6 shrink-0 text-emerald-400" />}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={cn("text-sm font-semibold", upCount > 0 ? "text-amber-400" : "text-foreground")}> <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"} {upCount > 0 ? `${upCount} Update${upCount === 1 ? "" : "s"} verfügbar` : "Alles aktuell"}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."} {upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."}
</div> </div>
</div> </div>
{upCount > 0 && ( {upCount > 0 && (
<span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400"> <span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400">
Ansehen <ChevronRight className="h-4 w-4" /> Ansehen <ChevronRight className="h-4 w-4" />
</span> </span>
)} )}
</button> </button>
{/* Was gerade läuft — beide Lanes */} {/* Was gerade läuft — beide Lanes */}
<div> <div>
<div className="text-xs text-muted-foreground mb-2">Was gerade läuft</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"> <div className="rounded-2xl border border-border/60 overflow-hidden">
{[ {[
{ icon: Cpu, label: "Lucys Hirn", model: brain?.name, warm: isWarm(brain?.name) }, { 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) }, { icon: Code, label: "Coder (Vibe-Coding)", model: coder?.name, warm: isWarm(coder?.name) },
].map((r) => ( ].map((r) => (
<div key={r.label} className="flex items-center gap-3 px-4 py-3 border-b border-border/40 last:border-0"> <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")} /> <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" /> <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-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="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")}> <span className={cn("text-xs shrink-0", r.warm ? "text-emerald-400" : "text-muted-foreground")}>
{r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"} {r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */} {/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard /> <SystemStatusCard />
<TokenPerformanceCard /> <TokenPerformanceCard />
</div> </div>
</div> </div>
) )
} }
+34 -34
View File
@@ -1,34 +1,34 @@
import { import {
LayoutDashboard, LayoutDashboard,
Boxes, Boxes,
Brain, Brain,
Plug, Plug,
Bot, Bot,
AppWindow, AppWindow,
SquareTerminal, SquareTerminal,
HelpCircle, HelpCircle,
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide" export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide"
export interface NavItem { export interface NavItem {
id: ViewId id: ViewId
label: string label: string
hint: string hint: string
icon: LucideIcon icon: LucideIcon
} }
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette. // Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [ export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard }, { id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes }, { id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain }, { id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug }, { id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot }, { 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: "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: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle }, { id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
] ]
+46 -46
View File
@@ -1,46 +1,46 @@
import { useState } from "react" import { useState } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar" import { JobsBar } from "@/components/models/JobsBar"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench" import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover" import { Discover } from "./models/Discover"
export function ModelsView() { export function ModelsView() {
const [tab, setTab] = useState<"cockpit" | "discover">("cockpit") const [tab, setTab] = useState<"cockpit" | "discover">("cockpit")
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4"> <div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div> <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"> <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 Modell-Manager
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an. Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an.
</p> </p>
</div> </div>
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start"> <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) => ( {(["cockpit", "discover"] as const).map((t) => (
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={cn( className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer", "rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t tab === t
? "bg-primary text-primary-foreground shadow-md shadow-primary/10" ? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{t === "cockpit" ? "Werkbank" : "Modelle finden"} {t === "cockpit" ? "Werkbank" : "Modelle finden"}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<JobsBar /> <JobsBar />
<div className="transition-all duration-300"> <div className="transition-all duration-300">
{tab === "cockpit" ? <ModelsWorkbench /> : <Discover />} {tab === "cockpit" ? <ModelsWorkbench /> : <Discover />}
</div> </div>
</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 { useState } from "react"
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp } from "lucide-react" import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useModels, useUpdates, useDiscover } from "@/lib/queries" import { useModels, useUpdates, useDiscover } from "@/lib/queries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { fmtBytes } from "@/lib/format" import { fmtBytes } from "@/lib/format"
import { FitBadge } from "@/components/models/ModelBadges" import { FitBadge } from "@/components/models/ModelBadges"
import { ModelBrowse } from "./ModelBrowse" import { ModelBrowse } from "./ModelBrowse"
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES). // Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = { const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
fast: { fast: {
title: "Schnelles Alltags-Hirn", title: "Schnelles Alltags-Hirn",
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.", desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
icon: Zap icon: Zap
}, },
heavy: { heavy: {
title: "Schweres Reasoning", title: "Schweres Reasoning",
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.", desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
icon: Brain icon: Brain
}, },
coder: { coder: {
title: "Coden & Entwicklung", title: "Coden & Entwicklung",
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.", desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
icon: Code icon: Code
}, },
vision: { vision: {
title: "Bilder & Vision", title: "Bilder & Vision",
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.", desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
icon: Eye icon: Eye
}, },
hermes: { hermes: {
title: "Lucys Hirn (Agent)", title: "Lucys Hirn (Agent)",
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.", desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
icon: BrainCircuit icon: BrainCircuit
}, },
scout: { scout: {
title: "Multimodal-Allrounder", title: "Multimodal-Allrounder",
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.", desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
icon: Compass icon: Compass
} }
} }
export function Discover() { export function Discover() {
const { data, isLoading: loading, error: loadErr } = useDiscover() const { data, isLoading: loading, error: loadErr } = useDiscover()
const { data: modelsResp } = useModels() const { data: modelsResp } = useModels()
const { data: updates } = useUpdates() const { data: updates } = useUpdates()
const models = modelsResp?.models ?? [] const models = modelsResp?.models ?? []
const error = loadErr ? String(loadErr) : "" const error = loadErr ? String(loadErr) : ""
const [installing, setInstalling] = useState<Record<string, string>>({}) const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({}) const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [mode, setMode] = useState<"recommended" | "browse">("recommended") const [mode, setMode] = useState<"recommended" | "browse">("recommended")
async function install(repo: string, role: string, quant: string, toolCapable: boolean) { async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "Starte..." })) setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
try { try {
await api("/api/models/install", { await api("/api/models/install", {
method: "POST", method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }), body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
}) })
setInstalling((s) => ({ ...s, [repo]: "Download läuft" })) setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
} catch (e) { } catch (e) {
setInstalling((s) => ({ ...s, [repo]: "Fehler" })) setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
} }
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */} {/* 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"> <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]) => ( {([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
<button <button
key={m} key={m}
onClick={() => setMode(m)} onClick={() => setMode(m)}
className={cn( className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer", "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", mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
)} )}
> >
{label} {label}
</button> </button>
))} ))}
</div> </div>
{mode === "browse" ? ( {mode === "browse" ? (
<ModelBrowse /> <ModelBrowse />
) : loading ? ( ) : loading ? (
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div> <div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
) : (error || !data) ? ( ) : (error || !data) ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400"> <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}). Empfehlungsdienst temporär nicht erreichbar ({error}).
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{/* Informational Header */} {/* 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 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> <div>
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM. Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" /> <Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span> <span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
</div> </div>
</div> </div>
{/* Sockets/Slots Grid */} {/* Sockets/Slots Grid */}
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{data.categories.map((cat) => { {data.categories.map((cat) => {
const meta = ROLE_METADATA[cat.role] || { const meta = ROLE_METADATA[cat.role] || {
title: cat.title || cat.role, title: cat.title || cat.role,
desc: "Spezifisches Modell für diese Systemrolle.", desc: "Spezifisches Modell für diese Systemrolle.",
icon: Layers icon: Layers
} }
const IconComponent = meta.icon const IconComponent = meta.icon
// Check if a model is installed for this role — per Rolle ODER Alias: // 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 // 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"). // role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
const installedModel = models.find( const installedModel = models.find(
(m) => m.role === cat.role || (m.aliases || []).includes(cat.role), (m) => m.role === cat.role || (m.aliases || []).includes(cat.role),
) )
// Check if an upgrade is available for this role // Check if an upgrade is available for this role
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role) const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
// Get the primary recommended model // Get the primary recommended model
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0] const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
if (!recommendedModel) return null if (!recommendedModel) return null
const isInstallingRecommended = installing[recommendedModel.repo] const isInstallingRecommended = installing[recommendedModel.repo]
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended) const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
const isExpanded = !!expandedAlternatives[cat.role] const isExpanded = !!expandedAlternatives[cat.role]
return ( return (
<div <div
key={cat.role} key={cat.role}
className={cn( 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", "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" installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
)} )}
> >
<div className="space-y-4"> <div className="space-y-4">
{/* Socket Header */} {/* Socket Header */}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center 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"> <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" /> <IconComponent className="h-5.5 w-5.5" />
</div> </div>
<div> <div>
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3> <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"> <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} Rolle: {cat.role}
</span> </span>
</div> </div>
</div> </div>
{/* Status Badges */} {/* Status Badges */}
{installedModel ? ( {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="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" /> <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aktiviert Aktiviert
</span> </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"> <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 Frei
</span> </span>
)} )}
</div> </div>
{/* Role Description */} {/* Role Description */}
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
{meta.desc} {meta.desc}
</p> </p>
{/* Current vs Recommended model card */} {/* Current vs Recommended model card */}
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5"> <div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
{installedModel ? ( {installedModel ? (
<div className="space-y-1.5"> <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-[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}> <div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
{installedModel.name.split("/").pop()} {installedModel.name.split("/").pop()}
</div> </div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2"> <div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span> <span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
<span></span> <span></span>
<span>Quant: {installedModel.quant || "GGUF"}</span> <span>Quant: {installedModel.quant || "GGUF"}</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div> <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}> <div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
{recommendedModel.name} {recommendedModel.name}
</div> </div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap"> <div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
<span>Ersteller: {recommendedModel.author}</span> <span>Ersteller: {recommendedModel.author}</span>
<span></span> <span></span>
<span>Quant: {recommendedModel.quant}</span> <span>Quant: {recommendedModel.quant}</span>
</div> </div>
<div className="flex items-center gap-1.5 pt-0.5"> <div className="flex items-center gap-1.5 pt-0.5">
<FitBadge fit={recommendedModel.fit} /> <FitBadge fit={recommendedModel.fit} />
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Main Action Button */} {/* Main Action Button */}
<div className="pt-1"> <div className="pt-1">
{installedModel ? ( {installedModel ? (
hasUpgrade ? ( hasUpgrade ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5"> <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 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> <span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
</div> </div>
<button <button
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")} onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!installing[hasUpgrade.repo]} 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" 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" /> <Download className="h-3.5 w-3.5" />
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"} {installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
</button> </button>
</div> </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"> <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 <Check className="h-4 w-4" /> Auf neuestem Stand
</div> </div>
) )
) : ( ) : (
<button <button
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")} onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!isInstallingRecommended} disabled={!!isInstallingRecommended}
className={cn( 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", "h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
isInstallingRecommended isInstallingRecommended
? "border-primary/40 bg-primary/5 text-primary" ? "border-primary/40 bg-primary/5 text-primary"
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10" : "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
)} )}
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
{isInstallingRecommended || "Optimales Modell einsetzen"} {isInstallingRecommended || "Optimales Modell einsetzen"}
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Collapsible alternatives list */} {/* Collapsible alternatives list */}
{alternativeModels.length > 0 && ( {alternativeModels.length > 0 && (
<div className="border-t border-border/20 pt-3"> <div className="border-t border-border/20 pt-3">
<button <button
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))} 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" 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" />} {isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span> <span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin"> <div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
{alternativeModels.map((alt) => ( {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 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="min-w-0">
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}> <div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
{alt.name} {alt.name}
</div> </div>
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5"> <div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
<span>Quant: {alt.quant}</span> <span>Quant: {alt.quant}</span>
<span></span> <span></span>
<span>{alt.fit.text}</span> <span>{alt.fit.text}</span>
</div> </div>
</div> </div>
<button <button
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")} onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
disabled={!!installing[alt.repo]} 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" 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"} {installing[alt.repo] || "Installieren"}
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
) )
})} })}
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }
@@ -1,104 +1,104 @@
import { Check, X, Zap } from "lucide-react" import { Check, X, Zap } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { fmtSize } from "@/lib/format" import { fmtSize } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta" import { roleMeta } from "@/lib/roleMeta"
import { RoleLabel } from "@/components/models/ModelBadges" import { RoleLabel } from "@/components/models/ModelBadges"
import type { ModelInfo, RoleRecResp } from "@/lib/api" import type { ModelInfo, RoleRecResp } from "@/lib/api"
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen // 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 // 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. // hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
const isProtected = (role?: string | null) => !!roleMeta(role).protected const isProtected = (role?: string | null) => !!roleMeta(role).protected
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: { export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
role: string role: string
roleRec: RoleRecResp | null roleRec: RoleRecResp | null
models: ModelInfo[] models: ModelInfo[]
onAssign: (modelName: string) => void onAssign: (modelName: string) => void
onClose: () => void onClose: () => void
}) { }) {
const rec = roleRec && roleRec.role === role ? roleRec : null const rec = roleRec && roleRec.role === role ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {} const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r }) rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge. // Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[] ? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models : models
return ( 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="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="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"> <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"> <h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={role} /> festlegen <RoleLabel role={role} /> festlegen
</h3> </h3>
<button <button
onClick={onClose} onClick={onClose}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer" className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong> 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> <span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
</p> </p>
{rec?.recommended && ( {rec?.recommended && (
<button <button
onClick={() => onAssign(rec.recommended!)} onClick={() => onAssign(rec.recommended!)}
title={recByName[rec.recommended]?.reason} 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" 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, "")} <Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button> </button>
)} )}
</div> </div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1"> <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. */} {/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(role) ? ( {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"> <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. 🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben wähle stattdessen ein anderes Modell.
</div> </div>
) : ( ) : (
<button <button
onClick={() => onAssign("")} 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" 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> <span>Zuweisung entfernen</span>
</button> </button>
)} )}
{ordered.map((m) => { {ordered.map((m) => {
const r = recByName[m.name] const r = recByName[m.name]
const isCur = m.role === role const isCur = m.role === role
const isRec = !!r?.recommended const isRec = !!r?.recommended
const unfit = !!r && !r.suitable const unfit = !!r && !r.suitable
return ( return (
<button <button
key={m.name} key={m.name}
onClick={() => onAssign(m.name)} onClick={() => onAssign(m.name)}
className={cn( 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", "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" isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30" : isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60" : unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30" : "text-foreground bg-background/20 border-border/30"
)} )}
> >
<div className="flex flex-col text-left min-w-0"> <div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5"> <span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")} {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>} {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>
<span className="text-[9px] text-muted-foreground mt-0.5"> <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}`} {r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span> </span>
</div> </div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />} {isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button> </button>
) )
})} })}
</div> </div>
</div> </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, 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**: 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; - `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 Mem0 EXTRAHIERT dauerhafte Fakten selbst (infer=True), dedupliziert semantisch. Nicht-blockierend
(Hintergrund-Worker), damit der Agent nie auf die LLM-Extraktion wartet. (Hintergrund-Worker), damit der Agent nie auf die LLM-Extraktion wartet.
- `prefetch` vor jedem Turn semantische Suche gegen die Nutzer-Message; relevante Fakten werden - `prefetch` vor jedem Turn semantische Suche gegen die Nutzer-Message; relevante Fakten werden
als Kontext eingeblendet (automatischer Recall). als Kontext eingeblendet (automatischer Recall).
**Context-only:** `get_tool_schemas()` liefert `[]` der Agent bekommt KEINE Memory-Tools. Damit **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 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 (`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. (NoThink-Fix + alleiniger Chroma-Besitzer) dieser Provider ist nur ein dünner HTTP-Client.
Aktivierung (in ~/.hermes/config.yaml): Aktivierung (in ~/.hermes/config.yaml):
memory: memory:
memory_enabled: true memory_enabled: true
provider: mc2-memory provider: mc2-memory
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import os
import queue import queue
import re import re
import threading import threading
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx import httpx
from agent.memory_provider import MemoryProvider from agent.memory_provider import MemoryProvider
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/") MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "") MC_TOKEN = os.environ.get("MC_TOKEN", "")
RECALL_LIMIT = int(os.environ.get("MC2_MEMORY_RECALL_LIMIT", "5")) 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 # 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. # Prompt-Prefix (oft leer), damit --cache-reuse über Turns greift.
RECALL_MIN_SCORE = float(os.environ.get("MC2_MEMORY_RECALL_MIN_SCORE", "0.5")) 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. # 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 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 # 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 # 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 # Schirm sichtbarer Text könnte Injection-Anweisungen tragen, die sonst DAUERHAFT ins Gedächtnis
# wandern und Tage später ungefragt wiederverwendet werden. # wandern und Tage später ungefragt wiederverwendet werden.
SKIP_UNTRUSTED = os.environ.get("MC2_MEMORY_SKIP_UNTRUSTED", "1") not in ("0", "false", "False", "no") SKIP_UNTRUSTED = os.environ.get("MC2_MEMORY_SKIP_UNTRUSTED", "1") not in ("0", "false", "False", "no")
UNTRUSTED_MARKERS = ("[Bildschirm-Sicht",) UNTRUSTED_MARKERS = ("[Bildschirm-Sicht",)
# Triviale Turns, die keinen dauerhaften Fakt tragen (reine Begrüßung/Quittung, Aufwärm-/Test- # 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 # Kommandos) → gar nicht erst zum Auto-Lernen schicken. Spart Extraktions-Läufe und verhindert
# Müll-Fakten aus Meta-/Ablauf-Turns. # Müll-Fakten aus Meta-/Ablauf-Turns.
_TRIVIAL_TURN_RE = re.compile( _TRIVIAL_TURN_RE = re.compile(
r"^\s*(?:(?:hi|hallo|hey|moin|servus|guten\s+(?:morgen|tag|abend)|danke\w*|" 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"ok(?:ay)?|alles\s+klar|passt|super|top|cool|nice|ja|nein|jo|jup|jap)[\s.,!?]*)+$"
r"|^\s*(?:aufw(?:ä|ae)rmen|/?test)\b", r"|^\s*(?:aufw(?:ä|ae)rmen|/?test)\b",
re.IGNORECASE, re.IGNORECASE,
) )
def _is_trivial_turn(text: str) -> bool: def _is_trivial_turn(text: str) -> bool:
return bool(_TRIVIAL_TURN_RE.search(text or "")) return bool(_TRIVIAL_TURN_RE.search(text or ""))
def _headers() -> dict: def _headers() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {} return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
class MC2MemoryProvider(MemoryProvider): class MC2MemoryProvider(MemoryProvider):
def __init__(self) -> None: def __init__(self) -> None:
self._session_id = "" self._session_id = ""
self._agent_context = "primary" self._agent_context = "primary"
self._q: "queue.Queue[list]" = queue.Queue(maxsize=200) self._q: "queue.Queue[list]" = queue.Queue(maxsize=200)
self._worker: Optional[threading.Thread] = None self._worker: Optional[threading.Thread] = None
self._stop = threading.Event() self._stop = threading.Event()
self._recall_cache: Dict[str, str] = {} self._recall_cache: Dict[str, str] = {}
# -- Identität / Verfügbarkeit ------------------------------------------- # -- Identität / Verfügbarkeit -------------------------------------------
def name(self) -> str: def name(self) -> str:
return "mc2-memory" return "mc2-memory"
def is_available(self) -> bool: def is_available(self) -> bool:
try: try:
r = httpx.get(f"{MC_URL}/api/health", timeout=3.0) r = httpx.get(f"{MC_URL}/api/health", timeout=3.0)
return r.status_code == 200 return r.status_code == 200
except Exception: except Exception:
return False return False
def initialize(self, session_id: str, **kwargs) -> None: def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id self._session_id = session_id
# Nur im primären Agent-Kontext lernen (cron/subagent-Systemprompts nicht einlernen). # Nur im primären Agent-Kontext lernen (cron/subagent-Systemprompts nicht einlernen).
self._agent_context = kwargs.get("agent_context", "primary") self._agent_context = kwargs.get("agent_context", "primary")
if self._worker is None or not self._worker.is_alive(): if self._worker is None or not self._worker.is_alive():
self._stop.clear() self._stop.clear()
self._worker = threading.Thread(target=self._run, name="mc2-memory-learn", daemon=True) self._worker = threading.Thread(target=self._run, name="mc2-memory-learn", daemon=True)
self._worker.start() self._worker.start()
def system_prompt_block(self) -> str: def system_prompt_block(self) -> str:
return ( return (
"Du hast ein dauerhaftes, geteiltes Langzeitgedächtnis (MC2/Mem0). Relevante Fakten " "Du hast ein dauerhaftes, geteiltes Langzeitgedächtnis (MC2/Mem0). Relevante Fakten "
"werden dir vor einem Turn automatisch eingeblendet; neue dauerhafte Fakten über den " "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 " "Nutzer und das Projekt werden nach dem Turn automatisch gelernt — du musst dafür "
"nichts tun." "nichts tun."
) )
# -- Recall (vor dem Turn) ----------------------------------------------- # -- Recall (vor dem Turn) -----------------------------------------------
def prefetch(self, query: str, *, session_id: str = "") -> str: def prefetch(self, query: str, *, session_id: str = "") -> str:
q = (query or "").strip() q = (query or "").strip()
if len(q) < 3: if len(q) < 3:
return "" return ""
if q in self._recall_cache: if q in self._recall_cache:
return self._recall_cache[q] return self._recall_cache[q]
try: try:
r = httpx.get(f"{MC_URL}/api/memory", params={"q": q}, headers=_headers(), timeout=5.0) r = httpx.get(f"{MC_URL}/api/memory", params={"q": q}, headers=_headers(), timeout=5.0)
r.raise_for_status() r.raise_for_status()
items = r.json() items = r.json()
except Exception as exc: # nie den Turn blockieren/abbrechen except Exception as exc: # nie den Turn blockieren/abbrechen
log.debug("mc2-memory prefetch failed: %s", exc) log.debug("mc2-memory prefetch failed: %s", exc)
return "" return ""
facts = [ facts = [
i for i in items i for i in items
if i.get("score") is None or float(i.get("score", 0)) >= RECALL_MIN_SCORE if i.get("score") is None or float(i.get("score", 0)) >= RECALL_MIN_SCORE
][:RECALL_LIMIT] ][:RECALL_LIMIT]
out = "" out = ""
if facts: if facts:
lines = "\n".join(f"- {f.get('content', '')}" for f in facts) lines = "\n".join(f"- {f.get('content', '')}" for f in facts)
out = f"Relevante Fakten aus dem Langzeitgedächtnis:\n{lines}" out = f"Relevante Fakten aus dem Langzeitgedächtnis:\n{lines}"
# Cache klein halten # Cache klein halten
if len(self._recall_cache) > 64: if len(self._recall_cache) > 64:
self._recall_cache.clear() self._recall_cache.clear()
self._recall_cache[q] = out self._recall_cache[q] = out
return out return out
# -- Lernen (nach dem Turn) ---------------------------------------------- # -- Lernen (nach dem Turn) ----------------------------------------------
def sync_turn(self, user_content: str, assistant_content: str, *, def sync_turn(self, user_content: str, assistant_content: str, *,
session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None) -> None: session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None) -> None:
if self._agent_context != "primary": if self._agent_context != "primary":
return return
u = (user_content or "").strip() u = (user_content or "").strip()
if len(u) < MIN_USER_LEN: if len(u) < MIN_USER_LEN:
return return
if _is_trivial_turn(u): if _is_trivial_turn(u):
log.debug("mc2-memory: trivialer Turn (Begrüßung/Quittung/Aufwärmen) — Auto-Lernen übersprungen") log.debug("mc2-memory: trivialer Turn (Begrüßung/Quittung/Aufwärmen) — Auto-Lernen übersprungen")
return return
if SKIP_UNTRUSTED and any(m in u for m in UNTRUSTED_MARKERS): 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") log.debug("mc2-memory: Turn mit untrusted Bildschirm-/Web-Inhalt — Auto-Lernen übersprungen")
return return
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}] msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
a = (assistant_content or "").strip() a = (assistant_content or "").strip()
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls). # 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 # 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. # sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
if messages: if messages:
tools = [] tools = []
for m in messages: for m in messages:
for tc in (m.get("tool_calls") or []): for tc in (m.get("tool_calls") or []):
name = ((tc.get("function") or {}).get("name") or "").strip() name = ((tc.get("function") or {}).get("name") or "").strip()
if name and name not in tools: if name and name not in tools:
tools.append(name) tools.append(name)
if tools: if tools:
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip() a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
if a: if a:
msgs.append({"role": "assistant", "content": a[:4000]}) msgs.append({"role": "assistant", "content": a[:4000]})
try: try:
self._q.put_nowait(msgs) self._q.put_nowait(msgs)
except queue.Full: except queue.Full:
log.debug("mc2-memory learn queue full — Turn übersprungen") log.debug("mc2-memory learn queue full — Turn übersprungen")
# Neue Fakten können gelandet sein → Recall-Cache invalidieren. # Neue Fakten können gelandet sein → Recall-Cache invalidieren.
self._recall_cache.clear() self._recall_cache.clear()
def _post_learn(self, msgs: list) -> None: def _post_learn(self, msgs: list) -> None:
try: try:
httpx.post(f"{MC_URL}/api/memory/learn", httpx.post(f"{MC_URL}/api/memory/learn",
json={"messages": msgs, "source": "hermes"}, json={"messages": msgs, "source": "hermes"},
headers=_headers(), timeout=90.0) headers=_headers(), timeout=90.0)
except Exception as exc: except Exception as exc:
log.debug("mc2-memory learn POST failed: %s", exc) log.debug("mc2-memory learn POST failed: %s", exc)
def _run(self) -> None: def _run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
msgs = self._q.get(timeout=1.0) msgs = self._q.get(timeout=1.0)
except queue.Empty: except queue.Empty:
continue continue
try: try:
self._post_learn(msgs) self._post_learn(msgs)
finally: finally:
self._q.task_done() self._q.task_done()
def _flush(self) -> None: def _flush(self) -> None:
"""Offene Turns garantiert rausschreiben — bei Session-Ende/CLI-Exit, wo der """Offene Turns garantiert rausschreiben — bei Session-Ende/CLI-Exit, wo der
Prozess sofort beendet wird (daemon-Worker würde sonst mitten im POST sterben).""" Prozess sofort beendet wird (daemon-Worker würde sonst mitten im POST sterben)."""
self._stop.set() self._stop.set()
if self._worker and self._worker.is_alive(): if self._worker and self._worker.is_alive():
self._worker.join(timeout=95.0) # laufenden Worker-POST zu Ende lassen self._worker.join(timeout=95.0) # laufenden Worker-POST zu Ende lassen
while True: # vom Worker nicht mehr abgeholte Turns while True: # vom Worker nicht mehr abgeholte Turns
try: try:
msgs = self._q.get_nowait() msgs = self._q.get_nowait()
except queue.Empty: except queue.Empty:
break break
self._post_learn(msgs) self._post_learn(msgs)
# -- Context-only: keine Agent-Tools → kein Tool-Loop -------------------- # -- Context-only: keine Agent-Tools → kein Tool-Loop --------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]: def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [] return []
def on_session_end(self, messages: List[Dict[str, Any]]) -> None: def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
self._flush() self._flush()
def shutdown(self) -> None: def shutdown(self) -> None:
self._flush() self._flush()
def register(ctx) -> None: def register(ctx) -> None:
ctx.register_memory_provider(MC2MemoryProvider()) ctx.register_memory_provider(MC2MemoryProvider())
+326 -326
View File
@@ -1,326 +1,326 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Mission Control 2.0 Stack-Management MCP Server (für Hermes). Mission Control 2.0 Stack-Management MCP Server (für Hermes).
Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern: Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern:
Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle
laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden, laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden,
System-Status lesen. Spricht die MC-2-REST-API (/api/*). System-Status lesen. Spricht die MC-2-REST-API (/api/*).
Damit ist Hermes echte Control-Plane des Stacks (Plan: MC-Mgmt-MCP, damit Hermes 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. den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes.
Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ), Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ),
damit kleine Modelle nicht in Aufruf-Schleifen laufen. damit kleine Modelle nicht in Aufruf-Schleifen laufen.
Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional). Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional).
Install: pip install mcp httpx Install: pip install mcp httpx
""" """
import json import json
import os import os
import httpx import httpx
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/") MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "") MC_TOKEN = os.environ.get("MC_TOKEN", "")
mcp = FastMCP("mission-control-stack") mcp = FastMCP("mission-control-stack")
def _h() -> dict: def _h() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {} return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
def _get(path: str, **params): def _get(path: str, **params):
r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30) r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _post(path: str, data: dict | None = None): def _post(path: str, data: dict | None = None):
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120) r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _put(path: str, data: dict): def _put(path: str, data: dict):
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20) r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _delete(path: str): def _delete(path: str):
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30) r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
# ── Modelle: ansehen / entdecken ──────────────────────────────────────────── # ── Modelle: ansehen / entdecken ────────────────────────────────────────────
@mcp.tool() @mcp.tool()
def list_models() -> str: def list_models() -> str:
"""Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten) """Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten)
und welche gerade geladen sind.""" und welche gerade geladen sind."""
data = _get("/api/models") data = _get("/api/models")
running = set(data.get("running") or []) running = set(data.get("running") or [])
lines = [] lines = []
for m in data.get("models", []): for m in data.get("models", []):
warm = " [LÄUFT]" if m["name"] in running else "" warm = " [LÄUFT]" if m["name"] in running else ""
lines.append( lines.append(
f"- {m['name']}{warm} (Rolle: {m.get('role') or ''}, ctx: {m.get('ctx')}, " f"- {m['name']}{warm} (Rolle: {m.get('role') or ''}, ctx: {m.get('ctx')}, "
f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})" f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})"
) )
return "\n".join(lines) or "Keine Modelle konfiguriert." return "\n".join(lines) or "Keine Modelle konfiguriert."
@mcp.tool() @mcp.tool()
def discover_models() -> str: def discover_models() -> str:
"""Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit). """Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit).
Nutze dies, bevor du ein neues Modell vorschlägst/installierst.""" Nutze dies, bevor du ein neues Modell vorschlägst/installierst."""
data = _get("/api/discover") data = _get("/api/discover")
out = [f"System-RAM: {data.get('sys_ram_gb')} GB"] out = [f"System-RAM: {data.get('sys_ram_gb')} GB"]
for c in data.get("categories", []): for c in data.get("categories", []):
rec = c.get("recommended") or "" rec = c.get("recommended") or ""
out.append(f"\n## {c['title']} (beste Wahl: {rec})") out.append(f"\n## {c['title']} (beste Wahl: {rec})")
for m in c["models"][:3]: for m in c["models"][:3]:
out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)") out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)")
return "\n".join(out) return "\n".join(out)
@mcp.tool() @mcp.tool()
def hf_search(q: str) -> str: def hf_search(q: str) -> str:
"""Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes """Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes
Modell finden willst, das nicht in discover_models auftaucht.""" Modell finden willst, das nicht in discover_models auftaucht."""
res = _get("/api/hf/search", q=q).get("results", []) res = _get("/api/hf/search", q=q).get("results", [])
if not res: if not res:
return f"Keine HF-Treffer für '{q}'." return f"Keine HF-Treffer für '{q}'."
return json.dumps(res[:15], ensure_ascii=False, indent=2) return json.dumps(res[:15], ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
def hf_quants(repo: str) -> str: def hf_quants(repo: str) -> str:
"""Listet die verfügbaren Quantisierungen (Q4_K_M, Q8_0, …) eines HF-Repos. """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.""" Nutze dies VOR install_model, um die passende Quant-Stufe zu wählen."""
d = _get("/api/hf/quants", repo=repo) d = _get("/api/hf/quants", repo=repo)
return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden." return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden."
# ── Modelle: installieren / löschen / laden ───────────────────────────────── # ── Modelle: installieren / löschen / laden ─────────────────────────────────
@mcp.tool() @mcp.tool()
def install_model(repo: str, role: str = "", quant: str = "Q4_K_M", def install_model(repo: str, role: str = "", quant: str = "Q4_K_M",
ctx: int = 0, jinja: bool = False) -> str: ctx: int = 0, jinja: bool = False) -> str:
"""Lädt ein GGUF-Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in """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. 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. 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.""" Gibt eine job_id zurück Fortschritt via list_jobs prüfen."""
body = {"repo": repo, "quant": quant, "jinja": jinja} body = {"repo": repo, "quant": quant, "jinja": jinja}
if role: if role:
body["role"] = role body["role"] = role
if ctx > 0: if ctx > 0:
body["ctx"] = ctx body["ctx"] = ctx
r = _post("/api/models/install", body) r = _post("/api/models/install", body)
return (f"Installation gestartet: model_id={r.get('model_id')}, job_id={r.get('job_id')}, " 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"{r.get('files')} Datei(en), ~{round((r.get('total_bytes') or 0)/1e9, 1)} GB. "
f"Fortschritt mit list_jobs prüfen.") f"Fortschritt mit list_jobs prüfen.")
@mcp.tool() @mcp.tool()
def list_jobs() -> str: def list_jobs() -> str:
"""Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt.""" """Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt."""
jobs = _get("/api/jobs").get("jobs", []) jobs = _get("/api/jobs").get("jobs", [])
if not jobs: if not jobs:
return "Keine Jobs." return "Keine Jobs."
out = [] out = []
for j in jobs: for j in jobs:
prog = j.get("progress") prog = j.get("progress")
p = f" {round(prog*100)}%" if isinstance(prog, (int, float)) else "" 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}") out.append(f"- [{j.get('id', '?')[:8]}] {j.get('label', '?')}{j.get('status', '?')}{p}")
return "\n".join(out) return "\n".join(out)
@mcp.tool() @mcp.tool()
def cancel_job(job_id: str) -> str: def cancel_job(job_id: str) -> str:
"""Bricht einen laufenden Job (z.B. Download) ab.""" """Bricht einen laufenden Job (z.B. Download) ab."""
ok = _post(f"/api/jobs/{job_id}/cancel").get("ok") ok = _post(f"/api/jobs/{job_id}/cancel").get("ok")
return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar." return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar."
@mcp.tool() @mcp.tool()
def delete_model(model_id: str) -> str: def delete_model(model_id: str) -> str:
"""Entfernt ein Modell aus llama-swap (Eintrag + Dateien). NUR wenn der Nutzer es """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.""" will oder es eindeutig veraltet/ersetzt ist vorher list_models prüfen."""
_delete(f"/api/models/{model_id}") _delete(f"/api/models/{model_id}")
return f"Modell '{model_id}' gelöscht." return f"Modell '{model_id}' gelöscht."
@mcp.tool() @mcp.tool()
def load_model(model_id: str) -> str: def load_model(model_id: str) -> str:
"""Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten """Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten
echten Request zu vermeiden.""" echten Request zu vermeiden."""
return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen." return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen."
@mcp.tool() @mcp.tool()
def unload_model(model_id: str = "") -> str: def unload_model(model_id: str = "") -> str:
"""Entlädt ein Modell aus dem Speicher (gibt RAM/GTT frei). Leer = ALLE entladen.""" """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" path = f"/api/models/{model_id}/unload" if model_id else "/api/models/unload"
return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen." return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen."
# ── Rollen & Routing ──────────────────────────────────────────────────────── # ── Rollen & Routing ────────────────────────────────────────────────────────
@mcp.tool() @mcp.tool()
def set_model_role(model_id: str, role: str = "") -> str: def set_model_role(model_id: str, role: str = "") -> str:
"""Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…). """Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…).
Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist.""" Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist."""
_post(f"/api/models/{model_id}/role", {"role": role or None}) _post(f"/api/models/{model_id}/role", {"role": role or None})
return f"Rolle für '{model_id}': {role or '— (entfernt)'}" return f"Rolle für '{model_id}': {role or '— (entfernt)'}"
@mcp.tool() @mcp.tool()
def set_route(name: str, target_alias: str) -> str: def set_route(name: str, target_alias: str) -> str:
"""Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias.""" """Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias."""
_put("/api/routing/route", {"name": name, "target_alias": target_alias}) _put("/api/routing/route", {"name": name, "target_alias": target_alias})
return f"Routing gesetzt: {name}{target_alias}" return f"Routing gesetzt: {name}{target_alias}"
@mcp.tool() @mcp.tool()
def routing_overview() -> str: def routing_overview() -> str:
"""Zeigt das aktuelle Gateway-Routing + Fallbacks.""" """Zeigt das aktuelle Gateway-Routing + Fallbacks."""
return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2) return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str: 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). """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). 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.""" Für Download+Eintrag in einem Schritt nutze install_model."""
res = _post("/api/models/register", res = _post("/api/models/register",
{"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja}) {"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja})
return f"Eingetragen als '{res.get('model_id')}'." return f"Eingetragen als '{res.get('model_id')}'."
# ── System / Status / Wartung ─────────────────────────────────────────────── # ── System / Status / Wartung ───────────────────────────────────────────────
@mcp.tool() @mcp.tool()
def system_status() -> str: def system_status() -> str:
"""Live-Auslastung der Box (CPU/RAM/GPU/Disk).""" """Live-Auslastung der Box (CPU/RAM/GPU/Disk)."""
s = _get("/api/system/status") s = _get("/api/system/status")
g = s.get("gpu") or {} g = s.get("gpu") or {}
return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · " return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · "
f"GPU {g.get('busy_percent', '')}% · Disk {(s.get('disk') or {}).get('percent', '')}%") f"GPU {g.get('busy_percent', '')}% · Disk {(s.get('disk') or {}).get('percent', '')}%")
@mcp.tool() @mcp.tool()
def list_services() -> str: def list_services() -> str:
"""Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice).""" """Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice)."""
data = _get("/api/system/services") data = _get("/api/system/services")
return "\n".join( return "\n".join(
f"{'' if s['ok'] else ''} {s['name']} ({s['url']})" f"{'' if s['ok'] else ''} {s['name']} ({s['url']})"
for s in data.get("services", []) for s in data.get("services", [])
) or "Keine Dienste." ) or "Keine Dienste."
@mcp.tool() @mcp.tool()
def restart_service(service: str) -> str: def restart_service(service: str) -> str:
"""Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 | """Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 |
hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart 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.""" (llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist."""
res = _post("/api/system/restart", {"service": service}) res = _post("/api/system/restart", {"service": service})
return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}" return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}"
@mcp.tool() @mcp.tool()
def backup_now() -> str: def backup_now() -> str:
"""Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten """Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten
Änderungen oder wenn der Nutzer ein Backup will.""" Änderungen oder wenn der Nutzer ein Backup will."""
r = _post("/api/system/backup") r = _post("/api/system/backup")
return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}" return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}"
@mcp.tool() @mcp.tool()
def list_backups() -> str: def list_backups() -> str:
"""Listet vorhandene Backups (Datei, Zeit, Größe).""" """Listet vorhandene Backups (Datei, Zeit, Größe)."""
bks = _get("/api/system/backups").get("backups", []) bks = _get("/api/system/backups").get("backups", [])
if not bks: if not bks:
return "Keine Backups." return "Keine Backups."
return json.dumps(bks, ensure_ascii=False, indent=2) return json.dumps(bks, ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
def token_stats() -> str: def token_stats() -> str:
"""Token-Verbrauch + Cloud-Ersparnis (was die lokale Nutzung gegenüber Cloud-APIs spart).""" """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) return json.dumps(_get("/api/system/token-stats"), ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
def check_updates() -> str: def check_updates() -> str:
"""Prüft, ob Updates für OS / Engine (llama.cpp) / Router (llama-swap) / Hermes anstehen.""" """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) return json.dumps(_get("/api/maintenance/updates"), ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
def apply_update(kind: str) -> str: def apply_update(kind: str) -> str:
"""Wendet ein Update an. kind: os | engine | swap | hermes. NUR wenn der Nutzer es will """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.""" oder check_updates ein anstehendes Update zeigt. Kann den jeweiligen Dienst kurz neu starten."""
kind = kind.lower().strip() kind = kind.lower().strip()
routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update", routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update",
"swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"} "swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"}
if kind not in routes: if kind not in routes:
return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes." return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes."
return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2) return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2)
# ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ───────── # ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ─────────
@mcp.tool() @mcp.tool()
def reminder_create(text: str, when: str, repeat: str = "") -> str: 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 """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 an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden
will ('erinner mich', 'sag mir um', 'jeden Morgen'). 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. 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 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). 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).""" text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor das ergänzt die Box)."""
try: try:
item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"] item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"]
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}" return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "") rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "")
return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}" return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}"
@mcp.tool() @mcp.tool()
def reminder_list() -> str: def reminder_list() -> str:
"""Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen """Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen
hiermit die Nummer ermitteln.""" hiermit die Nummer ermitteln."""
items = _get("/api/reminders")["items"] items = _get("/api/reminders")["items"]
if not items: if not items:
return "Keine Erinnerungen angelegt." return "Keine Erinnerungen angelegt."
rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"} 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) return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')}{i['text']}" for i in items)
@mcp.tool() @mcp.tool()
def reminder_delete(reminder_id: int) -> str: def reminder_delete(reminder_id: int) -> str:
"""Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf """Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf
ausdrücklichen Wunsch des Commanders.""" ausdrücklichen Wunsch des Commanders."""
try: try:
gone = _delete(f"/api/reminders/{reminder_id}")["deleted"] gone = _delete(f"/api/reminders/{reminder_id}")["deleted"]
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}" return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)." return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)."
@mcp.tool() @mcp.tool()
def service_logs(service: str, lines: int = 100) -> str: def service_logs(service: str, lines: int = 100) -> str:
"""Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway, """Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway,
mission-control-2, voice-service.""" mission-control-2, voice-service."""
return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines), return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines),
ensure_ascii=False, indent=2) ensure_ascii=False, indent=2)
if __name__ == "__main__": if __name__ == "__main__":
mcp.run() 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 # 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 # 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. # + torch zieht install.sh best-effort nach (CPU-Wheel), damit der Loop auch ohne Premium läuft.
fastapi fastapi
uvicorn uvicorn
python-multipart python-multipart
soundfile soundfile
numpy numpy
faster-whisper faster-whisper
edge-tts edge-tts
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller # 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. # als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
onnx-asr[cpu,hub] onnx-asr[cpu,hub]
# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features) # Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features)
transformers transformers