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
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