umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben

Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:39:07 +02:00
co-authored by Claude Opus 5.5
parent 3d2c9549fb
commit 907289d7dc
223 changed files with 8572 additions and 15393 deletions
+40 -635
View File
@@ -1,644 +1,49 @@
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
//
// v3-Umbau P1 (28.08.2026): Hier wurden frueher zwei Geheimnisse aus dem localStorage
// gelesen und an jeden mutierenden Request gehaengt — das Box-Sudo-Passwort (als Header
// UND im JSON-Rumpf) und der HuggingFace-Token. Da der Dienst-Nutzer auf der Box mit
// `NOPASSWD: ALL` laeuft, war ein XSS in dieser SPA gleichbedeutend mit Root.
// Das Sudo-Passwort ist ersatzlos weg (auf der Box gemessen: `sudo -n true` laeuft durch,
// es wurde nie gebraucht), der HF-Token liegt jetzt auf der Box (services/geheimnisse.py).
// Diese Datei kennt kein Geheimnis mehr — und soll auch nie wieder eines kennen.
// Dünner Zugriff auf das MC2-Backend. Bewusst ohne Anmelde- oder Geheimnis-Felder:
// Nichts, was der Browser speichert, reist hier mit (Lehre aus v3-Umbau P1).
/** Fehler mit dem, was das Backend WIRKLICH gesagt hat.
*
* Vorher warf der Helfer `new Error("500 Internal Server Error")` und verwarf den Rumpf.
* FastAPI legt den eigentlichen Grund aber in `detail` — der erreichte die Oberflaeche nie,
* und der Nutzer las "500" statt "Dienst 'llama-swap' nicht erlaubt". */
export class ApiError extends Error {
readonly status: number
readonly detail: string | null
constructor(status: number, statusText: string, detail: string | null) {
super(detail || `${status} ${statusText}`)
this.name = "ApiError"
this.status = status
this.detail = detail
export class ApiFehler extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail)
this.name = "ApiFehler"
}
}
/** Liest den Fehler-Rumpf, ohne selbst zu scheitern: Ein kaputter oder leerer Rumpf darf
* die Fehlermeldung nicht in einen zweiten Fehler verwandeln. */
async function fehlerText(res: Response): Promise<string | null> {
/** FastAPI legt Fehlertexte in `detail` ab — manchmal als Text, manchmal als Liste. */
function detailAus(body: unknown, status: number): string {
if (body && typeof body === "object" && "detail" in body) {
const d = (body as { detail: unknown }).detail
if (typeof d === "string") return d
if (Array.isArray(d)) return d.map((x) => (x as { msg?: string }).msg ?? String(x)).join("; ")
}
return `Die Box antwortet mit Fehler ${status}.`
}
export async function api<T>(pfad: string, init: RequestInit = {}): Promise<T> {
let antwort: Response
try {
const roh = await res.text()
if (!roh) return null
try {
const d = JSON.parse(roh)
const detail = d?.detail ?? d?.err ?? d?.message
if (typeof detail === "string" && detail.trim()) return detail.trim()
if (Array.isArray(detail) && detail.length) {
// FastAPI-Validierungsfehler: Liste aus {loc, msg, type}
return detail.map((e: any) => e?.msg).filter(Boolean).join("; ") || null
}
return null
} catch {
// Kein JSON (HTML-Fehlerseite eines Proxys o. AE.) — erste Zeile reicht als Hinweis.
return roh.slice(0, 200).split("\n")[0].trim() || null
}
antwort = await fetch(pfad, {
...init,
headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
})
} catch {
return null
throw new ApiFehler(0, "MC2 ist nicht erreichbar. Läuft die Box?")
}
const text = await antwort.text()
const body = text ? safeJson(text) : null
if (!antwort.ok) throw new ApiFehler(antwort.status, detailAus(body, antwort.status))
return body as T
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return text
}
}
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
})
if (!res.ok) throw new ApiError(res.status, res.statusText, await fehlerText(res))
return res.json() as Promise<T>
}
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
export interface GroupSpec {
swap: boolean
persist: boolean
members: string[]
}
export interface GroupsResp {
groups: Record<string, GroupSpec>
}
export const getGroups = () => api<GroupsResp>("/api/groups")
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
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 RoleRecModel {
name: string
current_role: string | null
params_b: number
quant: string
fit: Fit
suitable: boolean
incomplete: boolean
score: number
reason: string
recommended: boolean
}
export interface RoleRecResp {
role: string
recommended: string | null
models: RoleRecModel[]
}
export interface FitResp {
params_b: number
fit: Fit
optimal_ctx: number
assigned_ctx: number
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
sys_ram_gb: number
}
export interface ModelInfo {
name: string
role: string | null
aliases: string[]
api_ids: string[]
ctx: number | null
ttl: number | null
cmd: string
gguf_path: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
prompt_cache: boolean
spec_draft_model: string | null
spec_type: string | null
spec_active: boolean
parallel_slots: number
capabilities: Capabilities
}
export interface VocabFingerprint {
model: string | null
pre: string | null
n_vocab: number | null
arch: string | null
}
export interface DraftInfo {
path: string
filename: string
size_bytes: number | null
vocab: VocabFingerprint | null
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
}
export interface DraftsResp {
target_path: string
target_exists: boolean
target_vocab: VocabFingerprint | null
drafts: DraftInfo[]
}
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 {
mode?: string
endpoint?: string
heavy_threshold_chars?: number
routes: { name: string; target: string }[]
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean
}
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
// hinter den Lanes; hot-reload im Backend (kein Restart).
export interface RoutingPolicy {
fast: string
heavy: string
coder: string
coder_lite: string
heavy_chars: number
coding_escalate_chars: number
fast_no_think: boolean
}
export interface RoutingPolicyField {
key: keyof RoutingPolicy
label: string
type: "str" | "int" | "bool"
min?: number
max?: number
}
export interface RoutingPolicyMeta {
policy: RoutingPolicy
defaults: RoutingPolicy
fields: RoutingPolicyField[]
}
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
export interface GitInfo {
hash: string
date: string
subject: string
branch: string
dirty: boolean
path: string
}
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
export interface ComponentVersion {
type: "git" | "binary" | "unknown"
hash?: string
date?: string
subject?: string
branch?: string
dirty?: boolean
path?: string
version_text?: string
}
export interface Versions {
mc2: GitInfo | null
engine: ComponentVersion
hermes_agent: GitInfo | null
}
export interface SystemStatus {
cpu: { percent: number; cores: number | null }
ram: { total: number; used: number; percent: number }
gpu: {
busy_percent: number | null
vram_used: number | null
vram_total: number | null
gtt_used?: number | null
gtt_total?: number | null
} | null
temp: { cpu?: number; gpu?: number } | null
disk: { total: number; used: number; percent: number } | null
/** Sekunden seit dem Systemstart (v3-Umbau P3). null, wenn die Box es nicht liefert —
* die Statusleiste blendet das Feld dann aus, statt eine Zahl zu erfinden. */
uptime_s?: number | null
versions?: Versions
}
export interface ServiceInfo {
name: string
unit: string // echter systemd-Name — Basis für Restart/Logs/Matching (kein Namens-Raten)
url: string
ok: boolean
scope: "user" | "system"
}
export interface ServicesResp {
services: ServiceInfo[]
links: { engine_ui: string; gateway: string; hermes_ui: string }
}
export interface ComponentUpdate {
key: string // z.B. "hermes_agent"
name: string
current: string | null
latest: string | null
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
reachable: boolean | null
}
export interface UpdatesResp {
os: number
engine: number
swap: number
models: number
model_list: { role: string; title: string; repo: string }[]
last_check?: number | null
components?: ComponentUpdate[]
}
export interface UpdateDetails {
kind: "os" | "engine" | "swap" | "hermes"
error?: string
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
summary?: string
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
action_needed?: boolean | null
action_text?: string
// os
count?: number
packages?: { name: string; current: string; candidate: string }[]
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
// engine
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
name?: string | null
url?: string | null
body?: string | null
// hermes
branch?: string | null
behind?: number
commits?: { hash: string; subject: string; when: string }[]
}
export interface ConnectTool {
label: string
lang: string
snippet: string
note: string
}
export interface ConnectResp {
host: string
gateway_url: string
mc_url: string
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
}
export interface ConnectLine {
ok: boolean
detail: string
}
export interface ConnectHealth {
gateway: ConnectLine
memory: ConnectLine // Leitung 2 — Hermes-natives Gedächtnis (nur Status, nichts zu konfigurieren)
desktop_gateway: ConnectLine
}
// Graph-Ansicht des Wissens-Vaults (GET /api/wissen/graph) — trägt aus historischen Gründen
// noch „Memory" im Namen; die Daten kommen aus dem Vault, nicht aus einem Gedächtnis-Dienst.
export interface MemoryGraphNode {
id: string
content: string
category: string
source: string
}
export interface MemoryGraph {
nodes: MemoryGraphNode[]
edges: { source: string; target: string; weight: number }[]
}
export interface AgentStatus {
gateway_url: string
box_console_url?: string
hermes_ui_url?: string
gateway_reachable: boolean
box_console_reachable?: boolean
hermes_ui_reachable?: boolean
home_exists: boolean
brain_model?: string
has_config: boolean
has_skills: boolean
has_memories: boolean
telegram_enabled?: boolean
mcp_server_count?: number
pc_executor_reachable?: boolean
pc_executor_url?: string
}
export interface Job {
id: string
label: string
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
state: "queued" | "running" | "done" | "failed" | "canceled"
progress?: number
total_bytes?: number
done_bytes?: number
rate_bps?: number
eta_s?: number
}
// Werkzeug-Verlauf des Agenten (GET /api/agent/aktivitaet, v3-Umbau P6).
// Gelesen aus Hermes' Log — Denkstrom und Werkzeug-Argumente stehen dort NICHT und
// tauchen darum auch hier nicht auf.
export interface WerkzeugRuf {
zeit: string
lauf: string | null
werkzeug: string
dauer_s: number
zeichen: number | null
ok: boolean
fehler: string | null
}
export interface WerkzeugBilanz {
werkzeug: string
rufe: number
fehler: number
dauer_schnitt_s: number
letzter_fehler: string | null
}
export interface AgentLauf {
lauf: string
von: string
bis: string
rufe: number
fehler: number
}
export interface AktivitaetResp {
verfuegbar: boolean
pfad: string
rufe: WerkzeugRuf[]
werkzeuge: WerkzeugBilanz[]
laeufe: AgentLauf[]
}
export interface Health {
status: string
version: string
engine_reachable: boolean
gateway_reachable: boolean
brain?: { role: string; model: string | null; ready: boolean }
}
// 24-h-Metrik-Verlauf (GET /api/system/history?minutes=N) — Basis der Cockpit-Zeitachse.
// tp/tc sind Token-GESAMTZÄHLER; Raten rechnet das Frontend aus den Deltas.
export interface HistoryPoint {
t: number // Epoch-Sekunden
cpu: number | null
ram: number | null
gpu: number | null
disk: number | null
tp: number | null
tc: number | null
}
export interface HistoryResp {
sample_s: number
points: HistoryPoint[]
}
export interface TokenStats {
prompt_tokens: number
completion_tokens: number
total_tokens: number
saved_usd: number
saved_eur: number
pricing?: Record<string, { in: number; out: number }>
}
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
// stt · vision · hirn · gen. Der Gedächtnis-Abruf läuft seit der Mem0-Ablösung Hermes-intern
// (kein Rückruf an MC2) und steckt daher unsichtbar in hirn_ms.
export interface VoiceTurn {
id: string
ts: number // Epoch-Sekunden
session: string
kind: string
had_images: boolean
stt_ms: number | null
vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Gedächtnis + LLM-TTFT)
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
total_ms: number
error: string | null
}
export interface VoiceTraceResp {
turns: VoiceTurn[]
}
export interface ModelsResp {
models: ModelInfo[]
running?: string[]
}
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
export interface IdeenItem {
id: string
titel: string
body: string
status: string // triage | todo | ready | running | blocked | review | done
assignee: string | null
welt?: string // "box" = Werkstatt baut · "ide" = für Zed vorbereitet
art?: string // "box" | "idee" (nur durchdacht) | "projekt" (Repo vorbereitet)
erstellt: number | null
fertig: number | null
von: string | null
frage?: string | null // bei status=blocked: die Frage/der Grund des Workers
frage_kind?: string | null // needs_input | capability | transient | ""
eltern?: string[] // offene Vorgänger-Karten (task_links) — leer = startbereit
wartet_auf?: string[] // Titel der offenen Vorgänger (max. 3, für die Anzeige)
notiz?: string | null // bei running: jüngste Heartbeat-Notiz des Workers
projekt?: string | null // Familien-Schlüssel (verkettete Karten) — null = Einzelkarte
prio?: number // Dispatcher-Reihenfolge: priority DESC, created ASC
puls_alter?: number | null // bei running: Sekunden seit letztem Lebenszeichen (Hänger-Warnung)
gestartet?: number | null // bei running: started_at (Epoch)
}
// Karten-Familie (verkettete Karten = ein Projekt) für die gruppierte Queue-Sicht
export interface IdeenProjekt {
key: string
titel: string
gesamt: number
fertig: number
laeuft: number
haengt: number
}
export interface IdeenLogResp {
available: boolean
lines: string[]
}
export interface IdeenResp {
available: boolean
items: IdeenItem[]
offen: number
fehler?: string
projekte?: IdeenProjekt[]
}
// Ergebnis einer fertigen Queue-Aufgabe (Abschluss-Zusammenfassung des Workers)
export interface IdeenErgebnis {
available: boolean
summary: string
result: string
}
// Konzept einer IDE-Karte — aus dem Gitea-Repo oder aus ~/konzepte auf der Box
export interface IdeenKonzept {
available: boolean
repo?: string | null
clone_url?: string
quelle?: "repo" | "datei"
pfad?: string // nur quelle=datei: Pfad auf der Box
datei?: string
konzept?: string
error?: string
}
// ── Chronik (Timeline aus dem Melde-Briefkasten) ─────────────────────────────
export interface ChronikItem {
id: number
ts: number
subject: string
text: string
source: string
priority: "normal" | "silent"
}
export interface ChronikResp {
items: ChronikItem[]
}
// ── Wissens-Vault (Traum-Notizen, read-only) ─────────────────────────────────
export interface WissenFile {
path: string
name: string
dir: string
title: string
mtime: number
neu: boolean
}
export interface WissenResp {
available: boolean
files: WissenFile[]
}
export interface WissenDatei {
path: string
content: string
mtime: number
}
// ── Zeitmaschine (Snapshots + Restore) ───────────────────────────────────────
export interface Snapshot {
snapshot: string
file: string
size_mb: number
}
export interface ZeitmaschineResp {
available: boolean
backups: Snapshot[]
}
// ── Erinnerungen (Wecker/Routinen) ───────────────────────────────────────────
export interface Reminder {
id: number
text: string
next_ts: number
repeat: string // '' einmalig | daily | weekdays | weekly
created_ts: number
}
export interface HermesBrainModel {
name: string
filename?: string
params_b: number | null
quant?: string
size_bytes?: number | null
version?: number | null
gguf_path?: string
incomplete?: boolean
}
export interface HermesBrainCandidate {
repo: string
name: string
version: number
params_b: number
downloads: number
fit: Fit
}
export interface HermesBrainBudget {
gtt_gb: number
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
warm_projected_gb: number // alle persist-Modelle warm (Info)
free_after_gb: number // frei nach Brain + größtem on-demand
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
}
export interface HermesBrainResp {
current: HermesBrainModel | null
recommended: HermesBrainCandidate | null
update_available: boolean
budget?: HermesBrainBudget | null
}
// ── Ideen-Log (Live Worker Log) ──────────────────────────────────────────────
export const getIdeenLog = (id: string) => api<IdeenLogResp>(`/api/ideen/${id}/log`)
export const post = <T>(pfad: string, daten?: unknown) =>
api<T>(pfad, { method: "POST", body: daten === undefined ? undefined : JSON.stringify(daten) })