1334 lines
54 KiB
HTML
1334 lines
54 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="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">
|
||
<div class="search-row">
|
||
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
|
||
</div>
|
||
<div class="search-row">
|
||
<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>
|
||
<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>
|
||
|
||
<!-- 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,
|
||
selectedId: 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() {
|
||
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;
|
||
}
|
||
|
||
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() {
|
||
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');
|
||
}
|
||
|
||
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;
|
||
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();
|
||
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>
|
||
</body>
|
||
</html>
|