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
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
}
]
}
+12
View File
@@ -0,0 +1,12 @@
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
frontend/node_modules/
frontend/dist/
# Env / local
*.env
.DS_Store
+43
View File
@@ -0,0 +1,43 @@
# Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5. Greenfield-Neuaufbau —
siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
**Schichten:** Engine (llama-swap) · Routing-Gateway (LiteLLM, `model: auto`) ·
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phase 0 (Gerüst)
- `backend/` — FastAPI: `GET /api/health`, `GET /api/models` (read-only aus llama-swap config).
- `frontend/` — Vite + React + Tailwind v4 + shadcn-Style, App-Shell mit Cmd+K, Dark-default.
## Entwickeln
**Backend:**
```bash
cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m uvicorn app:app --port 9000
```
**Frontend (Dev, proxyt /api → :9000):**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
## Env-Vars (Auswahl)
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_GATEWAY_URL` | `http://127.0.0.1:4000` | LiteLLM-Gateway |
| `MC_PORT` | `9000` | MC-Backend-Port |
+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
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Frontend bauen (vor jedem Deploy auf dem Entwickler-PC). Output → frontend/dist,
# das committet wird (kein Node-Build auf der Box).
set -euo pipefail
cd "$(dirname "$0")/../frontend"
npm ci
npm run build
echo "OK — frontend/dist gebaut."
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Deploy AUF DER BOX: git pull (Source) → venv-Sync → rsync nach /opt → Restart.
# Läuft als User hitonabi. KEIN eingebettetes Passwort — der Restart nutzt die
# NOPASSWD-sudoers-Whitelist (nur `systemctl restart mission-control-2`).
#
# Voraussetzungen (einmalig):
# - Gitea-Repo angelegt + hier geklont nach ~/mission-control-2
# - sudoers: hitonabi ALL=(root) NOPASSWD: /usr/bin/systemctl restart mission-control-2
# - systemd-Unit installiert (siehe mission-control-2.service)
set -euo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-2}"
PROD="${MC2_PROD:-/opt/mission-control-2}"
cd "$SRC"
git fetch -q origin
git reset -q --hard origin/main
# Backend-venv auf der Box sicherstellen + Abhängigkeiten aktuell halten.
if [ ! -d "$SRC/backend/.venv" ]; then
python3 -m venv "$SRC/backend/.venv"
fi
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# Nach /opt spiegeln (Frontend-dist kommt aus dem Repo mit).
sudo rsync -a --delete \
--exclude '.git' --exclude 'node_modules' \
"$SRC/" "$PROD/"
# venv separat syncen (rsync oben schließt sie nicht aus, aber Pfade im venv sind
# absolut — daher venv direkt im PROD neu aufbauen, robust):
if [ ! -d "$PROD/backend/.venv" ]; then
sudo python3 -m venv "$PROD/backend/.venv"
fi
sudo "$PROD/backend/.venv/bin/python" -m pip install -q -r "$PROD/backend/requirements.txt"
sudo systemctl restart mission-control-2
echo "OK — Mission Control 2.0 neu gestartet (:9001)."
+23
View File
@@ -0,0 +1,23 @@
# systemd-Unit für Mission Control 2.0 (läuft PARALLEL zu v1 auf eigenem Port 9001).
# Ablage: ~/.config/systemd/user/ (user-Dienst) ODER /etc/systemd/system/ (system, User=hitonabi).
# Aktivieren: systemctl --user enable --now mission-control-2 (bzw. system-weit)
[Unit]
Description=Mission Control 2.0 (Cockpit)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/mission-control-2/backend
ExecStart=/opt/mission-control-2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001
Environment=MC_PORT=9001
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
Environment=MC_GATEWAY_URL=http://127.0.0.1:4000
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3060
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "mission-control-2-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/node": "^22.10.1",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3",
"vite": "^6.0.3"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Mission Control 2.0",
"short_name": "MC2",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": []
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from "react"
import { Command as CommandIcon } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ModelsView } from "@/views/ModelsView"
import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils"
export default function App() {
const [view, setView] = useState<ViewId>("models")
const [health, setHealth] = useState<Health | null>(null)
useEffect(() => {
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null))
load()
const t = setInterval(load, 10000)
return () => clearInterval(t)
}, [])
const active = NAV.find((n) => n.id === view)!
return (
<div className="flex h-full">
<CommandPalette onNavigate={setView} />
{/* Sidebar */}
<aside className="flex w-60 shrink-0 flex-col border-r border-border bg-card/40">
<div className="flex items-center gap-2 px-5 py-4">
<div className="h-7 w-7 rounded-lg bg-primary" />
<div className="leading-tight">
<div className="text-sm font-semibold">Mission Control</div>
<div className="text-xs text-muted-foreground">2.0</div>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-2">
{NAV.map((item) => (
<button
key={item.id}
onClick={() => setView(item.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
view === item.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</button>
))}
</nav>
<div className="px-4 py-3 text-xs text-muted-foreground">
{health ? (
<span className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 rounded-full",
health.engine_reachable ? "bg-emerald-500" : "bg-amber-500",
)}
/>
Engine {health.engine_reachable ? "online" : "offline"} · v{health.version}
</span>
) : (
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-500" /> Backend offline
</span>
)}
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-6">
<div className="text-sm text-muted-foreground">{active.hint}</div>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev)
}}
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent"
>
<CommandIcon className="h-3.5 w-3.5" />
<span>Springen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">K</kbd>
</button>
</header>
<main className="flex-1 overflow-y-auto p-6">
{view === "models" && <ModelsView />}
{view !== "models" && <Placeholder title={active.label} hint={active.hint} />}
</main>
</div>
</div>
)
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react"
import { Command } from "cmdk"
import { NAV, type ViewId } from "@/nav"
export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void }) {
const [open, setOpen] = useState(false)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault()
setOpen((o) => !o)
}
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [])
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Befehlspalette"
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[12vh]"
onClick={() => setOpen(false)}
>
<div
className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Command.Input
autoFocus
placeholder="Springe zu… (Modelle, Routing, System …)"
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Command.List className="max-h-80 overflow-y-auto p-2">
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
Nichts gefunden.
</Command.Empty>
<Command.Group heading="Bereiche" className="px-1 py-1 text-xs text-muted-foreground">
{NAV.map((item) => (
<Command.Item
key={item.id}
value={`${item.label} ${item.hint}`}
onSelect={() => {
onNavigate(item.id)
setOpen(false)
}}
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<item.icon className="h-4 w-4 text-primary" />
<span>{item.label}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">{item.hint}</span>
</Command.Item>
))}
</Command.Group>
</Command.List>
</div>
</Command.Dialog>
)
}
+38
View File
@@ -0,0 +1,38 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type Variant = "default" | "outline" | "ghost"
type Size = "default" | "sm" | "icon"
const variants: Record<Variant, string> = {
default: "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
ghost: "bg-transparent hover:bg-accent hover:text-accent-foreground",
}
const sizes: Record<Size, string> = {
default: "h-9 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
icon: "h-9 w-9",
}
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
),
)
Button.displayName = "Button"
+82
View File
@@ -0,0 +1,82 @@
@import "tailwindcss";
/* Design-System (von v3 übernommen): EINE Akzentfarbe Teal #2dd4bf.
Dark als Default; Light als Fallback. Tokens als CSS-Variablen shadcn-Style. */
:root {
--background: 0 0% 100%;
--foreground: 222 22% 12%;
--card: 0 0% 100%;
--card-foreground: 222 22% 12%;
--popover: 0 0% 100%;
--popover-foreground: 222 22% 12%;
--primary: 172 70% 38%;
--primary-foreground: 0 0% 100%;
--muted: 220 14% 95%;
--muted-foreground: 220 9% 42%;
--accent: 220 14% 94%;
--accent-foreground: 222 22% 12%;
--border: 220 13% 88%;
--input: 220 13% 88%;
--ring: 172 66% 50%;
--radius: 0.65rem;
}
.dark {
--background: 222 24% 7%;
--foreground: 210 20% 92%;
--card: 222 22% 10%;
--card-foreground: 210 20% 92%;
--popover: 222 24% 9%;
--popover-foreground: 210 20% 92%;
--primary: 172 66% 50%;
--primary-foreground: 222 47% 8%;
--muted: 220 14% 15%;
--muted-foreground: 215 14% 62%;
--accent: 220 14% 17%;
--accent-foreground: 210 20% 92%;
--border: 220 14% 19%;
--input: 220 14% 19%;
--ring: 172 66% 50%;
}
/* Tailwind-v4-Mapping: macht bg-background, text-foreground, border-border nutzbar
und an die obigen (themable) Variablen gebunden. */
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
* {
border-color: hsl(var(--border));
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
+30
View File
@@ -0,0 +1,30 @@
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...init,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T>
}
export interface ModelInfo {
name: string
role: string | null
aliases: string[]
api_ids: string[]
ctx: number | null
ttl: number | null
cmd: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
}
export interface Health {
status: string
version: string
engine_reachable: boolean
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react"
import ReactDOM from "react-dom/client"
import App from "./App"
import "./index.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+28
View File
@@ -0,0 +1,28 @@
import {
Boxes,
Route,
Cpu,
Brain,
Plug,
Bot,
type LucideIcon,
} from "lucide-react"
export type ViewId = "models" | "routing" | "system" | "memory" | "connect" | "agent"
export interface NavItem {
id: ViewId
label: string
hint: string
icon: LucideIcon
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "models", label: "Modelle & Routing", hint: "Modelle kuratieren, Gruppen & Auto-Routing", icon: Boxes },
{ id: "routing", label: "Routing", hint: "Gateway-Regeln: schnell ↔ schwer", icon: Route },
{ id: "system", label: "System", hint: "Metriken, Dienste, Updates", icon: Cpu },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & WebUI öffnen", icon: Bot },
]
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useState } from "react"
import { api, type ModelInfo } from "@/lib/api"
function fmtSize(b: number | null) {
if (!b) return "—"
const gb = b / 1024 ** 3
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
}
function fmtCtx(c: number | null) {
if (!c) return "—"
return `${Math.round(c / 1024)}k`
}
export function ModelsView() {
const [models, setModels] = useState<ModelInfo[]>([])
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
useEffect(() => {
api<{ models: ModelInfo[] }>("/api/models")
.then((d) => setModels(d.models))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Modelle &amp; Routing</h1>
<p className="text-sm text-muted-foreground">
In llama-swap konfigurierte Modelle (Phase 0: read-only). Installieren, Gruppen &amp;
Auto-Routing folgen in Phase 1.
</p>
</div>
{loading && <div className="text-sm text-muted-foreground">Lade</div>}
{error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Engine nicht erreichbar oder keine Config gefunden ({error}).
</div>
)}
{!loading && !error && (
<div className="overflow-hidden rounded-xl border border-border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Modell</th>
<th className="px-4 py-2 font-medium">Rolle</th>
<th className="px-4 py-2 font-medium">Kontext</th>
<th className="px-4 py-2 font-medium">Quant</th>
<th className="px-4 py-2 font-medium">Größe</th>
</tr>
</thead>
<tbody>
{models.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Keine Modelle konfiguriert.
</td>
</tr>
)}
{models.map((m) => (
<tr key={m.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">
{m.name}
{m.incomplete && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
unvollständig
</span>
)}
</td>
<td className="px-4 py-2.5">
{m.role ? (
<span className="rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary">
{m.role}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtCtx(m.ctx)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{m.quant || "—"}</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { Construction } from "lucide-react"
export function Placeholder({ title, hint }: { title: string; hint: string }) {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">{title}</h1>
<p className="text-sm text-muted-foreground">{hint}</p>
</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-card/50 py-20 text-center">
<Construction className="h-8 w-8 text-muted-foreground" />
<div className="text-sm text-muted-foreground">Kommt in einer späteren Phase.</div>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import path from "node:path"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
proxy: {
// Dev: /api → FastAPI-Backend
"/api": { target: "http://127.0.0.1:9000", changeOrigin: true },
},
},
build: { outDir: "dist" },
})