Expert retry loop enhanced: - On "Unknown column" error, auto-DESCRIBEs the failing table - DESCRIBE result injected into re-plan context - Unmapped tables handled via SELECT * LIMIT fallback - Recovery test step 4: abrechnungsinformationen (unmapped) → success Graph animation queue: - Events queued and played sequentially with 200ms interval - Prevents bulk HUD events from canceling each other's animations - Node pulses and edge flashes play one by one Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
263 lines
9.5 KiB
JavaScript
263 lines
9.5 KiB
JavaScript
/** Pipeline graph: Cytoscape visualization + animation. */
|
|
|
|
import { initNodesFromGraph } from './awareness.js';
|
|
|
|
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);
|
|
initNodesFromGraph(graph);
|
|
}
|
|
} 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;
|
|
// Queue the animation instead of executing immediately
|
|
_enqueue(() => {
|
|
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;
|
|
}
|
|
}); // 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');
|
|
};
|