1627 lines
66 KiB
HTML
1627 lines
66 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" onclick="resetGraphView()">Reset view</button>
|
||
<button class="btn" onclick="fitGraphView()">Fit</button>
|
||
<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>
|
||
<span class="graph-mode">Live physics on</span>
|
||
</div>
|
||
<canvas id="graphCanvas" width="440" height="520"></canvas>
|
||
<div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
|
||
<div class="graph-live" id="graphLive">
|
||
<div class="graph-live-title">Live</div>
|
||
<div id="graphLiveFeed" class="graph-live-feed"></div>
|
||
</div>
|
||
<div class="graph-legend">
|
||
<div><strong>Graph</strong>: Live Cluster wie Obsidian. Zoom per Pinch, Pan per Drag. 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 source"></span> Quelle</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();
|
||
} else if (graphState && graphState.raf) {
|
||
cancelAnimationFrame(graphState.raf);
|
||
graphState.raf = null;
|
||
}
|
||
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 hint = document.getElementById('graphHint');
|
||
const sel = document.getElementById('graphLimit');
|
||
const selected = sel ? parseInt(sel.value || '0', 10) : 0;
|
||
const maxEngrams = Number.isNaN(selected) ? 0 : selected;
|
||
if (sel) localStorage.setItem('graphLimit', String(maxEngrams));
|
||
graphState.loadingToken = (graphState.loadingToken || 0) + 1;
|
||
const token = graphState.loadingToken;
|
||
_graphResetData();
|
||
_graphResizeCanvas();
|
||
_graphInitInteractions();
|
||
if (hint) hint.textContent = 'Graph startet...';
|
||
_graphDraw();
|
||
try {
|
||
let offset = 0;
|
||
const chunkSize = maxEngrams && maxEngrams <= 1000 ? 400 : 900;
|
||
while (token === graphState.loadingToken) {
|
||
const limit = maxEngrams ? Math.min(chunkSize, Math.max(0, maxEngrams - offset)) : chunkSize;
|
||
if (limit <= 0) break;
|
||
const g = await api(`/api/graph_chunk?offset=${offset}&limit=${limit}`);
|
||
_graphMergePayload(g, {progressive: true});
|
||
graphState.loadedEngrams = Math.min(g.next_offset || offset, g.total_engrams || 0);
|
||
graphState.totalEngrams = g.total_engrams || graphState.totalEngrams || 0;
|
||
graphState.lastModified = g.max_modified || graphState.lastModified;
|
||
_graphDraw();
|
||
if (g.done || (maxEngrams && graphState.loadedEngrams >= maxEngrams)) break;
|
||
offset = g.next_offset || (offset + limit);
|
||
await new Promise(resolve => setTimeout(resolve, 16));
|
||
}
|
||
fitGraphView();
|
||
} 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(); }
|
||
|
||
async function loadGraphChanges() {
|
||
if (!graphState.lastModified) return;
|
||
try {
|
||
const g = await api(`/api/graph_changes?since=${encodeURIComponent(graphState.lastModified)}&limit=600`);
|
||
if ((g.nodes || []).length || (g.edges || []).length) {
|
||
_graphMergePayload(g, {progressive: true});
|
||
_graphPushLive(`Delta +${(g.nodes || []).filter(n => n.kind === 'engram').length} Einträge, +${(g.edges || []).length} Kanten`);
|
||
_graphDraw();
|
||
} else {
|
||
graphState.lastModified = g.max_modified || graphState.lastModified;
|
||
}
|
||
} catch (e) {
|
||
_graphPushLive(`Delta-Fehler: ${e && e.message ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
|
||
// ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
|
||
let graphState = {
|
||
nodes: [],
|
||
edges: [],
|
||
sim: [],
|
||
links: [],
|
||
nodeById: new Map(),
|
||
simById: new Map(),
|
||
degree: new Map(),
|
||
physicsOn: true,
|
||
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}
|
||
edgeKeys: new Set(),
|
||
sourceIndex: new Map(),
|
||
sourceCounts: new Map(),
|
||
nextSourceIndex: 0,
|
||
loadingToken: 0,
|
||
totalEngrams: 0,
|
||
loadedEngrams: 0,
|
||
lastModified: null,
|
||
liveFeed: [],
|
||
lastFrameAt: 0,
|
||
};
|
||
|
||
function _graphCanvas() { return document.getElementById('graphCanvas'); }
|
||
function _graphCtx() { return _graphCanvas().getContext('2d'); }
|
||
|
||
function _graphResizeCanvas() {
|
||
const canvas = _graphCanvas();
|
||
if (!canvas) return;
|
||
const w = canvas.parentElement.clientWidth - 24;
|
||
canvas.width = Math.max(320, w);
|
||
canvas.height = Math.max(560, Math.min(980, (window.innerHeight || 900) - 250));
|
||
}
|
||
|
||
function _graphResetData() {
|
||
if (graphState.raf) cancelAnimationFrame(graphState.raf);
|
||
graphState.nodes = [];
|
||
graphState.edges = [];
|
||
graphState.sim = [];
|
||
graphState.links = [];
|
||
graphState.nodeById = new Map();
|
||
graphState.simById = new Map();
|
||
graphState.degree = new Map();
|
||
graphState.edgeKeys = new Set();
|
||
graphState.sourceIndex = new Map();
|
||
graphState.sourceCounts = new Map();
|
||
graphState.nextSourceIndex = 0;
|
||
graphState.totalEngrams = 0;
|
||
graphState.loadedEngrams = 0;
|
||
graphState.lastModified = null;
|
||
graphState.physicsOn = true;
|
||
graphState.panX = 0;
|
||
graphState.panY = 0;
|
||
graphState.zoom = 1;
|
||
graphState.lastFrameAt = 0;
|
||
}
|
||
|
||
function _graphHashUnit(s) {
|
||
let h = 2166136261;
|
||
const str = String(s || '');
|
||
for (let i = 0; i < str.length; i++) {
|
||
h ^= str.charCodeAt(i);
|
||
h = Math.imul(h, 16777619);
|
||
}
|
||
return ((h >>> 0) / 4294967295);
|
||
}
|
||
|
||
function _graphPalette(seed) {
|
||
const palette = [
|
||
[34, 211, 238], // cyan
|
||
[244, 114, 182], // pink
|
||
[168, 85, 247], // purple
|
||
[74, 222, 128], // green
|
||
[248, 113, 113], // red
|
||
[96, 165, 250], // blue
|
||
[251, 191, 36], // amber
|
||
[45, 212, 191], // teal
|
||
];
|
||
return palette[Math.floor(_graphHashUnit(seed) * palette.length) % palette.length];
|
||
}
|
||
|
||
function _graphSourceCenter(source) {
|
||
const key = source || 'unknown';
|
||
if (!graphState.sourceIndex.has(key)) {
|
||
graphState.sourceIndex.set(key, graphState.nextSourceIndex++);
|
||
}
|
||
const i = graphState.sourceIndex.get(key);
|
||
const angle = i * 2.399963;
|
||
const ring = i < 1 ? 0 : 260 + Math.sqrt(i) * 95;
|
||
return {
|
||
x: Math.cos(angle) * ring,
|
||
y: Math.sin(angle) * ring * 0.82,
|
||
};
|
||
}
|
||
|
||
function _graphPlaceNode(n) {
|
||
if (n.kind === 'source') {
|
||
return _graphSourceCenter((n.label || n.id || '').replace(/^source:/, ''));
|
||
}
|
||
if (n.kind === 'tag') {
|
||
const angle = _graphHashUnit(n.id) * Math.PI * 2;
|
||
const ring = 520 + (_graphHashUnit(n.id + ':r') * 420);
|
||
return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring};
|
||
}
|
||
if (n.kind === 'host') {
|
||
const angle = _graphHashUnit(n.id) * Math.PI * 2;
|
||
const ring = 760 + (_graphHashUnit(n.id + ':r') * 260);
|
||
return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring};
|
||
}
|
||
|
||
// Obsidian-like "brain": each source gets a lobe, entries fill it as a
|
||
// golden-angle cloud so 50k+ nodes appear immediately without edge layout.
|
||
const source = n.source || 'unknown';
|
||
const local = (graphState.sourceCounts.get(source) || 0) + 1;
|
||
graphState.sourceCounts.set(source, local);
|
||
const center = _graphSourceCenter(source);
|
||
const angle = local * 2.399963 + _graphHashUnit(n.id) * 0.7;
|
||
const radius = 18 + Math.sqrt(local) * 9 + _graphHashUnit(n.id + ':r') * 38;
|
||
const squash = 0.72 + _graphHashUnit(source) * 0.46;
|
||
return {
|
||
x: center.x + Math.cos(angle) * radius * squash,
|
||
y: center.y + Math.sin(angle) * radius * (1.55 - squash),
|
||
};
|
||
}
|
||
|
||
function _graphEnsureSimNode(n) {
|
||
const existing = graphState.simById.get(n.id);
|
||
if (existing) {
|
||
Object.assign(existing, {
|
||
kind: n.kind || existing.kind,
|
||
label: n.label || existing.label,
|
||
weight: n.weight ?? existing.weight,
|
||
verdict: n.verdict ?? existing.verdict,
|
||
confidence: n.confidence ?? existing.confidence,
|
||
created: n.created ?? existing.created,
|
||
modified: n.modified ?? existing.modified,
|
||
last_accessed: n.last_accessed ?? existing.last_accessed,
|
||
source: n.source ?? existing.source,
|
||
predict_locked: n.predict_locked ?? existing.predict_locked,
|
||
createdMs: Date.parse(n.created || existing.created || '') || existing.createdMs || 0,
|
||
modifiedMs: Date.parse(n.modified || existing.modified || '') || existing.modifiedMs || 0,
|
||
});
|
||
graphState.nodeById.set(n.id, {...(graphState.nodeById.get(n.id) || {}), ...n});
|
||
return existing;
|
||
}
|
||
const p = _graphPlaceNode(n);
|
||
const sim = {
|
||
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,
|
||
createdMs: Date.parse(n.created || '') || 0,
|
||
modifiedMs: Date.parse(n.modified || '') || 0,
|
||
last_accessed: n.last_accessed,
|
||
source: n.source,
|
||
predict_locked: n.predict_locked,
|
||
x: p.x,
|
||
y: p.y,
|
||
vx: 0, vy: 0,
|
||
};
|
||
graphState.nodes.push(n);
|
||
graphState.nodeById.set(n.id, n);
|
||
graphState.sim.push(sim);
|
||
graphState.simById.set(n.id, sim);
|
||
return sim;
|
||
}
|
||
|
||
function _graphMergePayload(payload, opts = {}) {
|
||
const incomingNodes = payload.nodes || [];
|
||
const incomingEdges = payload.edges || [];
|
||
for (const n of incomingNodes) _graphEnsureSimNode(n);
|
||
for (const e of incomingEdges) {
|
||
const a = graphState.simById.get(e.from);
|
||
const b = graphState.simById.get(e.to);
|
||
if (!a || !b) continue;
|
||
const key = `${e.from}\u0000${e.to}\u0000${e.kind || ''}`;
|
||
if (graphState.edgeKeys.has(key)) continue;
|
||
graphState.edgeKeys.add(key);
|
||
graphState.edges.push(e);
|
||
graphState.links.push({a, b, kind: e.kind, weight: e.weight || 1.0});
|
||
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
|
||
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
|
||
}
|
||
graphState.lastModified = payload.max_modified || graphState.lastModified;
|
||
graphState.search = state.search || graphState.search || '';
|
||
if (opts.progressive && graphState.sim.length < 2500) {
|
||
const iters = graphState.sim.length < 900 ? 12 : 3;
|
||
for (let i = 0; i < iters; i++) _graphStepPhysics(0.28);
|
||
}
|
||
if (state.view === 'graph' && graphState.physicsOn && !graphState.raf) {
|
||
graphState.raf = requestAnimationFrame(_graphLoop);
|
||
}
|
||
}
|
||
|
||
function _graphPushLive(text) {
|
||
const feed = document.getElementById('graphLiveFeed');
|
||
if (!feed || !text) return;
|
||
const ts = new Date().toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
|
||
graphState.liveFeed.unshift(`${ts} ${text}`);
|
||
graphState.liveFeed = graphState.liveFeed.slice(0, 8);
|
||
feed.innerHTML = graphState.liveFeed.map(x => `<div>${escapeHtml(x)}</div>`).join('');
|
||
}
|
||
|
||
function _graphNodeRadius(n) {
|
||
const d = graphState.degree.get(n.id) || 0;
|
||
const huge = graphState.sim.length > 20000;
|
||
const base = n.kind === 'tag' ? (huge ? 3 : 4) : (n.kind === 'host' ? 5 : (n.kind === 'source' ? 13 : (huge ? 1.9 : 5.5)));
|
||
const w = (n.weight || 0);
|
||
const bonus = Math.min(huge ? 2.5 : 6, Math.sqrt(Math.max(0, w)) * 0.8);
|
||
return Math.max(huge ? 1.4 : 3, Math.min(huge ? 9 : 18, base + Math.sqrt(d) * (huge ? 0.35 : 1) + 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})`;
|
||
}
|
||
if (n.kind === 'source') {
|
||
const [r,g,b] = mix(_graphPalette(n.label || n.id));
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
if (n.kind === 'engram') {
|
||
let base = _graphPalette(n.source || n.id);
|
||
const verdict = (n.verdict || '').toString();
|
||
if (verdict === 'confirmed_false') base = [248, 113, 113];
|
||
else if (verdict === 'confirmed_true') {
|
||
const src = _graphPalette(n.source || n.id);
|
||
base = [Math.round((src[0] + 74) / 2), Math.round((src[1] + 222) / 2), Math.round((src[2] + 128) / 2)];
|
||
}
|
||
|
||
const now = graphState.drawNow || Date.now();
|
||
const created = n.createdMs || 0;
|
||
const ageMin = created ? (now - created) / 60000 : 999999;
|
||
const rec = Math.max(0, Math.min(0.45, (30 - ageMin) / 30 * 0.45));
|
||
const bump = (c) => Math.round(c + (255 - c) * rec);
|
||
const [r,g,b] = mix(base).map(bump);
|
||
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();
|
||
|
||
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();
|
||
if (graphState.sim.length > 6000) {
|
||
_graphStepAmbient(alpha);
|
||
return;
|
||
}
|
||
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 _graphStepAmbient(alpha = 1.0) {
|
||
const now = Date.now();
|
||
const t = now / 1000;
|
||
const sim = graphState.sim;
|
||
const count = sim.length;
|
||
if (!count) return;
|
||
// Keep the full 50k+ graph alive without doing an expensive all-node force
|
||
// solve: tiny deterministic drift plus stronger movement on fresh/hub nodes.
|
||
const sample = Math.min(count, 900);
|
||
const start = Math.floor((t * 97) % count);
|
||
for (let k = 0; k < sample; k++) {
|
||
const n = sim[(start + k * 13) % count];
|
||
const amp = (n.kind === 'engram') ? 0.018 : 0.05;
|
||
n.x += Math.sin(t * 0.7 + _graphHashUnit(n.id) * 6.283) * amp * alpha;
|
||
n.y += Math.cos(t * 0.6 + _graphHashUnit(n.id + ':y') * 6.283) * amp * alpha;
|
||
}
|
||
const recentCutoff = now - 12 * 60 * 1000;
|
||
for (const n of sim) {
|
||
if (n.kind === 'source' || n.kind === 'tag' || (n.modifiedMs || n.createdMs || 0) > recentCutoff) {
|
||
const home = n.kind === 'engram' ? _graphSourceCenter(n.source || 'unknown') : null;
|
||
if (home) {
|
||
n.vx += (home.x - n.x) * 0.00003 * alpha;
|
||
n.vy += (home.y - n.y) * 0.00003 * alpha;
|
||
}
|
||
n.vx *= 0.92; n.vy *= 0.92;
|
||
n.x += n.vx; n.y += n.vy;
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
const bg = ctx.createRadialGradient(canvas.width * 0.52, canvas.height * 0.48, 10, canvas.width * 0.5, canvas.height * 0.5, Math.max(canvas.width, canvas.height) * 0.72);
|
||
bg.addColorStop(0, '#121a2b');
|
||
bg.addColorStop(0.55, '#070b16');
|
||
bg.addColorStop(1, '#02040a');
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
graphState.drawNow = Date.now();
|
||
ctx.save();
|
||
ctx.translate(graphState.panX, graphState.panY);
|
||
ctx.scale(graphState.zoom, graphState.zoom);
|
||
|
||
const term = (graphState.search || '').trim();
|
||
const dense = graphState.sim.length > 12000;
|
||
const drawEdges = !dense || term || graphState.selectedId;
|
||
if (drawEdges) for (const l of graphState.links) {
|
||
const isMatchEdge = term && (_graphMatches(l.a, term) || _graphMatches(l.b, term));
|
||
const selectedEdge = graphState.selectedId && (l.a.id === graphState.selectedId || l.b.id === graphState.selectedId);
|
||
if (dense && !isMatchEdge && !selectedEdge && l.kind !== 'link') continue;
|
||
const w = Math.max(0.2, Math.min(3.0, (l.weight || 1.0)));
|
||
ctx.lineWidth = (0.6 + w) / graphState.zoom;
|
||
ctx.globalAlpha = isMatchEdge || selectedEdge ? 0.9 : (dense ? 0.12 : (0.20 + 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;
|
||
|
||
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();
|
||
}
|
||
|
||
const fill = _graphNodeFill(n);
|
||
const important = n.kind !== 'engram' || isMatch || graphState.selectedId === n.id || ((graphState.drawNow - (n.modifiedMs || n.createdMs || 0)) < 10 * 60 * 1000);
|
||
if (important) {
|
||
ctx.save();
|
||
ctx.shadowColor = fill;
|
||
ctx.shadowBlur = (n.kind === 'source' ? 14 : 8) / graphState.zoom;
|
||
ctx.beginPath();
|
||
ctx.fillStyle = fill;
|
||
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
} else {
|
||
ctx.beginPath();
|
||
ctx.fillStyle = fill;
|
||
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 = graphState.drawNow;
|
||
const created = n.createdMs || 0;
|
||
const modified = n.modifiedMs || 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();
|
||
const loaded = graphState.totalEngrams ? ` | engrams=${graphState.loadedEngrams}/${graphState.totalEngrams}` : '';
|
||
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}${loaded}` + (term ? ` | match=${matches}` : '');
|
||
}
|
||
|
||
function _graphLoop() {
|
||
if (!graphState.physicsOn) return;
|
||
const now = performance.now();
|
||
const fps = graphState.sim.length > 25000 ? 8 : (graphState.sim.length > 8000 ? 14 : 30);
|
||
if (!graphState.lastFrameAt || now - graphState.lastFrameAt >= (1000 / fps)) {
|
||
graphState.lastFrameAt = now;
|
||
_graphStepPhysics(0.75);
|
||
_graphDraw();
|
||
}
|
||
graphState.raf = requestAnimationFrame(_graphLoop);
|
||
}
|
||
|
||
function setPhysicsStrength(v) {
|
||
// Kept for older cached pages; physics is now always live.
|
||
}
|
||
|
||
function toggleGraphPhysics() {
|
||
graphState.physicsOn = true;
|
||
if (!graphState.raf) graphState.raf = requestAnimationFrame(_graphLoop);
|
||
}
|
||
|
||
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 - ((minX + maxX) / 2) * graphState.zoom;
|
||
graphState.panY = canvas.height / 2 - ((minY + maxY) / 2) * graphState.zoom;
|
||
_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') {
|
||
const t = Date.now();
|
||
const stats = state.lastEvent.stats || {};
|
||
const jobs = state.lastEvent.jobs || {};
|
||
_graphPushLive(`Stats total=${stats.total ?? '-'} pending=${stats.pending ?? '-'} jobs=${Array.isArray(jobs.units) ? jobs.units.length : '-'}`);
|
||
if (!state._lastGraphDelta || (t - state._lastGraphDelta) > 5000) {
|
||
state._lastGraphDelta = t;
|
||
loadGraphChanges();
|
||
}
|
||
}
|
||
} 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>
|