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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user