UI: Auftragsbuch, Chronik+Zeitmaschine, Wissens-Wiki, Morgenlage, Erinnerungen

Neue Views: Auftragsbuch (Karten mit Diff/Kritiker-Kontext, Annehmen/Ablehnen mit
Fangnetz-Dialog), Chronik (Tages-Timeline aus dem Briefkasten + Zeitmaschine mit
Ein-Klick-Restore), Wissen (Vault als klickbares Wiki, [[Links]] springen).
Cockpit: Auftragsbuch-Kachel + Braucht-dich-Eintrag, Morgenlage-Karte (juengstes
Chef-Verdikt), Anstehendes (Erinnerungen anlegen/loeschen), Wissen/Chronik-Kacheln.
Gedaechtnis: Dubletten-Dialog zeigt jetzt konkret, was bleibt und was wegfaellt.
dist mitgebaut (tsc+vite gruen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-08 18:42:22 +02:00
parent 4dc5ca7343
commit 1985a692bf
18 changed files with 1919 additions and 706 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BrJPl95N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CMtwLeqK.css">
<script type="module" crossorigin src="/assets/index-BylmMpBk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7apENt0.css">
</head>
<body>
<div id="root"></div>
+7 -1
View File
@@ -17,6 +17,9 @@ import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView"
import { AuftragsbuchView } from "@/views/AuftragsbuchView"
import { ChronikView } from "@/views/ChronikView"
import { WissenView } from "@/views/WissenView"
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
@@ -230,14 +233,17 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && (
{!["dashboard", "auftraege", "models", "connect", "memory", "wissen", "chronik", "agent", "terminal", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
+3 -1
View File
@@ -27,7 +27,9 @@ export function CustomDialog({ type, title, message, defaultValue, autoValue, au
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{message}</p>
{/* whitespace-pre-line + Scroll: mehrzeilige Botschaften (z. B. Dubletten-Vorschau,
Annehmen-Warnungen) sauber rendern statt zu einer Wurst zusammenzufallen. */}
<p className="max-h-72 overflow-y-auto whitespace-pre-line text-xs text-muted-foreground leading-relaxed scrollbar-thin">{message}</p>
{type === "prompt" && (
<div className="flex gap-2">
+86
View File
@@ -443,6 +443,92 @@ export interface ModelsResp {
running?: string[]
}
// ── Auftragsbuch (Vorschlags-Inbox) ──────────────────────────────────────────
export interface AuftragStatus {
state: "laeuft" | "eingespielt" | "fehlgeschlagen" | "rollback" | "zurueckgerollt" | "kritisch" | "abgelehnt"
detail: string
ts: number
}
export interface AuftragItem {
branch: string
kind: string // wartung | orchestrator | doku | feature
subject: string
body: string
author: string
ts: number | null
ahead: number
behind: number
shortstat: string
files: { status: string; path: string }[]
files_truncated: boolean
frontend_ohne_build: boolean // Frontend-Quelltext ohne dist → Box kann nicht bauen
status: AuftragStatus | null
}
export interface SkillKandidat {
file: string
title: string
preview: string
mtime: number
}
export interface AuftragsbuchResp {
available: boolean
items: AuftragItem[]
skill_kandidaten: SkillKandidat[]
open_count: number
}
// ── 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
+45
View File
@@ -6,6 +6,8 @@ import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-quer
import {
api,
type AgentStatus,
type AuftragsbuchResp,
type ChronikResp,
type ConnectResp,
type ConnectHealth,
type DiscoverResp,
@@ -17,6 +19,7 @@ import {
type Memory,
type MemoryGraph,
type ModelsResp,
type Reminder,
type RoutingResp,
type RoutingPolicyMeta,
type ServicesResp,
@@ -24,6 +27,8 @@ import {
type TokenStats,
type UpdatesResp,
type VoiceTraceResp,
type WissenResp,
type ZeitmaschineResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
@@ -47,8 +52,48 @@ export const qk = {
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const,
auftragsbuch: ["auftragsbuch"] as const,
chronik: ["chronik"] as const,
wissen: ["wissen"] as const,
zeitmaschine: ["zeitmaschine"] as const,
reminders: ["reminders"] as const,
}
// Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden.
export const useAuftragsbuch = (refetchInterval = 8_000) =>
useQuery({
queryKey: qk.auftragsbuch,
queryFn: () => api<AuftragsbuchResp>("/api/auftragsbuch"),
refetchInterval,
})
export const useChronik = (limit = 150, refetchInterval = 15_000) =>
useQuery({
queryKey: qk.chronik,
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
refetchInterval,
select: (d) => d.items ?? [],
})
export const useWissen = () =>
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useZeitmaschine = (refetchInterval = 30_000) =>
useQuery({
queryKey: qk.zeitmaschine,
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
refetchInterval,
})
export const useReminders = (refetchInterval = 20_000) =>
useQuery({
queryKey: qk.reminders,
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
refetchInterval,
select: (d) => d.items ?? [],
})
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
useQuery({
+7 -1
View File
@@ -2,6 +2,9 @@ import {
LayoutDashboard,
Boxes,
Brain,
Inbox,
History,
Library,
Plug,
Bot,
AppWindow,
@@ -10,7 +13,7 @@ import {
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide"
export type ViewId = "dashboard" | "auftraege" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "terminal" | "konsole" | "guide"
export interface NavItem {
id: ViewId
@@ -22,8 +25,11 @@ export interface NavItem {
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard },
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library },
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
{ id: "terminal", label: "Hermes GUI", hint: "Eingebaute Hermes-Weboberfläche (Threads & Tool-Calls)", icon: AppWindow },
+318
View File
@@ -0,0 +1,318 @@
import { useState } from "react"
import {
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw,
} from "lucide-react"
import { api, type AuftragItem, type SkillKandidat } from "@/lib/api"
import { useAuftragsbuch, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// Auftragsbuch = die Vorschlags-Inbox der Box. Werkstatt/Orchestrator liefern propose-only-
// Branches, der Traum liefert Skill-Kandidaten — hier fällt die Entscheidung per Klick.
// Annehmen (Branch) = Merge→Push→Deploy→Health mit Auto-Rollback; alles detached auf der Box.
const KIND_LABEL: Record<string, string> = {
wartung: "Werkstatt",
orchestrator: "Orchestrator",
doku: "Doku",
feature: "Feature",
}
const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> = {
laeuft: { label: "wird eingespielt …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
rollback: { label: "Rollback läuft …", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10", spin: true },
eingespielt: { label: "eingespielt ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
fehlgeschlagen: { label: "fehlgeschlagen", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
zurueckgerollt: { label: "zurückgerollt", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
kritisch: { label: "KRITISCH — Box prüfen", cls: "text-red-200 border-red-500/60 bg-red-500/20" },
abgelehnt: { label: "abgelehnt", cls: "text-muted-foreground border-border/60 bg-background/30" },
}
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" })
}
export function AuftragsbuchView() {
const { data, isLoading, refetch, isFetching } = useAuftragsbuch()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [busy, setBusy] = useState<Record<string, boolean>>({})
const reload = () => qc.invalidateQueries({ queryKey: qk.auftragsbuch })
async function act(key: string, path: string, body: object, done?: string) {
setBusy((b) => ({ ...b, [key]: true }))
try {
await api(path, { method: "POST", body: JSON.stringify(body) })
if (done) showAlert("Erledigt", done)
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [key]: false }))
}
}
const items = data?.items ?? []
const kandidaten = data?.skill_kandidaten ?? []
const empty = items.length === 0 && kandidaten.length === 0
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Auftragsbuch
</h1>
<p className="text-sm text-muted-foreground">
Was die Box gebaut oder sich ausgedacht hat du entscheidest. Annehmen spielt es mit Fangnetz ein (Health-Check, automatischer Rollback).
</p>
</div>
<button onClick={() => refetch()}
className="flex h-9 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-3 text-xs font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
<RefreshCw className={cn("h-3.5 w-3.5", isFetching && "animate-spin")} /> Aktualisieren
</button>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Das Auftragsbuch lebt auf der Box (liest die Gitea-Branches dort). Im lokalen Dev-Modus ist hier nichts zu sehen.
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Vorschläge werden geladen
</div>
)}
{data?.available && empty && (
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-5">
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
<div>
<p className="text-sm font-semibold text-foreground">Nichts zu entscheiden</p>
<p className="text-xs text-muted-foreground">Keine offenen Vorschläge. Werkstatt, Orchestrator und Traum melden sich hier, sobald es etwas gibt.</p>
</div>
</div>
)}
{items.length > 0 && (
<section className="space-y-3">
<SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel>
{items.map((it) => (
<ProposalCard key={it.branch} item={it} busy={busy} onAct={act} showConfirm={showConfirm} />
))}
</section>
)}
{kandidaten.length > 0 && (
<section className="space-y-3">
<SectionLabel icon={Sparkles}>Skill-Kandidaten aus dem Traum ({kandidaten.length})</SectionLabel>
{kandidaten.map((k) => (
<KandidatCard key={k.file} k={k} busy={busy} onAct={act} showConfirm={showConfirm} />
))}
</section>
)}
{dialogElement}
</div>
)
}
function SectionLabel({ icon: Icon, children }: { icon: any; children: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" /> {children}
</p>
)
}
function StateBadge({ item }: { item: AuftragItem }) {
const st = item.status
if (!st) return null
const ui = STATE_UI[st.state]
if (!ui) return null
return (
<span className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)} title={st.detail}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
)
}
function ProposalCard({
item, busy, onAct, showConfirm,
}: {
item: AuftragItem
busy: Record<string, boolean>
onAct: (key: string, path: string, body: object, done?: string) => void
showConfirm: (title: string, msg: string, ok: () => void) => void
}) {
const [open, setOpen] = useState(false)
const [diff, setDiff] = useState<string | null>(null)
const [diffLoading, setDiffLoading] = useState(false)
const running = item.status?.state === "laeuft" || item.status?.state === "rollback"
const decided = item.status?.state === "eingespielt"
async function loadDiff() {
const next = !open
setOpen(next)
if (next && diff === null) {
setDiffLoading(true)
try {
const r = await api<{ diff: string; truncated: boolean }>(`/api/auftragsbuch/diff?branch=${encodeURIComponent(item.branch)}`)
setDiff(r.diff + (r.truncated ? "\n… (gekürzt)" : ""))
} catch (e: any) {
setDiff(`(Diff nicht ladbar: ${e?.message || e})`)
} finally {
setDiffLoading(false)
}
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary">
{KIND_LABEL[item.kind] ?? item.kind}
</span>
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
<StateBadge item={item} />
</div>
<p className="mt-1.5 text-sm font-semibold text-foreground">{item.subject || "(ohne Titel)"}</p>
{item.body && <p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground line-clamp-6">{item.body}</p>}
</div>
{!running && !decided && (
<div className="flex shrink-0 items-center gap-2">
<button
disabled={!!busy[item.branch]}
onClick={() =>
showConfirm("Vorschlag annehmen?",
`'${item.branch}' wird auf main gemergt und live eingespielt. Die Zentrale startet dabei kurz neu. Geht der Health-Check danach rot, rollt die Box automatisch zurück.${item.frontend_ohne_build ? "\n\nAchtung: Enthält Frontend-Quelltext ohne gebautes Bundle — die Oberfläche bleibt alt, bis am PC gebaut wird." : ""}`,
() => onAct(item.branch, "/api/auftragsbuch/annehmen", { branch: item.branch }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
{busy[item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Annehmen
</button>
<button
disabled={!!busy["ab:" + item.branch]}
onClick={() =>
showConfirm("Vorschlag ablehnen?",
`Der Branch '${item.branch}' wird auf Gitea gelöscht. Die Arbeit bleibt in der Historie, aber die Karte verschwindet.`,
() => onAct("ab:" + item.branch, "/api/auftragsbuch/ablehnen", { branch: item.branch }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
{busy["ab:" + item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Ablehnen
</button>
</div>
)}
</div>
{item.status && (running || !decided) && item.status.detail && (
<p className="text-[11px] text-muted-foreground">{item.status.detail}</p>
)}
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.ahead} Commit{item.ahead === 1 ? "" : "s"}</span>
{item.shortstat && <span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.shortstat.trim()}</span>}
{item.behind > 0 && (
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="main ist seit diesem Vorschlag weitergelaufen">
<AlertTriangle className="h-3 w-3" /> {item.behind} hinter main
</span>
)}
{item.frontend_ohne_build && (
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="Die Box kann kein Frontend bauen (kein Node) — nach dem Annehmen bleibt die Oberfläche alt, bis am PC gebaut wird.">
<AlertTriangle className="h-3 w-3" /> Frontend ohne Build
</span>
)}
</div>
<div>
<button onClick={loadDiff}
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<FileDiff className="h-3.5 w-3.5" /> {item.files.length} Datei{item.files.length === 1 ? "" : "en"} Änderungen ansehen
</button>
{open && (
<div className="mt-2 space-y-2">
<div className="flex flex-wrap gap-1.5">
{item.files.map((f) => (
<span key={f.path} className="rounded bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<b className={cn(f.status.startsWith("A") && "text-emerald-400", f.status.startsWith("D") && "text-red-400", f.status.startsWith("M") && "text-sky-400")}>{f.status}</b> {f.path}
</span>
))}
{item.files_truncated && <span className="text-[10px] text-muted-foreground/60"></span>}
</div>
<div className="max-h-96 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3">
{diffLoading
? <span className="text-[11px] text-muted-foreground">Diff wird geladen </span>
: <pre className="text-[10px] leading-relaxed text-foreground/80 whitespace-pre">{
(diff ?? "").split("\n").map((ln, i) => (
<span key={i} className={cn("block", ln.startsWith("+") && !ln.startsWith("+++") && "text-emerald-300/90 bg-emerald-500/5", ln.startsWith("-") && !ln.startsWith("---") && "text-red-300/90 bg-red-500/5", (ln.startsWith("@@") || ln.startsWith("diff ")) && "text-sky-300/90")}>{ln || " "}</span>
))
}</pre>}
</div>
</div>
)}
</div>
</div>
)
}
function KandidatCard({
k, busy, onAct, showConfirm,
}: {
k: SkillKandidat
busy: Record<string, boolean>
onAct: (key: string, path: string, body: object, done?: string) => void
showConfirm: (title: string, msg: string, ok: () => void) => void
}) {
const [open, setOpen] = useState(false)
return (
<div className="rounded-2xl border border-violet-500/25 bg-violet-500/[0.04] p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-violet-500/30 bg-violet-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-violet-300">Traum-Idee</span>
<span className="font-mono text-[11px] text-muted-foreground truncate">{k.file}</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(k.mtime)}</span>
</div>
<p className="mt-1.5 text-sm font-semibold text-foreground">{k.title}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
disabled={!!busy["sk:" + k.file]}
onClick={() =>
showConfirm("Werkstatt beauftragen?",
`Der Kandidat '${k.title}' geht als Auftrag an die Werkstatt (Orchestrator, propose-only). Das Ergebnis erscheint später als neuer Patch-Vorschlag hier im Auftragsbuch — nichts geht ungefragt live.`,
() => onAct("sk:" + k.file, "/api/auftragsbuch/skill/annehmen", { file: k.file },
"Die Werkstatt arbeitet. Das Ergebnis taucht als neuer Vorschlag im Auftragsbuch auf (das kann einige Minuten dauern)."))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
{busy["sk:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Hammer className="h-3.5 w-3.5" />} Beauftragen
</button>
<button
disabled={!!busy["skab:" + k.file]}
onClick={() =>
showConfirm("Kandidat verwerfen?",
`'${k.title}' wandert ins Archiv des Wissens-Vaults (verworfen/). Der Traum schlägt ihn nicht erneut vor.`,
() => onAct("skab:" + k.file, "/api/auftragsbuch/skill/ablehnen", { file: k.file }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
{busy["skab:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Verwerfen
</button>
</div>
</div>
<button onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<Inbox className="h-3.5 w-3.5" /> Vorschlag lesen
</button>
{open && (
<pre className="max-h-72 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3 text-[11px] leading-relaxed text-foreground/85 whitespace-pre-wrap">{k.preview}</pre>
)}
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import { useMemo, useState } from "react"
import {
History, HeartPulse, ShieldAlert, Bell, Moon, Radar, Inbox, Loader2,
Archive, RotateCcw, AlarmClock, MessageCircle, Sparkles, type LucideIcon,
} from "lucide-react"
import { api, type ChronikItem, type Snapshot } from "@/lib/api"
import { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// Chronik = die lesbare Geschichte dessen, was die Box von allein getan und gemeldet hat
// (Quelle: Melde-Briefkasten). Daneben die Zeitmaschine: Snapshots ansehen und per Klick
// zu einem Stand zurückkehren — mit hartem Bestätigungsdialog und Pre-Restore-Sicherung.
const SOURCE_UI: Record<string, { icon: LucideIcon; label: string; cls: string }> = {
sentry: { icon: HeartPulse, label: "Health-Wächter", cls: "text-red-300 bg-red-500/10 border-red-500/25" },
alarm: { icon: ShieldAlert, label: "Alarm", cls: "text-red-200 bg-red-500/15 border-red-500/40" },
notify: { icon: Bell, label: "Meldung", cls: "text-sky-300 bg-sky-500/10 border-sky-500/25" },
"chef-gutachter": { icon: Moon, label: "Morgenlage", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
radar: { icon: Radar, label: "Radar", cls: "text-teal-300 bg-teal-500/10 border-teal-500/25" },
auftragsbuch: { icon: Inbox, label: "Auftragsbuch", cls: "text-primary bg-primary/10 border-primary/25" },
zeitmaschine: { icon: RotateCcw, label: "Zeitmaschine", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
reminder: { icon: AlarmClock, label: "Erinnerung", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
traum: { icon: Sparkles, label: "Traum", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
}
const SOURCE_FALLBACK = { icon: MessageCircle, label: "Box", cls: "text-muted-foreground bg-background/40 border-border/50" }
function dayKey(ts: number): string {
return new Date(ts * 1000).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" })
}
export function ChronikView() {
const { data: items = [], isLoading } = useChronik()
const [limitPerDay, setLimitPerDay] = useState(true)
const grouped = useMemo(() => {
const g: { day: string; items: ChronikItem[] }[] = []
for (const it of items) {
const day = dayKey(it.ts)
const last = g[g.length - 1]
if (last && last.day === day) last.items.push(it)
else g.push({ day, items: [it] })
}
return g
}, [items])
return (
<div className="space-y-7">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Chronik
</h1>
<p className="text-sm text-muted-foreground">
Was deine Box von allein getan und gemeldet hat und die Zeitmaschine, falls du zu einem früheren Stand zurück willst.
</p>
</div>
<Zeitmaschine />
<section className="space-y-4">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<History className="h-3.5 w-3.5" /> Verlauf
</p>
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Chronik wird geladen
</div>
)}
{!isLoading && items.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier.
</div>
)}
{grouped.map(({ day, items: dayItems }) => (
<div key={day}>
<p className="mb-2 text-xs font-bold text-foreground/70">{day}</p>
<div className="space-y-2 border-l-2 border-border/40 pl-4">
{(limitPerDay ? dayItems.slice(0, 8) : dayItems).map((it) => {
const ui = SOURCE_UI[it.source] ?? SOURCE_FALLBACK
const Icon = ui.icon
return (
<div key={it.id} className="relative rounded-xl border border-border/50 bg-card/40 p-3 backdrop-blur-sm">
<span className="absolute -left-[23px] top-4 h-2.5 w-2.5 rounded-full bg-border ring-4 ring-background/60" />
<div className="flex flex-wrap items-center gap-2">
<span className={cn("flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-bold", ui.cls)}>
<Icon className="h-3 w-3" /> {ui.label}
</span>
{it.subject && <span className="text-[10px] font-semibold text-muted-foreground">{it.subject}</span>}
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
{new Date(it.ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{it.text}</p>
</div>
)
})}
{limitPerDay && dayItems.length > 8 && (
<button onClick={() => setLimitPerDay(false)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
{dayItems.length - 8} weitere anzeigen
</button>
)}
</div>
</div>
))}
</section>
</div>
)
}
// ── Zeitmaschine: Snapshots + Ein-Klick-Restore ───────────────────────────────
function Zeitmaschine() {
const { data } = useZeitmaschine()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [busy, setBusy] = useState<string | null>(null)
const [backupBusy, setBackupBusy] = useState(false)
const [showAll, setShowAll] = useState(false)
const backups = data?.backups ?? []
const shown = showAll ? backups : backups.slice(0, 4)
function fmtSnap(s: Snapshot): string {
// Zeitstempel-Format aus backup.sh: 2026-07-08T03-30-01 o. ä. → lesbar machen.
const m = s.snapshot.match(/^(\d{4})-(\d{2})-(\d{2})[T_-](\d{2})[-:](\d{2})/)
if (!m) return s.snapshot
return `${m[3]}.${m[2]}.${m[1]}, ${m[4]}:${m[5]} Uhr`
}
async function restore(s: Snapshot) {
setBusy(s.file)
try {
await api("/api/zeitmaschine/restore", { method: "POST", body: JSON.stringify({ file: s.file }) })
showAlert("Zeitmaschine läuft",
"Die Wiederherstellung wurde gestartet. Die Dienste starten gleich neu — die Zentrale ist kurz weg und meldet sich von selbst zurück. Vorher wurde automatisch ein Sicherheits-Backup des jetzigen Zustands gemacht.")
} catch (e: any) {
showAlert("Start fehlgeschlagen", String(e?.message || e))
} finally {
setBusy(null)
}
}
async function backupNow() {
setBackupBusy(true)
try {
const r = await api<{ ok: boolean; snapshot?: string; error?: string }>("/api/system/backup", { method: "POST" })
if (r.ok) showAlert("Gesichert", `Snapshot ${r.snapshot} wurde angelegt.`)
else showAlert("Backup fehlgeschlagen", r.error || "Unbekannter Fehler")
qc.invalidateQueries({ queryKey: qk.zeitmaschine })
} catch (e: any) {
showAlert("Backup fehlgeschlagen", String(e?.message || e))
} finally {
setBackupBusy(false)
}
}
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Archive className="h-3.5 w-3.5" /> Zeitmaschine gesicherte Stände
</p>
<button onClick={backupNow} disabled={backupBusy || !data?.available}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer disabled:opacity-50">
{backupBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Archive className="h-3.5 w-3.5" />} Jetzt sichern
</button>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Die Snapshots liegen auf der Box im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{data?.available && backups.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-5 text-center text-xs text-muted-foreground">
Noch keine Snapshots. Das nächtliche Backup (03:30) legt sie automatisch an oder Jetzt sichern".
</div>
)}
{shown.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
{shown.map((s, idx) => (
<div key={s.file} className="flex items-center justify-between gap-3 rounded-xl border border-border/50 bg-card/40 p-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground">
{fmtSnap(s)} {idx === 0 && <span className="ml-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-bold text-emerald-300">neuester</span>}
</p>
<p className="font-mono text-[10px] text-muted-foreground/70 truncate" title={s.file}>{s.size_mb.toLocaleString("de-DE")} MB · {s.file}</p>
</div>
<button
disabled={busy !== null}
onClick={() =>
showConfirm("Wirklich zu diesem Stand zurück?",
`Die Box wird auf den Snapshot vom ${fmtSnap(s)} zurückgesetzt (Gedächtnis, Hermes-Config, Modell-Router). Der JETZIGE Zustand wird vorher automatisch gesichert — der Schritt ist also umkehrbar. Die Dienste starten neu, die Zentrale ist kurz weg.`,
() => restore(s))}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2.5 text-[10px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
{busy === s.file ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />} Zurück
</button>
</div>
))}
</div>
)}
{backups.length > 4 && !showAll && (
<button onClick={() => setShowAll(true)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
Alle {backups.length} Snapshots anzeigen
</button>
)}
{dialogElement}
</section>
)
}
+6 -1
View File
@@ -121,8 +121,13 @@ export function MemoryView() {
try {
const dry = await api<DedupeResult>("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: false }) })
if (dry.duplicate_count === 0) { showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber."); return }
// Konkrete Vorschau statt bloßer Zahl: was bleibt, was fliegt (erste 5 Gruppen).
const shorten = (s: string) => (s.length > 90 ? s.slice(0, 90) + "…" : s)
const preview = dry.groups.slice(0, 5).map((g) =>
`✓ bleibt: „${shorten(g.keep.content)}"\n✗ weg: ${g.remove.map((r) => `${shorten(r.content)}"`).join(", ")}`
).join("\n\n") + (dry.groups.length > 5 ? `\n\n… und ${dry.groups.length - 5} weitere Gruppe(n).` : "")
showConfirm("Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden:\n\n${preview}\n\nZusammenführen?`,
async () => {
try { await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }); reloadMemory() }
catch (e: any) { showAlert("Fehler", `Fehler beim Löschen: ${e.message}`) }
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useMemo, useState } from "react"
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
import { api, type WissenDatei, type WissenFile } from "@/lib/api"
import { useWissen } from "@/lib/queries"
import { cn } from "@/lib/utils"
// Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki.
// [[wiki-links]] springen zur verlinkten Notiz; Markdown wird leichtgewichtig gerendert
// (bewusst ohne externe Markdown-Lib — Überschriften, Listen, fett/kursiv, Code reichen hier).
const DIR_LABEL: Record<string, string> = {
"": "Allgemein",
traeume: "Träume",
muster: "Muster",
"skill-kandidaten": "Skill-Kandidaten",
"skill-kandidaten/beauftragt": "Skill-Kandidaten · beauftragt",
"skill-kandidaten/verworfen": "Skill-Kandidaten · verworfen",
}
export function WissenView() {
const { data, isLoading } = useWissen()
const [active, setActive] = useState<string | null>(null)
const [doc, setDoc] = useState<WissenDatei | null>(null)
const [docLoading, setDocLoading] = useState(false)
const [q, setQ] = useState("")
const files = useMemo(() => data?.files ?? [], [data])
// Name → Pfad für [[wiki-links]] (Dateiname ohne .md, case-insensitive).
const byName = useMemo(() => {
const m = new Map<string, string>()
for (const f of files) m.set(f.name.toLowerCase(), f.path)
return m
}, [files])
const filtered = useMemo(() => {
if (!q.trim()) return files
const ql = q.toLowerCase()
return files.filter((f) => f.title.toLowerCase().includes(ql) || f.path.toLowerCase().includes(ql))
}, [files, q])
const groups = useMemo(() => {
const g: { dir: string; files: WissenFile[] }[] = []
for (const f of filtered) {
const found = g.find((x) => x.dir === f.dir)
if (found) found.files.push(f)
else g.push({ dir: f.dir, files: [f] })
}
// INDEX zuerst, dann Träume, dann Rest alphabetisch
return g.sort((a, b) => (a.dir === "" ? -1 : b.dir === "" ? 1 : a.dir.localeCompare(b.dir)))
}, [filtered])
// Beim ersten Laden: INDEX.md öffnen, falls vorhanden.
useEffect(() => {
if (!active && files.length) {
const index = files.find((f) => f.path.toLowerCase() === "index.md")
setActive(index?.path ?? files[0].path)
}
}, [files, active])
useEffect(() => {
if (!active) return
setDocLoading(true)
api<WissenDatei>(`/api/wissen/datei?pfad=${encodeURIComponent(active)}`)
.then(setDoc)
.catch(() => setDoc({ path: active, content: "*(Notiz nicht ladbar)*", mtime: 0 }))
.finally(() => setDocLoading(false))
}, [active])
const openWikiLink = (name: string) => {
const path = byName.get(name.toLowerCase())
if (path) setActive(path)
}
return (
<div className="space-y-5">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Wissen
</h1>
<p className="text-sm text-muted-foreground">
Was sich die Box nachts erschließt Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki.
</p>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Der Wissens-Vault liegt auf der Box (~/wissens-vault) im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Vault wird geladen
</div>
)}
{data?.available && files.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an morgen früh steht hier die erste.
</div>
)}
{files.length > 0 && (
<div className="flex flex-col gap-4 lg:flex-row">
{/* Datei-Liste */}
<aside className="w-full shrink-0 space-y-3 lg:w-72">
<div className="relative">
<input value={q} onChange={(e) => setQ(e.target.value)} type="search"
placeholder="Notizen durchsuchen…" aria-label="Notizen durchsuchen"
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin">
{groups.map(({ dir, files: gf }) => (
<div key={dir || "__root"}>
<p className="mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
<FolderOpen className="h-3 w-3" /> {DIR_LABEL[dir] ?? dir}
</p>
<div className="space-y-0.5">
{gf.map((f) => (
<button key={f.path} onClick={() => setActive(f.path)}
className={cn("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",
active === f.path ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate" title={f.title}>{f.name}</span>
{f.neu && <span className="ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300"><Sparkles className="h-2.5 w-2.5" />neu</span>}
</button>
))}
</div>
</div>
))}
</div>
</aside>
{/* Inhalt */}
<div className="min-w-0 flex-1 rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
{docLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Notiz wird geladen </div>
) : doc ? (
<>
<div className="mb-3 flex items-center justify-between border-b border-border/40 pb-2">
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground"><Library className="h-3.5 w-3.5" /> {doc.path}</span>
{doc.mtime > 0 && (
<span className="text-[10px] text-muted-foreground/60">
Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
<Markdown text={doc.content} onWikiLink={openWikiLink} known={byName} />
</>
) : null}
</div>
</div>
)}
</div>
)
}
// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ──
function Markdown({ text, onWikiLink, known }: {
text: string
onWikiLink: (name: string) => void
known: Map<string, 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-lg font-bold mt-1 mb-3" : level === 2 ? "text-base font-bold mt-4 mb-2" : "text-sm font-bold mt-3 mb-1.5"
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], onWikiLink, known, 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], onWikiLink, known, key)}</li>)
return
}
flushList(key)
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, onWikiLink, known, 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="max-w-3xl">{out}</div>
}
// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
function inline(text: string, onWikiLink: (n: string) => void, known: Map<string, string>, keyBase: string): React.ReactNode[] {
const parts = text.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
return parts.map((p, i) => {
const key = `${keyBase}-${i}`
const wiki = p.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/)
if (wiki) {
const target = wiki[1].trim()
const label = (wiki[2] ?? wiki[1]).trim()
const exists = known.has(target.toLowerCase())
return exists ? (
<button key={key} onClick={() => onWikiLink(target)}
className="rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer">{label}</button>
) : (
<span key={key} className="rounded bg-background/40 px-1 text-muted-foreground" title="Notiz existiert (noch) nicht">{label}</span>
)
}
if (p.startsWith("**") && p.endsWith("**")) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return <i key={key}>{p.slice(1, -1)}</i>
if (p.startsWith("`") && p.endsWith("`")) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300">{p.slice(1, -1)}</code>
return <span key={key}>{p}</span>
})
}
+58 -5
View File
@@ -2,17 +2,20 @@ import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug, Bot, TerminalSquare, ScrollText,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Inbox, History, Library,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useQueryClient, qk,
useAuftragsbuch, useQueryClient, qk,
} from "@/lib/queries"
import { SquareTerminal } from "lucide-react"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile"
import { MorgenlageCard } from "./MorgenlageCard"
import { ErinnerungenCard } from "./ErinnerungenCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
@@ -31,6 +34,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch(20_000)
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
@@ -69,6 +73,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine > 0 ? 1 : 0) + (updates.swap > 0 ? 1 : 0) + (updates.models > 0 ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
const connectOk = connect?.gateway.ok && connect?.memory.ok
const openAuftraege = auftraege?.available ? auftraege.open_count : 0
return (
<div className="space-y-7">
@@ -83,18 +88,30 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
{/* Ehrliche Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} />
{/* Morgenlage — das jüngste Nacht-Gutachten (verschwindet, wenn keines da ist) */}
<MorgenlageCard />
{/* „Braucht dich" — offene Aktionen, sonst Ruhe */}
<NeedsYou
problems={health.problems}
pendingCount={pendingCount}
openAuftraege={openAuftraege}
busy={busy}
onRepair={runRepair}
onNavigate={onNavigate}
/>
{/* Bereichs-Kacheln */}
<section>
<ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CockpitTile
icon={Inbox} title="Auftragsbuch" value={auftraege?.available ? openAuftraege : "—"}
unit={openAuftraege === 1 ? "Vorschlag" : "Vorschläge"}
tone={!auftraege ? "loading" : openAuftraege > 0 ? "warn" : "ok"}
hint="Annehmen oder ablehnen"
onClick={() => onNavigate("auftraege")}
/>
<CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"} hint="Speicher, laden & Rollen"
@@ -129,6 +146,12 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
</div>
</section>
{/* Anstehendes — Erinnerungen & Routinen (GUI-Hälfte der Wecker-API) */}
<section>
<ZoneLabel>Anstehendes</ZoneLabel>
<ErinnerungenCard />
</section>
{/* Leistung — die Live-Graphen zurück auf der Startseite (User-Wunsch 07.07.:
System-Verlauf, tok/s und Turn-Latenz waren nach dem UI-v3-Umbau nur noch in
der alten Zentrale erreichbar). Karten sind autark (eigene Hooks/Polling). */}
@@ -162,6 +185,16 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
tone={!agent ? "loading" : agent.box_console_reachable ? "ok" : "warn"} hint="Direkte Box-Shell (SSH)"
onClick={() => onNavigate("konsole")}
/>
<CockpitTile
icon={Library} title="Wissen" value="öffnen"
tone="muted" hint="Lucys Wissens-Vault (Traum-Notizen)"
onClick={() => onNavigate("wissen")}
/>
<CockpitTile
icon={History} title="Chronik" value="öffnen"
tone="muted" hint="Autonome Taten + Zeitmaschine"
onClick={() => onNavigate("chronik")}
/>
</div>
</section>
@@ -225,18 +258,21 @@ function StatusLine({ verdict, memory, warns }: {
)
}
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates.
// Nichts offen → ruhige Bestätigung statt leerer Fläche.
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates
// + offene Vorschläge im Auftragsbuch. Nichts offen → ruhige Bestätigung statt leerer Fläche.
function NeedsYou({
problems, pendingCount, busy, onRepair,
problems, pendingCount, openAuftraege, busy, onRepair, onNavigate,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
openAuftraege: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
onNavigate: (v: string) => void
}) {
const hasUpdates = pendingCount > 0
const nothing = problems.length === 0 && !hasUpdates
const hasAuftraege = openAuftraege > 0
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege
return (
<section>
@@ -274,6 +310,23 @@ function NeedsYou({
)}
</div>
))}
{hasAuftraege && (
<button
onClick={() => onNavigate("auftraege")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 p-4 text-left transition-all hover:bg-primary/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-background/30 text-primary">
<Inbox className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{openAuftraege} Vorschl{openAuftraege === 1 ? "ag" : "äge"} im Auftragsbuch</p>
<p className="text-xs text-muted-foreground">Die Box hat etwas gebaut oder sich ausgedacht annehmen oder ablehnen.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-primary/70" />
</button>
)}
{hasUpdates && (
<button
onClick={() => openDrawer("maintenance")}
@@ -0,0 +1,117 @@
import { useState } from "react"
import { AlarmClock, Plus, Trash2, Loader2, X, Repeat } from "lucide-react"
import { api } from "@/lib/api"
import { useReminders, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// „Anstehendes": Erinnerungen & Routinen sichtbar und klickbar machen. Die API kann
// längst alles (Lucy legt per Stimme an) — hier kommt nur die GUI-Hälfte dazu.
const REPEAT_LABEL: Record<string, string> = {
"": "einmalig", daily: "täglich", weekdays: "werktags", weekly: "wöchentlich",
}
export function ErinnerungenCard() {
const { data: items = [], isLoading } = useReminders()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [showAdd, setShowAdd] = useState(false)
const [text, setText] = useState("")
const [when, setWhen] = useState("")
const [repeat, setRepeat] = useState("")
const [saving, setSaving] = useState(false)
const reload = () => qc.invalidateQueries({ queryKey: qk.reminders })
async function add() {
if (!text.trim() || !when) return
setSaving(true)
try {
await api("/api/reminders", { method: "POST", body: JSON.stringify({ text, when, repeat }) })
setText(""); setWhen(""); setRepeat(""); setShowAdd(false)
reload()
} catch (e: any) {
showAlert("Anlegen fehlgeschlagen", String(e?.message || e))
} finally {
setSaving(false)
}
}
function del(id: number, t: string) {
showConfirm("Erinnerung löschen?", `${t}" wird entfernt.`, async () => {
try { await api(`/api/reminders/${id}`, { method: "DELETE" }); reload() }
catch (e: any) { showAlert("Fehler", String(e?.message || e)) }
})
}
const sorted = [...items].sort((a, b) => a.next_ts - b.next_ts)
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mb-3 flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-bold text-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-amber-300">
<AlarmClock className="h-4 w-4" />
</span>
Anstehendes
</span>
<button onClick={() => setShowAdd((s) => !s)}
className="flex h-8 items-center gap-1 rounded-lg border border-border/60 bg-background/30 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
{showAdd ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />} {showAdd ? "Abbrechen" : "Neu"}
</button>
</div>
{showAdd && (
<div className="mb-3 space-y-2 rounded-xl border border-border/50 bg-background/30 p-3">
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Woran soll Lucy erinnern?"
aria-label="Erinnerungstext"
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 px-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
<div className="flex flex-wrap items-center gap-2">
<input type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)}
aria-label="Zeitpunkt"
className="h-9 rounded-lg border border-border/60 bg-card/45 px-2 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50 [color-scheme:dark]" />
<select value={repeat} onChange={(e) => setRepeat(e.target.value)} aria-label="Wiederholung"
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-card/45 px-2 text-xs font-semibold text-foreground outline-none">
{Object.entries(REPEAT_LABEL).map(([v, l]) => <option key={v} value={v} className="bg-popover">{l}</option>)}
</select>
<button onClick={add} disabled={saving || !text.trim() || !when}
className="ml-auto flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />} Anlegen
</button>
</div>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Wird geladen </p>
) : sorted.length === 0 ? (
<p className="text-xs text-muted-foreground">Keine Erinnerungen. Lucy sagt dir hier (und per Stimme) Bescheid, wenn etwas ansteht.</p>
) : (
<div className="space-y-1.5">
{sorted.slice(0, 6).map((r) => {
const overdue = r.next_ts * 1000 < Date.now() && !r.repeat
return (
<div key={r.id} className="group flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/20 px-2.5 py-2">
<div className="min-w-0">
<p className="truncate text-xs text-foreground">{r.text}</p>
<p className={cn("flex items-center gap-1.5 text-[10px]", overdue ? "text-amber-300" : "text-muted-foreground/70")}>
{new Date(r.next_ts * 1000).toLocaleString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
{r.repeat && <span className="flex items-center gap-0.5"><Repeat className="h-2.5 w-2.5" />{REPEAT_LABEL[r.repeat] ?? r.repeat}</span>}
{overdue && "· überfällig"}
</p>
</div>
<button onClick={() => del(r.id, r.text)} aria-label="Erinnerung löschen"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-red-500/5 hover:text-red-400 group-hover:opacity-100 cursor-pointer">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
{sorted.length > 6 && <p className="text-[10px] text-muted-foreground/60">+{sorted.length - 6} weitere</p>}
</div>
)}
{dialogElement}
</div>
)
}
@@ -0,0 +1,43 @@
import { useState } from "react"
import { Moon, ChevronDown, ChevronRight } from "lucide-react"
import { useChronik } from "@/lib/queries"
// Morgenlage: das jüngste Verdikt des nächtlichen Chef-Gutachters (gpt-oss urteilt im
// Leerlauf über die autonome Arbeit; der Feed spiegelt es still in den Briefkasten).
// Keine Lage vorhanden → Karte verschwindet ganz (Ruhe statt leerer Fläche).
export function MorgenlageCard() {
const { data: items = [] } = useChronik(150, 60_000)
const [open, setOpen] = useState(false)
const lage = items.find((i) => i.source === "chef-gutachter")
if (!lage) return null
const when = new Date(lage.ts * 1000).toLocaleString("de-DE", {
weekday: "long", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})
// VERDIKT-Zeile als Kurzfassung herausziehen (Raster des Chef-Gutachters).
const verdikt = lage.text.split("\n").find((l) => l.trim().startsWith("VERDIKT:"))?.replace(/^\s*VERDIKT:\s*/, "")
return (
<div className="rounded-2xl border border-violet-500/25 bg-violet-500/[0.05] p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-3 text-left cursor-pointer">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-violet-500/30 bg-background/30 text-violet-300">
<Moon className="h-4.5 w-4.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold text-foreground">Morgenlage</span>
<span className="block truncate text-xs text-muted-foreground">
{verdikt || lage.subject || "Nächtliches Gutachten über die autonome Arbeit"}
</span>
</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">{when}</span>
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
</button>
{open && (
<div className="mt-3 border-t border-violet-500/15 pt-3">
{lage.subject && <p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-violet-300/80">{lage.subject}</p>}
<p className="whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{lage.text}</p>
</div>
)}
</div>
)
}