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
+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>
)
}