twitter-tan-api / static /memory-graph.js
milan477's picture
Use Zep user and thread memory
8c346dc verified
Raw History Blame Contribute Delete
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 };