Neue Ansicht 'Von allein': Skills + Vorschlags-Bilanz der Box (+ schlafende P1-Basis)

User-Wunsch 15.07.: EIN Viewpoint, was die Box selbst geleistet hat -
Chronik ist zu kompliziert. Neue Operativ-Ansicht #eigenleben:
- Skills aus ~/.hermes/skills (= Lucys Satz) mit Klartext-Beschreibung,
  Nutzungszaehler (.usage.json) und Herkunft (selbst erstellt / von uns
  gebaut / mitgeliefert), aufklappbar mit vollem SKILL.md.
- Vorschlags-Bilanz: angenommen (Auftragsbuch-Chronik) + abgelehnt mit
  Grund (Lern-Journal) als eine Timeline + Zaehler.
Backend: services/eigenleben.py + routers/eigenleben.py (read-only).

Mit an Bord (User-Ja, SCHLAFEND): config.V1_UPSTREAM + app.py-Weiche +
gateway_forward aus dem P1-Gateway-Auszug - ohne MC_V1_UPSTREAM in der
Unit exakt altes Verhalten. Der aktive Rest von P1 kommt separat ueber
Branch umbau/p1-gateway-auszug (dort liegt auch diese View schon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-15 10:45:04 +02:00
parent 734cafd98f
commit 86bd72838a
31 changed files with 645 additions and 108 deletions
+3 -1
View File
@@ -21,6 +21,7 @@ const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ defa
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const EigenlebenView = lazy(() => import("@/views/EigenlebenView").then((m) => ({ default: m.EigenlebenView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
@@ -201,10 +202,11 @@ export default function App() {
{view === "memory" && <MemoryView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "eigenleben" && <EigenlebenView />}
{view === "agent" && <AgentView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "auftraege", "models", "connect", "memory", "wissen", "chronik", "agent", "konsole", "guide"].includes(view) && (
{!["dashboard", "auftraege", "eigenleben", "models", "connect", "memory", "wissen", "chronik", "agent", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</Suspense>
+28
View File
@@ -520,6 +520,34 @@ export interface ChronikResp {
items: ChronikItem[]
}
// ── Eigenleben („Von allein": Skills + Vorschlags-Bilanz der Box) ────────────
export interface EigenlebenSkill {
id: string
name: string
beschreibung: string
version: string
autor: string
herkunft: "selbst" | "repo" | "bundled"
herkunft_label: string
genutzt: number
zuletzt_genutzt: string | null
erstellt: string | null
status: string
geaendert: number
}
export interface EigenlebenEintrag {
art: string // "angenommen" | "abgelehnt" | sonstiger Chronik-State
titel: string
detail: string
ts: number
}
export interface EigenlebenResp {
available: boolean
skills: EigenlebenSkill[]
selbst_erstellt: number
bilanz: { eintraege: EigenlebenEintrag[]; angenommen: number; abgelehnt: number }
}
// ── Wissens-Vault (Traum-Notizen, read-only) ─────────────────────────────────
export interface WissenFile {
path: string
+9
View File
@@ -12,6 +12,7 @@ import {
type ConnectHealth,
type DiscoverResp,
type DraftsResp,
type EigenlebenResp,
type GroupsResp,
type HermesBrainResp,
type Health,
@@ -57,6 +58,7 @@ export const qk = {
auftragsbuch: ["auftragsbuch"] as const,
ideen: ["ideen"] as const,
chronik: ["chronik"] as const,
eigenleben: ["eigenleben"] as const,
wissen: ["wissen"] as const,
zeitmaschine: ["zeitmaschine"] as const,
reminders: ["reminders"] as const,
@@ -113,6 +115,13 @@ export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlic
export const useWissen = () =>
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useEigenleben = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.eigenleben,
queryFn: () => api<EigenlebenResp>("/api/eigenleben"),
refetchInterval,
})
export const useZeitmaschine = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.zeitmaschine,
+3 -1
View File
@@ -7,12 +7,13 @@ import {
Library,
Plug,
Bot,
Sparkles,
SquareTerminal,
HelpCircle,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "auftraege" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type ViewId = "dashboard" | "auftraege" | "eigenleben" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
@@ -28,6 +29,7 @@ export interface NavItem {
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "eigenleben", label: "Von allein", hint: "Was die Box selbst gebaut hat — Skills & Bilanz", icon: Sparkles, group: "Operativ" },
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain, group: "Wissen" },
{ id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
+223
View File
@@ -0,0 +1,223 @@
import { useMemo, useState } from "react"
import {
Sparkles, Wrench, Package, Loader2, CheckCircle2, XCircle, CircleDot,
ChevronDown, ChevronRight, BookOpen, type LucideIcon,
} from "lucide-react"
import { api, type EigenlebenEintrag, type EigenlebenSkill } from "@/lib/api"
import { useEigenleben } from "@/lib/queries"
import { cn } from "@/lib/utils"
// „Von allein" — die Antwort auf die User-Frage vom 15.07.2026: EIN Ort, der zeigt,
// was die Box selbst geleistet hat. Oben die Skills (Lucys Fähigkeiten-Regal, selbst
// erstellte zuerst, mit Klartext-Beschreibung + Nutzungszähler), darunter die
// Vorschlags-Bilanz (angenommen/abgelehnt mit Grund) — ohne Chronik-Rauschen.
const HERKUNFT_UI: Record<EigenlebenSkill["herkunft"], { icon: LucideIcon; cls: string; erklaerung: string }> = {
selbst: {
icon: Sparkles,
cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/30",
erklaerung: "Diese Skills hat sich die Box selbst geschrieben — niemand hat sie ihr installiert.",
},
repo: {
icon: Wrench,
cls: "text-primary bg-primary/10 border-primary/25",
erklaerung: "Von uns gebaut und über das Repo installiert (deploy/skills).",
},
bundled: {
icon: Package,
cls: "text-muted-foreground bg-background/40 border-border/50",
erklaerung: "Kamen mit Hermes mit — Standard-Ausstattung.",
},
}
function datumKurz(iso: string | null): string {
if (!iso) return "—"
const d = new Date(iso)
return isNaN(d.getTime()) ? "—" : d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" })
}
export function EigenlebenView() {
const { data, isLoading } = useEigenleben()
const gruppen = useMemo(() => {
const g: { herkunft: EigenlebenSkill["herkunft"]; label: string; skills: EigenlebenSkill[] }[] = []
for (const s of data?.skills ?? []) {
const last = g[g.length - 1]
if (last && last.herkunft === s.herkunft) last.skills.push(s)
else g.push({ herkunft: s.herkunft, label: s.herkunft_label, skills: [s] })
}
return g
}, [data])
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">
Von allein
</h1>
<p className="text-sm text-muted-foreground">
Was deine Box selbst geleistet hat: ihre Fähigkeiten (Skills) und was aus ihren Vorschlägen wurde.
Das ist derselbe Skill-Satz, mit dem Lucy spricht und die Nacht-Worker arbeiten.
</p>
</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" /> Wird geladen
</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 Skills liegen auf der Box (~/.hermes/skills) im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{data?.available && (
<>
{/* Bilanz-Zähler */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Zahl label="Skills gesamt" wert={data.skills.length} />
<Zahl label="davon selbst erstellt" wert={data.selbst_erstellt} betone />
<Zahl label="Vorschläge angenommen" wert={data.bilanz.angenommen} />
<Zahl label="Vorschläge abgelehnt" wert={data.bilanz.abgelehnt} />
</div>
{/* Skills nach Herkunft */}
{gruppen.map((g) => {
const ui = HERKUNFT_UI[g.herkunft]
const Icon = ui.icon
return (
<section key={g.herkunft} className="space-y-3">
<div>
<p className={cn("inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-bold", ui.cls)}>
<Icon className="h-3.5 w-3.5" /> {g.label} · {g.skills.length}
</p>
<p className="mt-1 text-[11px] text-muted-foreground/70">{ui.erklaerung}</p>
</div>
<div className="grid gap-2 md:grid-cols-2">
{g.skills.map((s) => <SkillKarte key={s.id} skill={s} />)}
</div>
</section>
)
})}
{/* Vorschlags-Bilanz */}
<Bilanz eintraege={data.bilanz.eintraege} />
</>
)}
</div>
)
}
function Zahl({ label, wert, betone }: { label: string; wert: number; betone?: boolean }) {
return (
<div className={cn("rounded-xl border p-3", betone ? "border-emerald-500/30 bg-emerald-500/5" : "border-border/50 bg-card/40")}>
<p className={cn("font-space text-xl font-bold", betone ? "text-emerald-300" : "text-foreground")}>{wert}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">{label}</p>
</div>
)
}
function SkillKarte({ skill }: { skill: EigenlebenSkill }) {
const [offen, setOffen] = useState(false)
const [text, setText] = useState<string | null>(null)
const [laedt, setLaedt] = useState(false)
async function toggle() {
const next = !offen
setOffen(next)
if (next && text === null && !laedt) {
setLaedt(true)
try {
const r = await api<{ id: string; text: string }>(`/api/eigenleben/skill/${encodeURIComponent(skill.id)}`)
setText(r.text)
} catch {
setText("(Konnte den Skill-Text nicht laden.)")
} finally {
setLaedt(false)
}
}
}
return (
<div className="rounded-xl border border-border/50 bg-card/40 p-3 backdrop-blur-sm">
<button onClick={toggle} className="flex w-full items-start gap-2 text-left cursor-pointer">
{offen ? <ChevronDown className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : <ChevronRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{skill.name}</span>
{skill.genutzt > 0 && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] font-bold text-primary" title={`zuletzt: ${datumKurz(skill.zuletzt_genutzt)}`}>
{skill.genutzt}× genutzt
</span>
)}
{skill.status !== "active" && (
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-bold text-amber-300">{skill.status}</span>
)}
</div>
<p className={cn("mt-1 text-xs leading-relaxed text-foreground/75", !offen && "line-clamp-2")}>
{skill.beschreibung || "(keine Beschreibung im Skill hinterlegt)"}
</p>
</div>
</button>
{offen && (
<div className="mt-2 border-t border-border/40 pt-2">
<p className="mb-1.5 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60">
<BookOpen className="h-3 w-3" /> So funktioniert er (SKILL.md){skill.autor ? ` · Autor: ${skill.autor}` : ""}
</p>
{laedt
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <pre className="max-h-80 overflow-y-auto whitespace-pre-wrap rounded-lg bg-background/50 p-2.5 font-mono text-[10.5px] leading-relaxed text-foreground/80 scrollbar-thin">{text ?? ""}</pre>}
</div>
)}
</div>
)
}
function Bilanz({ eintraege }: { eintraege: EigenlebenEintrag[] }) {
const [alle, setAlle] = useState(false)
const shown = alle ? eintraege : eintraege.slice(0, 12)
return (
<section className="space-y-3">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<CircleDot className="h-3.5 w-3.5" /> Vorschlags-Bilanz was die Box vorschlug und was daraus wurde
</p>
{eintraege.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-6 text-center text-xs text-muted-foreground">
Noch keine Vorschläge verbucht. Sobald die Box etwas vorschlägt und du klickst, steht die Entscheidung hier.
</div>
)}
<div className="space-y-2">
{shown.map((e, i) => {
const angenommen = e.art === "angenommen"
const Icon = angenommen ? CheckCircle2 : e.art === "abgelehnt" ? XCircle : CircleDot
return (
<div key={`${e.titel}-${e.ts}-${i}`} className="flex items-start gap-2.5 rounded-xl border border-border/50 bg-card/40 p-3">
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0",
angenommen ? "text-emerald-400" : e.art === "abgelehnt" ? "text-red-400" : "text-amber-300")} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-semibold text-foreground">{e.titel}</span>
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-bold uppercase",
angenommen ? "bg-emerald-500/10 text-emerald-300" : e.art === "abgelehnt" ? "bg-red-500/10 text-red-300" : "bg-amber-500/10 text-amber-300")}>
{e.art}
</span>
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
{e.ts ? new Date(e.ts * 1000).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }) : ""}
</span>
</div>
{e.detail && <p className="mt-1 text-xs leading-relaxed text-foreground/70">{e.detail}</p>}
</div>
</div>
)
})}
</div>
{!alle && eintraege.length > 12 && (
<button onClick={() => setAlle(true)} className="text-[11px] text-muted-foreground underline hover:text-foreground cursor-pointer">
Alle {eintraege.length} Einträge anzeigen
</button>
)}
</section>
)
}