Nico 09374674e3 v0.16.1: Response-step card generation, history restore, graph animation mapping
Card generation moved to response step:
- Response LLM outputs JSON with "text" + optional "card"
- Cards use actual query data, not placeholder templates
- Plan step no longer includes emit_card (avoids {{template}} syntax)
- Fallback: raw text response if JSON parse fails

History restore on reconnect:
- Frontend fetches /api/history on WS connect
- Renders last 20 messages in chat panel
- Only restores if chat is empty (fresh load)

Graph animation:
- Dynamic node name → graph ID mapping from graph definition
- All nodes (including eras_expert) pulse correctly
- 200ms animation queue prevents bulk event overlap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:08:13 +02:00

269 lines
9.9 KiB
JavaScript

/** Pipeline graph: Cytoscape visualization + animation. */
import { initNodesFromGraph } from './awareness.js';
let cy = null;
let _dragEnabled = true;
// Maps HUD node names → graph node IDs (built from graph definition)
// e.g. {"eras_expert": "expert_eras", "pa_v1": "pa", "thinker_v2": "thinker"}
let _nodeNameToId = {};
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);
initNodesFromGraph(graph);
// Build HUD name → graph ID mapping: {impl_name: role}
_nodeNameToId = {};
for (const [role, impl] of Object.entries(graph.nodes || {})) {
_nodeNameToId[impl] = role; // "eras_expert" → "expert_eras"
_nodeNameToId[role] = role; // "expert_eras" → "expert_eras"
}
}
} 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');
});
}
// --- Animation queue: batch rapid events, play sequentially ---
const _animQueue = [];
let _animRunning = false;
const ANIM_INTERVAL = 200; // ms between queued animations
function _enqueue(fn) {
_animQueue.push(fn);
if (!_animRunning) _flushQueue();
}
function _flushQueue() {
if (!_animQueue.length) { _animRunning = false; return; }
_animRunning = true;
const fn = _animQueue.shift();
fn();
setTimeout(_flushQueue, ANIM_INTERVAL);
}
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;
// Resolve HUD node name to graph ID (e.g. "eras_expert" → "expert_eras")
const graphId = _nodeNameToId[node] || node;
_enqueue(() => {
if (graphId && cy.getElementById(graphId).length) pulseNode(graphId);
switch (event) {
case 'perceived': pulseNode('input'); flashEdge('user', 'input'); break;
case 'decided':
pulseNode(graphId); flashEdge(graphId, 'output');
break;
case 'routed': pulseNode(_nodeNameToId['pa_v1'] || 'pa'); break;
case 'reflex_path': pulseNode('input'); flashEdge('input', 'output'); break;
case 'streaming': if (graphId === '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(graphId); break;
case 'tool_result': pulseNode(graphId); break;
case 'thinking': pulseNode(graphId); break;
case 'planned': pulseNode(graphId); break;
case 'tick': pulseNode('sensor'); break;
}
}); // end _enqueue
}
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');
};