diff --git a/cron_tasks/import_context_buffer.py b/cron_tasks/import_context_buffer.py index c3aeef5..32e97f5 100644 --- a/cron_tasks/import_context_buffer.py +++ b/cron_tasks/import_context_buffer.py @@ -15,34 +15,35 @@ from pathlib import Path BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain") WORKSPACE = Path("/root/.openclaw/workspace") -HANDLER = WORKSPACE / "context-buffer" / "handler.py" +CURRENT_DIR = WORKSPACE / "context-buffer" / "current" 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: - result = subprocess.run( - ["python3", str(HANDLER), "search", "--status", "done"], - capture_output=True, text=True, timeout=30 - ) - if result.returncode != 0: - raise Exception(f"Handler error: {result.stderr}") - topics = json.loads(result.stdout) + with open(index_path) as f: + idx = json.load(f) + topics = [] + for tid, t in idx.get("topics", {}).items(): + status = t.get("status", "active") + if status in ("done", "completed"): + # 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: print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)) 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: print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False)) return diff --git a/fastapi_app.py b/fastapi_app.py index 275efc5..a09ed0c 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict: verdict = "confirmed_false" else: verdict = "unknown" - return { + result = { "id": row["id"], "content": row["content"], "confidence": meta.get("confidence", 0.0), @@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict: "access_count": meta.get("access_count", 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: @@ -902,6 +908,31 @@ def api_refresh(engram_id: str): 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") 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]}" diff --git a/static/style.css b/static/style.css index 157f383..8ae430d 100644 --- a/static/style.css +++ b/static/style.css @@ -14,6 +14,7 @@ body { margin: 0 auto; min-height: 100vh; background: #141419; + width: 100%; } /* ─── Stats Bar ───────────────────────────────────────────────────────────── */ @@ -75,10 +76,22 @@ body { /* ─── Search ──────────────────────────────────────────────────────────────── */ .search-box { display: flex; + flex-direction: column; gap: 8px; padding: 10px 12px; 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 */ @@ -196,7 +209,9 @@ body { .legend-dot.match{ background:#f7d154; } .graph-hint{ padding: 4px 12px 10px; } #searchInput { + width: 100%; flex: 1; + min-width: 0; background: #1e1e28; border: 1px solid #2a2a3a; border-radius: 10px; @@ -207,6 +222,8 @@ body { } #searchInput:focus { border-color: #6c8af5; } #filterSelect { + flex: 1; + min-width: 0; background: #1e1e28; border: 1px solid #2a2a3a; border-radius: 10px; @@ -216,6 +233,32 @@ body { 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 { padding: 0 12px 8px; @@ -266,6 +309,10 @@ body { transition: transform 0.15s ease, border-color 0.2s ease; 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.confirmed { border-left: 4px solid #3a7d3a; } .card.rejected { border-left: 4px solid #8a3a3a; } @@ -455,3 +502,13 @@ body { @media (pointer: coarse) { 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; } +} diff --git a/systemd/openclaw-secondbrain-ingest-memory.path b/systemd/openclaw-secondbrain-ingest-memory.path index 5679b37..d61a188 100644 --- a/systemd/openclaw-secondbrain-ingest-memory.path +++ b/systemd/openclaw-secondbrain-ingest-memory.path @@ -4,7 +4,6 @@ PartOf=openclaw-secondbrain.target [Path] PathModified=/root/.openclaw/workspace/memory -DirectoryNotEmpty=true [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index daea77d..221466c 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -2,7 +2,7 @@ - + 🧠 Second Brain @@ -12,8 +12,10 @@
-Total
-OK
+
-Rej
-Pending
-Err
+
-Avg
@@ -24,14 +26,23 @@ @@ -108,6 +119,7 @@ let state = { autoRefresh: true, view: 'cards', lastEvent: null, + selectedId: null, }; // ─── Fetch ────────────────────────────────────────────────────────────────── @@ -132,8 +144,10 @@ async function loadStats() { const s = await api('/api/stats'); document.getElementById('statTotal').textContent = s.total; document.getElementById('statConfirmed').textContent = s.confirmed; + document.getElementById('statRejected').textContent = (s.rejected ?? '-'); document.getElementById('statPending').textContent = s.pending; document.getElementById('statErrors').textContent = s.errors; + document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-'; } function updateStatsFromEvent(ev) { @@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) { const s = ev.stats; document.getElementById('statTotal').textContent = s.total; document.getElementById('statConfirmed').textContent = s.confirmed; + if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-'); document.getElementById('statPending').textContent = s.pending; 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) { @@ -161,19 +177,74 @@ function setView(view) { } 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.filter === 'confirmed') url += '&confirmed=1'; if (state.filter === 'pending') url += '&confirmed=0'; if (state.filter === 'rejected') url += '&verdict=confirmed_false'; if (state.filter === 'errors') url += '&tag=error'; + return url; +} - const data = await api(url); - state.items = data.items; - renderCards(); - 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; +async function exportCurrent() { + const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase(); + const limit = 100; + const max = 5000; + let offset = 0; + 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() { @@ -1001,9 +1072,11 @@ function escapeHtml(t) { } // ─── Actions ──────────────────────────────────────────────────────────────── -async function confirm(id, ev) { +async function confirm(id, ev, ctx = 'card') { 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`, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, @@ -1014,9 +1087,11 @@ async function confirm(id, ev) { if (state.view === 'status') loadStatus(); } -async function reject(id, ev) { +async function reject(id, ev, ctx = 'card') { 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`, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, @@ -1052,18 +1127,62 @@ async function createEngram() { async function showDetail(id) { const item = await api(`/api/engrams/${id}`); 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 => ` +
+ ${s.engram_id.substring(0,8)} + ${escapeHtml(s.preview || s.content_preview || '')} + +
+ `).join('') : 'Keine Vorschläge'; body.innerHTML = ` -

Engramm ${item.id.substring(0,8)}

-

Confidence: ${Math.round(item.confidence*100)}%

-

Confirmed: ${item.confirmed ? '✅' : '❌'}

-

Tags: ${item.tags.map(t => ''+t+'').join(' ')}

-

Content:

-
${escapeHtml(item.content)}
-

History:

- -

Links: ${item.links?.join(', ') || 'none'}

+

Engramm ${item.id.substring(0,8)}

+
verdict
${renderVerdictPill(item)} ${Math.round(item.confidence*100)}%
+
source
${escapeHtml(item.source || '-')}
+
created
${fmtDate(item.created)}
+
modified
${fmtDate(item.modified)}
+
access
${item.access_count ?? 0} • grounding ${item.grounding ?? 0}
+
tags
${(item.tags || []).map(t => ''+escapeHtml(t)+'').join(' ') || '-'}
+ +
Content
+
${escapeHtml(item.content || '')}
+ +
+
Vorschläge
+
${suggHtml}
+
+ +
+
Links
+
+ ${links.length ? links.map(l => `${l.substring(0,8)}`).join(' ') : 'none'} +
+
+ + ${Array.isArray(item.evidence) && item.evidence.length ? ` +
+
Evidence
+ +
` : ''} + +
+
History
+ +
+ + `; document.getElementById('detailModal').classList.add('open'); } @@ -1072,6 +1191,15 @@ function closeModal() { 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 ───────────────────────────────────────────────────────────── function nextPage() { state.offset += state.limit; @@ -1114,6 +1242,92 @@ setInterval(() => { // ─── Init ─────────────────────────────────────────────────────────────────── loadStats(); 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 => ` +
+ ${s.engram_id.substring(0,8)} + ${escapeHtml(s.preview || s.content_preview || '')} + +
+ `).join('') : 'Keine Vorschläge'; + return ` +
+
+ ${Math.round(item.confidence*100)}% + ${renderVerdictPill(item)} + ${item.tags.map(t => ''+t+'').join('')} + ${fmtDate(item.created)} +
+
+ ${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''} +
+
+ Vorschläge: ${suggHtml} +
+ +
+ `; + }).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(); +}