feat(2.0): Phase 0 — Greenfield-Skeleton (FastAPI + React/shadcn)

Backend: FastAPI mit /api/health + /api/models (read-only aus llama-swap
config, Logik aus v1 portiert). Frontend: Vite + React + Tailwind v4 +
shadcn-Style App-Shell mit Cmd+K, Dark-default, PWA-Manifest; Modelle-View
live aus /api/models. Deploy-Geruest (systemd-Unit :9001, build/deploy-
Skripte, NOPASSWD-sudoers, kein Klartext-Passwort).

Verifiziert: Backend-Endpunkte + Frontend-Build + gerenderte Shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-24 22:17:45 +02:00
commit b805c294eb
30 changed files with 3972 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
"""
Mission Control 2.0 — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import health, models
app = FastAPI(title="Mission Control 2.0", version=VERSION)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def no_cache(request: Request, call_next):
resp = await call_next(request)
if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache"
return resp
app.include_router(health.router)
app.include_router(models.router)
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
@app.get("/{full_path:path}")
def spa(full_path: str):
index = FRONTEND_DIST / "index.html"
if index.exists():
return FileResponse(index)
return {"detail": "frontend not built"}
+34
View File
@@ -0,0 +1,34 @@
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# --- Routing-Gateway (LiteLLM, model: auto) ----------------------------------
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", "http://127.0.0.1:4000").rstrip("/")
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-phase0"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
httpx>=0.27
ruamel.yaml>=0.18
View File
+17
View File
@@ -0,0 +1,17 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
from fastapi import APIRouter
from config import VERSION
from services import llamaswap
router = APIRouter(prefix="/api")
@router.get("/health")
def health() -> dict:
return {
"status": "ok",
"version": VERSION,
"engine_reachable": llamaswap.engine_reachable(),
}
+13
View File
@@ -0,0 +1,13 @@
"""Modelle-Endpoint (Phase 0: read-only Liste aus der llama-swap config.yaml)."""
from fastapi import APIRouter
from services import llamaswap
router = APIRouter(prefix="/api")
@router.get("/models")
def models() -> dict:
items = llamaswap.list_models()
return {"models": items, "count": len(items)}
View File
+94
View File
@@ -0,0 +1,94 @@
"""
Engine-Service: liest die llama-swap config.yaml (read-only in Phase 0) und
spricht die llama-swap-API (/v1/models, /running). Schreiblogik (Modelle
installieren, Gruppen/Routing verwalten) kommt in Phase 1.
Logik portiert aus Mission Control v1 (llamaswap.py + routers/models.py),
auf das Nötigste reduziert.
"""
import os
import re
import httpx
from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml
# Kanonische Rollen (vereinheitlicht ggü. v1: kein manager/reviewer mehr).
# Eine Quelle der Wahrheit — Capability-Erkennung läuft separat über model_caps (Phase 1).
ROLE_IDS = {"vision", "coder", "reasoning", "agent", "scout"}
_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE)
def read_config() -> dict:
"""llama-swap config.yaml laden. Existiert sie nicht (z.B. lokaler Dev-PC
ohne Engine), wird ein leeres Modell-Set zurückgegeben statt zu werfen."""
if not CONFIG_PATH.exists():
return {"models": {}}
with CONFIG_PATH.open("r", encoding="utf-8") as f:
data = yaml.load(f) or {}
if not data.get("models"):
data["models"] = {}
return data
def _parse_model(name: str, spec: dict) -> dict:
"""Ein config.yaml-Modell in ein flaches UI-Objekt übersetzen."""
spec = spec or {}
cmd = str(spec.get("cmd", "")).strip()
ctx = None
if (m := _CTX_RE.search(cmd)):
ctx = int(m.group(1))
path = ""
filename = ""
quant = ""
size_bytes = None
if (m := _PATH_RE.search(cmd)):
path = m.group(1).replace("'", "").replace('"', "")
filename = os.path.basename(path)
if os.path.exists(path):
size_bytes = os.path.getsize(path)
if (q := _QUANT_RE.search(path)):
quant = q.group(1).upper()
aliases = spec.get("aliases") or []
if isinstance(aliases, str):
aliases = [aliases]
aliases = [str(a) for a in aliases]
# Rolle = erster Alias; Legacy-Fallback: Key selbst ist eine Rolle.
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
return {
"name": name,
"role": role,
"aliases": aliases,
"api_ids": [name] + aliases,
"ctx": ctx,
"ttl": spec.get("ttl"),
"cmd": cmd,
"filename": filename,
"quant": quant,
"size_bytes": size_bytes,
# "incomplete" = Eintrag ohne hinterlegtes Modell (-m), z.B. Platzhalter.
"incomplete": not path,
}
def list_models() -> list[dict]:
"""Alle in der config.yaml konfigurierten Modelle (read-only)."""
cfg = read_config()
return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()]
def engine_reachable() -> bool:
"""Ist die llama-swap-API erreichbar?"""
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200
except Exception:
return False