Files
second-brain/templates/dashboard.html

1228 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no">
<title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<!-- Stats Header -->
<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="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="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>
<div class="tabs-bar">
<button class="tab-btn active" id="tabCards" onclick="setView('cards')">Cards</button>
<button class="tab-btn" id="tabGraph" onclick="setView('graph')">Graph</button>
<button class="tab-btn" id="tabStatus" onclick="setView('status')">Status</button>
</div>
<!-- Search -->
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
<select id="filterSelect">
<option value="all">Alle</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="errors">Errors</option>
</select>
</div>
<!-- New Engram -->
<div class="new-engram">
<textarea id="newContent" placeholder="Neues Engramm..."></textarea>
<input type="text" id="newTags" placeholder="Tags (comma sep)" />
<button onclick="createEngram()"> Speichern</button>
</div>
<!-- Cards -->
<div class="cards" id="cards"></div>
<!-- Graph -->
<div class="graph" id="graph" style="display:none;">
<div class="graph-controls">
<button class="btn primary" id="btnGraphPhysics" onclick="toggleGraphPhysics()">Physics: off</button>
<button class="btn" onclick="resetGraphView()">Reset view</button>
<button class="btn" onclick="fitGraphView()">Fit</button>
<label class="muted small" style="display:flex;align-items:center;gap:8px">
<span>Physics</span>
<input id="physicsStrength" type="range" min="0" max="100" value="60" oninput="setPhysicsStrength(this.value)" style="width:140px">
<span id="physicsStrengthVal">60</span>
</label>
<select class="btn" id="graphLimit" onchange="reloadGraph()" title="Wie viele Knoten laden? 0=all">
<option value="0">Nodes: all</option>
<option value="200">Nodes: 200</option>
<option value="1000">Nodes: 1000</option>
<option value="5000">Nodes: 5000</option>
</select>
<button class="btn" onclick="reloadGraph()">Reload</button>
</div>
<canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
<div class="graph-legend">
<div><strong>Graph</strong>: Zoom per Pinch (2 Finger), Pan per Drag (1 Finger). Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.</div>
<div class="legend-row"><span class="legend-dot engram"></span> Engram</div>
<div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
<div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div>
</div>
</div>
<!-- Status -->
<div class="status" id="status" style="display:none;"></div>
<!-- Pagination -->
<div class="pagination" id="pagination">
<button id="btnPrev" onclick="prevPage()"></button>
<span id="pageNum">1</span>
<button id="btnNext" onclick="nextPage()"></button>
</div>
<div class="footer">
<span id="lastUpdate">--:--</span>
<button onclick="manualRefresh()" class="refresh-btn"></button>
</div>
</div>
<!-- Detail Modal -->
<div class="modal" id="detailModal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal()">×</button>
<div id="modalBody"></div>
</div>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let state = {
items: [],
offset: 0,
limit: 10,
filter: 'all',
search: '',
autoRefresh: true,
view: 'cards',
lastEvent: null,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(path, opts);
if (!r.ok) {
let msg = r.statusText;
try {
const j = await r.json();
msg = j.error || j.detail || msg;
if (Array.isArray(msg)) msg = JSON.stringify(msg);
if (typeof msg !== 'string') msg = JSON.stringify(msg);
} catch (e) {
// ignore JSON parse errors
}
throw new Error(msg);
}
return r.json();
}
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) {
if (!ev || !ev.stats) return;
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) {
state.view = view;
document.getElementById('tabCards').classList.toggle('active', view === 'cards');
document.getElementById('tabGraph').classList.toggle('active', view === 'graph');
document.getElementById('tabStatus').classList.toggle('active', view === 'status');
document.getElementById('cards').style.display = view === 'cards' ? '' : 'none';
document.getElementById('pagination').style.display = view === 'cards' ? '' : 'none';
document.getElementById('graph').style.display = view === 'graph' ? '' : 'none';
document.getElementById('status').style.display = view === 'status' ? '' : 'none';
if (view === 'graph') loadGraph();
if (view === 'status') loadStatus();
}
async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.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';
const data = await api(url);
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;
}
async function loadStatus() {
const reqs = await Promise.allSettled([
api('/api/config'),
api('/api/db_info'),
api('/api/jobs'),
api('/api/insights?limit=8'),
api('/api/storage_stats'),
api('/api/pending?limit=20&offset=0'),
]);
const pick = (i, fallback) => (reqs[i].status === 'fulfilled' ? reqs[i].value : fallback);
const err = (i) => (reqs[i].status === 'rejected' ? (reqs[i].reason && reqs[i].reason.message ? reqs[i].reason.message : String(reqs[i].reason)) : null);
const cfg = pick(0, { workspace: '-', db_path: '-' });
const db = pick(1, { db_path: '-', mtime: null });
const jobs = pick(2, { items: [], error: err(2) });
const ins = pick(3, { pending: '-', top_tags: [], top_hosts: [], error: err(3) });
const stor = pick(4, { sql: { total_engrams: '-', confirmed: '-', pending: '-', by_source: {} }, vector: { chroma_size_bytes: 0, embedding_cache_files: 0 }, obsidian: { configured: false } });
const pend = pick(5, { items: [], error: err(5) });
const el = document.getElementById('status');
const jobsHtml = (jobs.items || []).map(j => `
<div class="kv-row">
<div class="kv-key">${j.unit}</div>
<div class="kv-val">${j.error ? ('ERR: ' + j.error) : (j.active + '/' + j.sub)}</div>
</div>
`).join('');
const topTags = (ins.top_tags || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const topHosts = (ins.top_hosts || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const bySource = Object.entries((stor.sql && stor.sql.by_source) ? stor.sql.by_source : {})
.slice(0, 8)
.map(([k,v]) => `<span class="pill">${k}: ${v}</span>`)
.join(' ');
const pendItems = (pend.items || []);
const pendHtml = pendItems.map(p => `
<div class="kv-row" onclick="showDetail('${p.id}')">
<div class="kv-key">${(p.source||'').slice(0,12)}</div>
<div class="kv-val">
<span class="pill">${p.id.substring(0,8)}</span>
${escapeHtml((p.content||'').substring(0,120))}${(p.content||'').length>120?'…':''}
<div class="actions" style="margin-top:6px" onclick="event.stopPropagation()">
<button class="btn-ok" onclick="confirm('${p.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${p.id}', event)">❌</button>
</div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="panel">
<div class="panel-title">Config</div>
<div class="kv-row"><div class="kv-key">workspace</div><div class="kv-val">${cfg.workspace}</div></div>
<div class="kv-row"><div class="kv-key">db</div><div class="kv-val">${db.db_path}</div></div>
<div class="kv-row"><div class="kv-key">db mtime</div><div class="kv-val">${new Date(db.mtime).toLocaleString()}</div></div>
</div>
<div class="panel">
<div class="panel-title">Storage</div>
<div class="kv-row"><div class="kv-key">SQL</div><div class="kv-val">${stor.sql.total_engrams} engrams (ok ${stor.sql.confirmed}, pending ${stor.sql.pending})</div></div>
<div class="kv-row"><div class="kv-key">Vector</div><div class="kv-val">chroma ${(stor.vector.chroma_size_bytes/1024/1024).toFixed(1)} MB, cache ${stor.vector.embedding_cache_files} files</div></div>
<div class="kv-row"><div class="kv-key">Obsidian</div><div class="kv-val">${stor.obsidian.configured ? 'configured' : 'not configured'}</div></div>
<div class="kv-row"><div class="kv-key">By source</div><div class="kv-val">${bySource || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Jobs</div>
${jobs.error ? `<div class="muted">Fehler: ${escapeHtml(jobs.error)}</div>` : (jobsHtml || '<div class="muted">Keine Daten</div>')}
</div>
<div class="panel">
<div class="panel-title">Insights</div>
${ins.error ? `<div class="muted">Fehler: ${escapeHtml(ins.error)}</div>` : ''}
<div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div>
<div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div>
<div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Pending Queue (latest)</div>
${pend.error ? `<div class="muted">Fehler: ${escapeHtml(pend.error)}</div>` : (pendHtml || '<div class="muted">Keine Pendings</div>')}
</div>
`;
}
async function loadGraph() {
const sel = document.getElementById('graphLimit');
const fromSel = sel ? parseInt(sel.value || '0', 10) : NaN;
const fromStore = parseInt(localStorage.getItem('graphLimit') || '0', 10);
const q = (!Number.isNaN(fromSel)) ? fromSel : (Number.isNaN(fromStore) ? 0 : fromStore);
if (sel) sel.value = String(q);
if (sel) localStorage.setItem('graphLimit', String(q));
const hint = document.getElementById('graphHint');
if (hint) hint.textContent = 'Lade Graph…';
try {
const g = await api(`/api/graph?limit_nodes=${q}`);
renderGraph(g.nodes || [], g.edges || []);
} catch (e) {
if (hint) hint.textContent = `Graph-Fehler: ${e && e.message ? e.message : String(e)}`;
const canvas = _graphCanvas();
const ctx = _graphCtx();
if (canvas && ctx) ctx.clearRect(0,0,canvas.width,canvas.height);
}
}
function reloadGraph() { loadGraph(); }
// ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
let graphState = {
nodes: [],
edges: [],
sim: [],
links: [],
nodeById: new Map(),
simById: new Map(),
degree: new Map(),
physicsOn: false,
draggingId: null,
selectedId: null,
panning: false,
lastX: 0,
lastY: 0,
panX: 0,
panY: 0,
zoom: 1,
raf: null,
search: '',
pointers: new Map(),
pinchStartDist: null,
pinchStartZoom: null,
pinchStartPan: null,
down: null, // {pointerId, cx, cy, t}
physicsStrength: parseInt(localStorage.getItem('physicsStrength') || '60', 10),
};
function _graphCanvas() { return document.getElementById('graphCanvas'); }
function _graphCtx() { return _graphCanvas().getContext('2d'); }
function _graphNodeRadius(n) {
const d = graphState.degree.get(n.id) || 0;
const base = n.kind === 'tag' ? 4 : (n.kind === 'host' ? 5 : 7);
const w = (n.weight || 0);
const bonus = Math.min(6, Math.sqrt(Math.max(0, w)) * 0.8);
return Math.max(3, Math.min(18, base + Math.sqrt(d) + bonus));
}
function _graphNodeFill(n) {
const d = graphState.degree.get(n.id) || 0;
const t = Math.max(0, Math.min(0.55, d / 18)); // higher degree -> brighter
const mix = (rgb) => rgb.map(c => Math.round(c + (255 - c) * t));
if (n.kind === 'tag') {
const [r,g,b] = mix([167, 139, 250]);
return `rgb(${r},${g},${b})`;
}
if (n.kind === 'host') {
const [r,g,b] = mix([245, 158, 11]);
return `rgb(${r},${g},${b})`;
}
const verdict = (n.verdict || '').toString();
let base = [96, 165, 250]; // pending/unknown = blue
if (verdict === 'confirmed_true') base = [34, 197, 94]; // green
else if (verdict === 'confirmed_false') base = [239, 68, 68]; // red
else if (verdict === 'probable_true') base = [52, 211, 153]; // teal
else if (verdict === 'probable_false') base = [251, 191, 36]; // amber
// Recency brightening (brand new pops)
const now = Date.now();
const created = Date.parse(n.created || '') || 0;
const ageMin = created ? (now - created) / 60000 : 999999;
const rec = Math.max(0, Math.min(0.35, (30 - ageMin) / 30 * 0.35));
const bump = (c) => Math.round(c + (255 - c) * rec);
const [r,g,b] = mix(base).map(bump);
return `rgb(${r},${g},${b})`;
}
function _graphMatches(n, term) {
const t = (term || '').trim().toLowerCase();
if (!t) return false;
const id = (n.id || '').toLowerCase();
const label = (n.label || '').toLowerCase();
return id.includes(t) || label.includes(t);
}
function _graphWorldFromScreen(cx, cy) {
return {
x: (cx - graphState.panX) / graphState.zoom,
y: (cy - graphState.panY) / graphState.zoom,
};
}
function _graphHitTest(wx, wy) {
for (let i = graphState.sim.length - 1; i >= 0; i--) {
const n = graphState.sim[i];
const r = _graphNodeRadius(n) + 2;
const dx = wx - n.x;
const dy = wy - n.y;
if ((dx*dx + dy*dy) <= r*r) return n;
}
return null;
}
function _graphInitInteractions() {
const canvas = _graphCanvas();
if (canvas._graphBound) return;
canvas._graphBound = true;
const toCanvasXY = (ev) => {
const rect = canvas.getBoundingClientRect();
return { cx: ev.clientX - rect.left, cy: ev.clientY - rect.top };
};
const pinchMidpoint = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
return { cx: (pts[0].cx + pts[1].cx) / 2, cy: (pts[0].cy + pts[1].cy) / 2 };
};
const pinchDistance = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
const dx = pts[0].cx - pts[1].cx;
const dy = pts[0].cy - pts[1].cy;
return Math.sqrt(dx*dx + dy*dy);
};
canvas.addEventListener('pointerdown', (ev) => {
canvas.setPointerCapture(ev.pointerId);
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
graphState.down = { pointerId: ev.pointerId, cx: pos.cx, cy: pos.cy, t: Date.now() };
// When 2 pointers: start pinch
if (graphState.pointers.size === 2) {
graphState.panning = false;
graphState.draggingId = null;
graphState.down = null;
graphState.pinchStartDist = pinchDistance();
graphState.pinchStartZoom = graphState.zoom;
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
_graphDraw();
return;
}
// Single pointer: pan or drag node
const { cx, cy } = pos;
const w = _graphWorldFromScreen(cx, cy);
const hit = _graphHitTest(w.x, w.y);
graphState.lastX = cx; graphState.lastY = cy;
if (hit) {
graphState.draggingId = hit.id;
graphState.selectedId = hit.id;
} else {
graphState.panning = true;
graphState.selectedId = null;
}
_graphDraw();
});
canvas.addEventListener('pointermove', (ev) => {
if (!graphState.pointers.has(ev.pointerId)) return;
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// Pinch zoom (2 pointers)
if (graphState.pointers.size >= 2 && graphState.pinchStartDist) {
const dist = pinchDistance();
const mid = pinchMidpoint();
if (!dist || !mid) return;
const zoomOld = graphState.zoom;
const wx = (mid.cx - graphState.panX) / zoomOld;
const wy = (mid.cy - graphState.panY) / zoomOld;
const factor = dist / graphState.pinchStartDist;
const next = Math.max(0.35, Math.min(3.0, graphState.pinchStartZoom * factor));
graphState.zoom = next;
graphState.panX = mid.cx - wx * graphState.zoom;
graphState.panY = mid.cy - wy * graphState.zoom;
_graphDraw();
return;
}
// Single pointer
const { cx, cy } = pos;
const dx = cx - graphState.lastX;
const dy = cy - graphState.lastY;
graphState.lastX = cx; graphState.lastY = cy;
if (graphState.draggingId) {
const w = _graphWorldFromScreen(cx, cy);
const n = graphState.simById.get(graphState.draggingId);
if (n) { n.x = w.x; n.y = w.y; n.vx = 0; n.vy = 0; }
_graphDraw();
return;
}
if (graphState.panning) {
graphState.panX += dx;
graphState.panY += dy;
_graphDraw();
}
});
const endPointer = (ev) => {
if (graphState.pointers.has(ev.pointerId)) graphState.pointers.delete(ev.pointerId);
try { canvas.releasePointerCapture(ev.pointerId); } catch {}
if (graphState.pointers.size < 2) {
graphState.pinchStartDist = null;
graphState.pinchStartZoom = null;
graphState.pinchStartPan = null;
}
const pos = toCanvasXY(ev);
const down = graphState.down && graphState.down.pointerId === ev.pointerId ? graphState.down : null;
graphState.down = null;
const moved = down ? Math.hypot(pos.cx - down.cx, pos.cy - down.cy) : 999;
const isTap = !!down && moved <= 8 && (Date.now() - down.t) <= 500;
const w = _graphWorldFromScreen(pos.cx, pos.cy);
const hit = _graphHitTest(w.x, w.y);
graphState.draggingId = null;
graphState.panning = false;
if (isTap && hit) {
graphState.selectedId = hit.id;
_graphDraw();
if (hit.kind === 'engram') {
showDetail(hit.id);
} else if (hit.kind === 'tag') {
const label = (hit.label || '').replace(/^tag:/, '');
const inp = document.getElementById('searchInput');
inp.value = label;
inp.dispatchEvent(new Event('input'));
}
return;
}
_graphDraw();
};
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
}
function renderGraph(nodes, edges) {
const canvas = _graphCanvas();
const hint = document.getElementById('graphHint');
const ctx = _graphCtx();
// sync physics slider
const slider = document.getElementById('physicsStrength');
const sliderVal = document.getElementById('physicsStrengthVal');
const s = Math.max(0, Math.min(100, parseInt(graphState.physicsStrength || 60, 10)));
graphState.physicsStrength = s;
if (slider) slider.value = String(s);
if (sliderVal) sliderVal.textContent = String(s);
const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, w);
canvas.height = Math.max(520, Math.min(900, (window.innerHeight || 900) - 260));
graphState.nodes = nodes || [];
graphState.edges = edges || [];
graphState.nodeById = new Map(graphState.nodes.map(n => [n.id, n]));
graphState.degree = new Map();
for (const e of graphState.edges) {
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
}
if (!graphState.nodes.length) {
hint.textContent = 'Graph: keine Nodes.';
ctx.clearRect(0,0,canvas.width,canvas.height);
return;
}
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
// Build adjacency for deterministic layout (O(n+e))
const adj = new Map();
const addAdj = (a, b) => {
if (!adj.has(a)) adj.set(a, []);
adj.get(a).push(b);
};
for (const e of graphState.edges) {
addAdj(e.from, e.to);
addAdj(e.to, e.from);
}
const degree = graphState.degree;
const visited = new Set();
const comps = [];
for (const n of graphState.nodes) {
if (visited.has(n.id)) continue;
const q = [n.id];
visited.add(n.id);
const comp = [];
while (q.length) {
const cur = q.pop();
comp.push(cur);
const neigh = adj.get(cur) || [];
for (const nb of neigh) {
if (!visited.has(nb)) {
visited.add(nb);
q.push(nb);
}
}
}
comps.push(comp);
}
comps.sort((a, b) => b.length - a.length);
// component centers in a loose grid/spiral
const centers = [];
const gap = 240;
const cols = Math.max(1, Math.floor(canvas.width / gap));
for (let i = 0; i < comps.length; i++) {
const cx = (i % cols) * gap - ((cols - 1) * gap) / 2;
const cy = Math.floor(i / cols) * gap - (Math.floor((comps.length - 1) / cols) * gap) / 2;
centers.push({ cx, cy });
}
const pos = new Map();
const R = 46;
for (let ci = 0; ci < comps.length; ci++) {
const comp = comps[ci];
const center = centers[ci] || { cx: 0, cy: 0 };
// hub: highest degree engram preferred
let hub = comp[0];
let hubScore = -1;
for (const id of comp) {
const n = graphState.nodeById.get(id) || {};
const score = (degree.get(id) || 0) + (n.kind === 'engram' ? 3 : 0);
if (score > hubScore) { hubScore = score; hub = id; }
}
// BFS layers from hub
const layer = new Map();
layer.set(hub, 0);
const qq = [hub];
while (qq.length) {
const cur = qq.shift();
const l = layer.get(cur) || 0;
const neigh = adj.get(cur) || [];
for (const nb of neigh) {
if (!layer.has(nb)) {
layer.set(nb, l + 1);
qq.push(nb);
}
}
}
const buckets = new Map();
for (const id of comp) {
const l = layer.get(id);
const ll = (typeof l === 'number') ? l : 99;
if (!buckets.has(ll)) buckets.set(ll, []);
buckets.get(ll).push(id);
}
// Place hubs first, then rings
pos.set(hub, { x: center.cx, y: center.cy });
const maxLayer = Math.max(...Array.from(buckets.keys()));
for (let l = 1; l <= maxLayer; l++) {
const ids = buckets.get(l) || [];
if (!ids.length) continue;
// Stable order: engrams first, then by degree desc
ids.sort((a, b) => {
const na = graphState.nodeById.get(a) || {};
const nb = graphState.nodeById.get(b) || {};
const ka = na.kind === 'engram' ? 0 : (na.kind === 'tag' ? 1 : 2);
const kb = nb.kind === 'engram' ? 0 : (nb.kind === 'tag' ? 1 : 2);
if (ka !== kb) return ka - kb;
return (degree.get(b) || 0) - (degree.get(a) || 0);
});
const rad = l * R;
for (let i = 0; i < ids.length; i++) {
const ang = (i / ids.length) * Math.PI * 2;
pos.set(ids[i], {
x: center.cx + Math.cos(ang) * rad,
y: center.cy + Math.sin(ang) * rad,
});
}
}
}
graphState.sim = graphState.nodes.map(n => {
const p = pos.get(n.id) || { x: (Math.random() - 0.5) * canvas.width, y: (Math.random() - 0.5) * canvas.height };
return {
id: n.id,
kind: n.kind,
label: n.label || n.id,
weight: n.weight,
verdict: n.verdict,
confidence: n.confidence,
created: n.created,
modified: n.modified,
last_accessed: n.last_accessed,
predict_locked: n.predict_locked,
x: p.x,
y: p.y,
vx: 0, vy: 0,
};
});
graphState.simById = new Map(graphState.sim.map(n => [n.id, n]));
graphState.links = graphState.edges
.map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind, weight: e.weight || 1.0}))
.filter(l => l.a && l.b);
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
graphState.search = state.search || '';
graphState.selectedId = null;
_graphInitInteractions();
// Physics is expensive; pre-relax a little but keep it optional for big graphs.
const relaxIters = graphState.sim.length <= 700 ? 70 : 12;
for (let i = 0; i < relaxIters; i++) _graphStepPhysics(0.85);
_graphDraw();
}
function _graphStepPhysics(alpha = 1.0) {
const canvas = _graphCanvas();
const repulsion = (graphState.sim.length > 700) ? 120 : 180;
const damping = 0.86;
const target = 80;
const springK = 0.018;
const sim = graphState.sim;
if (sim.length > 700) {
// Fast approximate repulsion via spatial hashing (checks only local neighbor cells).
const cell = 120;
const grid = new Map();
const key = (cx, cy) => `${cx},${cy}`;
for (let i = 0; i < sim.length; i++) {
const n = sim[i];
const cx = Math.floor(n.x / cell);
const cy = Math.floor(n.y / cell);
const k = key(cx, cy);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
for (let i = 0; i < sim.length; i++) {
const a = sim[i];
const cx = Math.floor(a.x / cell);
const cy = Math.floor(a.y / cell);
for (let ox = -1; ox <= 1; ox++) {
for (let oy = -1; oy <= 1; oy++) {
const idxs = grid.get(key(cx + ox, cy + oy));
if (!idxs) continue;
for (const j of idxs) {
if (j === i) continue;
const b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.12;
const f = (repulsion / d2) * alpha * 0.55;
a.vx += dx*f; a.vy += dy*f;
}
}
}
}
} else {
for (let i = 0; i < sim.length; i++) {
for (let j = i + 1; j < sim.length; j++) {
const a = sim[i], b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.02;
const f = (repulsion / d2) * alpha;
a.vx += dx*f; a.vy += dy*f;
b.vx -= dx*f; b.vy -= dy*f;
}
}
}
for (const l of graphState.links) {
const a = l.a, b = l.b;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const f = (dist - target) * springK * alpha;
const fx = (dx/dist) * f, fy = (dy/dist) * f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
for (const n of sim) {
n.vx *= damping; n.vy *= damping;
n.x += n.vx; n.y += n.vy;
const pad = 10;
const bx = canvas.width / graphState.zoom;
const by = canvas.height / graphState.zoom;
n.x = Math.max(-bx/2 + pad, Math.min(bx/2 - pad, n.x));
n.y = Math.max(-by/2 + pad, Math.min(by/2 - pad, n.y));
}
}
function _graphEdgeColor(kind) {
const k = (kind || '').toString().toLowerCase();
if (k.includes('tag')) return '#7c3aed';
if (k.includes('host')) return '#f59e0b';
if (k.includes('ref')) return '#10b981';
return '#3a3a55';
}
function _graphDraw() {
const canvas = _graphCanvas();
const ctx = _graphCtx();
const hint = document.getElementById('graphHint');
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.translate(graphState.panX, graphState.panY);
ctx.scale(graphState.zoom, graphState.zoom);
for (const l of graphState.links) {
const term = (graphState.search || '').trim();
const isMatchEdge = term && (_graphMatches(l.a, term) || _graphMatches(l.b, term));
const w = Math.max(0.2, Math.min(3.0, (l.weight || 1.0)));
ctx.lineWidth = (0.6 + w) / graphState.zoom;
ctx.globalAlpha = isMatchEdge ? 0.85 : (0.25 + Math.min(0.35, w * 0.18));
ctx.strokeStyle = isMatchEdge ? '#f7d154' : _graphEdgeColor(l.kind);
ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
const term = (graphState.search || '').trim();
let matches = 0;
for (const n of graphState.sim) {
const r = _graphNodeRadius(n);
const isMatch = _graphMatches(n, term);
if (isMatch) matches++;
if (isMatch) {
ctx.beginPath();
ctx.strokeStyle = '#f7d154';
ctx.lineWidth = 3 / graphState.zoom;
ctx.arc(n.x, n.y, r + 3, 0, Math.PI*2);
ctx.stroke();
}
if (graphState.selectedId === n.id) {
ctx.beginPath();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2 / graphState.zoom;
ctx.arc(n.x, n.y, r + 2, 0, Math.PI*2);
ctx.stroke();
}
ctx.beginPath();
ctx.fillStyle = _graphNodeFill(n);
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill();
// status/recency/lock border
let stroke = null;
const v = (n.verdict || '').toString();
if (v === 'confirmed_true') stroke = '#bbf7d0';
else if (v === 'confirmed_false') stroke = '#fecaca';
else if (v) stroke = '#c7d2fe';
const now = Date.now();
const created = Date.parse(n.created || '') || 0;
const modified = Date.parse(n.modified || '') || 0;
const isNew = created && (now - created) < (30 * 60 * 1000);
const isHot = modified && (now - modified) < (10 * 60 * 1000);
if (isNew) stroke = '#f7d154';
if (isHot) stroke = '#ffffff';
if (n.predict_locked) stroke = '#a78bfa';
if (stroke) {
ctx.beginPath();
ctx.strokeStyle = stroke;
ctx.lineWidth = (n.predict_locked ? 3 : 1.5) / graphState.zoom;
ctx.arc(n.x, n.y, r + 0.8, 0, Math.PI*2);
ctx.stroke();
}
}
ctx.restore();
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
}
function _graphLoop() {
if (!graphState.physicsOn) return;
const speed = 0.25 + (Math.max(0, Math.min(100, graphState.physicsStrength || 60)) / 100) * 0.95;
_graphStepPhysics(speed);
_graphDraw();
graphState.raf = requestAnimationFrame(_graphLoop);
}
function setPhysicsStrength(v) {
const n = Math.max(0, Math.min(100, parseInt(v || '0', 10)));
graphState.physicsStrength = n;
localStorage.setItem('physicsStrength', String(n));
const el = document.getElementById('physicsStrengthVal');
if (el) el.textContent = String(n);
}
function toggleGraphPhysics() {
graphState.physicsOn = !graphState.physicsOn;
const b = document.getElementById('btnGraphPhysics');
const fast = (graphState.sim || []).length > 700;
b.textContent = `Physics: ${graphState.physicsOn ? ('on' + (fast ? ' (fast)' : '')) : 'off'}`;
b.classList.toggle('primary', graphState.physicsOn);
if (graphState.physicsOn) {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = requestAnimationFrame(_graphLoop);
} else {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = null;
_graphDraw();
}
}
function resetGraphView() {
const canvas = _graphCanvas();
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
_graphDraw();
}
function fitGraphView() {
const canvas = _graphCanvas();
if (!graphState.sim.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of graphState.sim) {
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
}
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const zx = (canvas.width - 40) / w;
const zy = (canvas.height - 40) / h;
graphState.zoom = Math.max(0.35, Math.min(2.5, Math.min(zx, zy)));
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
_graphDraw();
}
function graphApplySearch(term) {
graphState.search = term || '';
_graphDraw();
}
// Real-time updates via SSE
function startEvents() {
try {
const es = new EventSource('/api/events');
es.onmessage = (msg) => {
try {
state.lastEvent = JSON.parse(msg.data);
updateStatsFromEvent(state.lastEvent);
if (state.view === 'status') {
// refresh status panels without heavy re-render: just rerun loadStatus occasionally
loadStatus();
}
if (state.view === 'graph') {
// fetch graph less often (every ~15s)
const t = Date.now();
if (!state._lastGraphFetch || (t - state._lastGraphFetch) > 15000) {
state._lastGraphFetch = t;
loadGraph();
}
}
} catch (e) {}
};
es.onerror = () => {
// keep UI usable even if SSE drops
};
} catch (e) {}
}
startEvents();
function renderCards() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => `
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${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="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('');
}
function renderVerdictPill(item) {
const v = (item.verdict || '').toString();
if (!v) return '';
let cls = 'v-unknown';
let label = v;
if (v === 'confirmed_true') { cls = 'v-true'; label = 'TRUE'; }
else if (v === 'confirmed_false') { cls = 'v-false'; label = 'FALSE'; }
else if (v === 'probable_true') { cls = 'v-prob-true'; label = 'LIKELY'; }
else if (v === 'probable_false') { cls = 'v-prob-false'; label = 'UNLIKELY'; }
else if (v === 'unknown') { cls = 'v-unknown'; label = 'UNKNOWN'; }
return `<span class="verdict-pill ${cls}">${label}</span>`;
}
function fmtDate(iso) {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
// ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev, ctx = 'card') {
ev.stopPropagation();
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'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
}
async function reject(id, ev, ctx = 'card') {
ev.stopPropagation();
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'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
}
async function refresh(id, ev) {
ev.stopPropagation();
await api(`/api/engrams/${id}/refresh`, {method: 'POST'});
await loadCards();
}
async function createEngram() {
const content = document.getElementById('newContent').value;
const tags = document.getElementById('newTags').value;
if (!content.trim()) return;
await api('/api/engrams', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({content, tags})
});
document.getElementById('newContent').value = '';
document.getElementById('newTags').value = '';
await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
}
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 => `
<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 = `
<h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
<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>
<div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
<div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
<div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></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>
<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">
${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');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
// ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
loadCards();
}
function prevPage() {
state.offset = Math.max(0, state.offset - state.limit);
loadCards();
}
function manualRefresh() {
loadCards(); loadStats();
}
// ─── Search ─────────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value;
state.offset = 0;
loadCards();
if (state.view === 'graph') graphApplySearch(state.search);
});
document.getElementById('filterSelect').addEventListener('change', (e) => {
state.filter = e.target.value;
state.offset = 0;
loadCards();
});
// ─── Auto Refresh ───────────────────────────────────────────────────────────
setInterval(() => {
if (!state.autoRefresh) return;
loadStats();
loadCards();
const now = new Date();
document.getElementById('lastUpdate').textContent =
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}, 5000);
// ─── 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();
});
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.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${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>
</body>
</html>