/** Pipeline graph: Cytoscape visualization + animation. */ let cy = null; let _dragEnabled = true; let _physicsRunning = false; let _physicsLayout = null; let _colaSpacing = 25; let _colaStrengthMult = 1.0; let _panEnabled = true; const NODE_COLORS = { user: '#444', input: '#f59e0b', sensor: '#3b82f6', director: '#a855f7', pa: '#a855f7', thinker: '#f97316', interpreter: '#06b6d4', expert_eras: '#f97316', expert_plankiste: '#f97316', output: '#10b981', ui: '#10b981', memorizer: '#a855f7', s3_audit: '#ef4444', }; const NODE_COLUMNS = { user: 0, input: 1, sensor: 1, director: 2, pa: 2, thinker: 2, interpreter: 2, s3_audit: 2, expert_eras: 2, expert_plankiste: 2, output: 3, ui: 3, memorizer: 4, }; function buildGraphElements(graph, mx, cw, mid, row1, row2) { const elements = []; const roles = Object.keys(graph.nodes); elements.push({ data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } }); const columns = {}; for (const role of roles) { const col = NODE_COLUMNS[role] !== undefined ? NODE_COLUMNS[role] : 2; if (!columns[col]) columns[col] = []; columns[col].push(role); } for (const [col, colRoles] of Object.entries(columns)) { const c = parseInt(col); const count = colRoles.length; for (let i = 0; i < count; i++) { const role = colRoles[i]; const ySpread = (row2 - row1); const y = count === 1 ? mid : row1 + (ySpread * i / (count - 1)); const label = role === 'memorizer' ? 'memo' : role.replace(/_v\d+$/, '').replace('expert_', ''); elements.push({ data: { id: role, label }, position: { x: mx + cw * c, y } }); } } const nodeIds = new Set(elements.map(e => e.data.id)); const cytoEdges = graph.cytoscape ? graph.cytoscape.edges : []; if (cytoEdges.length) { for (const edge of cytoEdges) { const d = edge.data; if (!nodeIds.has(d.source) || !nodeIds.has(d.target)) continue; const edgeData = { id: d.id, source: d.source, target: d.target }; if (d.condition === 'reflex') edgeData.reflex = true; if (d.edge_type === 'context') edgeData.ctx = true; elements.push({ data: edgeData }); } } else { for (const edge of graph.edges) { const targets = Array.isArray(edge.to) ? edge.to : [edge.to]; for (const tgt of targets) { if (!nodeIds.has(edge.from) || !nodeIds.has(tgt)) continue; const edgeData = { id: `e-${edge.from}-${tgt}`, source: edge.from, target: tgt }; if (edge.condition === 'reflex') edgeData.reflex = true; if (edge.type === 'context') edgeData.ctx = true; elements.push({ data: edgeData }); } } } elements.push({ data: { id: 'e-user-input', source: 'user', target: 'input' } }); return elements; } export async function initGraph() { const container = document.getElementById('pipeline-graph'); if (!container || typeof cytoscape === 'undefined') return; const rect = container.getBoundingClientRect(); const W = rect.width || 900; const H = rect.height || 180; const mx = W * 0.07; const cw = (W - mx * 2) / 4; const row1 = H * 0.25, mid = H * 0.5, row2 = H * 0.75; let graphElements = null; try { const resp = await fetch('/api/graph/active'); if (resp.ok) { const graph = await resp.json(); graphElements = buildGraphElements(graph, mx, cw, mid, row1, row2); } } catch (e) {} if (!graphElements) { graphElements = [ { data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } }, { data: { id: 'input', label: 'input' }, position: { x: mx + cw, y: row1 } }, { data: { id: 'thinker', label: 'thinker' }, position: { x: mx + cw * 2, y: mid } }, { data: { id: 'output', label: 'output' }, position: { x: mx + cw * 3, y: mid } }, ]; } cy = cytoscape({ container, elements: graphElements, style: [ { selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '18px', 'min-zoomed-font-size': 10, 'font-family': 'system-ui, sans-serif', 'font-weight': 700, 'color': '#aaa', 'background-color': '#181818', 'border-width': 1, 'border-opacity': 0.3, 'border-color': '#444', 'width': 48, 'height': 48, 'transition-property': 'background-color, border-color, width, height', 'transition-duration': '0.3s', }}, ...Object.entries(NODE_COLORS).map(([id, color]) => ({ selector: `#${id}`, style: { 'border-color': color, 'color': color } })), { selector: '#user', style: { 'color': '#888' } }, { selector: '#sensor', style: { 'width': 40, 'height': 40, 'font-size': '15px' } }, { selector: 'node.active', style: { 'background-color': '#333', 'border-width': 3, 'width': 56, 'height': 56, }}, { selector: 'edge', style: { 'width': 1.5, 'line-color': '#333', 'target-arrow-color': '#333', 'target-arrow-shape': 'triangle', 'arrow-scale': 0.7, 'curve-style': 'bezier', 'transition-property': 'line-color, target-arrow-color, width', 'transition-duration': '0.3s', }}, { selector: 'edge[?reflex]', style: { 'line-style': 'dashed', 'line-dash-pattern': [4, 4], 'line-color': '#2a2a2a' } }, { selector: 'edge[?ctx]', style: { 'line-style': 'dotted', 'line-color': '#1a1a2e', 'width': 1 } }, { selector: 'edge.active', style: { 'line-color': '#888', 'target-arrow-color': '#888', 'width': 2.5 } }, ], layout: { name: 'preset' }, userZoomingEnabled: true, userPanningEnabled: true, wheelSensitivity: 0.3, boxSelectionEnabled: false, autoungrabify: false, selectionType: 'single', }); container.addEventListener('contextmenu', e => e.stopPropagation(), true); if (typeof cytoscapeCola !== 'undefined') cytoscape.use(cytoscapeCola); startPhysics(); cy.on('zoom', () => { const z = cy.zoom(); cy.nodes().style('font-size', Math.round(12 / z) + 'px'); }); } function pulseNode(id) { if (!cy) return; const node = cy.getElementById(id); if (!node.length) return; node.addClass('active'); setTimeout(() => node.removeClass('active'), 1500); } function flashEdge(sourceId, targetId) { if (!cy) return; const edge = cy.edges().filter(e => e.data('source') === sourceId && e.data('target') === targetId); if (!edge.length) return; edge.addClass('active'); setTimeout(() => edge.removeClass('active'), 1000); } export function graphAnimate(event, node) { if (!cy) return; if (node && cy.getElementById(node).length) pulseNode(node); switch (event) { case 'perceived': pulseNode('input'); flashEdge('user', 'input'); break; case 'decided': if (node === 'director_v2' || node === 'director' || node === 'pa_v1') { pulseNode(node); flashEdge(node, 'thinker'); } else { pulseNode(node || 'thinker'); flashEdge('thinker', 'output'); } break; case 'routed': pulseNode('pa'); break; case 'reflex_path': pulseNode('input'); flashEdge('input', 'output'); break; case 'streaming': if (node === 'output') pulseNode('output'); break; case 'controls': case 'machine_created': case 'machine_transition': pulseNode('ui'); break; case 'updated': pulseNode('memorizer'); flashEdge('output', 'memorizer'); break; case 'tool_call': pulseNode(node || 'thinker'); break; case 'tool_result': if (cy.getElementById('interpreter').length) pulseNode('interpreter'); break; case 'thinking': if (node) pulseNode(node); break; case 'tick': pulseNode('sensor'); break; } } export function startPhysics() { if (!cy) return; stopPhysics(); try { const rect = document.getElementById('pipeline-graph').getBoundingClientRect(); _physicsLayout = cy.layout({ name: 'cola', animate: true, infinite: true, fit: false, nodeSpacing: _colaSpacing, edgeElasticity: e => { const base = e.data('ctx') ? 0.1 : e.data('reflex') ? 0.2 : 0.6; return base * _colaStrengthMult; }, boundingBox: { x1: 0, y1: 0, w: rect.width, h: rect.height }, }); _physicsLayout.run(); _physicsRunning = true; } catch (e) {} } export function stopPhysics() { if (_physicsLayout) { try { _physicsLayout.stop(); } catch(e) {} _physicsLayout = null; } _physicsRunning = false; } // Expose control functions for HTML onclick window.adjustCola = (param, delta) => { if (!cy) return; if (param === 'spacing') _colaSpacing = Math.max(5, Math.min(80, _colaSpacing + delta)); else if (param === 'strength') _colaStrengthMult = Math.max(0.1, Math.min(3.0, _colaStrengthMult + delta * 0.2)); startPhysics(); }; window.toggleDrag = () => { if (!cy) return; _dragEnabled = !_dragEnabled; cy.autoungrabify(!_dragEnabled); document.getElementById('btn-drag').textContent = 'drag: ' + (_dragEnabled ? 'on' : 'off'); }; window.togglePan = () => { if (!cy) return; _panEnabled = !_panEnabled; cy.userPanningEnabled(_panEnabled); cy.userZoomingEnabled(_panEnabled); document.getElementById('btn-pan').textContent = 'pan: ' + (_panEnabled ? 'on' : 'off'); };