Merge wartung/produkt-ausbau: Auftragsbuch + Chronik/Zeitmaschine + Wissens-Wiki + Morgenlage + Erinnerungen

This commit is contained in:
Hitonabi
2026-07-08 18:42:53 +02:00
29 changed files with 2629 additions and 706 deletions
+8
View File
@@ -22,6 +22,8 @@ from starlette.requests import Request
from config import FRONTEND_DIST, VERSION from config import FRONTEND_DIST, VERSION
from routers import ( from routers import (
agent, agent,
auftragsbuch,
chronik,
connect, connect,
console, console,
gateway_proxy, gateway_proxy,
@@ -33,6 +35,8 @@ from routers import (
routing, routing,
system, system,
voice, voice,
wissen,
zeitmaschine,
) )
from routers import reminders as reminders_router from routers import reminders as reminders_router
from services import memory as memory_svc from services import memory as memory_svc
@@ -118,6 +122,10 @@ app.include_router(
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten ) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router( app.include_router(
console.router console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all ) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
+1
View File
@@ -1,4 +1,5 @@
fastapi>=0.115 fastapi>=0.115
python-multipart>=0.0.9
uvicorn[standard]>=0.30 uvicorn[standard]>=0.30
httpx>=0.27 httpx>=0.27
ruamel.yaml>=0.18 ruamel.yaml>=0.18
+62
View File
@@ -0,0 +1,62 @@
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import auftragsbuch
router = APIRouter(prefix="/api")
class BranchIn(BaseModel):
branch: str
class KandidatIn(BaseModel):
file: str
@router.get("/auftragsbuch")
def list_proposals() -> dict:
return auftragsbuch.list_proposals()
@router.get("/auftragsbuch/diff")
def diff(branch: str) -> dict:
res = auftragsbuch.diff_of(branch)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/annehmen")
def skill_accept(body: KandidatIn) -> dict:
res = auftragsbuch.skill_accept(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/ablehnen")
def skill_reject(body: KandidatIn) -> dict:
res = auftragsbuch.skill_reject(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
return res
+15
View File
@@ -0,0 +1,15 @@
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
from services import announce
router = APIRouter(prefix="/api")
@router.get("/chronik")
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
+71
View File
@@ -0,0 +1,71 @@
"""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 time
from pathlib import Path
from fastapi import APIRouter, HTTPException
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
@router.get("/wissen")
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")
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.")
+71
View File
@@ -0,0 +1,71 @@
"""Zeitmaschine — Snapshots ansehen und per Klick zu einem Stand zurückkehren.
Die Maschinerie existiert komplett (deploy/backup.sh Timer 03:30, Off-Box-Kopie,
deploy/restore.sh mit Pre-Restore-Sicherung); hier kommt nur der letzte Meter dazu:
Liste + Inhalt in der UI und ein Restore-Start als EIGENE systemd-Unit — restore.sh
stoppt mission-control-2 selbst, als Kind des Backends stürbe der Lauf mittendrin."""
import os
import re
import subprocess
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import backup as backup_svc
router = APIRouter(prefix="/api")
_REPO_ROOT = Path(__file__).resolve().parents[2]
RESTORE_SH = _REPO_ROOT / "deploy" / "restore.sh"
_FILE_RX = re.compile(r"^mc2-state-[0-9T:_-]+\.tar\.gz$")
class RestoreIn(BaseModel):
file: str
@router.get("/zeitmaschine")
def list_snapshots() -> dict:
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
if not p.is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
return {"file": file, "components": backup_svc.snapshot_components(p)}
@router.post("/zeitmaschine/restore")
def restore(body: RestoreIn) -> dict:
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
Sicherheits-Backup des aktuellen Zustands — der Schritt ist also reversibel."""
if os.name != "posix":
raise HTTPException(400, "Wiederherstellen geht nur auf der Box.")
if not _FILE_RX.match(body.file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
if not (backup_svc.BACKUP_DIR / body.file).is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
try:
from services import announce
announce.add(f"Zeitmaschine: Wiederherstellung von {body.file} gestartet — "
"die Dienste starten gleich neu.", "[Zeitmaschine]", "zeitmaschine", "silent")
except Exception:
pass
unit = f"mc2-restore-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(RESTORE_SH), "--yes", body.file],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
raise HTTPException(500, f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}")
return {"ok": True, "unit": unit}
+8
View File
@@ -89,6 +89,14 @@ def notify_telegram(subject: str, text: str) -> None:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True) log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
def list_recent(limit: int = 150) -> list[dict]:
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
with _lock:
state = _load()
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
def list_after(after: int | None, limit: int = 20) -> dict: def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen """Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern.""" Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
+316
View File
@@ -0,0 +1,316 @@
"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet deploy/auftrag-annehmen.sh als EIGENE systemd-Unit (detached):
Merge im isolierten Worktree → Push origin/main → Deploy → Health → bei Rot Revert+Alarm.
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {"ts": 0.0}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _git(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPO), *args],
capture_output=True, text=True, timeout=timeout)
def _fetch_throttled() -> None:
now = time.time()
if now - _fetch_cache["ts"] < _FETCH_EVERY:
return
_fetch_cache["ts"] = now
try:
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch fehlgeschlagen", exc_info=True)
def _statuses() -> dict:
try:
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
except Exception:
return {}
def _set_status(branch: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
except OSError:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _remote_branches() -> list[str]:
r = _git(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
if name and name != "HEAD" and name.startswith(PREFIXES):
out.append(name)
return out
def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches (mit Commit-/Diff-Zusammenfassung + Status) und
Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
_fetch_throttled()
statuses = _statuses()
items = []
for branch in _remote_branches():
ref = f"origin/{branch}"
try:
ahead = int((_git(["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and (statuses.get(branch, {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
show = _git(["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
stat = (_git(["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
if len(parts) >= 2:
files.append({"status": parts[0][:2], "path": parts[-1]})
paths = [f["path"] for f in files]
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
"body": body.strip()[:2000],
"author": author.strip(),
"ts": int(cts) if cts.strip().isdigit() else None,
"ahead": ahead,
"behind": behind,
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Frontend-Quelltext ohne gebautes Bundle: Box kann nicht bauen (kein Node) —
# nach dem Annehmen bliebe die Oberfläche alt, bis am PC gebaut wird.
"frontend_ohne_build": frontend_src and not frontend_dist,
"status": statuses.get(branch),
})
except Exception:
log.warning("auftragsbuch: Branch %s nicht lesbar", branch, exc_info=True)
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
kandidaten = list_skill_kandidaten()
open_count = sum(1 for i in items
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str) -> dict:
if not _available():
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches():
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str) -> dict:
"""Annehmen: detached Runner starten (Merge→Push→Deploy→Health→ggf. Rollback)."""
if not _available():
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches():
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
state = (_statuses().get(branch) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / "auftrag-annehmen.sh"
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", branch.lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(runner), branch],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(branch, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str) -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Meldung in den Briefkasten — ehrlich und sichtbar."""
if not _available():
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches():
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
_set_status(branch, "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht")
try:
from services import announce
announce.add(f"Vorschlag '{branch}' wurde abgelehnt und der Branch gelöscht.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True}
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
def _kandidaten_dir() -> Path:
return VAULT / "skill-kandidaten"
def list_skill_kandidaten() -> list[dict]:
d = _kandidaten_dir()
if os.name != "posix" or not d.is_dir():
return []
out = []
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
text = p.read_text(encoding="utf-8", errors="replace")
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
out.append({"file": p.name, "title": first[:160],
"preview": text[:3000], "mtime": p.stat().st_mtime})
except OSError:
continue
return out
def _vault_git(args: list[str]) -> None:
try:
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
except Exception:
pass
def _kandidat_path(fname: str) -> Path | None:
if not _KANDIDAT_RX.match(fname):
return None
p = (_kandidaten_dir() / fname).resolve()
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
return None
return p
def skill_accept(fname: str) -> dict:
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
if not _available():
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
beauftragt = _kandidaten_dir() / "beauftragt"
beauftragt.mkdir(parents=True, exist_ok=True)
target = beauftragt / p.name
try:
content = p.read_text(encoding="utf-8", errors="replace")
p.rename(target)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
+ content)
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
try:
order_file.write_text(order, encoding="utf-8")
except OSError as exc:
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
unit = f"mc2-skill-auftrag-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
try:
from services import announce
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True, "unit": unit}
def skill_reject(fname: str) -> dict:
if not _available():
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
verworfen = _kandidaten_dir() / "verworfen"
verworfen.mkdir(parents=True, exist_ok=True)
try:
p.rename(verworfen / p.name)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
return {"ok": True}
+5
View File
@@ -27,6 +27,11 @@ def _latest() -> Path | None:
return snaps[0] if snaps else None return snaps[0] if snaps else None
def snapshot_components(tarball: Path) -> list[str]:
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
return _components(tarball)
def _components(tarball: Path) -> list[str]: def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI).""" """Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try: try:
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
#
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
#
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
set -uo pipefail
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
API="${MC_API:-http://127.0.0.1:9001}"
SLUG="$(echo "$BRANCH" | tr '/' '-')"
WT="/tmp/annahme-$SLUG"
LOG="/tmp/annahme-$SLUG.log"
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
status(){ # $1=state $2=detail
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
import json, os, sys, time
p, branch, state, detail = sys.argv[1:5]
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
d = {}
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
tmp = p + ".tmp"
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
os.replace(tmp, p)
PY
}
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
fail(){
status "fehlgeschlagen" "$1"
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
cleanup_wt
exit 1
}
status "laeuft" "Merge wird vorbereitet"
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
PRE="$(git rev-parse origin/main)"
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
cleanup_wt
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "origin/$BRANCH" -m "Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)" >>"$LOG" 2>&1; then
git -C "$WT" merge --abort 2>/dev/null || true
fail "Merge-Konflikt mit main — der Branch ist veraltet, bitte am PC auflösen"
fi
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
if [ -n "${PYS// /}" ]; then
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
# shellcheck disable=SC2086
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
fi
status "laeuft" "Push nach main läuft"
PUSHED=0
for _ in 1 2 3; do
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
sleep 5
done
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
cleanup_wt
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
DEPLOY_OK=1
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
HEALTH=0
if [ "$DEPLOY_OK" = 1 ]; then
for _ in $(seq 1 18); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
sleep 5
done
fi
if [ "$HEALTH" = 1 ]; then
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
status "eingespielt" "Deploy grün, Health-Check bestanden"
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
exit 0
fi
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
status "rollback" "Health rot — automatischer Revert läuft"
git fetch -q origin || true
cleanup_wt
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
cleanup_wt
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
R=0
for _ in $(seq 1 12); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
sleep 5
done
if [ "$R" = 1 ]; then
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
exit 1
fi
fi
cleanup_wt
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
exit 2
+14
View File
@@ -82,3 +82,17 @@ else
echo "## CHEF-VERDIKT (Richter: $RICHTER)" echo "## CHEF-VERDIKT (Richter: $RICHTER)"
echo "$VERDIKT" echo "$VERDIKT"
fi fi
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
if [ -n "${VERDIKT// /}" ]; then
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
"source": "chef-gutachter", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
fi
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="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BrJPl95N.js"></script> <script type="module" crossorigin src="/assets/index-BylmMpBk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CMtwLeqK.css"> <link rel="stylesheet" crossorigin href="/assets/index-B7apENt0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7 -1
View File
@@ -17,6 +17,9 @@ import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App" import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView" 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() { export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab 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"> <main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />} {view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "models" && <ModelsView />} {view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />} {view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />} {view === "memory" && <MemoryView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "agent" && <AgentView />} {view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />} {view === "terminal" && <TerminalView />}
{view === "konsole" && <KonsoleView />} {view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />} {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} /> <Placeholder title={active.label} hint={active.hint} />
)} )}
</main> </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" /> <X className="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </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" && ( {type === "prompt" && (
<div className="flex gap-2"> <div className="flex gap-2">
+86
View File
@@ -443,6 +443,92 @@ export interface ModelsResp {
running?: string[] 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 { export interface HermesBrainModel {
name: string name: string
filename?: string filename?: string
+45
View File
@@ -6,6 +6,8 @@ import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-quer
import { import {
api, api,
type AgentStatus, type AgentStatus,
type AuftragsbuchResp,
type ChronikResp,
type ConnectResp, type ConnectResp,
type ConnectHealth, type ConnectHealth,
type DiscoverResp, type DiscoverResp,
@@ -17,6 +19,7 @@ import {
type Memory, type Memory,
type MemoryGraph, type MemoryGraph,
type ModelsResp, type ModelsResp,
type Reminder,
type RoutingResp, type RoutingResp,
type RoutingPolicyMeta, type RoutingPolicyMeta,
type ServicesResp, type ServicesResp,
@@ -24,6 +27,8 @@ import {
type TokenStats, type TokenStats,
type UpdatesResp, type UpdatesResp,
type VoiceTraceResp, type VoiceTraceResp,
type WissenResp,
type ZeitmaschineResp,
} from "./api" } from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate). // 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, memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const, memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] 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). // Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) => export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
useQuery({ useQuery({
+7 -1
View File
@@ -2,6 +2,9 @@ import {
LayoutDashboard, LayoutDashboard,
Boxes, Boxes,
Brain, Brain,
Inbox,
History,
Library,
Plug, Plug,
Bot, Bot,
AppWindow, AppWindow,
@@ -10,7 +13,7 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react" } 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 { export interface NavItem {
id: ViewId id: ViewId
@@ -22,8 +25,11 @@ export interface NavItem {
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette. // Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [ export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard }, { 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: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain }, { 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: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot }, { 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 }, { 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 { try {
const dry = await api<DedupeResult>("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: false }) }) 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 } 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", 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 () => { async () => {
try { await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }); reloadMemory() } 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}`) } 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 { import {
Boxes, Brain, Server, ShieldAlert, Plug, Bot, TerminalSquare, ScrollText, Boxes, Brain, Server, ShieldAlert, Plug, Bot, TerminalSquare, ScrollText,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight, HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Inbox, History, Library,
} from "lucide-react" } from "lucide-react"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth, useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useQueryClient, qk, useAuftragsbuch, useQueryClient, qk,
} from "@/lib/queries" } from "@/lib/queries"
import { SquareTerminal } from "lucide-react" import { SquareTerminal } from "lucide-react"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth" import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile" import { CockpitTile } from "./CockpitTile"
import { MorgenlageCard } from "./MorgenlageCard"
import { ErinnerungenCard } from "./ErinnerungenCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard" import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard" import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard" import { LatencyCard } from "@/components/dashboard/LatencyCard"
@@ -31,6 +34,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const { data: memory } = useMemory() const { data: memory } = useMemory()
const { data: agent } = useAgentStatus() const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth() const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch(20_000)
const health = useLucyHealth() const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog() 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 ? (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) (updates?.components?.filter((c) => c.update === true).length ?? 0)
const connectOk = connect?.gateway.ok && connect?.memory.ok const connectOk = connect?.gateway.ok && connect?.memory.ok
const openAuftraege = auftraege?.available ? auftraege.open_count : 0
return ( return (
<div className="space-y-7"> <div className="space-y-7">
@@ -83,18 +88,30 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
{/* Ehrliche Status-Zeile */} {/* Ehrliche Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} /> <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 */} {/* „Braucht dich" — offene Aktionen, sonst Ruhe */}
<NeedsYou <NeedsYou
problems={health.problems} problems={health.problems}
pendingCount={pendingCount} pendingCount={pendingCount}
openAuftraege={openAuftraege}
busy={busy} busy={busy}
onRepair={runRepair} onRepair={runRepair}
onNavigate={onNavigate}
/> />
{/* Bereichs-Kacheln */} {/* Bereichs-Kacheln */}
<section> <section>
<ZoneLabel>Bereiche</ZoneLabel> <ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <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 <CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm" icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"} hint="Speicher, laden & Rollen" tone={running > 0 ? "ok" : "muted"} hint="Speicher, laden & Rollen"
@@ -129,6 +146,12 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
</div> </div>
</section> </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.: {/* 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 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). */} 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)" tone={!agent ? "loading" : agent.box_console_reachable ? "ok" : "warn"} hint="Direkte Box-Shell (SSH)"
onClick={() => onNavigate("konsole")} 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> </div>
</section> </section>
@@ -225,18 +258,21 @@ function StatusLine({ verdict, memory, warns }: {
) )
} }
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates. // „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates
// Nichts offen → ruhige Bestätigung statt leerer Fläche. // + offene Vorschläge im Auftragsbuch. Nichts offen → ruhige Bestätigung statt leerer Fläche.
function NeedsYou({ function NeedsYou({
problems, pendingCount, busy, onRepair, problems, pendingCount, openAuftraege, busy, onRepair, onNavigate,
}: { }: {
problems: ReturnType<typeof useLucyHealth>["problems"] problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number pendingCount: number
openAuftraege: number
busy: Record<string, boolean> busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void onRepair: (id: string, r: Repair) => void
onNavigate: (v: string) => void
}) { }) {
const hasUpdates = pendingCount > 0 const hasUpdates = pendingCount > 0
const nothing = problems.length === 0 && !hasUpdates const hasAuftraege = openAuftraege > 0
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege
return ( return (
<section> <section>
@@ -274,6 +310,23 @@ function NeedsYou({
)} )}
</div> </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 && ( {hasUpdates && (
<button <button
onClick={() => openDrawer("maintenance")} 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>
)
}