feat(2.0): Phase 1 — Engine + Routing (Herzstueck)

Backend-Services: fit/caps/sources (portiert), discover (live HF + Fit +
Caps + ranked recommendation), llama-swap write/register + groups (Ko-
Residenz swap:false), LiteLLM-Gateway-Config + gateway-Service (model:auto +
Fallbacks). Router: discover/fit/register/groups/routing; health zeigt
gateway_reachable. Frontend: Modelle&Routing mit Caps-Chips, Fit-Badges,
Discover-Tab (live), Routing-View.

Lokal verifiziert: Backend-Smoke (alle Endpunkte) + Frontend-Build +
Browser (Shell, Discover, Caps/Fit). Box-Verifikation offen.

Docs: README + docs/STATUS.md (Phasen-Tracker + Resume-Guide).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 08:01:12 +02:00
parent 1b421e30f9
commit cff3f0b1a8
25 changed files with 1253 additions and 235 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CP2tEA8E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGVxdi48.css">
<script type="module" crossorigin src="/assets/index-BeAucPZJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NuZDhqZy.css">
</head>
<body>
<div id="root"></div>
+5 -1
View File
@@ -3,6 +3,7 @@ import { Command as CommandIcon } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ModelsView } from "@/views/ModelsView"
import { RoutingView } from "@/views/RoutingView"
import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils"
@@ -88,7 +89,10 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-6">
{view === "models" && <ModelsView />}
{view !== "models" && <Placeholder title={active.label} hint={active.hint} />}
{view === "routing" && <RoutingView />}
{view !== "models" && view !== "routing" && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
import type { Capabilities } from "@/lib/api"
function Chip({ children, tone = "muted" }: { children: React.ReactNode; tone?: "muted" | "primary" | "warn" }) {
const tones = {
muted: "bg-muted text-muted-foreground",
primary: "bg-primary/15 text-primary",
warn: "bg-amber-500/15 text-amber-500",
}
return (
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${tones[tone]}`}>{children}</span>
)
}
export function CapsChips({ caps }: { caps?: Capabilities }) {
if (!caps) return null
return (
<span className="inline-flex flex-wrap gap-1">
{caps.coder && <Chip>💻 Code</Chip>}
{caps.vision && <Chip>👁 Bild</Chip>}
{caps.reasoning && <Chip>🧠 Reason</Chip>}
{caps.moe && <Chip tone="primary">🧩 MoE{caps.active_b ? `·${caps.active_b}b` : ""}</Chip>}
{caps.tools === "yes" && <Chip tone="primary">🛠 Tools</Chip>}
{caps.tools === "likely" && <Chip tone="warn">🛠 Tools?</Chip>}
{caps.embedding && <Chip>🔢 Embed</Chip>}
</span>
)
}
+58
View File
@@ -9,6 +9,26 @@ export async function api<T = unknown>(path: string, init?: RequestInit): Promis
return res.json() as Promise<T>
}
export interface Capabilities {
moe: boolean
active_b: number | null
tools: "yes" | "likely" | "no"
vision: boolean
coder: boolean
reasoning: boolean
embedding: boolean
ctx: number | null
params_b: number | null
arch: string | null
}
export interface Fit {
level: "perfect" | "marginal" | "too_tight"
text: string
req_gb: number
tps: number
}
export interface ModelInfo {
name: string
role: string | null
@@ -17,14 +37,52 @@ export interface ModelInfo {
ctx: number | null
ttl: number | null
cmd: string
gguf_path: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
capabilities: Capabilities
}
export interface DiscoverModel {
name: string
author: string
repo: string
role: string
params_b: number
quant: string
tags: string[]
downloads: number
fit: Fit
optimal_ctx: number
caps: Capabilities
}
export interface DiscoverCategory {
role: string
title: string
icon: string
models: DiscoverModel[]
recommended: string | null
}
export interface DiscoverResp {
updated: number
categories: DiscoverCategory[]
sys_ram_gb: number
}
export interface RoutingResp {
routes: { name: string; target: string; api_base: string | null }[]
fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean
}
export interface Health {
status: string
version: string
engine_reachable: boolean
gateway_reachable: boolean
}
+136 -61
View File
@@ -1,18 +1,31 @@
import { useEffect, useState } from "react"
import { api, type ModelInfo } from "@/lib/api"
import { api, type DiscoverResp, type Fit, type ModelInfo } from "@/lib/api"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
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`
return c ? `${Math.round(c / 1024)}k` : "—"
}
export function ModelsView() {
function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-500",
marginal: "bg-amber-500/15 text-amber-500",
too_tight: "bg-red-500/15 text-red-500",
}[fit.level]
return (
<span className={cn("rounded px-1.5 py-0.5 text-[11px] font-medium", tone)}>
{fit.text} · ~{fit.req_gb} GB
</span>
)
}
function Installed() {
const [models, setModels] = useState<ModelInfo[]>([])
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
@@ -24,71 +37,133 @@ export function ModelsView() {
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-sm text-muted-foreground">Lade</div>
if (error)
return (
<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>
)
return (
<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">Fähigkeiten</th>
<th className="px-4 py-2 font-medium">Kontext</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}</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">
<CapsChips caps={m.capabilities} />
</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">{fmtSize(m.size_bytes)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function Discover() {
const [data, setData] = useState<DiscoverResp | null>(null)
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
useEffect(() => {
api<DiscoverResp>("/api/discover")
.then(setData)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-sm text-muted-foreground">Suche aktuelle Modelle</div>
if (error || !data)
return (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Modell-Quellen gerade nicht erreichbar ({error}).
</div>
)
return (
<div className="space-y-6">
<div className="text-xs text-muted-foreground">
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · = beste Wahl je Kategorie
</div>
{data.categories.map((cat) => (
<div key={cat.role} className="space-y-2">
<h3 className="text-sm font-semibold">{cat.title}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{cat.models.map((m) => (
<div key={m.repo} className="rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-2">
{cat.recommended === m.repo && <span title="beste Wahl"></span>}
<span className="truncate text-sm font-medium">{m.name}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">{m.author}</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<CapsChips caps={m.caps} />
<FitBadge fit={m.fit} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
export function ModelsView() {
const [tab, setTab] = useState<"installed" | "discover">("installed")
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.
Installierte Modelle (mit Fähigkeiten) und aktuell beste Modelle für deine Hardware.
</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>
)}
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm">
{(["installed", "discover"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-md px-3 py-1.5 transition-colors",
tab === t ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{t === "installed" ? "Installiert" : "Modelle finden"}
</button>
))}
</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>
)}
{tab === "installed" ? <Installed /> : <Discover />}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react"
import { api, type RoutingResp } from "@/lib/api"
import { cn } from "@/lib/utils"
export function RoutingView() {
const [data, setData] = useState<RoutingResp | null>(null)
const [error, setError] = useState("")
useEffect(() => {
api<RoutingResp>("/api/routing").then(setData).catch((e) => setError(String(e)))
}, [])
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Routing</h1>
<p className="text-sm text-muted-foreground">
Der LiteLLM-Gateway bündelt alle Modelle zu einem Endpunkt. <code>auto</code> = schnell im
Alltag, eskaliert bei Bedarf auf <code>heavy</code>. Gilt für Hermes <em>und</em> Vibe Coding.
</p>
</div>
{error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Gateway-Config nicht lesbar ({error}).
</div>
)}
{data && (
<>
<div className="flex items-center gap-2 text-sm">
<span
className={cn(
"h-2 w-2 rounded-full",
data.gateway_reachable ? "bg-emerald-500" : "bg-amber-500",
)}
/>
Gateway {data.gateway_reachable ? "online" : "offline (Config wird trotzdem angezeigt)"}
</div>
<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">Gateway-Modell</th>
<th className="px-4 py-2 font-medium"> Backend (llama-swap)</th>
</tr>
</thead>
<tbody>
{data.routes.map((r) => (
<tr key={r.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">{r.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.target}</td>
</tr>
))}
</tbody>
</table>
</div>
{data.fallbacks.length > 0 && (
<div className="rounded-xl border border-border bg-card p-4 text-sm">
<div className="mb-2 font-medium">Eskalation (Fallbacks)</div>
<ul className="space-y-1 text-muted-foreground">
{data.fallbacks.map((f, i) => {
const [k, v] = Object.entries(f)[0]
return (
<li key={i}>
<code>{k}</code> <code>{v.join(", ")}</code>
</li>
)
})}
</ul>
</div>
)}
</>
)}
</div>
)
}