Merge wartung/produkt-ausbau: Auftragsbuch + Chronik/Zeitmaschine + Wissens-Wiki + Morgenlage + Erinnerungen
This commit is contained in:
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)}
|
||||||
@@ -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.")
|
||||||
@@ -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}
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-693
File diff suppressed because one or more lines are too long
+775
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`) }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user