From 6cb29ee70e1b3db4ace4107d46df3f198691cdc8 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 7 Aug 2026 17:03:30 +0200 Subject: [PATCH] feat: show running state of skills in frontend using psutil --- backend/routers/skills.py | 20 ++- backend/routers/wissen.py | 151 ---------------- frontend/src/views/SkillsView.tsx | 11 +- frontend/src/views/WissenView.tsx | 283 ------------------------------ 4 files changed, 25 insertions(+), 440 deletions(-) delete mode 100644 backend/routers/wissen.py delete mode 100644 frontend/src/views/WissenView.tsx diff --git a/backend/routers/skills.py b/backend/routers/skills.py index ed5beff..75800c5 100644 --- a/backend/routers/skills.py +++ b/backend/routers/skills.py @@ -1,5 +1,6 @@ import os import subprocess +import psutil from fastapi import APIRouter from pydantic import BaseModel @@ -18,6 +19,22 @@ def list_skills(): skills_dir = os.path.join(repo_root, "deploy", "skills") skills = [] + # Check currently running hermes skill processes + running_skills = set() + for p in psutil.process_iter(['name', 'cmdline']): + try: + cmdline = p.info.get('cmdline') + if cmdline and any("hermes" in arg for arg in cmdline): + for arg in cmdline: + if "Führe den " in arg and " Skill aus" in arg: + # Extract skill name from "Führe den 'skill_name' Skill aus" + import re + match = re.search(r"Führe den '(.+?)' Skill aus", arg) + if match: + running_skills.add(match.group(1)) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + if os.path.exists(skills_dir) and os.path.isdir(skills_dir): for entry in os.scandir(skills_dir): if entry.is_dir(): @@ -40,7 +57,8 @@ def list_skills(): skills.append({ "name": entry.name, - "description": description or "Keine Beschreibung verfügbar." + "description": description or "Keine Beschreibung verfügbar.", + "running": entry.name in running_skills }) return {"skills": skills} diff --git a/backend/routers/wissen.py b/backend/routers/wissen.py deleted file mode 100644 index ac92634..0000000 --- a/backend/routers/wissen.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares -Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron -(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert.""" - -import os -import re -import time -from pathlib import Path - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -router = APIRouter(prefix="/api") - -VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser() - - -def _available() -> bool: - return VAULT.is_dir() - - -def _title_of(p: Path) -> str: - """Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename.""" - try: - with p.open(encoding="utf-8", errors="replace") as fh: - for line in fh: - s = line.strip() - if s: - return s.lstrip("# ").strip()[:160] - except OSError: - pass - return p.stem - - -class VaultFile(BaseModel): - path: str - name: str - dir: str - title: str - mtime: float - neu: bool - - -class WissenListResponse(BaseModel): - available: bool - files: list[VaultFile] - - -class WissenFileResponse(BaseModel): - path: str - content: str - mtime: float - - -@router.get("/wissen", response_model=WissenListResponse) -def list_vault() -> dict: - """Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter).""" - if not _available(): - return {"available": False, "files": []} - files = [] - now = time.time() - for p in sorted(VAULT.rglob("*.md")): - if ".git" in p.parts: - continue - rel = p.relative_to(VAULT).as_posix() - st = p.stat() - files.append({ - "path": rel, - "name": p.stem, - "dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "", - "title": _title_of(p), - "mtime": st.st_mtime, - "neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht" - }) - files.sort(key=lambda f: f["mtime"], reverse=True) - return {"available": True, "files": files} - - -@router.get("/wissen/datei", response_model=WissenFileResponse) -def read_file(pfad: str) -> dict: - """Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to).""" - if not _available(): - raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).") - try: - target = (VAULT / pfad).resolve() - if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file(): - raise HTTPException(404, "Notiz nicht gefunden.") - return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000], - "mtime": target.stat().st_mtime} - except HTTPException: - raise - except (ValueError, OSError): - raise HTTPException(404, "Notiz nicht lesbar.") - - -@router.get("/wissen/graph") -def graph_vault() -> dict: - """Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links).""" - if not _available(): - return {"nodes": [], "edges": []} - - nodes = [] - edges = [] - # Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID) - name_to_id = {} - - # 1. Alle Nodes sammeln - for p in VAULT.rglob("*.md"): - if ".git" in p.parts: - continue - rel = p.relative_to(VAULT).as_posix() - name_to_id[p.stem.lower()] = rel - - # Kategorie aus dem Ordner ableiten - cat = "knowledge" - parent = p.parent.name if p.parent != VAULT else "" - if parent == "traeume": - cat = "events" - elif parent == "muster": - cat = "rules" - elif parent == "skill-kandidaten": - cat = "identity" - - nodes.append({ - "id": rel, - "content": p.stem, # stem ist oft kürzer/prägnanter als der Titel - "category": cat, - "source": "wiki" - }) - - # 2. Edges extrahieren (Wiki-Links [[Name]]) - link_rx = re.compile(r"\[\[([^\]]+)\]\]") - for n in nodes: - try: - target = VAULT / n["id"] - content = target.read_text(encoding="utf-8", errors="replace") - # Set, um mehrfache Links auf dieselbe Datei zu entduplizieren - found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)} - - for link in found_links: - target_id = name_to_id.get(link) - if target_id and target_id != n["id"]: - edges.append({ - "source": n["id"], - "target": target_id, - "weight": 1.0 - }) - except OSError: - pass - - return {"nodes": nodes, "edges": edges} diff --git a/frontend/src/views/SkillsView.tsx b/frontend/src/views/SkillsView.tsx index 4885056..e363dfd 100644 --- a/frontend/src/views/SkillsView.tsx +++ b/frontend/src/views/SkillsView.tsx @@ -9,6 +9,7 @@ import { JobsBar } from "@/components/models/JobsBar" interface Skill { name: string description: string + running?: boolean } interface SkillsResp { @@ -98,16 +99,16 @@ export function SkillsView() {
diff --git a/frontend/src/views/WissenView.tsx b/frontend/src/views/WissenView.tsx deleted file mode 100644 index fcbf697..0000000 --- a/frontend/src/views/WissenView.tsx +++ /dev/null @@ -1,283 +0,0 @@ -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, useMemoryGraph } from "@/lib/queries" -import { GraphView } from "./GraphView" -import { GraphErrorBoundary } from "./GraphErrorBoundary" -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 = { - "": "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(null) - const [doc, setDoc] = useState(null) - const [docLoading, setDocLoading] = useState(false) - const [q, setQ] = useState("") - const [showGraph, setShowGraph] = useState(false) - - const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph) - - const files = useMemo(() => data?.files ?? [], [data]) - - // Name → Pfad für [[wiki-links]] (Dateiname ohne .md, case-insensitive). - const byName = useMemo(() => { - const m = new Map() - 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(`/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 ( -
-
-
-

- Wissen -

-

- Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki. -

-
- {files.length > 0 && ( -
- - -
- )} -
- - {data && !data.available && ( -
- Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar. -
- )} - - {isLoading && ( -
- Vault wird geladen … -
- )} - - {data?.available && files.length === 0 && ( -
- Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste. -
- )} - - {showGraph ? ( -
- {graphLoading && ( -
-
- Graph wird berechnet … -
-
- )} - - {graphData && ( - { - if (id) { - setActive(id) - setShowGraph(false) - } - }} - /> - )} - -
- ) : files.length > 0 && ( -
- {/* Datei-Liste */} - - - {/* Inhalt */} -
- {docLoading ? ( -
Notiz wird geladen …
- ) : doc ? ( - <> -
- {doc.path} - {doc.mtime > 0 && ( - - Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })} - - )} -
- - - ) : null} -
-
- )} -
- ) -} - -// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ── -function Markdown({ text, onWikiLink, known }: { - text: string - onWikiLink: (name: string) => void - known: Map -}) { - 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(
    {list}
) - list = [] - } - } - - lines.forEach((raw, i) => { - const key = `l${i}` - if (code !== null) { - if (raw.trimEnd() === "```") { - out.push(
{code.join("\n")}
) - 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(

{inline(h[2], onWikiLink, known, key)}

) - return - } - const li = line.match(/^\s*[-*•]\s+(.*)$/) - if (li) { - list.push(
  • {inline(li[1], onWikiLink, known, key)}
  • ) - return - } - flushList(key) - out.push(

    {inline(line, onWikiLink, known, key)}

    ) - }) - flushList("end") - if (code !== null) out.push(
    {(code as string[]).join("\n")}
    ) - - return
    {out}
    -} - -// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject). -function inline(text: string, onWikiLink: (n: string) => void, known: Map, 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 ? ( - - ) : ( - {label} - ) - } - if (p.startsWith("**") && p.endsWith("**")) return {p.slice(2, -2)} - if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return {p.slice(1, -1)} - if (p.startsWith("`") && p.endsWith("`")) return {p.slice(1, -1)} - return {p} - }) -}