784 lines
30 KiB
HTML
784 lines
30 KiB
HTML
<!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="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>
|
||
</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>
|
||
<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) throw new Error((await r.json()).error || r.statusText);
|
||
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('statPending').textContent = s.pending;
|
||
document.getElementById('statErrors').textContent = s.errors;
|
||
}
|
||
|
||
function updateStatsFromEvent(ev) {
|
||
if (!ev || !ev.stats) return;
|
||
const s = ev.stats;
|
||
document.getElementById('statTotal').textContent = s.total;
|
||
document.getElementById('statConfirmed').textContent = s.confirmed;
|
||
document.getElementById('statPending').textContent = s.pending;
|
||
document.getElementById('statErrors').textContent = s.errors;
|
||
}
|
||
|
||
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;
|
||
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 loadStatus() {
|
||
const [cfg, db, jobs, ins, stor] = await Promise.all([
|
||
api('/api/config'),
|
||
api('/api/db_info'),
|
||
api('/api/jobs'),
|
||
api('/api/insights?limit=8'),
|
||
api('/api/storage_stats'),
|
||
]);
|
||
const pend = await api('/api/pending?limit=20&offset=0');
|
||
|
||
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>
|
||
${jobsHtml || '<div class="muted">Keine Daten</div>'}
|
||
</div>
|
||
<div class="panel">
|
||
<div class="panel-title">Insights</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>
|
||
${pendHtml || '<div class="muted">Keine Pendings</div>'}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function loadGraph() {
|
||
const g = await api('/api/graph?limit_nodes=200');
|
||
renderGraph(g.nodes || [], g.edges || []);
|
||
}
|
||
|
||
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,
|
||
panning: false,
|
||
lastX: 0,
|
||
lastY: 0,
|
||
panX: 0,
|
||
panY: 0,
|
||
zoom: 1,
|
||
raf: null,
|
||
search: '',
|
||
pointers: new Map(),
|
||
pinchStartDist: null,
|
||
pinchStartZoom: null,
|
||
pinchStartPan: null,
|
||
};
|
||
|
||
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 : 6;
|
||
return Math.max(3, Math.min(14, base + Math.sqrt(d)));
|
||
}
|
||
|
||
function _graphNodeFill(n) {
|
||
if (n.kind === 'tag') return '#8a9aff';
|
||
return '#6c8af5';
|
||
}
|
||
|
||
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);
|
||
|
||
// When 2 pointers: start pinch
|
||
if (graphState.pointers.size === 2) {
|
||
graphState.panning = false;
|
||
graphState.draggingId = 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;
|
||
else graphState.panning = true;
|
||
});
|
||
|
||
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;
|
||
}
|
||
graphState.draggingId = null;
|
||
graphState.panning = false;
|
||
};
|
||
canvas.addEventListener('pointerup', endPointer);
|
||
canvas.addEventListener('pointercancel', endPointer);
|
||
}
|
||
|
||
function renderGraph(nodes, edges) {
|
||
const canvas = _graphCanvas();
|
||
const hint = document.getElementById('graphHint');
|
||
const ctx = _graphCtx();
|
||
|
||
const w = canvas.parentElement.clientWidth - 24;
|
||
canvas.width = Math.max(320, Math.min(520, w));
|
||
canvas.height = 520;
|
||
|
||
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 || !graphState.edges.length) {
|
||
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags im Sample).';
|
||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||
return;
|
||
}
|
||
|
||
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
|
||
graphState.sim = graphState.nodes.map(n => ({
|
||
id: n.id,
|
||
kind: n.kind,
|
||
label: n.label || n.id,
|
||
x: (Math.random() - 0.5) * canvas.width,
|
||
y: (Math.random() - 0.5) * canvas.height,
|
||
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}))
|
||
.filter(l => l.a && l.b);
|
||
|
||
graphState.panX = canvas.width / 2;
|
||
graphState.panY = canvas.height / 2;
|
||
graphState.zoom = 1;
|
||
graphState.search = state.search || '';
|
||
|
||
_graphInitInteractions();
|
||
for (let i = 0; i < 120; i++) _graphStepPhysics(0.9);
|
||
_graphDraw();
|
||
}
|
||
|
||
function _graphStepPhysics(alpha = 1.0) {
|
||
const canvas = _graphCanvas();
|
||
const repulsion = 180;
|
||
const damping = 0.86;
|
||
const target = 80;
|
||
const springK = 0.018;
|
||
|
||
const sim = graphState.sim;
|
||
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 _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);
|
||
|
||
ctx.globalAlpha = 0.45;
|
||
ctx.strokeStyle = '#3a3a55';
|
||
ctx.lineWidth = 1 / graphState.zoom;
|
||
for (const l of graphState.links) {
|
||
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();
|
||
}
|
||
|
||
ctx.beginPath();
|
||
ctx.fillStyle = _graphNodeFill(n);
|
||
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
|
||
ctx.fill();
|
||
}
|
||
|
||
ctx.restore();
|
||
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
|
||
}
|
||
|
||
function _graphLoop() {
|
||
if (!graphState.physicsOn) return;
|
||
_graphStepPhysics(1.0);
|
||
_graphDraw();
|
||
graphState.raf = requestAnimationFrame(_graphLoop);
|
||
}
|
||
|
||
function toggleGraphPhysics() {
|
||
graphState.physicsOn = !graphState.physicsOn;
|
||
const b = document.getElementById('btnGraphPhysics');
|
||
b.textContent = `Physics: ${graphState.physicsOn ? 'on' : '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) {
|
||
ev.stopPropagation();
|
||
const reason = document.getElementById('reason-'+id).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();
|
||
}
|
||
|
||
async function reject(id, ev) {
|
||
ev.stopPropagation();
|
||
const reason = document.getElementById('reason-'+id).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();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function showDetail(id) {
|
||
const item = await api(`/api/engrams/${id}`);
|
||
const body = document.getElementById('modalBody');
|
||
body.innerHTML = `
|
||
<h3>Engramm ${item.id.substring(0,8)}</h3>
|
||
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p>
|
||
<p><b>Confirmed:</b> ${item.confirmed ? '✅' : '❌'}</p>
|
||
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p>
|
||
<p><b>Content:</b></p>
|
||
<div class="detail-content">${escapeHtml(item.content)}</div>
|
||
<p><b>History:</b></p>
|
||
<ul class="history">
|
||
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)} — ${h.action} (${h.note})</li>`).join('')}
|
||
</ul>
|
||
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
|
||
`;
|
||
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();
|
||
</script>
|
||
</body>
|
||
</html>
|