Spaces:
Paused
Paused
Download static/memory-graph.js from bryel-labs/twitter-tan-api: direct link, hf CLI and curl.
- Browser
- Download file 19.1 kB
-
https://huggingface.co/spaces/bryel-labs/twitter-tan-api/resolve/main/static/memory-graph.js
- Command line
-
hf download hf://spaces/bryel-labs/twitter-tan-api/static/memory-graph.js
-
curl -L -o memory-graph.js https://huggingface.co/spaces/bryel-labs/twitter-tan-api/resolve/main/static/memory-graph.js
19.1 kB
| const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; | |
| const graphToggle = document.querySelector("#memory-graph-toggle"); | |
| const graphPanel = document.querySelector("#memory-graph-panel"); | |
| const graphClose = document.querySelector("#memory-graph-close"); | |
| const graphReset = document.querySelector("#memory-graph-reset"); | |
| const graphSvg = document.querySelector("#memory-graph-svg"); | |
| const graphStatus = document.querySelector("#memory-graph-status"); | |
| const graphViewButtons = [...document.querySelectorAll("[data-graph-view]")]; | |
| const relationshipList = document.querySelector("#memory-relationship-list"); | |
| const relationshipCount = document.querySelector("#memory-relationship-count"); | |
| const relationshipNote = document.querySelector("#memory-relationship-note"); | |
| const graphState = { | |
| payload: null, | |
| loading: null, | |
| mode: "retrieved", | |
| retrievalQuery: "", | |
| retrievedIds: [], | |
| retrievedRanks: new Map(), | |
| selectedNodeId: null, | |
| visibleNodes: [], | |
| visibleEdges: [], | |
| nodeById: new Map(), | |
| positions: new Map(), | |
| viewport: { x: 0, y: 0, scale: 1 }, | |
| pan: null, | |
| }; | |
| function svgElement(name, attributes = {}) { | |
| const element = document.createElementNS(SVG_NAMESPACE, name); | |
| for (const [key, value] of Object.entries(attributes)) { | |
| element.setAttribute(key, String(value)); | |
| } | |
| return element; | |
| } | |
| function graphNodeLabel(node) { | |
| if (!node) return "Unknown"; | |
| if (node.kind === "memory") { | |
| const rank = graphState.retrievedRanks.get(node.id); | |
| return rank ? `Memory ${String(rank).padStart(2, "0")}` : "Memory"; | |
| } | |
| return node.name || node.id; | |
| } | |
| function graphNodeDescription(node) { | |
| if (!node) return "Unknown node"; | |
| if (node.kind === "memory") return node.text || node.evidence_text || node.id; | |
| return `${node.name || node.id}${node.type ? ` · ${node.type}` : ""}`; | |
| } | |
| function predicateLabel(predicate) { | |
| return String(predicate || "RELATED_TO") | |
| .toLocaleLowerCase() | |
| .replaceAll("_", " "); | |
| } | |
| function shortLabel(value, length = 25) { | |
| const text = String(value || ""); | |
| return text.length > length ? `${text.slice(0, length - 1)}…` : text; | |
| } | |
| function hashNumber(value) { | |
| let hash = 2166136261; | |
| for (const character of value) { | |
| hash ^= character.codePointAt(0); | |
| hash = Math.imul(hash, 16777619); | |
| } | |
| return hash >>> 0; | |
| } | |
| function calculateLayout(nodes, edges) { | |
| const positions = nodes.map((node, index) => { | |
| const hash = hashNumber(node.id); | |
| const angle = ((hash % 3600) / 3600) * Math.PI * 2; | |
| const kindRadius = node.kind === "memory" ? 245 : node.kind === "value" ? 185 : 120; | |
| const jitter = ((hash >>> 12) % 70) - 35; | |
| return { | |
| x: 500 + Math.cos(angle) * (kindRadius + jitter) + ((index % 5) - 2), | |
| y: 310 + Math.sin(angle) * (kindRadius + jitter) * 0.72, | |
| }; | |
| }); | |
| const indexById = new Map(nodes.map((node, index) => [node.id, index])); | |
| const links = edges | |
| .map((edge) => [indexById.get(edge.source), indexById.get(edge.target)]) | |
| .filter(([source, target]) => source !== undefined && target !== undefined); | |
| const iterations = nodes.length > 160 ? 70 : 105; | |
| for (let iteration = 0; iteration < iterations; iteration += 1) { | |
| const forceX = new Float64Array(nodes.length); | |
| const forceY = new Float64Array(nodes.length); | |
| for (let index = 0; index < nodes.length; index += 1) { | |
| forceX[index] += (500 - positions[index].x) * 0.004; | |
| forceY[index] += (310 - positions[index].y) * 0.004; | |
| } | |
| for (let left = 0; left < nodes.length; left += 1) { | |
| for (let right = left + 1; right < nodes.length; right += 1) { | |
| let dx = positions[left].x - positions[right].x; | |
| let dy = positions[left].y - positions[right].y; | |
| let distanceSquared = dx * dx + dy * dy; | |
| if (distanceSquared < 4) { | |
| dx = ((left % 3) - 1) || 0.5; | |
| dy = ((right % 3) - 1) || -0.5; | |
| distanceSquared = dx * dx + dy * dy; | |
| } | |
| const strength = (nodes.length > 160 ? 730 : 1250) / Math.max(distanceSquared, 36); | |
| const x = dx * strength; | |
| const y = dy * strength; | |
| forceX[left] += x; | |
| forceY[left] += y; | |
| forceX[right] -= x; | |
| forceY[right] -= y; | |
| } | |
| } | |
| for (const [source, target] of links) { | |
| const dx = positions[target].x - positions[source].x; | |
| const dy = positions[target].y - positions[source].y; | |
| const distance = Math.max(Math.hypot(dx, dy), 1); | |
| const ideal = nodes[source].kind === "memory" || nodes[target].kind === "memory" ? 52 : 86; | |
| const strength = (distance - ideal) * 0.018; | |
| const x = (dx / distance) * strength; | |
| const y = (dy / distance) * strength; | |
| forceX[source] += x; | |
| forceY[source] += y; | |
| forceX[target] -= x; | |
| forceY[target] -= y; | |
| } | |
| const cooling = 0.9 - (iteration / iterations) * 0.48; | |
| for (let index = 0; index < nodes.length; index += 1) { | |
| positions[index].x += Math.max(-8, Math.min(8, forceX[index])) * cooling; | |
| positions[index].y += Math.max(-8, Math.min(8, forceY[index])) * cooling; | |
| } | |
| } | |
| const xs = positions.map((position) => position.x); | |
| const ys = positions.map((position) => position.y); | |
| const minX = Math.min(...xs); | |
| const maxX = Math.max(...xs); | |
| const minY = Math.min(...ys); | |
| const maxY = Math.max(...ys); | |
| const width = Math.max(maxX - minX, 1); | |
| const height = Math.max(maxY - minY, 1); | |
| return new Map(nodes.map((node, index) => [node.id, { | |
| x: 65 + ((positions[index].x - minX) / width) * 870, | |
| y: 60 + ((positions[index].y - minY) / height) * 500, | |
| }])); | |
| } | |
| function graphBackedRetrievalIds() { | |
| if (!graphState.payload) return []; | |
| const ids = new Set(graphState.payload.nodes.map((node) => node.id)); | |
| return graphState.retrievedIds.filter((id) => ids.has(id)); | |
| } | |
| function selectSubgraph() { | |
| const allNodes = graphState.payload.nodes; | |
| const allEdges = graphState.payload.edges; | |
| const retrievedIds = new Set(graphBackedRetrievalIds()); | |
| if (graphState.mode !== "retrieved" || !retrievedIds.size) { | |
| return { nodes: allNodes, edges: allEdges, focused: false }; | |
| } | |
| const nodeIds = new Set(retrievedIds); | |
| const edges = allEdges.filter((edge) => { | |
| const related = retrievedIds.has(edge.evidence_id) | |
| || retrievedIds.has(edge.source) | |
| || retrievedIds.has(edge.target); | |
| if (related) { | |
| nodeIds.add(edge.source); | |
| nodeIds.add(edge.target); | |
| if (edge.evidence_id) nodeIds.add(edge.evidence_id); | |
| } | |
| return related; | |
| }); | |
| return { | |
| nodes: allNodes.filter((node) => nodeIds.has(node.id)), | |
| edges, | |
| focused: true, | |
| }; | |
| } | |
| function applyViewport() { | |
| const scene = graphSvg.querySelector(".memory-graph-scene"); | |
| if (!scene) return; | |
| const { x, y, scale } = graphState.viewport; | |
| scene.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`); | |
| } | |
| function resetViewport() { | |
| graphState.viewport = { x: 0, y: 0, scale: 1 }; | |
| applyViewport(); | |
| } | |
| function selectNode(nodeId) { | |
| if (!graphState.nodeById.has(nodeId)) return; | |
| graphState.selectedNodeId = nodeId; | |
| for (const element of graphSvg.querySelectorAll(".memory-graph-node")) { | |
| element.classList.toggle("is-selected", element.dataset.nodeId === nodeId); | |
| } | |
| renderRelationships(); | |
| } | |
| function nodeChip(nodeId) { | |
| const node = graphState.nodeById.get(nodeId); | |
| const chip = document.createElement("button"); | |
| chip.type = "button"; | |
| chip.className = "graph-node-chip"; | |
| chip.dataset.nodeKind = node?.kind || "unknown"; | |
| chip.textContent = graphNodeLabel(node) || nodeId; | |
| chip.title = graphNodeDescription(node); | |
| chip.addEventListener("click", () => selectNode(nodeId)); | |
| return chip; | |
| } | |
| function relationshipCandidates() { | |
| const selected = graphState.nodeById.get(graphState.selectedNodeId); | |
| let candidates = graphState.visibleEdges; | |
| if (selected) { | |
| candidates = selected.kind === "memory" | |
| ? candidates.filter((edge) => ( | |
| edge.evidence_id === selected.id | |
| || edge.source === selected.id | |
| || edge.target === selected.id | |
| )) | |
| : candidates.filter((edge) => edge.source === selected.id || edge.target === selected.id); | |
| } | |
| const semantic = candidates.filter((edge) => edge.predicate !== "ABOUT_SUBJECT"); | |
| return semantic.length ? semantic : candidates; | |
| } | |
| function renderRelationships() { | |
| relationshipList.replaceChildren(); | |
| const selected = graphState.nodeById.get(graphState.selectedNodeId); | |
| const candidates = relationshipCandidates(); | |
| const unique = []; | |
| const seen = new Set(); | |
| for (const edge of candidates) { | |
| const key = `${edge.source}\u0000${edge.predicate}\u0000${edge.target}\u0000${edge.evidence_id || ""}`; | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| unique.push(edge); | |
| } | |
| relationshipCount.textContent = String(unique.length); | |
| relationshipNote.textContent = selected | |
| ? `Showing connections for ${graphNodeDescription(selected)}.` | |
| : "Select a node to narrow the connected relationships."; | |
| for (const edge of unique.slice(0, 40)) { | |
| const row = document.createElement("article"); | |
| row.className = "memory-relationship"; | |
| const triplet = document.createElement("div"); | |
| triplet.className = "memory-relationship-triplet"; | |
| const predicate = document.createElement("span"); | |
| predicate.className = "graph-predicate-chip"; | |
| predicate.textContent = predicateLabel(edge.predicate); | |
| triplet.append(nodeChip(edge.source), predicate, nodeChip(edge.target)); | |
| row.append(triplet); | |
| const evidence = graphState.nodeById.get(edge.evidence_id); | |
| if (evidence?.kind === "memory") { | |
| const evidenceText = document.createElement("p"); | |
| evidenceText.textContent = evidence.text || evidence.evidence_text || ""; | |
| row.append(evidenceText); | |
| } | |
| relationshipList.append(row); | |
| } | |
| if (!unique.length) { | |
| const empty = document.createElement("p"); | |
| empty.className = "memory-graph-empty"; | |
| empty.textContent = "No connected relationships are available for this node."; | |
| relationshipList.append(empty); | |
| } else if (unique.length > 40) { | |
| const remaining = document.createElement("p"); | |
| remaining.className = "memory-graph-empty"; | |
| remaining.textContent = `${unique.length - 40} more relationships are visible in the graph.`; | |
| relationshipList.append(remaining); | |
| } | |
| } | |
| function renderGraph() { | |
| if (!graphState.payload) return; | |
| const { nodes, edges, focused } = selectSubgraph(); | |
| graphState.visibleNodes = nodes; | |
| graphState.visibleEdges = edges; | |
| graphState.nodeById = new Map(graphState.payload.nodes.map((node) => [node.id, node])); | |
| graphState.positions = calculateLayout(nodes, edges); | |
| const visibleIds = new Set(nodes.map((node) => node.id)); | |
| const firstRetrieved = graphBackedRetrievalIds().find((id) => visibleIds.has(id)); | |
| if (!visibleIds.has(graphState.selectedNodeId)) { | |
| graphState.selectedNodeId = firstRetrieved | |
| || (visibleIds.has("person-garry-tan") ? "person-garry-tan" : nodes[0]?.id || null); | |
| } | |
| graphSvg.replaceChildren(); | |
| const defs = svgElement("defs"); | |
| const marker = svgElement("marker", { | |
| id: "graph-arrow", | |
| viewBox: "0 0 10 10", | |
| refX: 9, | |
| refY: 5, | |
| markerWidth: 5, | |
| markerHeight: 5, | |
| orient: "auto-start-reverse", | |
| }); | |
| marker.append(svgElement("path", { d: "M 0 0 L 10 5 L 0 10 z" })); | |
| defs.append(marker); | |
| graphSvg.append(defs); | |
| const scene = svgElement("g", { class: "memory-graph-scene" }); | |
| const edgeLayer = svgElement("g", { class: "memory-graph-edges" }); | |
| const nodeLayer = svgElement("g", { class: "memory-graph-nodes" }); | |
| const retrievedSet = new Set(graphBackedRetrievalIds()); | |
| for (const edge of edges) { | |
| const source = graphState.positions.get(edge.source); | |
| const target = graphState.positions.get(edge.target); | |
| if (!source || !target) continue; | |
| const line = svgElement("line", { | |
| x1: source.x, | |
| y1: source.y, | |
| x2: target.x, | |
| y2: target.y, | |
| "marker-end": "url(#graph-arrow)", | |
| }); | |
| line.classList.toggle("is-retrieved", retrievedSet.has(edge.evidence_id)); | |
| const title = svgElement("title"); | |
| title.textContent = `${graphNodeLabel(graphState.nodeById.get(edge.source))} ${predicateLabel(edge.predicate)} ${graphNodeLabel(graphState.nodeById.get(edge.target))}`; | |
| line.append(title); | |
| edgeLayer.append(line); | |
| } | |
| const degrees = new Map(); | |
| for (const edge of edges) { | |
| degrees.set(edge.source, (degrees.get(edge.source) || 0) + 1); | |
| degrees.set(edge.target, (degrees.get(edge.target) || 0) + 1); | |
| } | |
| for (const node of nodes) { | |
| const position = graphState.positions.get(node.id); | |
| const group = svgElement("g", { | |
| class: "memory-graph-node", | |
| transform: `translate(${position.x} ${position.y})`, | |
| tabindex: 0, | |
| role: "button", | |
| "aria-label": graphNodeDescription(node), | |
| }); | |
| group.dataset.nodeId = node.id; | |
| group.dataset.nodeKind = node.kind || "unknown"; | |
| group.classList.toggle("is-retrieved", retrievedSet.has(node.id)); | |
| group.classList.toggle("is-selected", graphState.selectedNodeId === node.id); | |
| const radius = retrievedSet.has(node.id) ? 13 : node.kind === "entity" ? 10 : node.kind === "value" ? 9 : 6; | |
| group.append(svgElement("circle", { r: radius })); | |
| const shouldLabel = retrievedSet.has(node.id) | |
| || node.kind !== "memory" && (focused || (degrees.get(node.id) || 0) >= 2); | |
| if (shouldLabel) { | |
| const label = svgElement("text", { x: radius + 5, y: 3.5 }); | |
| label.textContent = node.kind === "memory" | |
| ? graphNodeLabel(node).replace("Memory ", "R") | |
| : shortLabel(node.name || node.id); | |
| group.append(label); | |
| } | |
| const title = svgElement("title"); | |
| title.textContent = graphNodeDescription(node); | |
| group.append(title); | |
| group.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| selectNode(node.id); | |
| }); | |
| group.addEventListener("keydown", (event) => { | |
| if (event.key === "Enter" || event.key === " ") { | |
| event.preventDefault(); | |
| selectNode(node.id); | |
| } | |
| }); | |
| nodeLayer.append(group); | |
| } | |
| scene.append(edgeLayer, nodeLayer); | |
| graphSvg.append(scene); | |
| resetViewport(); | |
| const graphIds = graphBackedRetrievalIds(); | |
| if (focused) { | |
| graphStatus.textContent = `${graphIds.length} ranked graph memories · ${nodes.length} nodes · ${edges.length} evidence-linked edges${graphState.retrievalQuery ? ` · query: ${graphState.retrievalQuery}` : ""}`; | |
| } else if (graphState.mode === "retrieved" && graphState.retrievedIds.length) { | |
| graphStatus.textContent = "The latest results are methodology-only; showing the complete graph."; | |
| } else if (graphState.mode === "retrieved") { | |
| graphStatus.textContent = `${nodes.length} nodes · ${edges.length} edges · send a prompt to highlight its ranked retrieval`; | |
| } else { | |
| graphStatus.textContent = `${nodes.length} nodes · ${edges.length} evidence-backed edges`; | |
| } | |
| renderRelationships(); | |
| } | |
| async function loadGraph() { | |
| if (graphState.payload) return graphState.payload; | |
| if (graphState.loading) return graphState.loading; | |
| graphStatus.textContent = "Loading graph…"; | |
| graphState.loading = fetch("/v1/memory-graph") | |
| .then(async (response) => { | |
| const payload = await response.json().catch(() => ({})); | |
| if (!response.ok || !Array.isArray(payload.nodes) || !Array.isArray(payload.edges)) { | |
| throw new Error("Knowledge graph unavailable"); | |
| } | |
| graphState.payload = payload; | |
| renderGraph(); | |
| return payload; | |
| }) | |
| .catch((error) => { | |
| graphStatus.textContent = error.message || "Knowledge graph unavailable"; | |
| relationshipNote.textContent = "The graph data could not be loaded."; | |
| throw error; | |
| }) | |
| .finally(() => { | |
| graphState.loading = null; | |
| }); | |
| return graphState.loading; | |
| } | |
| function openGraph() { | |
| graphPanel.hidden = false; | |
| graphToggle.setAttribute("aria-expanded", "true"); | |
| graphToggle.textContent = "Hide knowledge graph"; | |
| loadGraph().catch(() => {}); | |
| } | |
| function closeGraph() { | |
| graphPanel.hidden = true; | |
| graphToggle.setAttribute("aria-expanded", "false"); | |
| graphToggle.textContent = "Knowledge graph"; | |
| } | |
| function setGraphRetrieval(memory) { | |
| const custom = Array.isArray(memory?.systems) | |
| ? memory.systems.find((system) => system.id === "mem0-custom") | |
| : null; | |
| const memories = Array.isArray(custom?.memories) ? custom.memories : []; | |
| graphState.retrievedIds = memories.map((item) => item.id).filter(Boolean); | |
| graphState.retrievedRanks = new Map( | |
| graphState.retrievedIds.map((id, index) => [id, index + 1]), | |
| ); | |
| graphState.retrievalQuery = typeof memory?.query === "string" ? memory.query : ""; | |
| graphState.selectedNodeId = null; | |
| if (graphState.payload) renderGraph(); | |
| } | |
| graphToggle.addEventListener("click", () => { | |
| if (graphPanel.hidden) openGraph(); | |
| else closeGraph(); | |
| }); | |
| graphClose.addEventListener("click", closeGraph); | |
| graphReset.addEventListener("click", resetViewport); | |
| for (const button of graphViewButtons) { | |
| button.addEventListener("click", () => { | |
| graphState.mode = button.dataset.graphView; | |
| for (const candidate of graphViewButtons) { | |
| candidate.setAttribute("aria-pressed", String(candidate === button)); | |
| } | |
| if (graphState.payload) renderGraph(); | |
| }); | |
| } | |
| graphSvg.addEventListener("wheel", (event) => { | |
| event.preventDefault(); | |
| const rectangle = graphSvg.getBoundingClientRect(); | |
| const pointX = ((event.clientX - rectangle.left) / rectangle.width) * 1000; | |
| const pointY = ((event.clientY - rectangle.top) / rectangle.height) * 620; | |
| const previous = graphState.viewport.scale; | |
| const next = Math.max(0.55, Math.min(4, previous * (event.deltaY < 0 ? 1.12 : 0.89))); | |
| graphState.viewport.x = pointX - ((pointX - graphState.viewport.x) / previous) * next; | |
| graphState.viewport.y = pointY - ((pointY - graphState.viewport.y) / previous) * next; | |
| graphState.viewport.scale = next; | |
| applyViewport(); | |
| }, { passive: false }); | |
| graphSvg.addEventListener("pointerdown", (event) => { | |
| if (event.target.closest(".memory-graph-node")) return; | |
| graphState.pan = { | |
| pointerId: event.pointerId, | |
| clientX: event.clientX, | |
| clientY: event.clientY, | |
| x: graphState.viewport.x, | |
| y: graphState.viewport.y, | |
| }; | |
| graphSvg.setPointerCapture(event.pointerId); | |
| graphSvg.classList.add("is-panning"); | |
| }); | |
| graphSvg.addEventListener("pointermove", (event) => { | |
| if (!graphState.pan || graphState.pan.pointerId !== event.pointerId) return; | |
| const rectangle = graphSvg.getBoundingClientRect(); | |
| graphState.viewport.x = graphState.pan.x + ((event.clientX - graphState.pan.clientX) / rectangle.width) * 1000; | |
| graphState.viewport.y = graphState.pan.y + ((event.clientY - graphState.pan.clientY) / rectangle.height) * 620; | |
| applyViewport(); | |
| }); | |
| graphSvg.addEventListener("pointerup", (event) => { | |
| if (!graphState.pan || graphState.pan.pointerId !== event.pointerId) return; | |
| graphState.pan = null; | |
| graphSvg.classList.remove("is-panning"); | |
| graphSvg.releasePointerCapture(event.pointerId); | |
| }); | |
| window.memoryGraphNavigator = { setRetrieval: setGraphRetrieval, open: openGraph }; | |