15 Commits

Author SHA1 Message Date
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
2504327e35 refactor: event-driven tuning\n\n- ingest: switch to path unit (inotify) for immediate trigger\n- auto-review: every 30min + ExecStartPost after ingest\n- health-check: every 30min\n- import-context-buffer: every 15min\n\nRefs: #25 2026-05-31 14:10:12 +02:00
f22b911342 Merge PR #26: Event-driven proaktive Tasks 2026-05-31 14:01:19 +02:00
11 changed files with 413 additions and 63 deletions

View File

@@ -15,34 +15,35 @@ 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

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

@@ -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:
@@ -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

@@ -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,9 +1,9 @@
[Unit] [Unit]
Description=Run auto assign review daily at 14:30 Description=Run auto assign review every 30 minutes
PartOf=openclaw-secondbrain.target PartOf=openclaw-secondbrain.target
[Timer] [Timer]
OnCalendar=*-*-* 14:30:00 OnUnitActiveSec=30min
Persistent=true Persistent=true
[Install] [Install]

View File

@@ -1,9 +1,9 @@
[Unit] [Unit]
Description=Run health check at 02:00 and 14:00 daily Description=Run health check every 30 minutes
PartOf=openclaw-secondbrain.target PartOf=openclaw-secondbrain.target
[Timer] [Timer]
OnCalendar=*-*-* 02,14:00:00 OnUnitActiveSec=30min
Persistent=true Persistent=true
[Install] [Install]

View File

@@ -1,9 +1,9 @@
[Unit] [Unit]
Description=Import Context Buffer every 4 hours Description=Import Context Buffer every 15 minutes
PartOf=openclaw-secondbrain.target PartOf=openclaw-secondbrain.target
[Timer] [Timer]
OnCalendar=*-*-* 02,06,10,14,18,22:00:00 OnUnitActiveSec=15min
Persistent=true Persistent=true
[Install] [Install]

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Watch memory/ directory for changes to trigger ingest
PartOf=openclaw-secondbrain.target
[Path]
PathModified=/root/.openclaw/workspace/memory
[Install]
WantedBy=multi-user.target

View File

@@ -6,3 +6,5 @@ OnFailure=openclaw-secondbrain-notify@%n.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'
# Trigger auto-review after each ingest
ExecStartPost=/bin/systemctl start openclaw-secondbrain-auto-review.service

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,14 +26,23 @@
<!-- Search --> <!-- Search -->
<div class="search-box"> <div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Suche..." /> <div class="search-row">
<select id="filterSelect"> <input type="text" id="searchInput" placeholder="🔍 Suche..." />
<option value="all">Alle</option> </div>
<option value="pending">Pending</option> <div class="search-row">
<option value="confirmed">Confirmed</option> <select id="filterSelect">
<option value="rejected">Rejected</option> <option value="all">Alle</option>
<option value="errors">Errors</option> <option value="pending">Pending</option>
</select> <option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="errors">Errors</option>
</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>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${h.action} (${h.note})</li>`).join('')} <div style="margin-top:10px"><b>Content</b></div>
</ul> <div class="detail-content">${escapeHtml(item.content || '')}</div>
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
<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">
${item.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
</ul>
</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>