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
+173
View File
@@ -0,0 +1,173 @@
// Alle Abfragen und Aktionen der Oberfläche an einem Ort (TanStack Query).
//
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
// Sicherheitsnetz und laufen fünfmal seltener.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api, post } from "./api"
import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, Job, Modell, Nutzung, Radar, Sicherung, Start,
} from "./typen"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
export const useStart = () =>
useQuery({ queryKey: ["start"], queryFn: () => api<Start>("/api/start"), refetchInterval: takt(20_000) })
export const useModelle = () =>
useQuery({
queryKey: ["models"],
queryFn: () => api<{ models: Modell[]; running: string[] }>("/api/models"),
refetchInterval: takt(30_000),
})
export const useNutzung = () =>
useQuery({ queryKey: ["nutzung"], queryFn: () => api<Nutzung>("/api/modelle/nutzung"), refetchInterval: 600_000 })
export const useRadar = () =>
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
export const useJobs = () =>
useQuery({
queryKey: ["jobs"],
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
// Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
refetchInterval: (q) =>
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
})
export const useSicherungen = () =>
useQuery({
queryKey: ["sicherungen"],
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
})
export interface UpdateDetails {
kind: string
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
count?: number
packages?: { name: string; current: string; candidate: string }[]
summary?: string
action_needed?: boolean | null
action_text?: string
error?: string
}
export const useUpdateDetails = (art: string | null) =>
useQuery({
queryKey: ["update-details", art],
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
enabled: !!art,
staleTime: 600_000,
})
export interface UpdateLauf {
start: string | null
ende: string | null
status: string
fehler: string | null
job: string
}
export const useUpdateVerlauf = () =>
useQuery({ queryKey: ["updates-verlauf"], queryFn: () => api<{ laeufe: UpdateLauf[] }>("/api/updates/verlauf") })
export const useProtokoll = (dienst: string | null) =>
useQuery({
queryKey: ["protokoll", dienst],
queryFn: () => api<{ ok: boolean; text?: string; err?: string }>(
`/api/maintenance/logs?service=${encodeURIComponent(dienst ?? "")}&lines=200`),
enabled: !!dienst,
})
export interface Dienst {
name: string
unit: string
ok: boolean
scope: string
}
export const useDienste = () =>
useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") })
// --- Aktionen ----------------------------------------------------------------
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
fn: (v: V) => Promise<E>,
opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] },
) {
const qc = useQueryClient()
return useMutation({
mutationFn: fn,
onSuccess: (e, v) => {
const ergebnis = e as AktionsErgebnis
if (ergebnis && ergebnis.ok === false) {
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.")
} else {
melden("erfolg", opt.erfolg(v, e))
}
for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] })
},
onError: (f: Error) => melden("fehler", f.message),
})
}
export const useHinweisAktion = () =>
useAktion(
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
{ erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] },
)
const UPDATE_PFADE: Record<string, string> = {
alle: "/api/maintenance/update-all",
pruefen: "/api/maintenance/check-updates",
os: "/api/maintenance/os-update",
engine: "/api/maintenance/engine-update",
swap: "/api/maintenance/swap-update",
hermes: "/api/maintenance/hermes-update",
}
export const useUpdateStarten = () =>
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), {
erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."),
neuLaden: ["jobs", "start"],
})
export const useJetztSichern = () =>
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
erfolg: () => "Sicherung erstellt.",
neuLaden: ["sicherungen", "start"],
})
export const useZurueckspielen = () =>
useAktion((datei: string) => post<AktionsErgebnis>("/api/zeitmaschine/restore", { file: datei }), {
erfolg: () => "Zurückspielen läuft. Die Dienste starten gleich neu.",
neuLaden: ["sicherungen"],
})
export const useModellLaden = () =>
useAktion((name: string) => post<AktionsErgebnis>(`/api/models/${encodeURIComponent(name)}/load`), {
erfolg: (name) => `${name} wird geladen.`,
neuLaden: ["models", "start"],
})
export const useRadarAktion = () =>
useAktion(
({ id, aktion }: { id: string; aktion: "uebernehmen" | "verwerfen"; name: string }) =>
post<AktionsErgebnis>(`/api/radar/${encodeURIComponent(id)}/${aktion}`),
{
erfolg: (v) => (v.aktion === "uebernehmen" ? `${v.name} übernommen.` : `${v.name} verworfen.`),
neuLaden: ["radar", "models", "start"],
},
)
export const useRadarSuche = () =>
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
erfolg: () => "Radar sucht nach neuen Modellen.",
neuLaden: ["radar"],
})
+78
View File
@@ -0,0 +1,78 @@
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne
// DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
import type { UpdateDetails } from "./abfragen"
import type { Modell, Start } from "./typen"
// --- Rundinstrument -----------------------------------------------------------------
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
const MX = 100
const MY = 95
const START = 210 // Grad, links unten
const SPANNE = 240
function punkt(grad: number, r: number): [number, number] {
const rad = (grad * Math.PI) / 180
return [MX + r * Math.cos(rad), MY - r * Math.sin(rad)]
}
const r2 = (n: number) => Math.round(n * 100) / 100
/** SVG-Pfad eines Bogens von Anteil `von` bis `bis` (0…1) auf Radius `r`. */
export function bogenPfad(von: number, bis: number, r: number): string {
const a = Math.max(0, Math.min(1, von))
const b = Math.max(a, Math.min(1, bis))
const [x0, y0] = punkt(START - SPANNE * a, r)
const [x1, y1] = punkt(START - SPANNE * b, r)
const gross = (b - a) * SPANNE > 180 ? 1 : 0
return `M ${r2(x0)} ${r2(y0)} A ${r} ${r} 0 ${gross} 1 ${r2(x1)} ${r2(y1)}`
}
/** Farbe der Temperatur: grün bis 70 °C, dann Bernstein, ab 85 °C rot. */
export function temperaturFarbe(grad: number | null | undefined) {
if (grad == null) return "var(--text-3)"
if (grad >= 85) return "var(--rot)"
if (grad >= 70) return "var(--bernstein)"
return "var(--gruen)"
}
// --- Hauptwarnleuchte ---------------------------------------------------------------
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
export function hauptleuchte(z: Start["zustand"]) {
if (!z.waechter_wach) {
return { art: "stumm" as const, oben: "Wächter", mitte: "SCHWEIGT", unten: "Seit über 5 Minuten kein Prüflauf" }
}
if (z.update_laeuft) {
return { art: "info" as const, oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Hinweise ruhen bis zum Ende" }
}
if (z.anzahl === 0) {
return { art: "ok" as const, oben: "Alles", mitte: "IN ORDNUNG", unten: "Keine offenen Hinweise" }
}
const mitte = `${z.anzahl} HINWEIS${z.anzahl === 1 ? "" : "E"}`
return z.stufe === "rot"
? { art: "rot" as const, oben: "Störung", mitte, unten: "Drücken zum Ansehen" }
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
}
// --- Updates ------------------------------------------------------------------------
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
if (!d) return { laeuft: "…", neu: null }
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
const pre = id === "engine" ? "b" : "v"
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
}
// --- Modelle ------------------------------------------------------------------------
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
export function rolleVon(m: Modell): "hirn" | "coder" | null {
const a = m.aliases.map((x) => x.toLowerCase())
if (a.includes("hermes") || a.includes("fast")) return "hirn"
if (a.includes("coder")) return "coder"
return null
}
-116
View File
@@ -1,116 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { ApiError, api } from "./api"
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
try {
await api(pfad, init)
} catch (e) {
if (e instanceof ApiError) return e
throw e
}
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
}
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
function antwort(status: number, rumpf: string, ok = status < 400): Response {
return {
ok,
status,
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
text: async () => rumpf,
json: async () => JSON.parse(rumpf),
} as unknown as Response
}
function fetchAttrappe(res: Response) {
const spion = vi.fn(async () => res)
vi.stubGlobal("fetch", spion)
return spion
}
afterEach(() => vi.unstubAllGlobals())
describe("api()", () => {
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
})
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
await expect(api("/api/maintenance/restart", { method: "POST" }))
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
})
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
await expect(api("/api/models/install", { method: "POST" }))
.rejects.toThrowError("repo fehlt; quant ungueltig")
})
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
fetchAttrappe(antwort(500, ""))
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
})
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
const fehler = await fehlerVon("/api/test")
expect(fehler.status).toBe(502)
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
expect(typeof fehler.message).toBe("string")
expect(fehler.message.length).toBeGreaterThan(0)
})
it("traegt status und detail am Fehlerobjekt", async () => {
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
const fehler = await fehlerVon("/api/test")
expect(fehler.status).toBe(400)
expect(fehler.detail).toBe("kaputt")
})
})
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
// Bequemlichkeit je wieder einbaut, macht sie rot.
describe("api() traegt keine Geheimnisse", () => {
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
localStorage.setItem("mc_sudo_password", "geheim123")
localStorage.setItem("mc_hf_token", "hf_geheim")
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
await api("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: "llama-swap" }),
})
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
const kopfzeilen = JSON.stringify(init.headers ?? {})
const rumpf = String(init.body ?? "")
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
expect(kopfzeilen).not.toContain("geheim123")
expect(rumpf).not.toContain("geheim123")
expect(rumpf).not.toContain("hf_geheim")
expect(rumpf).not.toContain("sudo_password")
expect(rumpf).not.toContain("hf_token")
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
})
it("erfindet auch ohne Rumpf keinen", async () => {
localStorage.setItem("mc_sudo_password", "geheim123")
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
await api("/api/maintenance/reboot", { method: "POST" })
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
expect(init.body ?? null).toBeNull()
})
})
+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) })
-95
View File
@@ -1,95 +0,0 @@
// Ereignisstrom — Gegenstück zu backend/routers/events.py.
//
// EIN EventSource auf /api/stream. Zwei Ereignisarten:
// · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
// · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
//
// Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
// sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
// pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
//
// v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
// `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
// unabhängig davon, ob sich etwas geändert hatte. Beide Poller sind ersatzlos weg,
// solange der Strom steht.
//
// WICHTIG (der Grund, warum der Metrik-Speicher kein Store ist): `metrik` feuert jede
// Sekunde. Der Speicher liegt bewusst AUSSERHALB von React (lib/metricsStore.ts,
// useSyncExternalStore) — so rendern nur die Karten neu, die den Verlauf wirklich
// abonnieren, nicht die ganze Shell.
import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
import { pushMetrik } from "@/lib/metricsStore"
let verbunden = false
export const sseVerbunden = () => verbunden
/** Rohform eines `metrik`-Ereignisses (siehe services/system.py → metrik_punkt). */
export interface MetrikPunkt {
cpu: number
ram: number
ram_used: number
ram_total: number
gpu: number | null
disk: number | null
temp_cpu: number | null
temp_gpu: number | null
uptime_s: number | null
/** GESAMTZÄHLER, keine Rate — die rechnet der Speicher aus zwei Punkten selbst. */
tok_p: number
tok_c: number
}
export function useEventStream() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/stream")
// Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst
// begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der
// Übergang: einmal beim Reißen, einmal beim Heilen.
let warSchonMalVerbunden = false
es.onopen = () => {
verbunden = true
stromLageSetzen("live")
if (warSchonMalVerbunden) melden("erfolg", "Verbindung zur Zentrale ist wieder da.")
warSchonMalVerbunden = true
// Beim (Wieder-)Verbinden einmal alles nachziehen: Was während der Trennung
// passiert ist, hat der Strom nicht gemeldet.
qc.invalidateQueries()
}
es.onerror = () => {
if (!verbunden) return
verbunden = false
// „nachlauf" statt „getrennt": EventSource reconnectet selbst, und bis dahin
// pollen die Queries weiter — die Zahlen sind also älter, aber nicht tot.
stromLageSetzen("nachlauf")
melden("warnung", "Verbindung zur Zentrale verloren — die Anzeigen laufen im Nachlauf.")
// Sofort auf den normalen Takt zurück, statt bis zum nächsten Refetch zu warten.
qc.invalidateQueries()
}
es.addEventListener("invalidate", (e) => {
try {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
stromEreignis()
} catch { /* kaputtes Event bricht den Strom nicht */ }
})
es.addEventListener("metrik", (e) => {
try {
pushMetrik(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch { /* ein kaputter Messpunkt darf den Strom nicht reißen */ }
})
return () => {
verbunden = false
stromLageSetzen("getrennt")
es.close()
}
}, [qc])
}
-38
View File
@@ -1,38 +0,0 @@
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
export function gb(b: number): string {
return (b / 1024 ** 3).toFixed(1)
}
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
export function fmtBytes(b?: number): string {
if (!b) return ""
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
return `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
export function fmtSize(b?: number | null): string {
if (!b) return "—"
const g = b / 1024 ** 3
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
export function fmtEta(s?: number): string {
if (!s) return ""
const m = Math.floor(s / 60)
return m > 0 ? `${m} min` : `${s} s`
}
/** Kontextlänge → "32k"; "—" bei null. */
export function fmtCtx(c: number | null): string {
return c ? `${Math.round(c / 1024)}k` : "—"
}
/** Unix-Sekunden → "25.07., 21:04"; "—" bei 0/null/undefined. */
export function fmtWhen(ts: number | null | undefined): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
}
-70
View File
@@ -1,70 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest"
import { hashUmleiten } from "./hashUmleitung"
import { NAV, navFuerPfad } from "@/nav"
function adresseSetzen(url: string) {
window.history.replaceState(null, "", url)
}
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
// Tests machen sichtbar, was dabei kaputtginge.
describe("hashUmleiten()", () => {
beforeEach(() => adresseSetzen("/"))
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
for (const eintrag of NAV) {
adresseSetzen("/#" + eintrag.id)
hashUmleiten()
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
}
})
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
adresseSetzen("/#/models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
})
it("behält vorhandene Suchparameter", () => {
adresseSetzen("/?system=logs#models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
expect(window.location.search).toBe("?system=logs")
})
it("fasst einen unbekannten Hash nicht an", () => {
adresseSetzen("/cockpit#irgendwas")
hashUmleiten()
expect(window.location.pathname).toBe("/cockpit")
})
it("tut ohne Hash gar nichts", () => {
adresseSetzen("/modelle?reiter=routing")
hashUmleiten()
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
})
})
describe("navFuerPfad()", () => {
it("findet den Eintrag zum exakten Pfad", () => {
expect(navFuerPfad("/modelle")?.id).toBe("models")
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
})
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
})
it("liefert nichts für einen unbekannten Pfad", () => {
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
})
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
const treffer = navFuerPfad("/modelle")
expect(treffer?.pfad).toBe("/modelle")
})
})
-26
View File
@@ -1,26 +0,0 @@
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
//
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
//
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
//
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
import { NAV, type ViewId } from "@/nav"
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
NAV.map((n) => [n.id, n.pfad]),
) as Record<ViewId, string>
export function hashUmleiten(): void {
const hash = window.location.hash.replace(/^#\/?/, "")
if (!hash) return
const ziel = ALT_ZU_PFAD[hash as ViewId]
if (!ziel) return
window.history.replaceState(null, "", ziel + window.location.search)
}
-71
View File
@@ -1,71 +0,0 @@
import { cn } from "@/lib/utils"
// Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX,
// bewusst ohne externe Lib). Überschriften, Listen, fett/kursiv, `code`, Codeblöcke.
// Verwandt mit dem wiki-link-Renderer in WissenView, hier aber ohne [[links]] —
// gedacht für Doku-Anzeige (z. B. das KONZEPT.md eines vorbereiteten IDE-Projekts).
export function Markdown({ text, className }: { text: string; className?: string }) {
const lines = (text || "").split("\n")
const out: React.ReactNode[] = []
let list: React.ReactNode[] = []
let code: string[] | null = null
const flushList = (key: string) => {
if (list.length) {
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
list = []
}
}
lines.forEach((raw, i) => {
const key = `l${i}`
if (code !== null) {
if (raw.trimEnd() === "```") {
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
code = null
} else code.push(raw)
return
}
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
const line = raw.trimEnd()
if (!line.trim()) { flushList(key); return }
const h = line.match(/^(#{1,4})\s+(.*)$/)
if (h) {
flushList(key)
const level = h[1].length
const cls = level === 1 ? "text-base font-bold mt-1 mb-2" : level === 2 ? "text-sm font-bold mt-4 mb-1.5" : "text-xs font-bold mt-3 mb-1 uppercase tracking-wide text-muted-foreground"
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], key)}</p>)
return
}
const li = line.match(/^\s*[-*•]\s+(.*)$/)
if (li) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], key)}</li>)
return
}
const ol = line.match(/^\s*\d+\.\s+(.*)$/)
if (ol) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(ol[1], key)}</li>)
return
}
flushList(key)
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, key)}</p>)
})
flushList("end")
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
return <div className={cn("max-w-3xl", className)}>{out}</div>
}
// Inline: **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
function inline(text: string, keyBase: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
return parts.map((p, i) => {
const key = `${keyBase}-${i}`
if (/^\*\*[^*]+\*\*$/.test(p)) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
if (/^\*[^*]+\*$/.test(p)) return <i key={key}>{p.slice(1, -1)}</i>
if (/^`[^`]+`$/.test(p)) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-foreground/90">{p.slice(1, -1)}</code>
return <span key={key}>{p}</span>
})
}
+40
View File
@@ -0,0 +1,40 @@
// Kurze Rückmeldungen nach einer Aktion („Dienst neu gestartet“). Außerhalb von React
// gehalten, damit jede Stelle melden kann, ohne einen Kontext durchzureichen.
import { useSyncExternalStore } from "react"
export type MeldungsArt = "erfolg" | "fehler" | "info"
export interface Meldung {
id: number
art: MeldungsArt
text: string
}
let meldungen: Meldung[] = []
let naechste = 1
const hoerer = new Set<() => void>()
const melde = () => hoerer.forEach((h) => h())
export function melden(art: MeldungsArt, text: string) {
const id = naechste++
meldungen = [...meldungen, { id, art, text }].slice(-4)
melde()
// Fehler bleiben, bis man sie wegklickt — die will man lesen können.
if (art !== "fehler") window.setTimeout(() => entfernen(id), 6000)
}
export function entfernen(id: number) {
meldungen = meldungen.filter((m) => m.id !== id)
melde()
}
export const useMeldungen = () =>
useSyncExternalStore(
(h) => {
hoerer.add(h)
return () => {
hoerer.delete(h)
}
},
() => meldungen,
)
-120
View File
@@ -1,120 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { act, renderHook } from "@testing-library/react"
import type { MetrikPunkt } from "./events"
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
async function frischerStore() {
vi.resetModules()
return await import("./metricsStore")
}
function punkt(p: Partial<MetrikPunkt> = {}): MetrikPunkt {
return {
cpu: 10, ram: 20, ram_used: 1, ram_total: 2, gpu: null, disk: null,
temp_cpu: null, temp_gpu: null, uptime_s: 100, tok_p: 0, tok_c: 0, ...p,
}
}
describe("metricsStore", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
})
it("beginnt leer", () => {
const { result } = renderHook(() => store.useSysHistory())
expect(result.current).toEqual([])
})
it("meldet neue Messpunkte an die Abonnenten", () => {
const { result } = renderHook(() => store.useSysHistory())
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
expect(result.current).toHaveLength(1)
expect(result.current[0].cpu).toBe(10)
})
// Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
// pro Tag in einem Dauerläufer-Tab.
it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
const { result } = renderHook(() => store.useSysHistory())
const n = store.MAX_POINTS + 20
act(() => {
for (let i = 0; i < n; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
})
expect(result.current).toHaveLength(store.MAX_POINTS)
expect(result.current[0].t).toBe(n - store.MAX_POINTS)
expect(result.current[store.MAX_POINTS - 1].t).toBe(n - 1)
})
it("hält System- und Token-Verlauf auseinander", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
expect(sys.current).toHaveLength(0)
expect(tok.current).toHaveLength(1)
expect(tok.current[0].completion).toBe(7)
})
// useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
// Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Änderung ein NEUES Array (Referenzwechsel)", () => {
const { result } = renderHook(() => store.useSysHistory())
const vorher = result.current
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
expect(result.current).not.toBe(vorher)
})
})
// ── Der Einstieg des Ereignisstroms (v3-Umbau P4) ───────────────────────────
// Der Server schickt Token-GESAMTZÄHLER, keine Raten — die Umrechnung passiert hier.
// Genau dort steckt die Fehleranfälligkeit: ein Backend-Neustart setzt die Zähler
// zurück, und aus einem Rückwärtssprung darf keine negative Rate werden.
describe("pushMetrik (Strom-Einstieg)", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
vi.useFakeTimers()
})
it("schreibt beim ersten Punkt NOCH KEINE Rate — dafür fehlt der Vorgänger", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
expect(sys.current).toHaveLength(1)
expect(tok.current).toHaveLength(0)
})
it("rechnet aus zwei Zählerständen eine Rate pro Sekunde", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
act(() => {
vi.advanceTimersByTime(2_000)
store.pushMetrik(punkt({ tok_p: 300, tok_c: 90 }))
})
expect(result.current).toHaveLength(1)
expect(result.current[0].prompt).toBeCloseTo(100, 1) // 200 Token in 2 s
expect(result.current[0].completion).toBeCloseTo(20, 1) // 40 Token in 2 s
})
it("macht aus einem Zähler-Rücksprung keine negative Rate", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 5_000, tok_c: 5_000 })))
act(() => {
vi.advanceTimersByTime(1_000)
store.pushMetrik(punkt({ tok_p: 0, tok_c: 0 })) // Backend neu gestartet
})
expect(result.current[0].prompt).toBe(0)
expect(result.current[0].completion).toBe(0)
})
it("hält den letzten Messpunkt für die Statusleiste bereit", () => {
const { result } = renderHook(() => store.useLetzteMetrik())
expect(result.current).toBeNull()
act(() => store.pushMetrik(punkt({ uptime_s: 4_242, temp_cpu: 71 })))
expect(result.current?.uptime_s).toBe(4_242)
expect(result.current?.temp_cpu).toBe(71)
})
})
-114
View File
@@ -1,114 +0,0 @@
import { useEffect, useRef, useSyncExternalStore } from "react"
import { useSystemStatus, useTokenStats } from "@/lib/queries"
import type { MetrikPunkt } from "@/lib/events"
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
export type RateSample = { t: number; prompt: number; completion: number }
// v3-Umbau P4: Der Strom liefert jede Sekunde einen Punkt statt alle drei. Bei gleicher
// Fenstergröße (~2 Min Live-Verlauf) heißt das dreimal so viele Punkte.
// Exportiert, damit der Test gegen DIESEN Wert prüft und nicht gegen eine Kopie, die
// beim nächsten Takt-Wechsel still danebenliegt.
export const MAX_POINTS = 120
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
//
// Bewusst KEIN Zustand-Store (v3-Umbau P3/P4): Hier landet jede Sekunde ein Wert. Ein
// Store-Update pro Sekunde würde jede abonnierende Komponente neu rendern; mit einem
// eigenen `useSyncExternalStore` außerhalb von React rendern nur die Karten neu, die den
// Verlauf wirklich lesen. Das ist der Grund, warum diese Datei nicht in app/store.ts
// aufgegangen ist.
let sysHist: SysSample[] = []
let tokHist: RateSample[] = []
const listeners = new Set<() => void>()
const emit = () => listeners.forEach((l) => l())
function subscribe(l: () => void) {
listeners.add(l)
return () => { listeners.delete(l) }
}
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
// Der ZULETZT empfangene Messpunkt. Warum eigens gehalten: Seit P4 ist der Poller von
// /api/system/status abgeschaltet, solange der Strom steht — die Query liefert dann nur
// noch den Stand vom Seitenaufbau. Ohne diesen Wert froeren Betriebszeit, Temperatur und
// die aktuellen Prozentzahlen bei der ersten gemessenen Sekunde ein.
let letzteMetrik: MetrikPunkt | null = null
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
// Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
// Zähler statt Raten — so ist er zustandslos und ein verpasster Punkt verfälscht nichts.
let letzteToken: { p: number; c: number; t: number } | null = null
/** Einstieg für den Ereignisstrom (lib/events.ts). Ein Messpunkt → beide Verläufe. */
export function pushMetrik(m: MetrikPunkt) {
const jetzt = Date.now()
letzteMetrik = m
pushSys({ t: jetzt, cpu: m.cpu ?? 0, ram: m.ram ?? 0, gpu: m.gpu, disk: m.disk })
if (letzteToken) {
const dt = Math.max((jetzt - letzteToken.t) / 1000, 0.001)
// Nie negativ: Startet das Backend neu, springen die Zähler zurück — daraus eine
// negative Rate zu zeichnen wäre schlicht falsch.
pushTok({
t: jetzt,
prompt: Math.max(0, (m.tok_p - letzteToken.p) / dt),
completion: Math.max(0, (m.tok_c - letzteToken.c) / dt),
})
}
letzteToken = { p: m.tok_p, c: m.tok_c, t: jetzt }
}
/** Sammler für den Rückfall-Betrieb (in der Shell eingehängt, also stets gemountet).
*
* Seit P4 kommen die Messpunkte über den Strom. Dieser Sammler füttert den Verlauf nur
* noch aus den POLLERN — also genau dann, wenn der Strom gerissen ist und die Queries
* wieder im Normaltakt nachfragen. Ohne ihn stünden die Graphen bei einem Ausfall still,
* obwohl die Daten da wären.
*
* Doppelte Punkte kann es nicht geben: Steht der Strom, sind die Poller entspannt und
* liefern kaum neue Zeitstempel; der Filter unten wirft alles weg, was jünger als eine
* Sekunde nach dem letzten Punkt ist.
*/
export function useMetricsFeeder() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
const prevTok = useRef<{ p: number; c: number; t: number } | null>(null)
useEffect(() => {
if (!sys) return
const jetzt = Date.now()
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
pushSys({
t: jetzt,
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
disk: sys.disk?.percent ?? null,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sysAt])
useEffect(() => {
if (!ts) return
const now = Date.now()
const p = ts.prompt_tokens, c = ts.completion_tokens
if (prevTok.current) {
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
pushTok({
t: now,
prompt: Math.max(0, (p - prevTok.current.p) / dt),
completion: Math.max(0, (c - prevTok.current.c) / dt),
})
}
}
prevTok.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tsAt])
}
-262
View File
@@ -1,262 +0,0 @@
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import { sseVerbunden } from "./events"
import {
api,
type AgentStatus,
type AktivitaetResp,
type ChronikResp,
type ConnectResp,
type ConnectHealth,
type DiscoverResp,
type DraftsResp,
type GroupsResp,
type HermesBrainResp,
type Health,
type HistoryResp,
type IdeenLogResp,
type IdeenResp,
type Job,
type MemoryGraph,
type ModelsResp,
type Reminder,
type RoutingResp,
type RoutingPolicyMeta,
type ServicesResp,
type SystemStatus,
type TokenStats,
type UpdatesResp,
type VoiceTraceResp,
type WissenResp,
type ZeitmaschineResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = {
health: ["health"] as const,
systemStatus: ["system-status"] as const,
services: ["services"] as const,
models: ["models"] as const,
groups: ["groups"] as const,
routing: ["routing"] as const,
routingPolicy: ["routing-policy"] as const,
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
agentAktivitaet: ["agent-aktivitaet"] as const,
hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const,
connectHealth: ["connect-health"] as const,
memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const,
ideen: ["ideen"] as const,
chronik: ["chronik"] as const,
wissen: ["wissen"] as const,
zeitmaschine: ["zeitmaschine"] as const,
reminders: ["reminders"] as const,
}
// ── Polling-Takte ────────────────────────────────────────────────────────────
// EINE Stelle für alle Intervalle. Regel: Die Live-Graphen (System/Token) takten
// schnell, alles andere gemächlich — Mutationen invalidieren ohnehin gezielt,
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
const TAKT = {
graph: 3_000, // System + Token: seit P4 nur noch Rueckfall/Randdaten (siehe unten)
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace
normal: 10_000, // Dienste, Routing, Agent-Status, Health
gemuetlich: 30_000, // Chronik, Ideen
traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen
} as const
// UMBAU v3 P3a: Läuft der SSE-Eventstrom (/api/events, siehe lib/events.ts), sind die
// Poller der ereignishaften Quellen nur noch SICHERHEITSNETZ — der Strom invalidiert
// bei Änderung sofort. Verbunden = Takt ×5; Strom weg = alter Takt (EventSource
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
// UMBAU v3 P4: Die MESSWERTE (CPU, RAM, GPU, Temperatur, Token-Zähler, Betriebszeit)
// kommen jetzt gepusht — Ereignis `metrik`, jede Sekunde. Der Poller dieser beiden
// Endpunkte war die halbe Grundlast der Startseite (Befund B-12).
//
// Er wird trotzdem NICHT abgeschaltet, sondern gedrosselt. Grund: Beide Antworten tragen
// mehr als die Messwerte — `/api/system/status` liefert die Versions-Hashes (Sidebar-Fuß),
// `/api/system/token-stats` die Gesamtsumme und die Cloud-Ersparnis, für die das Backend
// die Tarife auflöst (Preis-Logik ist dort die einzige Wahrheit, sie hier nachzubauen wäre
// eine zweite). Beides ändert sich langsam — einmal pro Minute reicht dafür völlig.
//
// 3 s → 60 s ist eine Zwanzigstel-Last; abschalten hätte diese Felder einfrieren lassen.
const langsamWennStrom = (ms: number) => () => (sseVerbunden() ? 60_000 : ms)
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht.
export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.ideen,
queryFn: () => api<IdeenResp>("/api/ideen"),
refetchInterval: relax(refetchInterval),
})
// Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen
export const useIdeenLog = (id: string, enabled: boolean = false) =>
useQuery({
queryKey: ["ideen-log", id],
queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`),
enabled,
refetchInterval: 4000, // alle ~4s
staleTime: 0,
})
// Limit gehört in den Key: zwei Aufrufer mit verschiedenen Limits würden sich sonst
// denselben Cache-Eintrag gegenseitig überschreiben. SSE invalidiert das Präfix ["chronik"].
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: [...qk.chronik, limit],
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
refetchInterval: relax(refetchInterval),
select: (d) => d.items ?? [],
})
export const useWissen = () =>
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useZeitmaschine = (refetchInterval: number = TAKT.traege, enabled = true) =>
useQuery({
queryKey: qk.zeitmaschine,
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
refetchInterval,
enabled,
})
export const useReminders = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.reminders,
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
refetchInterval: relax(refetchInterval),
select: (d) => d.items ?? [],
})
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) =>
useQuery({
queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
refetchInterval,
select: (d) => d.turns ?? [],
})
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/wissen/graph"),
enabled,
staleTime: 60 * 1000,
})
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"),
refetchInterval: langsamWennStrom(refetchInterval) })
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
export const useMetricHistory = (minutes: number, enabled = true) =>
useQuery({
queryKey: ["metrics-history", minutes],
queryFn: () => api<HistoryResp>(`/api/system/history?minutes=${minutes}`),
enabled,
refetchInterval: 60_000,
})
export const useServices = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
// Der SSE-Strom invalidiert "models" bei Laden/Entladen → Poller ist nur Sicherheitsnetz.
export const useModels = (refetchInterval: number = TAKT.schnell) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval: relax(refetchInterval) })
export const useGroups = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
// Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
// `enabled=false` pausiert den Beobachter (z. B. geschlossene System-Schublade) —
// der Cache bleibt geteilt, es pollt nur niemand umsonst.
export const useJobs = (refetchInterval = 3_000, enabled = true) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
// Seit P4 meldet der Fingerabdruck-Sammler Zustand UND Fortschritt der Jobs —
// der Poller ist damit auch hier nur noch Sicherheitsnetz.
refetchInterval: relax(refetchInterval),
enabled,
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"),
refetchInterval: langsamWennStrom(refetchInterval) })
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Werkzeug-Verlauf des Agenten. Liest das Ende von Hermes' Log — billig, aber es
// aendert sich nur, wenn Lucy wirklich arbeitet; der gemuetliche Takt reicht.
export const useWerkzeugverlauf = (refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.agentAktivitaet,
queryFn: () => api<AktivitaetResp>("/api/agent/aktivitaet?limit=80"),
refetchInterval: relax(refetchInterval),
})
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
// Updates-Check ist teuer (apt/git auf der Box) — träge pollen reicht völlig.
export const useUpdates = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
export const useDrafts = (target?: string) =>
useQuery({
queryKey: qk.drafts(target),
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
enabled: !!target,
})
export const useConnect = (params?: string) =>
useQuery({
queryKey: qk.connect(params),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
})
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useConnectHealth = () =>
useQuery({
queryKey: qk.connectHealth,
queryFn: () => api<ConnectHealth>("/api/connect/health"),
refetchInterval: 15000,
})
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
export function invalidate(qc: QueryClient, ...keys: (readonly unknown[])[]) {
for (const key of keys) qc.invalidateQueries({ queryKey: key })
}
export { useQueryClient }
-145
View File
@@ -1,145 +0,0 @@
import {
Brain,
BrainCog,
Code,
Eye,
BookMarked,
Telescope,
Scale,
ListOrdered,
type LucideIcon,
} from "lucide-react"
// ── EINE Quelle der Wahrheit für ALLE Serving-Rollen ────────────────────────
// Klartext statt Kürzel: jede Rolle bekommt einen Zweck-Namen, ein Icon und
// eine Ein-Satz-Erklärung, damit die UI ohne Fachjargon verständlich ist.
// Das technische Kürzel (`role`) bleibt für den späteren Experten-Modus erhalten.
//
// Die Rollen hier spiegeln den ECHTEN Stand der Box (/api/models → role):
// hermes · heavy · coder · coder-lite · vision · embed · scout
// (Kein Phantom-`fast`: das ist nur ein Lane-Alias, keine Modell-Rolle. Lucys
// Alltags-Hirn ist die Rolle `hermes` = Qwen3.6.)
export interface RoleMeta {
role: string // technisches Kürzel (Experten-Modus)
label: string // Klartext-Zweckname
short: string // Kurzform für enge Badges
icon: LucideIcon
desc: string // Tooltip / Erklärung in einfacher Sprache
tone: string // Farbklassen fürs Badge
protected?: boolean // Hirn & Gedächtnis: lebenswichtig → nicht löschbar/entladbar (Etappe 3)
}
// Reihenfolge = Anzeige-Reihenfolge in den Slot-Übersichten (Wichtigstes zuerst).
export const ROLE_META: RoleMeta[] = [
{
role: "hermes",
label: "Lucys Hirn",
short: "Hirn",
icon: Brain,
desc: "Lucys Alltags-Verstand — denkt und redet mit dir. Immer bereit.",
tone: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
protected: true,
},
{
role: "embed",
label: "Lucys Gedächtnis",
short: "Gedächtnis",
icon: BookMarked,
desc: "Merkt sich Wichtiges und findet es später wieder.",
tone: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
protected: true,
},
{
role: "reranker",
label: "Gedächtnis-Sortierer",
short: "Sortierer",
icon: ListOrdered,
desc: "Ordnet gefundene Erinnerungen nach echter Relevanz — die Feinsortierung hinter dem Gedächtnis.",
tone: "bg-lime-500/15 text-lime-400 border-lime-500/25",
},
{
role: "vision",
label: "Lucys Augen",
short: "Augen",
icon: Eye,
desc: "Sieht Bilder und Screenshots und beschreibt, was drauf ist.",
tone: "bg-pink-500/15 text-pink-400 border-pink-500/25",
},
{
role: "heavy",
label: "Großes Hirn",
short: "Groß",
icon: BrainCog,
desc: "Das schwere Geschütz — wird für besonders knifflige Fragen dazugeholt.",
tone: "bg-amber-500/15 text-amber-400 border-amber-500/25",
},
{
role: "coder-lite",
label: "Programmierer (schnell)",
short: "Code schnell",
icon: Code,
desc: "Schnelles Programmieren im Alltag.",
tone: "bg-violet-500/15 text-violet-400 border-violet-500/25",
},
{
role: "coder",
label: "Programmierer",
short: "Code",
icon: Code,
desc: "Baut und plant. Der Kopf der Coding-Mannschaft — er hält den Faden und verteilt Zuarbeit.",
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
protected: true,
},
{
role: "scout",
label: "Späher",
short: "Späher",
icon: Telescope,
desc: "Schneller erster Blick und grobe Einschätzung.",
tone: "bg-teal-500/15 text-teal-400 border-teal-500/25",
},
{
role: "kritiker",
label: "Kritiker",
short: "Kritiker",
icon: Scale,
desc: "Liest dem Programmierer nach jeder Etappe gegen — bewusst aus einer FREMDEN Modellfamilie (Mistral statt Qwen), damit er andere blinde Flecken hat.",
tone: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
},
]
// Kanonische Rollen-Reihenfolge (ersetzt die alte, veraltete ROLES-Liste).
export const ROLES: string[] = ROLE_META.map((m) => m.role)
const BY_ROLE: Record<string, RoleMeta> = Object.fromEntries(
ROLE_META.map((m) => [m.role, m]),
)
// Fallback für unbekannte/neue Rollen — nie „undefined", damit die UID nicht bricht.
const UNKNOWN = (role?: string | null): RoleMeta => ({
role: role || "?",
label: role || "unbekannt",
short: role || "?",
icon: Brain,
desc: "Rolle ohne hinterlegten Klartext-Namen.",
tone: "bg-slate-500/15 text-slate-300 border-slate-500/25",
})
// Aliase, die auf eine echte Rolle zeigen (z.B. Lane-Alias `fast` = Hirn).
const ALIAS: Record<string, string> = {
fast: "hermes",
coder_lite: "coder-lite",
}
export function roleMeta(role?: string | null): RoleMeta {
if (!role) return UNKNOWN(role)
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNKNOWN(role)
}
// Klartext-Name einer Rolle (mit optionaler Kurzform).
export const roleLabel = (role?: string | null, short = false) =>
short ? roleMeta(role).short : roleMeta(role).label
// Farbton — bleibt kompatibel zur bisherigen roleTone()-Signatur.
export const roleTone = (role?: string | null) => roleMeta(role).tone
+89
View File
@@ -0,0 +1,89 @@
// Live-Strom — Gegenstück zu backend/routers/events.py (/api/stream).
//
// Ein EventSource, zwei Ereignisarten:
// · `invalidate` stößt die betroffenen Abfragen an (z. B. „start“, wenn der Wächter
// etwas Neues findet — sein Stand ändert sich höchstens einmal pro Minute)
// · `metrik` jede Sekunde ein Messpunkt für die Rundinstrumente
//
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
import { useEffect, useSyncExternalStore } from "react"
import { useQueryClient } from "@tanstack/react-query"
export interface MetrikPunkt {
cpu: number
ram: number
ram_used: number
ram_total: number
gpu: number | null
disk: number | null
temp_cpu: number | null
temp_gpu: number | null
uptime_s: number | null
tok_p: number
tok_c: number
}
export type StromLage = "verbinde" | "live" | "getrennt"
let lage: StromLage = "verbinde"
let letzte: MetrikPunkt | null = null
const hoerer = new Set<() => void>()
const melde = () => hoerer.forEach((h) => h())
function abonniere(h: () => void) {
hoerer.add(h)
return () => {
hoerer.delete(h)
}
}
export const stromVerbunden = () => lage === "live"
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
/** Nur für Tests: Messpunkt direkt einspeisen. */
export function metrikEinspeisen(m: MetrikPunkt | null) {
letzte = m
melde()
}
function setzeLage(neu: StromLage) {
if (neu !== lage) {
lage = neu
melde()
}
}
export function useStrom() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/stream")
es.onopen = () => {
setzeLage("live")
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
qc.invalidateQueries()
}
es.onerror = () => setzeLage("getrennt")
es.addEventListener("invalidate", (e) => {
try {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
} catch {
/* ein kaputtes Ereignis reißt den Strom nicht */
}
})
es.addEventListener("metrik", (e) => {
try {
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch {
/* ein kaputter Messpunkt reißt den Strom nicht */
}
})
return () => {
es.close()
setzeLage("getrennt")
}
}, [qc])
}
+153
View File
@@ -0,0 +1,153 @@
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py,
// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py.
export type Stufe = "rot" | "gelb"
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
export interface HinweisAktion {
id: "neustart" | "protokoll" | "job-wiederholen" | string
label: string
dienst?: string
job?: string
}
export interface Hinweis {
id: string
stufe: Stufe
titel: string
text: string
quelle: string
seit: number
zuletzt: number
takte?: number
aktionen: HinweisAktion[]
}
export interface VerlaufEintrag {
ts: number
art: "neu" | "erledigt" | "auto"
id: string
text: string
}
export interface Lampe {
id: string
label: string
zustand: LampenZustand
wert: string
}
export interface FlugplanEintrag {
zeit: string
titel: string
text: string
status: "erledigt" | "fehler" | "geplant"
}
export interface Start {
zustand: {
stufe: "ok" | Stufe
anzahl: number
waechter_wach: boolean
stand: number | null
update_laeuft: boolean
}
hinweise: Hinweis[]
verlauf: VerlaufEintrag[]
lampen: Lampe[]
box: {
ram_used: number | null
ram_total: number | null
temp_cpu: number | null
temp_gpu: number | null
platte: { used: number; total: number; percent: number } | null
uptime_s: number | null
}
updates: {
bausteine: { id: string; name: string; neu: string }[]
modelle: { role: string; title: string; repo: string }[]
}
flugplan: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }
}
export interface AktionsErgebnis {
ok: boolean
text?: string
out?: string
err?: string
detail?: string
}
export interface Nutzung {
tage: number
absender: { name: string; anfragen: number }[]
je_tag: Record<string, Record<string, number>>
letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
zuletzt_geladen: Record<string, string>
}
export interface Modell {
name: string
role: string | null
aliases: string[]
ctx: number | null
ttl: number | null
filename: string
quant: string
size_bytes: number | null
spec_active: boolean
parallel_slots: number
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
}
export interface Job {
id: string
label: string
state: "running" | "done" | "failed" | "cancelled" | string
progress?: number | null
log?: string[]
started?: number
finished?: number | null
}
export interface Sicherung {
snapshot: string
file: string
size_mb: number
mtime?: number
}
export type RadarStatus =
| "neu" | "wartet" | "getestet" | "bestanden" | "durchgefallen" | "uebernommen" | "verworfen"
export interface RadarKandidat {
id: string
name: string
rolle: "hirn" | "coder" | string
repo: string
datei: string
groesse_gb: number | null
passt: boolean
eng: boolean
quelle: string
status: RadarStatus
begruendung: string
}
export interface RadarTest {
id: string
kandidat: string
rolle: string
datum: string
ergebnis: "bestanden" | "durchgefallen" | "abgebrochen"
werte: Record<string, unknown>
vergleich: Record<string, unknown>
zusammenfassung: string
}
export interface Radar {
naechster_test: string | null
letzter_test: string | null
kandidaten: RadarKandidat[]
tests: RadarTest[]
}
-59
View File
@@ -1,59 +0,0 @@
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
//
// Nutzung:
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
// ...
// showConfirm("Titel", "Wirklich?", () => doIt())
// return (<>{dialogElement}...</>)
import { useCallback, useState } from "react"
import { CustomDialog } from "@/components/CustomDialog"
interface DialogState {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function useDialog() {
const [dialog, setDialog] = useState<DialogState | null>(null)
const close = useCallback(() => setDialog(null), [])
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
setDialog({
type: "alert", title, message,
onConfirm: () => { setDialog(null); onConfirm?.() },
})
}, [])
const showConfirm = useCallback(
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setDialog({
type: "confirm", title, message,
onConfirm: () => { setDialog(null); onConfirm() },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const showPrompt = useCallback(
(title: string, message: string, defaultValue: string,
onConfirm: (val?: string) => void, onCancel?: () => void,
opts?: { autoValue?: string; autoLabel?: string }) => {
setDialog({
type: "prompt", title, message, defaultValue,
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
onConfirm: (val) => { setDialog(null); onConfirm(val) },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
return { showAlert, showConfirm, showPrompt, close, dialogElement }
}
-235
View File
@@ -1,235 +0,0 @@
// „Geht's Lucy gut?" — bündelt die vielen technischen Status-Signale zu wenigen
// Lucy-Fähigkeiten in Klartext, inkl. Gesamt-Verdikt, Speicher-Ampel und einer
// konkreten 1-Klick-Reparatur pro Problem. EINE Quelle für das Status-Panel.
import { Brain, BookMarked, Eye, Plug, Bot, Mic, ShieldAlert, Server, type LucideIcon } from "lucide-react"
import { useHealth, useServices, useModels, useSystemStatus } from "./queries"
export type CheckStatus = "ok" | "warn" | "down" | "loading"
// Reparatur-Rezept: entweder einen systemd-Dienst neu starten oder ein Modell laden.
// WICHTIG: `service` sind die ECHTEN Unit-Namen (nicht die Anzeige-Namen der
// Dienste-Karte), damit /api/maintenance/restart sie akzeptiert.
export type Repair =
| { kind: "restart"; service: string; label: string; needsSudo?: boolean }
| { kind: "loadModel"; model: string; label: string }
export interface LucyCheck {
id: string
label: string // Klartext-Name der Fähigkeit
icon: LucideIcon
status: CheckStatus
detail: string // Ein Satz: was ist gerade los
critical: boolean // zählt fürs Gesamt-Verdikt „Problem"
repair?: Repair
}
export type Verdict = "loading" | "gut" | "warn" | "problem"
export interface MemoryAmpel {
status: "ok" | "warn" | "full" | "unknown"
usedGb: number
totalGb: number
pct: number
text: string
}
export interface LucyHealth {
verdict: Verdict
checks: LucyCheck[]
memory: MemoryAmpel
problems: LucyCheck[] // kritische Down-Checks (für den Schnellzugriff im Banner)
warns: LucyCheck[] // nicht-kritische Hinweise — damit das Banner SAGT, was los ist
unreachable: boolean // Zentrale (MC2) antwortet nicht → alle anderen Anzeigen sind veraltet
}
export function useLucyHealth(): LucyHealth {
const { data: health, isError: healthErr } = useHealth()
const { data: svc, isError: svcErr } = useServices()
const { data: modelsResp } = useModels()
const { data: sys } = useSystemStatus()
// React Query behält bei Fehlern die letzten Daten — ohne diesen Riegel zeigte die UI
// bei totem Backend dauerhaft den letzten grünen Stand („eingefrorene Ampel", Review 15.07.).
const unreachable = healthErr || svcErr
const loaded = !!health && !!svc
// Matching über den ECHTEN systemd-Unit-Namen (Backend liefert `unit`) — vorher wurde
// fragil im deutschen Anzeige-Namen gesucht ("gateway (integr" …).
const svcOk = (unit: string): boolean | undefined =>
svc?.services.find((s) => s.unit === unit)?.ok
const svcHas = (unit: string): boolean => !!svc?.services.some((s) => s.unit === unit)
const engineOk = health?.engine_reachable
const brainReady = health?.brain?.ready
const brainName =
health?.brain?.model ||
modelsResp?.models.find((m) => m.role === "hermes")?.name ||
""
const checks: LucyCheck[] = []
// 0) Die Zentrale selbst. Antwortet MC2 nicht, ist ALLES andere Spekulation —
// dieser Check übertrumpft die übrigen (die zeigen sonst eingefrorene alte Werte).
if (unreachable) {
checks.push({
id: "mc2", label: "Steuerpult (MC2)", icon: Server, critical: true, status: "down",
detail: "Die Zentrale antwortet nicht — alle Anzeigen hier können veraltet sein. Läuft die Box? Deploy aktiv?",
})
}
// 1) Lucys Hirn — die wichtigste Fähigkeit. Engine läuft UND das Hirn-Modell ist geladen?
checks.push(((): LucyCheck => {
const base = { id: "brain", label: "Lucys Hirn", icon: Brain, critical: true }
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
if (engineOk === false)
return {
...base,
status: "down",
detail: "Die Motor-Maschine (Engine) läuft nicht — Lucy kann gerade gar nicht denken.",
repair: { kind: "restart", service: "llama-swap", label: "Motor neu starten", needsSudo: true },
}
if (brainReady === false)
return {
...base,
status: "down",
detail: "Lucys Hirn ist gerade eingeschlafen — einmal aufwecken.",
repair: brainName
? { kind: "loadModel", model: brainName, label: "Hirn aufwecken" }
: undefined,
}
return { ...base, status: "ok", detail: "Wach und ansprechbar." }
})())
// 2) Lucys Augen — das Vision-Modell (Rolle `vision`). Seit der Warm-Set-Diät 07.07. ist
// ON-DEMAND der NORMALZUSTAND (User-Entscheid: Augen nur, wenn Lucy an ist / IDE sie ruft;
// die Lucy-App wärmt sie beim Start selbst an). Ruhende Augen sind darum OK, keine Warnung.
checks.push(((): LucyCheck => {
const base = { id: "vision", label: "Lucys Augen", icon: Eye, critical: false }
if (!loaded || !modelsResp) return { ...base, status: "loading", detail: "Wird geprüft …" }
const vm = modelsResp.models.find((m) => m.role === "vision")
if (!vm)
return { ...base, status: "warn", detail: "Kein Augen-Modell eingerichtet — Lucy kann keine Bilder ansehen." }
const warm = (modelsResp.running ?? []).includes(vm.name)
return warm
? { ...base, status: "ok", detail: "Sieht gerade zu (geladen, solange Lucy sie nutzt)." }
: {
...base,
status: "ok",
detail: "Augen ruhen — sie laden von selbst, sobald Lucy startet oder ein Bild kommt.",
repair: { kind: "loadModel", model: vm.name, label: "Augen wecken" },
}
})())
// 3) Lucys Gedächtnis — Hermes Native Gedächtnis (integriert in hermes-gateway).
checks.push(((): LucyCheck => {
const base = { id: "memory", label: "Lucys Gedächtnis", icon: BookMarked, critical: true }
const ok = svcOk("hermes-gateway")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Hermes-Natives Gedächtnis aktiv (SQLite & Embeddings)." }
: {
...base,
status: "down",
detail: "Lucy kann sich gerade nichts merken — das Gateway antwortet nicht.",
repair: { kind: "restart", service: "hermes-gateway", label: "Gateway neu starten" },
}
})())
// 4) Verbindung (LLM-Gateway) — der Denkpfad, über den Apps/IDEs/Lucy die Modelle erreichen.
// Seit UMBAU v3 ist das der EIGENE mc2-gateway-Prozess; health.gateway_reachable prüft
// ihn jetzt wirklich (statt nur die Engine). Repariert wird die richtige Unit.
checks.push(((): LucyCheck => {
const base = { id: "gateway", label: "Verbindung (Gateway)", icon: Plug, critical: true }
const ok = health ? health.gateway_reachable : svcOk("mc2-gateway")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
const unit = svcHas("mc2-gateway") ? "mc2-gateway" : "mission-control-2"
return ok
? { ...base, status: "ok", detail: "Apps und IDEs können Lucy erreichen." }
: {
...base,
status: "down",
detail: "Der Modell-Gateway antwortet nicht — Lucy und die IDEs erreichen kein Modell.",
repair: { kind: "restart", service: unit, label: "Gateway neu starten" },
}
})())
// 4b) Der Wächter (Steward) — überwacht Warm-Set, Health-Flanken und Gedächtnis-Dubletten.
// Nicht kritisch fürs Antworten, aber ohne ihn merkt niemand, wenn etwas kippt.
checks.push(((): LucyCheck => {
const base = { id: "steward", label: "Wächter (Steward)", icon: ShieldAlert, critical: false }
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
if (!svcHas("mc2-steward"))
return { ...base, status: "ok", detail: "Auf dieser Installation nicht eingerichtet." }
return svcOk("mc2-steward")
? { ...base, status: "ok", detail: "Wacht über Warm-Set, Dienste und Gedächtnis." }
: {
...base,
status: "warn",
detail: "Der Wächter schläft — niemand meldet gerade Ausfälle oder wärmt Modelle nach.",
repair: { kind: "restart", service: "mc2-steward", label: "Wächter neu starten" },
}
})())
// 5) Lucys Agent (Hermes) — der Dienst, der als Lucy antwortet (Terminal/Telegram/Sprechen).
checks.push(((): LucyCheck => {
const base = { id: "agent", label: "Lucys Agent (Hermes)", icon: Bot, critical: true }
const ok = svcOk("hermes-gateway") // unit-Name (identisch zum alten Namens-Match, jetzt exakt)
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Lucy ist bereit zu reden und zu handeln." }
: {
...base,
status: "down",
detail: "Lucys Agent ist offline — sie reagiert gerade nicht.",
repair: { kind: "restart", service: "hermes-gateway", label: "Agent neu starten" },
}
})())
// 6) Lucys Stimme — Voice-Dienst (STT/TTS). Nicht kritisch: Chat geht auch ohne.
checks.push(((): LucyCheck => {
const base = { id: "voice", label: "Lucys Stimme", icon: Mic, critical: false }
const ok = svcOk("voice-service")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Lucy kann hören und sprechen." }
: {
...base,
status: "warn",
detail: "Sprechen ist gerade aus — Tippen geht weiter normal.",
repair: { kind: "restart", service: "voice-service", label: "Stimme neu starten" },
}
})())
// Speicher-Ampel = System-RAM, DIESELBE Quelle wie die RAM-Zeile in der
// System-Status-Kachel. Vorher zeigte die Pille den GTT-Pool (GPU-Sicht auf den
// Unified-Memory) — der weicht auf der APU vom System-RAM ab, wodurch „Box gesund"
// und „System-Status" zwei verschiedene Speicher-Zahlen zeigten. Eine Box, eine Zahl.
// Schwellen am RAM-Charakter geeicht: 4 Modelle warm ≈ 78 % gilt als gesund → grün.
const total = sys?.ram?.total ?? 0
const used = sys?.ram?.used ?? 0
const pct = sys?.ram?.percent ?? (total > 0 ? Math.min(100, (used / total) * 100) : 0)
const B = 1024 ** 3
const memory: MemoryAmpel = !total
? { status: "unknown", usedGb: 0, totalGb: 0, pct: 0, text: "Speicher-Auslastung unbekannt." }
: pct >= 93
? { status: "full", usedGb: used / B, totalGb: total / B, pct, text: "Speicher fast voll — Lucy könnte langsamer werden." }
: pct >= 85
? { status: "warn", usedGb: used / B, totalGb: total / B, pct, text: "Speicher gut gefüllt — noch okay." }
: { status: "ok", usedGb: used / B, totalGb: total / B, pct, text: "Genug Speicher frei." }
const anyLoading = checks.some((c) => c.status === "loading")
const problems = checks.filter((c) => c.critical && c.status === "down")
const warns = checks.filter((c) => c.status === "warn" || (!c.critical && c.status === "down"))
const memBad = memory.status === "full"
const verdict: Verdict = anyLoading && !problems.length
? "loading"
: problems.length
? "problem"
: warns.length || memBad
? "warn"
: "gut"
return { verdict, checks, memory, problems, warns, unreachable }
}
-42
View File
@@ -1,42 +0,0 @@
import { useMemo } from "react"
import { useSystemStatus } from "@/lib/queries"
import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
import type { SystemStatus } from "@/lib/api"
export type { SysSample }
/** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
*
* v3-Umbau P4: Die Query läuft nur noch minütlich, solange der Ereignisstrom steht —
* ihre MESSWERTE wären damit bis zu 60 s alt. Deshalb werden sie hier mit dem letzten
* Messpunkt aus dem Strom überschrieben. Alles, was der Strom NICHT trägt (VRAM-Detail,
* Kernzahl, Versionen), bleibt aus der Query: Der Strom soll schlank bleiben, und diese
* Felder ändern sich ohnehin selten.
*/
export function useSystemHistory() {
const { data: query, error } = useSystemStatus()
const live = useLetzteMetrik()
const hist = useSysHistory()
const sys = useMemo<SystemStatus | undefined>(() => {
if (!query) return query
if (!live) return query
return {
...query,
cpu: { ...query.cpu, percent: live.cpu ?? query.cpu.percent },
ram: {
...query.ram,
percent: live.ram ?? query.ram.percent,
used: live.ram_used ?? query.ram.used,
total: live.ram_total ?? query.ram.total,
},
// gtt_used/gtt_total bleiben aus der Query — der Strom trägt nur die Auslastung.
gpu: query.gpu ? { ...query.gpu, busy_percent: live.gpu ?? query.gpu.busy_percent } : query.gpu,
disk: query.disk ? { ...query.disk, percent: live.disk ?? query.disk.percent } : query.disk,
temp: { cpu: live.temp_cpu ?? query.temp?.cpu, gpu: live.temp_gpu ?? query.temp?.gpu },
uptime_s: live.uptime_s ?? query.uptime_s,
}
}, [query, live])
return { sys, hist, error }
}
+1 -11
View File
@@ -1,11 +1 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function resolveExternalUrl(url?: string): string {
if (!url) return ""
return url.replace(/127\.0\.0\.1|localhost/g, "192.168.178.151")
}
export { cn } from "cn"
+64
View File
@@ -0,0 +1,64 @@
import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit"
import { rolleVon, temperaturFarbe, versionen } from "./anzeige"
import type { Modell } from "./typen"
// Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit.
const JETZT = new Date("2026-09-23T20:45:00+02:00")
describe("flugplanZeit", () => {
it("benennt heute, gestern und morgen", () => {
expect(flugplanZeit("2026-09-23T03:00:00+02:00", JETZT)).toBe("HEUTE 03:00")
expect(flugplanZeit("2026-09-22T23:10:00+02:00", JETZT)).toBe("GESTERN 23:10")
expect(flugplanZeit("2026-09-24T03:00:00+02:00", JETZT)).toBe("MORGEN 03:00")
})
it("nimmt für die nächsten Tage den Wochentag", () => {
expect(flugplanZeit("2026-09-27T04:30:00+02:00", JETZT)).toBe("SO 04:30")
})
it("rechnet UTC-Zeiten in Berliner Zeit um", () => {
expect(flugplanZeit("2026-09-23T05:00:00Z", JETZT)).toBe("HEUTE 07:00")
})
})
describe("kleine Helfer", () => {
it("seit und vor", () => {
expect(seit(new Date("2026-09-07T11:03:00+02:00").getTime() / 1000, JETZT)).toBe("seit 07.09., 11:03")
expect(vor(JETZT.getTime() / 1000 - 40, JETZT.getTime())).toBe("vor 40 s")
expect(vor(JETZT.getTime() / 1000 - 2 * 86_400, JETZT.getTime())).toBe("vor 2 Tagen")
})
it("laufzeit und gbText", () => {
expect(laufzeit(10 * 86_400 + 14 * 3600 + 59)).toEqual([10, 14])
expect(gbText(55 * 1024 ** 3)).toBe("55 GB")
expect(gbText(1.9 * 1024 ** 4, 1)).toBe("1,9 TB")
})
it("Temperaturfarbe wechselt bei 70 und 85 Grad", () => {
expect(temperaturFarbe(37)).toBe("var(--gruen)")
expect(temperaturFarbe(70)).toBe("var(--bernstein)")
expect(temperaturFarbe(85)).toBe("var(--rot)")
})
})
describe("versionen", () => {
it("zeigt Motor-Builds mit b und nur echte Neuerungen", () => {
expect(versionen("engine", { kind: "engine", installed_build: 11057, latest_build: 11139 })).toEqual({ laeuft: "b11057", neu: "b11139" })
expect(versionen("engine", { kind: "engine", installed_build: 11139, latest_build: 11139 })).toEqual({ laeuft: "b11139", neu: null })
})
it("zählt beim Betriebssystem die Pakete", () => {
expect(versionen("os", { kind: "os", count: 24 })).toEqual({ laeuft: "Ubuntu", neu: "24 Pakete" })
})
})
describe("rolleVon", () => {
const m = (aliases: string[]): Modell => ({
name: "x", role: null, aliases, ctx: null, ttl: null, filename: "", quant: "", size_bytes: null, spec_active: false, parallel_slots: 1,
})
it("erkennt Hirn und Coder an den Aliasen", () => {
expect(rolleVon(m(["hermes", "fast"]))).toBe("hirn")
expect(rolleVon(m(["coder", "heavy"]))).toBe("coder")
expect(rolleVon(m(["debugger"]))).toBeNull()
})
})
+65
View File
@@ -0,0 +1,65 @@
// Anzeige-Helfer: Uhrzeiten, Dauer, Größen — alles auf Deutsch und in Box-Zeit.
const ZONE = "Europe/Berlin"
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
const tagKurz = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: ZONE })
const datumKurz = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: ZONE })
const tagIso = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
function tagSchluessel(d: Date) {
return tagIso.format(d)
}
/** "HEUTE 03:00", "GESTERN 23:10", "SA 08:00" (≤ 6 Tage entfernt), sonst "27.09. 04:30". */
export function flugplanZeit(iso: string, jetzt = new Date()): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return "–"
const heute = tagSchluessel(jetzt)
const gestern = tagSchluessel(new Date(jetzt.getTime() - 86_400_000))
const morgen = tagSchluessel(new Date(jetzt.getTime() + 86_400_000))
const t = tagSchluessel(d)
const zeit = uhr.format(d)
if (t === heute) return `HEUTE ${zeit}`
if (t === gestern) return `GESTERN ${zeit}`
if (t === morgen) return `MORGEN ${zeit}`
if (Math.abs(d.getTime() - jetzt.getTime()) < 6.5 * 86_400_000) {
return `${tagKurz.format(d).replace(".", "").toUpperCase()} ${zeit}`
}
// de-DE liefert „27.09.“ schon mit Schlusspunkt.
return `${datumKurz.format(d)} ${zeit}`
}
/** Unix-Sekunden → "seit 07.09., 11:03" bzw. "seit heute 07:02". */
export function seit(ts: number, jetzt = new Date()): string {
const d = new Date(ts * 1000)
if (tagSchluessel(d) === tagSchluessel(jetzt)) return `seit heute ${uhr.format(d)}`
return `seit ${datumKurz.format(d)}, ${uhr.format(d)}`
}
/** Unix-Sekunden → "vor 40 s", "vor 3 min", "vor 2 h", "vor 5 Tagen". */
export function vor(ts: number, jetzt = Date.now()): string {
const s = Math.max(0, Math.round(jetzt / 1000 - ts))
if (s < 60) return `vor ${s} s`
if (s < 3600) return `vor ${Math.round(s / 60)} min`
if (s < 86_400) return `vor ${Math.round(s / 3600)} h`
const tage = Math.round(s / 86_400)
return `vor ${tage} ${tage === 1 ? "Tag" : "Tagen"}`
}
/** Laufzeit in Sekunden → [Tage, Stunden]. */
export function laufzeit(s: number | null | undefined): [number, number] | null {
if (s == null || s < 0) return null
return [Math.floor(s / 86_400), Math.floor((s % 86_400) / 3600)]
}
export const gb = (bytes: number | null | undefined) =>
bytes == null ? null : bytes / 1024 ** 3
export function gbText(bytes: number | null | undefined, stellen = 0): string {
const g = gb(bytes)
if (g == null) return "–"
return g >= 1000 ? `${(g / 1024).toFixed(1).replace(".", ",")} TB` : `${g.toFixed(stellen).replace(".", ",")} GB`
}
export const zahl = (n: number) => n.toLocaleString("de-DE")