Spaces:
Sleeping
Sleeping
| /* ============================================================================ | |
| FALSIFY live UI — force-graph rendering, SSE stream, and the four signature | |
| animations that make "revised, not forgotten" visible: | |
| • strike — a refuted node flashes red and gains a dashed-red border | |
| • sweep — invalidation greys downstream nodes with a staggered delay | |
| • dissolve — a forgotten node shrinks to nothing, then is removed | |
| • rise — a promoted / newly-added node pulses green and settles | |
| State is never carried by color alone (green↔red are close under deuteranopia): | |
| every node draws its truth-state color AND a text label AND — when refuted — a | |
| dashed border, and every change is narrated in the revision log. | |
| ========================================================================== */ | |
| const API = window.__API_BASE__ || ""; // "" = same-origin (HF monolith) | |
| const $ = (id) => document.getElementById(id); | |
| const STATE = { | |
| alive: { color: "#22c55e", tag: "alive" }, | |
| refuted: { color: "#ef4444", tag: "refuted" }, | |
| invalidated: { color: "#9ca3af", tag: "invalidated" }, | |
| superseded: { color: "#f59e0b", tag: "superseded" }, | |
| forgotten: { color: "#4b5563", tag: "forgotten" }, | |
| }; | |
| const colorOf = (s) => (STATE[s] || STATE.alive).color; | |
| /* ------------------------------------------------------------------ graph state */ | |
| let Graph; // the force-graph instance | |
| let nodes = []; // {id,label,type,state,color, _flash,_flashColor,_shrink,_born} | |
| let links = []; // {source,target,relation, _pulse} | |
| const byId = () => Object.fromEntries(nodes.map((n) => [n.id, n])); | |
| /* animation timers tick every frame; the renderer reads them */ | |
| function tickAnimations() { | |
| let alive = false; | |
| for (const n of nodes) { | |
| if (n._flash > 0) { n._flash = Math.max(0, n._flash - 0.018); alive = true; } | |
| if (n._shrink > 0) { n._shrink = Math.min(1, n._shrink + 0.04); alive = true; } | |
| if (n._born > 0) { n._born = Math.max(0, n._born - 0.02); alive = true; } | |
| } | |
| for (const l of links) { | |
| if (l._pulse > 0) { l._pulse = Math.max(0, l._pulse - 0.03); alive = true; } | |
| } | |
| // drop fully-dissolved nodes | |
| const gone = nodes.filter((n) => n._shrink >= 1).map((n) => n.id); | |
| if (gone.length) removeNodes(gone); | |
| if (alive && Graph) Graph.nodeRelSize(Graph.nodeRelSize()); // nudge a redraw | |
| requestAnimationFrame(tickAnimations); | |
| } | |
| /* ------------------------------------------------------------------ rendering */ | |
| function drawNode(node, ctx, scale) { | |
| const base = 6; | |
| const grow = node._born > 0 ? 1 + node._born * 0.4 : 1; // "rise" scale-in | |
| const shrink = node._shrink > 0 ? 1 - node._shrink : 1; // "dissolve" scale-out | |
| const r = base * grow * shrink; | |
| if (r <= 0.2) return; | |
| const isRefuted = node.state === "refuted"; | |
| const alpha = shrink; | |
| ctx.globalAlpha = alpha; | |
| // soft state glow so the canvas feels alive | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, r + 5, 0, 2 * Math.PI); | |
| ctx.fillStyle = hexA(node.color, 0.12); | |
| ctx.fill(); | |
| // node body | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); | |
| ctx.fillStyle = node.color; | |
| ctx.fill(); | |
| // refuted = dashed red ring (the second channel, so it's not color-only) | |
| if (isRefuted) { | |
| ctx.setLineDash([3, 2]); | |
| ctx.strokeStyle = "#ef4444"; | |
| ctx.lineWidth = 1.6 / scale; | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } else { | |
| ctx.strokeStyle = "#0b1020"; | |
| ctx.lineWidth = 1.4 / scale; | |
| ctx.stroke(); | |
| } | |
| // flash ring (strike / state-change / rise) | |
| if (node._flash > 0) { | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, r + 4 + (1 - node._flash) * 8, 0, 2 * Math.PI); | |
| ctx.strokeStyle = hexA(node._flashColor || node.color, node._flash); | |
| ctx.lineWidth = 2.4 / scale; | |
| ctx.stroke(); | |
| } | |
| // label — always present (identity never color-alone) | |
| const label = node.label.length > 34 ? node.label.slice(0, 31) + "…" : node.label; | |
| const fs = Math.max(3.2, 11 / scale); | |
| ctx.font = `${fs}px Inter, sans-serif`; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "top"; | |
| ctx.fillStyle = "#cbd5e1"; | |
| ctx.globalAlpha = alpha * 0.9; | |
| ctx.fillText(label, node.x, node.y + r + 2); | |
| ctx.globalAlpha = 1; | |
| } | |
| function drawLink(link, ctx, scale) { | |
| const s = link.source, t = link.target; | |
| if (!s || !t || typeof s !== "object") return; | |
| ctx.beginPath(); | |
| ctx.moveTo(s.x, s.y); | |
| ctx.lineTo(t.x, t.y); | |
| if (link._pulse > 0) { | |
| ctx.strokeStyle = hexA("#34d399", link._pulse); | |
| ctx.lineWidth = 2.4 / scale; | |
| } else { | |
| ctx.strokeStyle = "rgba(148,163,184,.28)"; | |
| ctx.lineWidth = 1 / scale; | |
| } | |
| ctx.stroke(); | |
| } | |
| function initGraph() { | |
| Graph = ForceGraph()($("graph")) | |
| .backgroundColor("rgba(0,0,0,0)") | |
| .nodeRelSize(6) | |
| .nodeCanvasObject(drawNode) | |
| .nodePointerAreaPaint((node, color, ctx) => { | |
| ctx.fillStyle = color; ctx.beginPath(); | |
| ctx.arc(node.x, node.y, 9, 0, 2 * Math.PI); ctx.fill(); | |
| }) | |
| .linkCanvasObject(drawLink) | |
| .linkDirectionalArrowLength(2.5) | |
| .onNodeHover((n) => ($("graph").style.cursor = n ? "pointer" : "default")) | |
| .onNodeClick((n) => addMessage("system", nodeCard(n))) | |
| .cooldownTicks(120) | |
| .d3VelocityDecay(0.28); | |
| sizeGraph(); | |
| window.addEventListener("resize", sizeGraph); | |
| } | |
| function sizeGraph() { | |
| const el = $("graph-panel"); | |
| Graph.width(el.clientWidth).height(el.clientHeight); | |
| } | |
| /* ------------------------------------------------------------------ graph data ops */ | |
| function setGraph(g) { | |
| const prev = byId(); | |
| nodes = g.nodes.map((n) => ({ | |
| ...n, | |
| _flash: prev[n.id] ? 0 : 0.9, // new nodes "rise" | |
| _flashColor: "#22c55e", | |
| _born: prev[n.id] ? 0 : 1, | |
| _shrink: 0, | |
| x: prev[n.id]?.x, y: prev[n.id]?.y, // keep positions stable across refetches | |
| })); | |
| const idset = new Set(nodes.map((n) => n.id)); | |
| links = g.edges | |
| .filter((e) => idset.has(e.source) && idset.has(e.target)) | |
| .map((e) => ({ source: e.source, target: e.target, relation: e.relation, _pulse: 0 })); | |
| Graph.graphData({ nodes, links }); | |
| updateBackendUI(g.backend, g.scenario); | |
| } | |
| function removeNodes(ids) { | |
| const set = new Set(ids); | |
| nodes = nodes.filter((n) => !set.has(n.id)); | |
| links = links.filter((l) => { | |
| const s = typeof l.source === "object" ? l.source.id : l.source; | |
| const t = typeof l.target === "object" ? l.target.id : l.target; | |
| return !set.has(s) && !set.has(t); | |
| }); | |
| Graph.graphData({ nodes, links }); | |
| } | |
| async function refetchGraph() { | |
| const g = await fetch(`${API}/api/graph`).then((r) => r.json()); | |
| setGraph(g); | |
| } | |
| /* ------------------------------------------------------------------ SSE events */ | |
| function connectSSE() { | |
| const es = new EventSource(`${API}/api/events`); | |
| es.onopen = () => setConn("live", "live"); | |
| es.onerror = () => { setConn("reconnecting", "reconnecting…"); }; | |
| es.onmessage = (e) => { | |
| let ev; try { ev = JSON.parse(e.data); } catch { return; } | |
| handleEvent(ev); | |
| }; | |
| } | |
| function handleEvent(ev) { | |
| const map = byId(); | |
| if (ev.type === "graph_reset") { | |
| refetchGraph(); | |
| } else if (ev.type === "node_state_changed") { | |
| const n = map[ev.id]; | |
| if (!n) return; | |
| n.state = ev.state; | |
| n.color = colorOf(ev.state); | |
| n._flash = 1.0; | |
| n._flashColor = colorOf(ev.state); | |
| // light up edges INTO this node so the cascade visibly travels | |
| for (const l of links) { | |
| const t = typeof l.target === "object" ? l.target.id : l.target; | |
| if (t === ev.id) l._pulse = 1.0; | |
| } | |
| logEntry(ev.state, n.label, ev.id, whyFor(ev.state)); | |
| } else if (ev.type === "node_forgotten") { | |
| const n = map[ev.id]; | |
| if (n) { n._shrink = 0.01; logEntry("forgotten", n.label, ev.id, whyFor("forgotten")); } | |
| } else if (ev.type === "pipeline_step") { | |
| addMessage("system", `<div class="msg-title">${escapeHtml(ev.step)}</div>${escapeHtml(ev.detail || "")}`); | |
| } | |
| } | |
| const WHY = { | |
| refuted: "contradicted by the incoming fact", | |
| invalidated: "its critical support died", | |
| superseded: "its evidence collapsed; a rival now leads", | |
| forgotten: "orphaned — no live consumer (provenance kept)", | |
| alive: "promoted — still standing", | |
| }; | |
| const whyFor = (s) => WHY[s] || ""; | |
| /* ------------------------------------------------------------------ panels */ | |
| function addMessage(kind, html, warn) { | |
| const div = document.createElement("div"); | |
| div.className = `msg ${kind}${warn ? " warn" : ""}`; | |
| div.innerHTML = html; | |
| $("messages").appendChild(div); | |
| $("messages").scrollTop = $("messages").scrollHeight; | |
| $("msg-count").textContent = $("messages").querySelectorAll(".msg").length + " msgs"; | |
| clearCoach(); | |
| } | |
| function clearCoach() { const c = document.querySelector(".coach"); if (c) c.remove(); } | |
| function nodeCard(n) { | |
| return `<div class="msg-title">${escapeHtml(n.label)}</div> | |
| <div class="msg-meta">${n.type} · <span style="color:${n.color}">${STATE[n.state]?.tag || n.state}</span> · ${n.id.slice(0,8)}</div>`; | |
| } | |
| function logEntry(state, label, id, why) { | |
| const wrap = $("log"); | |
| const row = document.createElement("div"); | |
| row.className = "log-entry"; | |
| row.innerHTML = `<span class="log-dot" style="background:${colorOf(state)}"></span> | |
| <div class="log-body"> | |
| <div class="lg-title">${escapeHtml(shortLabel(label))} | |
| <span class="id">${state}</span></div> | |
| <div class="lg-why">${escapeHtml(why)}</div> | |
| </div>`; | |
| wrap.prepend(row); | |
| $("log-count").textContent = wrap.querySelectorAll(".log-entry").length + " events"; | |
| } | |
| function renderScoreboard(sb) { | |
| if (!sb) return; | |
| $("sb-q").textContent = sb.question || ""; | |
| $("falsify-answer").textContent = sb.falsify_answer || "—"; | |
| $("falsify-support").textContent = (sb.falsify_support || []).length | |
| ? "supported by: " + sb.falsify_support.join(", ") : ""; | |
| $("rag-answer").textContent = sb.rag_answer || "—"; | |
| const ragCard = $("rag-card"); | |
| if (sb.stale) { | |
| $("rag-note").textContent = "⚠ still cites a node FALSIFY refuted — a stale answer."; | |
| ragCard.classList.add("stale"); | |
| ragCard.classList.remove("stale-flash"); void ragCard.offsetWidth; | |
| ragCard.classList.add("stale-flash"); | |
| } else { | |
| $("rag-note").textContent = sb.rag_citations?.length ? "cites: " + sb.rag_citations[0] : ""; | |
| ragCard.classList.remove("stale"); | |
| } | |
| } | |
| /* ------------------------------------------------------------------ actions */ | |
| async function sendMessage() { | |
| const input = $("msg-input"); | |
| const msg = input.value.trim(); | |
| if (!msg) return; | |
| addMessage("user", escapeHtml(msg)); | |
| input.value = ""; autoGrow(); | |
| $("send-btn").disabled = true; | |
| try { | |
| const res = await fetch(`${API}/api/chat`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ message: msg, demo: true }), | |
| }).then((r) => r.json()); | |
| if (res.type === "answer") { renderScoreboard(res.data); addMessage("system", "Answered from the alive belief graph — see the scoreboard."); } | |
| else if (res.type === "revision") { summarizeRevision(res.data); await afterRevision(); } | |
| } catch (e) { addMessage("system", "Request failed: " + escapeHtml(String(e)), true); } | |
| $("send-btn").disabled = false; | |
| } | |
| function summarizeRevision(d) { | |
| if (!d.revised) { addMessage("system", "No contradiction found — the graph is unchanged."); return; } | |
| const pill = (t, c) => `<span class="pill" style="background:${c}22;color:${c}">${t}</span>`; | |
| addMessage("system", | |
| `<div class="msg-title">Belief revised</div> | |
| ${pill(d.refuted.length + " refuted", "#ef4444")} | |
| ${pill(d.invalidated.length + " invalidated", "#9ca3af")} | |
| ${pill(d.forgotten.length + " forgotten", "#4b5563")} | |
| <div class="msg-meta">epoch ${d.epoch} · provenance retained: ${d.retained_provenance.length}</div>`); | |
| } | |
| async function afterRevision() { | |
| const sb = await fetch(`${API}/api/scoreboard`).then((r) => r.json()); | |
| renderScoreboard(sb); | |
| } | |
| async function runDemo() { | |
| clearCoach(); | |
| addMessage("system", `<div class="msg-title">Running investigation…</div>watch the graph revise in real time.`); | |
| const res = await fetch(`${API}/api/demo`, { method: "POST" }).then((r) => r.json()); | |
| summarizeRevision(res.report); | |
| renderScoreboard(res.scoreboard); | |
| } | |
| async function runDiamond() { | |
| clearCoach(); | |
| addMessage("system", `<div class="msg-title">Diamond scenario</div>K2 rests on two supports — watch it survive one blow, then fall to the second.`); | |
| const res = await fetch(`${API}/api/diamond`, { method: "POST" }).then((r) => r.json()); | |
| addMessage("system", "Phase 1 done — K died, K2 survived. Phase 2 done — K2 collapsed."); | |
| renderScoreboard(res.scoreboard); | |
| } | |
| async function setScenario(kind) { | |
| const g = await fetch(`${API}/api/scenario`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ kind }), | |
| }).then((r) => r.json()); | |
| setGraph(g); | |
| addMessage("system", `Loaded the ${kind} investigation — ${g.nodes.length} beliefs.`); | |
| } | |
| async function resetGraph() { const g = await fetch(`${API}/api/reset`, { method: "POST" }).then((r) => r.json()); setGraph(g); addMessage("system", "Graph reset."); } | |
| async function verifyPersistence() { | |
| addMessage("system", "Re-reading belief state from the on-disk graph store…"); | |
| const res = await fetch(`${API}/api/verify`).then((r) => r.json()); | |
| if (!res.persisted) { addMessage("system", "Could not read persisted state: " + escapeHtml(res.error || ""), true); return; } | |
| const lines = Object.entries(res.summary || {}) | |
| .map(([t, c]) => `${t}: ${Object.entries(c).map(([s, n]) => `${n} ${s}`).join(", ")}`).join("<br>"); | |
| addMessage("system", `<div class="msg-title">✓ Beliefs are persisted on the graph</div>${lines}<div class="msg-meta">truth-state lives in storage, not in process memory.</div>`); | |
| } | |
| async function uploadFile(file) { | |
| addMessage("user", `📄 ${escapeHtml(file.name)}`); | |
| addMessage("system", "Ingesting into memory (add + cognify)…"); | |
| const fd = new FormData(); fd.append("file", file); | |
| const res = await fetch(`${API}/api/upload`, { method: "POST", body: fd }).then((r) => r.json()); | |
| if (res.ok) { setGraph(res.graph); addMessage("system", "Ingested — new evidence is now in the graph."); } | |
| else { addMessage("system", (res.error || "upload failed") + (res.hint ? `<div class="msg-meta">${res.hint}</div>` : ""), true); } | |
| } | |
| async function setBackend(mode) { | |
| const res = await fetch(`${API}/api/mode`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ mode }), | |
| }).then((r) => r.json()); | |
| updateBackendUI(res.backend); | |
| addMessage("system", res.backend === "cloud" | |
| ? "Backend → Cognee Cloud. All memory ops now run on the tenant." | |
| : "Backend → self-hosted (open source)."); | |
| } | |
| /* ------------------------------------------------------------------ ui helpers */ | |
| function updateBackendUI(backend, scenario) { | |
| if (backend) { | |
| document.querySelectorAll("#backend-toggle button").forEach((b) => | |
| b.classList.toggle("on", b.dataset.mode === backend)); | |
| } | |
| } | |
| function setConn(cls, label) { | |
| const el = $("conn"); el.className = "status-dot " + cls; | |
| $("conn-label").textContent = label; | |
| } | |
| function autoGrow() { const t = $("msg-input"); t.style.height = "auto"; t.style.height = Math.min(120, t.scrollHeight) + "px"; } | |
| function shortLabel(s) { return s.length > 40 ? s.slice(0, 37) + "…" : s; } | |
| function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } | |
| function hexA(hex, a) { | |
| const h = hex.replace("#", ""); | |
| const n = parseInt(h.length === 3 ? h.split("").map((x) => x + x).join("") : h, 16); | |
| return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; | |
| } | |
| /* ------------------------------------------------------------------ wire up */ | |
| function wire() { | |
| $("send-btn").onclick = sendMessage; | |
| $("msg-input").addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| }); | |
| $("msg-input").addEventListener("input", autoGrow); | |
| $("btn-run").onclick = runDemo; | |
| $("btn-simple").onclick = () => setScenario("simple"); | |
| $("btn-diamond").onclick = runDiamond; | |
| $("btn-reset").onclick = resetGraph; | |
| $("btn-verify").onclick = verifyPersistence; | |
| document.querySelectorAll("#backend-toggle button").forEach((b) => | |
| (b.onclick = () => setBackend(b.dataset.mode))); | |
| const dz = $("dropzone"), fi = $("file-input"); | |
| fi.onchange = () => fi.files[0] && uploadFile(fi.files[0]); | |
| ["dragover", "dragenter"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag"); })); | |
| ["dragleave", "drop"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag"); })); | |
| dz.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) uploadFile(f); }); | |
| // coach mark | |
| $("messages").innerHTML = `<div class="coach">Hit <b>▶ Run investigation</b> to watch a belief die — or drop a contradicting fact of your own.</div>`; | |
| } | |
| async function boot() { | |
| wire(); | |
| initGraph(); | |
| requestAnimationFrame(tickAnimations); | |
| connectSSE(); | |
| try { await refetchGraph(); } catch { addMessage("system", "Backend not reachable yet — retrying via events.", true); } | |
| } | |
| boot(); | |