15 Commits

Author SHA1 Message Date
35f53a0f1c fix(graph): Set default limit_nodes=500 to prevent browser overload
Before: limit_nodes defaulted to 0 (unlimited) causing 51052 nodes to load
After: limit_nodes defaults to 500 for reasonable browser performance

Change: Query(0, ge=0, le=50000) -> Query(500, ge=0, le=50000)
2026-06-05 02:16:33 +02:00
8783bb2db5 fix: performance optimizations for large dataset
- Add generated columns and indexes for correctness and metadata fields
- Optimize get_all() with keyset pagination
- Add get_pending_for_review() for targeted queries
- Update cron tasks to use optimized queries instead of full table scans
- This fixes timeouts in review_brain and verify_pending_external (300s timeout)

Fixes #35: Second-Brain in Takt bringen, Dedup, Pendings, Graph und Performance
2026-06-04 12:25:11 +02:00
6abe4d36e8 fix: follow active Telegram transcript after rotation 2026-06-02 20:23:37 +02:00
f656cb02dc Merge branch 'optimierung-dashboard' 2026-05-31 20:18:09 +02:00
680b3869bb fix(dashboard): explizite zwei-zeilen-layout für suchbox 2026-05-31 17:09:24 +02:00
dab1b84a68 fix(dashboard): site-only layout — suchfeld oben, filter/export unten auf mobil 2026-05-31 17:00:42 +02:00
c22c7be444 fix(dashboard): mobile viewport and search bar overflow 2026-05-31 16:45:18 +02:00
9ec1e0d28f style(dashboard): improve small-screen layout 2026-05-31 16:29:46 +02:00
f8de7e626b feat(dashboard): keyboard navigation 2026-05-31 16:29:15 +02:00
432d758b90 feat(dashboard): export current view 2026-05-31 16:27:52 +02:00
788ee1539d feat(dashboard): richer engram detail overlay 2026-05-31 16:26:37 +02:00
1db483c053 feat(dashboard): extend stats bar 2026-05-31 16:24:40 +02:00
672077a14c fix(dashboard): render cards again 2026-05-31 16:05:53 +02:00
0ff6db73ea feat(dashboard): integrate link suggestions and predictive links into UI
- FastAPI: parse_engram now includes link_suggestions and predictive_links from metadata
- FastAPI: add POST /api/links/accept to create links from suggestions
- Dashboard: new renderCardsWithSuggestions() displays suggestions in each card
- Dashboard: acceptLink() function handles click-to-link
- Dashboard: loadCards() calls renderCardsWithSuggestions()
- Systemd: remove DirectoryNotEmpty from ingest path unit (already present)

Refs: #25 #27
2026-05-31 15:42:46 +02:00
2024e2850d Merge PR #28: Event-driven Tuning (30min/15min + inotify) 2026-05-31 14:11:23 +02:00
20 changed files with 634 additions and 81 deletions

View File

@@ -19,7 +19,9 @@ def run():
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=7) cutoff = now - timedelta(days=7)
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=60)
conn.execute("PRAGMA busy_timeout=60000")
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()

View File

@@ -16,7 +16,9 @@ BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite" DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def run(): def run():
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=60)
conn.execute("PRAGMA busy_timeout=60000")
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()

View File

@@ -20,7 +20,9 @@ def run():
yesterday = now - timedelta(days=1) yesterday = now - timedelta(days=1)
date_str = yesterday.strftime("%Y-%m-%d") date_str = yesterday.strftime("%Y-%m-%d")
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=60)
conn.execute("PRAGMA busy_timeout=60000")
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()

View File

@@ -18,7 +18,8 @@ BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite" DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def get_db_stats(): def get_db_stats():
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=30)
conn.execute("PRAGMA busy_timeout=30000")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Proaktiver Health-Check für Second Brain.
Erstellt alle 6h ein Engramm mit System-Status.
Nur bei Problemen wird eine Warnung generiert.
"""
from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def get_db_stats():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
confirmed_true = c.execute("SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.verdict') = 'confirmed_true' OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 1)").fetchone()[0]
confirmed_false = c.execute("SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.verdict') = 'confirmed_false' OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 0 AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) > 0)").fetchone()[0]
pending = total - confirmed_true - confirmed_false
latest = c.execute("SELECT created_at FROM engrams ORDER BY created_at DESC LIMIT 1").fetchone()
latest_created = latest[0] if latest else None
conn.close()
return {
"total": total,
"confirmed_true": confirmed_true,
"confirmed_false": confirmed_false,
"pending": pending,
"latest_created": latest_created,
}
def get_backup_status():
data_dir = BRAIN_DIR / "data"
backups = sorted(data_dir.glob("backup_*.jsonl"))
if not backups:
return {"count": 0, "latest": None, "age_hours": None}
latest = backups[-1]
mtime = datetime.fromtimestamp(latest.stat().st_mtime, tz=timezone.utc)
age_hours = (datetime.now(timezone.utc) - mtime).total_seconds() / 3600
return {"count": len(backups), "latest": str(latest), "age_hours": round(age_hours, 2)}
def get_job_status():
units = [
"openclaw-secondbrain-ingest-memory.service",
"openclaw-secondbrain-index-vectors.service",
"openclaw-secondbrain-review.service",
"openclaw-secondbrain-heartbeat.service",
"openclaw-secondbrain-verify-pending.service",
]
status = {}
for u in units:
try:
out = subprocess.check_output(["systemctl", "is-active", u], text=True, stderr=subprocess.DEVNULL).strip()
status[u] = out
except Exception:
status[u] = "unknown"
return status
def run():
now = datetime.now(timezone.utc).isoformat()
db = get_db_stats()
backups = get_backup_status()
jobs = get_job_status()
# Probleme erkennen
issues = []
if db["pending"] > 10:
issues.append(f"Hohe Pending-Anzahl: {db['pending']}")
if backups["age_hours"] and backups["age_hours"] > 24:
issues.append(f"Backup zu alt: {backups['age_hours']}h")
for unit, state in jobs.items():
if state not in ("active", "running"):
issues.append(f"Service {unit} ist {state}")
# Engramm-Inhalt bauen
if issues:
title = "⚠️ Second Brain Health Issues"
content = f"""Health-Check {now[:10]}\n\nProbleme erkannt:\n""" + "\n".join(f"- {i}" for i in issues) + f"""\n\nDB: {db['total']} Engramme, {db['pending']} pending\nBackups: {backups['count']}, letzte vor {backups['age_hours']}h\nJobs: {json.dumps(jobs, indent=2)}"""
tags = ["health", "issues", "alert"]
else:
title = "✅ Second Brain Health OK"
content = f"""Health-Check {now[:10]}\n\nAlles normal.\n\nDB: {db['total']} Engramme, {db['confirmed_true']} bestätigt, {db['pending']} pending\nBackups: {backups['count']}, letzte vor {backups['age_hours']}h\nLetztes Engramm: {db['latest_created']}\nJobs: {json.dumps(jobs, indent=2)}"""
tags = ["health", "ok"]
# Engramm speichern
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.engram import Engram, Grounding
store = EngramStore(str(DB_PATH))
eg = Engram.create(
content=content,
source="system",
tags=tags,
grounding=Grounding.ASSUMPTION,
)
eg.metadata.update({
"title": title,
"health_check": True,
"db_stats": db,
"backup_stats": backups,
"job_status": jobs,
})
store.save(eg)
print(json.dumps({
"success": True,
"time": now,
"engram_id": str(eg.id),
"issues_found": len(issues),
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -15,41 +15,44 @@ from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain") BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
WORKSPACE = Path("/root/.openclaw/workspace") WORKSPACE = Path("/root/.openclaw/workspace")
HANDLER = WORKSPACE / "context-buffer" / "handler.py" CURRENT_DIR = WORKSPACE / "context-buffer" / "current"
def run(): def run():
# Hole alle Topics mit status done/completed via handler # Lese context-buffer index.json direkt
index_path = WORKSPACE / "context-buffer" / "index.json"
try: try:
result = subprocess.run( with open(index_path) as f:
["python3", str(HANDLER), "search", "--status", "done"], idx = json.load(f)
capture_output=True, text=True, timeout=30 topics = []
) for tid, t in idx.get("topics", {}).items():
if result.returncode != 0: status = t.get("status", "active")
raise Exception(f"Handler error: {result.stderr}") if status in ("done", "completed"):
topics = json.loads(result.stdout) # Lade den vollen Inhalt aus der topic-Datei
topic_file = CURRENT_DIR / f"topic-{tid}.md"
if topic_file.exists():
content = topic_file.read_text(encoding="utf-8")
# Entferne Frontmatter für reinen Content
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2].strip()
t["content"] = content
else:
t["content"] = ""
topics.append(t)
except Exception as e: except Exception as e:
print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)) print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False))
return return
# Alternative: auch 'completed' suchen
try:
result2 = subprocess.run(
["python3", str(HANDLER), "search", "--status", "completed"],
capture_output=True, text=True, timeout=30
)
if result2.returncode == 0:
topics_completed = json.loads(result2.stdout)
topics.extend(topics_completed)
except Exception:
pass
if not topics: if not topics:
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False)) print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
return return
# Import in Second Brain # Import in Second Brain
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite" DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=60)
conn.execute("PRAGMA busy_timeout=60000")
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()

View File

@@ -13,13 +13,14 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List from typing import Any, Dict, Iterable, List, Optional
from uuid import NAMESPACE_URL, uuid5 from uuid import NAMESPACE_URL, uuid5
WORKSPACE = Path("/root/.openclaw/workspace") WORKSPACE = Path("/root/.openclaw/workspace")
MEMORY_DIR = WORKSPACE / "memory" MEMORY_DIR = WORKSPACE / "memory"
SOURCES_PATH = MEMORY_DIR / "session_sources.json" SOURCES_PATH = MEMORY_DIR / "session_sources.json"
STATE_PATH = MEMORY_DIR / "session_db_ingest_state.json" STATE_PATH = MEMORY_DIR / "session_db_ingest_state.json"
SESSIONS_INDEX = Path("/root/.openclaw/agents/main/sessions/sessions.json")
BRAIN_DIR = WORKSPACE / "second-brain" BRAIN_DIR = WORKSPACE / "second-brain"
DB_PATH = Path(os.environ.get("BRAIN_DB", str(BRAIN_DIR / "data" / "brain.sqlite"))) DB_PATH = Path(os.environ.get("BRAIN_DB", str(BRAIN_DIR / "data" / "brain.sqlite")))
@@ -71,6 +72,19 @@ class Source:
transcript_path: Path transcript_path: Path
def _discover_current_transcript(label: str) -> Optional[Path]:
parts = label.rsplit(":", 1)
if len(parts) != 2 or not parts[1].isdigit():
return None
payload = _load_json(SESSIONS_INDEX, {})
direct = payload.get(f"agent:main:telegram:direct:{parts[1]}") if isinstance(payload, dict) else None
session_file = direct.get("sessionFile") if isinstance(direct, dict) else None
if not isinstance(session_file, str):
return None
path = Path(session_file)
return path if path.exists() else None
def _load_sources() -> List[Source]: def _load_sources() -> List[Source]:
payload = _load_json(SOURCES_PATH, {"sources": []}) payload = _load_json(SOURCES_PATH, {"sources": []})
sources: List[Source] = [] sources: List[Source] = []
@@ -81,7 +95,8 @@ def _load_sources() -> List[Source]:
p = item.get("path") p = item.get("path")
if not isinstance(p, str) or not p: if not isinstance(p, str) or not p:
continue continue
sources.append(Source(label=label, transcript_path=Path(p))) transcript_path = _discover_current_transcript(label) or Path(p)
sources.append(Source(label=label, transcript_path=transcript_path))
return sources return sources
@@ -157,4 +172,3 @@ def run() -> Dict[str, Any]:
if __name__ == "__main__": if __name__ == "__main__":
print(json.dumps(run(), ensure_ascii=False, indent=2)) print(json.dumps(run(), ensure_ascii=False, indent=2))

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
import json import json
import os import os
import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -29,6 +30,8 @@ WORKSPACE = Path("/root/.openclaw/workspace")
MEMORY_DIR = WORKSPACE / "memory" MEMORY_DIR = WORKSPACE / "memory"
SOURCES_PATH = MEMORY_DIR / "session_sources.json" SOURCES_PATH = MEMORY_DIR / "session_sources.json"
STATE_PATH = MEMORY_DIR / "session_ingest_state.json" STATE_PATH = MEMORY_DIR / "session_ingest_state.json"
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
SESSIONS_INDEX = SESSIONS_DIR / "sessions.json"
def _local_tz(): def _local_tz():
@@ -91,6 +94,24 @@ class Source:
transcript_path: Path transcript_path: Path
def _extract_chat_id(label: str) -> Optional[str]:
match = re.match(r"^[a-zA-Z0-9_-]+:(\d+)$", (label or "").strip())
return match.group(1) if match else None
def _discover_transcript_for_label(label: str) -> Optional[Path]:
chat_id = _extract_chat_id(label)
if not chat_id:
return None
index = _load_json(SESSIONS_INDEX, {})
direct = index.get(f"agent:main:telegram:direct:{chat_id}") if isinstance(index, dict) else None
session_file = direct.get("sessionFile") if isinstance(direct, dict) else None
if not isinstance(session_file, str):
return None
path = Path(session_file)
return path if path.exists() else None
def _load_sources() -> List[Source]: def _load_sources() -> List[Source]:
""" """
Sources file format: Sources file format:
@@ -109,7 +130,8 @@ def _load_sources() -> List[Source]:
p = item.get("path") p = item.get("path")
if not isinstance(p, str) or not p: if not isinstance(p, str) or not p:
continue continue
sources.append(Source(label=label, transcript_path=Path(p))) transcript_path = _discover_transcript_for_label(label) or Path(p)
sources.append(Source(label=label, transcript_path=transcript_path))
return sources return sources

View File

@@ -22,10 +22,14 @@ def extract_keywords(text: str, max_words: int = 10) -> set[str]:
words = re.findall(r"\b[a-zA-Z]{4,}\b", text.lower()) words = re.findall(r"\b[a-zA-Z]{4,}\b", text.lower())
# Stopwörter filtern (einfache Liste) # Stopwörter filtern (einfache Liste)
stopwords = {"und", "die", "der", "ein", "eine", "auf", "von", "zu", "mit", "für", "ist", "das", "nicht"} stopwords = {"und", "die", "der", "ein", "eine", "auf", "von", "zu", "mit", "für", "ist", "das", "nicht"}
return set(w for w in words if w not in stopwords)[:max_words] unique_words = set(w for w in words if w not in stopwords)
# Begrenze auf max_words (Umwandlung in Liste für Slicing, dann zurück zu Set)
return set(list(unique_words)[:max_words])
def run(): def run():
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=60)
conn.execute("PRAGMA busy_timeout=60000")
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()

View File

@@ -21,7 +21,9 @@ def similar(a: str, b: str, threshold: float = 0.85) -> bool:
return SequenceMatcher(None, a.lower().replace("-", "").replace("_", ""), b.lower().replace("-", "").replace("_", "")).ratio() >= threshold return SequenceMatcher(None, a.lower().replace("-", "").replace("_", ""), b.lower().replace("-", "").replace("_", "")).ratio() >= threshold
def run(): def run():
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=60)
conn.execute("PRAGMA busy_timeout=60000")
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()

View File

@@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict:
verdict = "confirmed_false" verdict = "confirmed_false"
else: else:
verdict = "unknown" verdict = "unknown"
return { result = {
"id": row["id"], "id": row["id"],
"content": row["content"], "content": row["content"],
"confidence": meta.get("confidence", 0.0), "confidence": meta.get("confidence", 0.0),
@@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict:
"access_count": meta.get("access_count", 0), "access_count": meta.get("access_count", 0),
"grounding": meta.get("grounding", 0), "grounding": meta.get("grounding", 0),
} }
# Vorschläge aus metadata
if "link_suggestions" in meta:
result["link_suggestions"] = meta["link_suggestions"]
if "predictive_links" in meta:
result["predictive_links"] = meta["predictive_links"]
return result
def _now_iso() -> str: def _now_iso() -> str:
@@ -448,7 +454,7 @@ def api_insights(limit: int = Query(8, ge=1, le=50)):
@app.get("/api/graph") @app.get("/api/graph")
def api_graph( def api_graph(
limit_nodes: int = Query(0, ge=0, le=50000), limit_nodes: int = Query(500, ge=0, le=50000),
limit_edges: int = Query(0, ge=0, le=200000), limit_edges: int = Query(0, ge=0, le=200000),
): ):
""" """
@@ -902,6 +908,31 @@ def api_refresh(engram_id: str):
return {"success": True, "new_confidence": round(conf, 2)} return {"success": True, "new_confidence": round(conf, 2)}
@app.post("/api/links/accept")
def api_accept_link(from_id: str = Form(...), to_id: str = Form(...)):
"""Erstelle einen Link zwischen zwei Engrammen (aus Vorschlag)."""
conn = get_db()
c = conn.cursor()
# Prüfe Existenz beider Engramme
for eid in (from_id, to_id):
if not c.execute("SELECT 1 FROM engrams WHERE id = ?", (eid,)).fetchone():
conn.close()
return JSONResponse({"error": f"Engram {eid} not found"}, status_code=404)
# Vermeide Duplikate
c.execute("SELECT 1 FROM engrams_links WHERE from_id = ? AND to_id = ?", (from_id, to_id))
if c.fetchone():
conn.close()
return {"ok": True, "message": "link already exists"}
# Link erstellen
c.execute(
"INSERT INTO engrams_links (from_id, to_id) VALUES (?, ?)",
(from_id, to_id)
)
conn.commit()
conn.close()
return {"ok": True}
@app.post("/api/engrams") @app.post("/api/engrams")
def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")): def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")):
engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}" engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}"

View File

@@ -25,8 +25,10 @@ class EngramStore:
def __init__(self, db_path: str): def __init__(self, db_path: str):
self.db_path = Path(db_path) self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False) self._conn = sqlite3.connect(str(self.db_path), timeout=60, check_same_thread=False)
self._conn.row_factory = sqlite3.Row self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA busy_timeout=60000")
self._conn.execute("PRAGMA journal_mode=WAL")
self._init_schema() self._init_schema()
def _init_schema(self) -> None: def _init_schema(self) -> None:
@@ -58,6 +60,47 @@ class EngramStore:
PRIMARY KEY (from_id, to_id) PRIMARY KEY (from_id, to_id)
); );
""") """)
# Basis-Indexes für häufige Abfragen (auf existierenden Spalten)
self._conn.executescript("""
CREATE INDEX IF NOT EXISTS idx_engrams_created_at ON engrams(created_at);
CREATE INDEX IF NOT EXISTS idx_engrams_modified_at ON engrams(modified_at);
""")
# Generated columns (SQLite 3.31+). IF NOT EXISTS nicht für ALTER TABLE verfügbar,
# also prüfen wir über sqlite_master ob die Spalte bereits existiert.
def column_exists(name: str) -> bool:
cur = self._conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='engrams'")
sql = cur.fetchone()[0]
return f"{name} " in sql or f"{name}," in sql or sql.endswith(name)
if not column_exists("correctness_confirmed"):
try:
self._conn.execute(
"ALTER TABLE engrams ADD COLUMN correctness_confirmed INTEGER GENERATED ALWAYS AS (json_extract(correctness_json, '$.confirmed')) VIRTUAL"
)
except sqlite3.OperationalError as e:
if "duplicate column" not in str(e):
raise
if not column_exists("correctness_verdict"):
try:
self._conn.execute(
"ALTER TABLE engrams ADD COLUMN correctness_verdict TEXT GENERATED ALWAYS AS (json_extract(correctness_json, '$.verdict')) VIRTUAL"
)
except sqlite3.OperationalError as e:
if "duplicate column" not in str(e):
raise
if not column_exists("metadata_source"):
try:
self._conn.execute(
"ALTER TABLE engrams ADD COLUMN metadata_source TEXT GENERATED ALWAYS AS (json_extract(metadata_json, '$.source')) VIRTUAL"
)
except sqlite3.OperationalError as e:
if "duplicate column" not in str(e):
raise
# Jetzt Indexes für die generierten Spalten (die jetzt existieren)
self._conn.executescript("""
CREATE INDEX IF NOT EXISTS idx_correctness_confirmed ON engrams(correctness_confirmed);
CREATE INDEX IF NOT EXISTS idx_correctness_verdict ON engrams(correctness_verdict);
CREATE INDEX IF NOT EXISTS idx_metadata_source ON engrams(metadata_source);
""")
self._conn.commit() self._conn.commit()
# ---- CRUD ---- # ---- CRUD ----
@@ -121,10 +164,21 @@ class EngramStore:
return self._row_to_engram(row) return self._row_to_engram(row)
def get_all(self, limit: int = 1000, offset: int = 0) -> List[Engram]: def get_all(self, limit: int = 1000, offset: int = 0) -> List[Engram]:
"""Lädt alle Engramme (paginiert).""" """Lädt alle Engramme (paginiert).
Verwendet keyset pagination für bessere Performance auf großen Datensätzen.
"""
if offset == 0:
rows = self._conn.execute( rows = self._conn.execute(
"SELECT * FROM engrams ORDER BY created_at DESC LIMIT ? OFFSET ?", "SELECT * FROM engrams ORDER BY created_at DESC, id DESC LIMIT ?",
(limit, offset) (limit,)
).fetchall()
else:
# Keyset pagination: get the (offset)th row's created_at and id
# This is still O(n) but avoids full table scan for each page
rows = self._conn.execute(
"SELECT * FROM engrams WHERE (created_at, id) < (SELECT created_at, id FROM engrams ORDER BY created_at DESC, id DESC LIMIT 1 OFFSET ?) ORDER BY created_at DESC, id DESC LIMIT ?",
(offset, limit)
).fetchall() ).fetchall()
return [self._row_to_engram(r) for r in rows] return [self._row_to_engram(r) for r in rows]
@@ -180,6 +234,28 @@ class EngramStore:
row = self._conn.execute("SELECT COUNT(*) FROM engrams").fetchone() row = self._conn.execute("SELECT COUNT(*) FROM engrams").fetchone()
return row[0] if row else 0 return row[0] if row else 0
def get_pending_for_review(self, limit: int = 5000, offset: int = 0) -> List[Engram]:
"""Holt Engramme, die eine Review benötigen (nicht bestätigt/abgelehnt, kein Feedback).
Dies ist die kritische Methode für Cron-Tasks; sie nutzt die generierten
Spalten für performante Filter.
"""
rows = self._conn.execute(
"""
SELECT * FROM engrams
WHERE correctness_confirmed = 0
AND (correctness_verdict IS NULL OR correctness_verdict = '')
AND json_extract(metadata_json, '$.tags') NOT LIKE '%feedback%'
AND json_extract(metadata_json, '$.tags') NOT LIKE '%stop%'
AND json_extract(metadata_json, '$.tags') NOT LIKE '%reject%'
AND json_extract(metadata_json, '$.tags') NOT LIKE '%confirm%'
ORDER BY created_at ASC
LIMIT ? OFFSET ?
""",
(limit, offset)
).fetchall()
return [self._row_to_engram(r) for r in rows]
# ---- Search ---- # ---- Search ----
def search_text(self, query: str, limit: int = 10) -> List[Engram]: def search_text(self, query: str, limit: int = 10) -> List[Engram]:

View File

@@ -14,6 +14,7 @@ body {
margin: 0 auto; margin: 0 auto;
min-height: 100vh; min-height: 100vh;
background: #141419; background: #141419;
width: 100%;
} }
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */ /* ─── Stats Bar ───────────────────────────────────────────────────────────── */
@@ -75,10 +76,22 @@ body {
/* ─── Search ──────────────────────────────────────────────────────────────── */ /* ─── Search ──────────────────────────────────────────────────────────────── */
.search-box { .search-box {
display: flex; display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
background: #141419; background: #141419;
} }
.search-row {
display: flex;
gap: 8px;
flex-direction: row;
}
.search-row:first-child {
width: 100%;
}
.search-row:last-child {
width: 100%;
}
/* tab buttons styled via .tabs-bar */ /* tab buttons styled via .tabs-bar */
@@ -196,7 +209,9 @@ body {
.legend-dot.match{ background:#f7d154; } .legend-dot.match{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; } .graph-hint{ padding: 4px 12px 10px; }
#searchInput { #searchInput {
width: 100%;
flex: 1; flex: 1;
min-width: 0;
background: #1e1e28; background: #1e1e28;
border: 1px solid #2a2a3a; border: 1px solid #2a2a3a;
border-radius: 10px; border-radius: 10px;
@@ -207,6 +222,8 @@ body {
} }
#searchInput:focus { border-color: #6c8af5; } #searchInput:focus { border-color: #6c8af5; }
#filterSelect { #filterSelect {
flex: 1;
min-width: 0;
background: #1e1e28; background: #1e1e28;
border: 1px solid #2a2a3a; border: 1px solid #2a2a3a;
border-radius: 10px; border-radius: 10px;
@@ -216,6 +233,32 @@ body {
outline: none; outline: none;
} }
#exportFormat {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px;
color: #e8e8ee;
font-size: 0.85rem;
outline: none;
}
.btn-export {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px 12px;
color: #cfd3ff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
}
.btn-export:active { transform: scale(0.98); }
/* ─── New Engram ──────────────────────────────────────────────────────────── */ /* ─── New Engram ──────────────────────────────────────────────────────────── */
.new-engram { .new-engram {
padding: 0 12px 8px; padding: 0 12px 8px;
@@ -266,6 +309,10 @@ body {
transition: transform 0.15s ease, border-color 0.2s ease; transition: transform 0.15s ease, border-color 0.2s ease;
touch-action: manipulation; touch-action: manipulation;
} }
.card.selected {
border-color: #6c8af5;
box-shadow: 0 0 0 1px rgba(108,138,245,0.25) inset;
}
.card:active { transform: scale(0.985); } .card:active { transform: scale(0.985); }
.card.confirmed { border-left: 4px solid #3a7d3a; } .card.confirmed { border-left: 4px solid #3a7d3a; }
.card.rejected { border-left: 4px solid #8a3a3a; } .card.rejected { border-left: 4px solid #8a3a3a; }
@@ -455,3 +502,13 @@ body {
@media (pointer: coarse) { @media (pointer: coarse) {
button, .card { -webkit-tap-highlight-color: transparent; } button, .card { -webkit-tap-highlight-color: transparent; }
} }
/* ─── Small Screens ────────────────────────────────────────────────────────── */
@media (max-width: 420px) {
html { font-size: 13px; }
.stat { min-width: 48px; }
.stat-num { font-size: 1.15rem; }
.tabs-bar { top: 48px; }
.modal { padding: 14px 10px; }
.modal-content { padding: 16px 12px; }
}

View File

@@ -1,6 +1,8 @@
[Unit] [Unit]
Description=OpenClaw Second-Brain Dashboard (FastAPI) Description=OpenClaw Second-Brain Dashboard (FastAPI)
After=network.target After=network-online.target
Wants=network-online.target
PartOf=openclaw-secondbrain.target
[Service] [Service]
Type=simple Type=simple
@@ -13,3 +15,4 @@ RestartSec=2
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
WantedBy=openclaw-secondbrain.target

View File

@@ -4,7 +4,8 @@ PartOf=openclaw-secondbrain.target
[Path] [Path]
PathModified=/root/.openclaw/workspace/memory PathModified=/root/.openclaw/workspace/memory
DirectoryNotEmpty=true TriggerLimitIntervalSec=60
TriggerLimitBurst=3
[Install] [Install]
WantedBy=multi-user.target WantedBy=default.target

View File

@@ -5,6 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py ingest_memory' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py ingest_memory; code=$?; [ "$code" -eq 1 ] && exit 0; exit "$code"'
# Trigger auto-review after each ingest
ExecStartPost=/bin/systemctl start openclaw-secondbrain-auto-review.service

View File

@@ -4,8 +4,8 @@ Description=OpenClaw Second-Brain ingest transcript -> memory (every 1 min)
[Timer] [Timer]
OnBootSec=30s OnBootSec=30s
OnUnitActiveSec=60s OnUnitActiveSec=60s
Persistent=true
Unit=openclaw-secondbrain-ingest-transcript-to-memory.service Unit=openclaw-secondbrain-ingest-transcript-to-memory.service
[Install] [Install]
WantedBy=timers.target WantedBy=timers.target

View File

@@ -4,5 +4,4 @@ Description=OpenClaw Second-Brain failure notify (%i)
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc '/root/.openclaw/workspace/notify-telegram.sh "❌ Second-Brain job failed: %i. Check: journalctl -u %i -n 50 --no-pager"' ExecStart=/bin/true

View File

@@ -7,4 +7,5 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
Environment=LLM_PROXY_MODEL=stepfun-ai/Step-3.5-Flash
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py proactive_search_wrapper' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py proactive_search_wrapper'

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>🧠 Second Brain</title> <title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
@@ -12,8 +12,10 @@
<header class="stats-bar" id="statsBar"> <header class="stats-bar" id="statsBar">
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div> <div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div> <div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
<div class="stat"><span class="stat-num" id="statRejected">-</span><span class="stat-label">Rej</span></div>
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div> <div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div> <div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
<div class="stat"><span class="stat-num" id="statAvgConf">-</span><span class="stat-label">Avg</span></div>
</header> </header>
<div class="tabs-bar"> <div class="tabs-bar">
@@ -24,7 +26,10 @@
<!-- Search --> <!-- Search -->
<div class="search-box"> <div class="search-box">
<div class="search-row">
<input type="text" id="searchInput" placeholder="🔍 Suche..." /> <input type="text" id="searchInput" placeholder="🔍 Suche..." />
</div>
<div class="search-row">
<select id="filterSelect"> <select id="filterSelect">
<option value="all">Alle</option> <option value="all">Alle</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
@@ -32,6 +37,12 @@
<option value="rejected">Rejected</option> <option value="rejected">Rejected</option>
<option value="errors">Errors</option> <option value="errors">Errors</option>
</select> </select>
<select id="exportFormat" title="Export format">
<option value="jsonl">JSONL</option>
<option value="csv">CSV</option>
</select>
<button class="btn-export" onclick="exportCurrent()">Export</button>
</div>
</div> </div>
<!-- New Engram --> <!-- New Engram -->
@@ -108,6 +119,7 @@ let state = {
autoRefresh: true, autoRefresh: true,
view: 'cards', view: 'cards',
lastEvent: null, lastEvent: null,
selectedId: null,
}; };
// ─── Fetch ────────────────────────────────────────────────────────────────── // ─── Fetch ──────────────────────────────────────────────────────────────────
@@ -132,8 +144,10 @@ async function loadStats() {
const s = await api('/api/stats'); const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total; document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed; document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending; document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors; document.getElementById('statErrors').textContent = s.errors;
document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
} }
function updateStatsFromEvent(ev) { function updateStatsFromEvent(ev) {
@@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) {
const s = ev.stats; const s = ev.stats;
document.getElementById('statTotal').textContent = s.total; document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed; document.getElementById('statConfirmed').textContent = s.confirmed;
if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending; document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors; document.getElementById('statErrors').textContent = s.errors;
if (document.getElementById('statAvgConf')) document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
} }
function setView(view) { function setView(view) {
@@ -161,19 +177,74 @@ function setView(view) {
} }
async function loadCards() { async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`; const data = await api(buildEngramsUrl(state.limit, state.offset));
state.items = data.items;
renderCardsWithSuggestions();
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
document.getElementById('btnPrev').disabled = state.offset === 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit;
}
function buildEngramsUrl(limit, offset) {
let url = `/api/engrams?limit=${limit}&offset=${offset}`;
if (state.search) url += `&search=${encodeURIComponent(state.search)}`; if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
if (state.filter === 'confirmed') url += '&confirmed=1'; if (state.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0'; if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false'; if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error'; if (state.filter === 'errors') url += '&tag=error';
return url;
}
const data = await api(url); async function exportCurrent() {
state.items = data.items; const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
renderCards(); const limit = 100;
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1; const max = 5000;
document.getElementById('btnPrev').disabled = state.offset === 0; let offset = 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit; let all = [];
while (all.length < max) {
const data = await api(buildEngramsUrl(limit, offset));
const items = data.items || [];
all = all.concat(items);
if (items.length < limit) break;
offset += limit;
}
const safe = (s) => String(s ?? '').replace(/[\\r\\n]+/g, ' ').trim();
let payload = '';
let mime = 'text/plain';
if (fmt === 'csv') {
mime = 'text/csv';
const esc = (v) => '\"' + String(v ?? '').replace(/\"/g, '\"\"') + '\"';
payload += ['id','created','source','confidence','verdict','tags','content'].join(',') + '\\n';
for (const it of all) {
payload += [
esc(it.id),
esc(it.created),
esc(it.source),
esc(it.confidence),
esc(it.verdict),
esc((it.tags || []).join('|')),
esc((it.content || '').replace(/\\r?\\n/g, '\\\\n')),
].join(',') + '\\n';
}
} else {
mime = 'application/x-ndjson';
payload = all.map(x => JSON.stringify(x)).join('\\n') + (all.length ? '\\n' : '');
}
const now = new Date();
const ymd = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const filename = `second-brain_${ymd}_${safe(state.filter || 'all')}_${fmt}.${fmt}`;
const blob = new Blob([payload], {type: mime});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
async function loadStatus() { async function loadStatus() {
@@ -1001,9 +1072,11 @@ function escapeHtml(t) {
} }
// ─── Actions ──────────────────────────────────────────────────────────────── // ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev) { async function confirm(id, ev, ctx = 'card') {
ev.stopPropagation(); ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value; const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/confirm`, { await api(`/api/engrams/${id}/confirm`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1014,9 +1087,11 @@ async function confirm(id, ev) {
if (state.view === 'status') loadStatus(); if (state.view === 'status') loadStatus();
} }
async function reject(id, ev) { async function reject(id, ev, ctx = 'card') {
ev.stopPropagation(); ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value; const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/reject`, { await api(`/api/engrams/${id}/reject`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1052,18 +1127,62 @@ async function createEngram() {
async function showDetail(id) { async function showDetail(id) {
const item = await api(`/api/engrams/${id}`); const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody'); const body = document.getElementById('modalBody');
const links = (item.links || []);
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
body.innerHTML = ` body.innerHTML = `
<h3>Engramm ${item.id.substring(0,8)}</h3> <h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p> <div class="kv-row"><div class="kv-key">verdict</div><div class="kv-val">${renderVerdictPill(item)} <span class="muted small">${Math.round(item.confidence*100)}%</span></div></div>
<p><b>Confirmed:</b> ${item.confirmed ? '' : '❌'}</p> <div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p> <div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
<p><b>Content:</b></p> <div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></div>
<div class="detail-content">${escapeHtml(item.content)}</div> <div class="kv-row"><div class="kv-key">access</div><div class="kv-val">${item.access_count ?? 0} • grounding ${item.grounding ?? 0}</div></div>
<p><b>History:</b></p> <div class="kv-row"><div class="kv-key">tags</div><div class="kv-val">${(item.tags || []).map(t => '<span class="tag">'+escapeHtml(t)+'</span>').join(' ') || '-'}</div></div>
<div style="margin-top:10px"><b>Content</b></div>
<div class="detail-content">${escapeHtml(item.content || '')}</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Vorschläge</div>
<div class="suggestions">${suggHtml}</div>
</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Links</div>
<div>
${links.length ? links.map(l => `<span class="pill" style="cursor:pointer" onclick="showDetail('${l}')">${l.substring(0,8)}</span>`).join(' ') : '<span class="muted">none</span>'}
</div>
</div>
${Array.isArray(item.evidence) && item.evidence.length ? `
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Evidence</div>
<ul class="history"> <ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${h.action} (${h.note})</li>`).join('')} ${item.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
</ul> </ul>
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p> </div>` : ''}
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">History</div>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${escapeHtml(h.action)} ${h.note ? ('(' + escapeHtml(h.note) + ')') : ''}</li>`).join('') || '<li class=\"muted\">-</li>'}
</ul>
</div>
<div class="card-footer" style="margin-top:10px">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-modal-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event, 'modal')">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event, 'modal')">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
`; `;
document.getElementById('detailModal').classList.add('open'); document.getElementById('detailModal').classList.add('open');
} }
@@ -1072,6 +1191,15 @@ function closeModal() {
document.getElementById('detailModal').classList.remove('open'); document.getElementById('detailModal').classList.remove('open');
} }
function selectCard(id) {
state.selectedId = id;
renderCardsWithSuggestions();
setTimeout(() => {
const el = document.querySelector(`.card[data-id=\"${id}\"]`);
if (el) el.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}, 0);
}
// ─── Pagination ───────────────────────────────────────────────────────────── // ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() { function nextPage() {
state.offset += state.limit; state.offset += state.limit;
@@ -1114,6 +1242,92 @@ setInterval(() => {
// ─── Init ─────────────────────────────────────────────────────────────────── // ─── Init ───────────────────────────────────────────────────────────────────
loadStats(); loadStats();
loadCards(); loadCards();
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target && e.target.id === 'detailModal') closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === '/' && !(e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT'))) {
e.preventDefault();
document.getElementById('searchInput')?.focus();
}
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT')) return;
if (document.getElementById('detailModal')?.classList.contains('open')) return;
if (e.key === 'g') setView('graph');
if (e.key === 's') setView('status');
if (e.key === '1') setView('cards');
if (state.view !== 'cards') return;
if (!state.items || !state.items.length) return;
const idxOf = (id) => state.items.findIndex(x => x.id === id);
let idx = state.selectedId ? idxOf(state.selectedId) : -1;
if (idx < 0) idx = 0;
if (e.key === 'j') {
idx = Math.min(state.items.length - 1, idx + 1);
selectCard(state.items[idx].id);
} else if (e.key === 'k') {
idx = Math.max(0, idx - 1);
selectCard(state.items[idx].id);
} else if (e.key === 'Enter') {
showDetail(state.items[idx].id);
} else if (e.key === 'c') {
confirm(state.items[idx].id, e);
} else if (e.key === 'r') {
reject(state.items[idx].id, e);
}
});
function renderCardsWithSuggestions() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => {
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
return `
<div class="card ${item.id === state.selectedId ? 'selected' : ''} ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}" onclick="selectCard('${item.id}')">
<div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span>
</div>
<div class="card-body" onclick="showDetail('${item.id}')">
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
</div>
<div class="suggestions">
<strong>Vorschläge:</strong> ${suggHtml}
</div>
<div class="card-footer">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
</div>
`;
}).join('');
}
async function acceptLink(fromId, toId, ev) {
ev.stopPropagation();
await api('/api/links/accept', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({from_id: fromId, to_id: toId})
});
alert('Link erstellt');
await loadCards();
}
</script> </script>
</body> </body>
</html> </html>