Nico 2d649fa448 v0.15.3: Domain context, iterative plan-execute, FK mappings, ES6 node inspector
Eras Expert domain context:
- Full Heizkostenabrechnung business model (Kunde>Objekte>Nutzeinheiten>Geraete)
- Known PK/FK mappings: kunden.Kundennummer, objekte.KundenID, etc.
- Correct JOIN example in SCHEMA prompt
- PA knows domain hierarchy for better job formulation

Iterative plan-execute in ExpertNode:
- DESCRIBE queries execute first, results injected into re-plan
- Re-plan uses actual column names from DESCRIBE
- Eliminates "Unknown column" errors on first query

Frontend:
- Node inspector: per-node cards with model, tokens, progress, last event
- Graph switcher buttons in top bar
- Clear button in top bar
- Nodes panel 300px wide
- WS reconnect on 1006 (deploy) without showing login
- Model info emitted on context HUD events

Domain context test: 21/21 (hierarchy, JOINs, FK, PA job quality)
Default graph: v4-eras

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

1139 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const msgs = document.getElementById('messages');
const inputEl = document.getElementById('input');
const statusEl = document.getElementById('status');
const traceEl = document.getElementById('trace');
let ws, wsTest, wsTrace, currentEl;
let _currentDashboard = []; // S3*: tracks what user sees in workspace
let authToken = localStorage.getItem('cog_token');
let authConfig = null;
let cy = null; // Cytoscape instance
// --- Pipeline Graph ---
// Node color palette by role
const NODE_COLORS = {
user: '#444', input: '#f59e0b', sensor: '#3b82f6',
director: '#a855f7', thinker: '#f97316', interpreter: '#06b6d4',
output: '#10b981', ui: '#10b981', memorizer: '#a855f7', s3_audit: '#ef4444',
};
// Layout columns: role -> column index
const NODE_COLUMNS = {
user: 0, input: 1, sensor: 1,
director: 2, thinker: 2, interpreter: 2, s3_audit: 2,
output: 3, ui: 3,
memorizer: 4,
};
function buildGraphElements(graph, mx, cw, mid, row1, row2) {
const elements = [];
const roles = Object.keys(graph.nodes);
// Always add user node
elements.push({ data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } });
// Group roles by column
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);
}
// Position nodes within each column
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+$/, '');
elements.push({ data: { id: role, label }, position: { x: mx + cw * c, y } });
}
}
// Collect valid node IDs for edge filtering
const nodeIds = new Set(elements.map(e => e.data.id));
// Add edges from graph definition
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 {
// Build edges from graph.edges array
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 });
}
}
}
// Always add user->input edge
elements.push({ data: { id: 'e-user-input', source: 'user', target: 'input' } });
return elements;
}
async function initGraph() {
const container = document.getElementById('pipeline-graph');
if (!container) { console.error('[graph] no #pipeline-graph container'); return; }
if (typeof cytoscape === 'undefined') { console.error('[graph] cytoscape not loaded'); return; }
// Force dimensions — flexbox may not have resolved yet
const rect = container.getBoundingClientRect();
const W = rect.width || container.offsetWidth || 900;
const H = rect.height || container.offsetHeight || 180;
console.log('[graph] init', W, 'x', H);
// Layout: group by real data flow
// Col 0: user (external)
// Col 1: input (perceive) + sensor (environment) — both feed INTO the core
// Col 2: director (plans) + thinker (executes) + S3* (audits) — the CORE
// Col 3: output (voice) + ui (dashboard) — RENDER to user
// Col 4: memorizer (remembers) — feeds BACK to core
const mx = W * 0.07;
const cw = (W - mx * 2) / 4;
const row1 = H * 0.25;
const mid = H * 0.5;
const row2 = H * 0.75;
// Fetch graph from API, fall back to v1 hardcoded layout
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);
console.log('[graph] loaded from API:', graph.name, graphElements.length, 'elements');
}
} catch (e) { console.warn('[graph] API fetch failed, using fallback:', 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 + 5 } },
{ data: { id: 'sensor', label: 'sensor' }, position: { x: mx + cw, y: row2 - 5 } },
{ data: { id: 'director', label: 'director' }, position: { x: mx + cw * 1.8, y: row1 - 10 } },
{ data: { id: 'thinker', label: 'thinker' }, position: { x: mx + cw * 2, y: mid } },
{ data: { id: 's3_audit', label: 'S3*' }, position: { x: mx + cw * 1.8, y: row2 + 10 } },
{ data: { id: 'output', label: 'output' }, position: { x: mx + cw * 3, y: row1 + 5 } },
{ data: { id: 'ui', label: 'ui' }, position: { x: mx + cw * 3, y: row2 - 5 } },
{ data: { id: 'memorizer', label: 'memo' }, position: { x: mx + cw * 4, y: mid } },
{ data: { id: 'e-user-input', source: 'user', target: 'input' } },
{ data: { id: 'e-input-thinker', source: 'input', target: 'thinker' } },
{ data: { id: 'e-input-output', source: 'input', target: 'output', reflex: true } },
{ data: { id: 'e-thinker-output', source: 'thinker', target: 'output' } },
{ data: { id: 'e-thinker-ui', source: 'thinker', target: 'ui' } },
{ data: { id: 'e-output-memo', source: 'output', target: 'memorizer' } },
{ data: { id: 'e-memo-director', source: 'memorizer', target: 'director' } },
{ data: { id: 'e-director-thinker', source: 'director', target: 'thinker' } },
{ data: { id: 'e-thinker-audit', source: 'thinker', target: 's3_audit' } },
{ data: { id: 'e-audit-thinker', source: 's3_audit', target: 'thinker', ctx: true } },
{ data: { id: 'e-sensor-thinker', source: 'sensor', target: 'thinker', ctx: true } },
{ data: { id: 'e-memo-sensor', source: 'memorizer', target: 'sensor', ctx: true } },
{ data: { id: 'e-ui-sensor', source: 'ui', target: 'sensor', ctx: true } },
];
}
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',
}},
// Node colors — dynamic from NODE_COLORS palette
...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: '#s3_audit', style: { 'width': 36, 'height': 36, 'font-size': '14px', 'border-style': 'dashed', 'border-opacity': 0.5 } },
// Active node (pulsed)
{ selector: 'node.active', style: {
'background-color': '#333',
'border-width': 3,
'width': 56,
'height': 56,
}},
// Edges
{ 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, // drag on by default
selectionType: 'single',
});
// Re-enable right-click
container.addEventListener('contextmenu', e => e.stopPropagation(), true);
// Register cola + start physics
if (typeof cytoscapeCola !== 'undefined') cytoscape.use(cytoscapeCola);
startPhysics();
// Keep font size constant regardless of zoom
cy.on('zoom', () => {
const z = cy.zoom();
const fontSize = Math.round(12 / z);
const sensorSize = Math.round(10 / z);
const auditSize = Math.round(9 / z);
cy.nodes().style('font-size', fontSize + 'px');
cy.getElementById('sensor').style('font-size', sensorSize + 'px');
cy.getElementById('s3_audit').style('font-size', auditSize + 'px');
});
}
// --- Graph controls ---
let _dragEnabled = true;
let _physicsRunning = false;
let _physicsLayout = null;
let _colaSpacing = 25;
let _colaStrengthMult = 1.0;
function 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();
}
function toggleDrag() {
if (!cy) return;
_dragEnabled = !_dragEnabled;
cy.autoungrabify(!_dragEnabled);
document.getElementById('btn-drag').textContent = 'drag: ' + (_dragEnabled ? 'on' : 'off');
}
function togglePhysics() {
if (!cy) return;
if (_physicsRunning) {
stopPhysics();
} else {
startPhysics();
}
}
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, // don't fight zoom
nodeSpacing: _colaSpacing,
nodeWeight: n => {
const w = { thinker: 80, input: 50, output: 50, memorizer: 40, director: 40, ui: 30, sensor: 20, s3_audit: 10, user: 60 };
return w[n.id()] || 30;
},
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) {
console.log('[graph] physics failed:', e);
}
}
function stopPhysics() {
if (_physicsLayout) {
try { _physicsLayout.stop(); } catch(e) {}
_physicsLayout = null;
}
_physicsRunning = false;
}
let _panEnabled = true;
function togglePan() {
if (!cy) return;
_panEnabled = !_panEnabled;
cy.userPanningEnabled(_panEnabled);
cy.userZoomingEnabled(_panEnabled);
document.getElementById('btn-pan').textContent = 'pan: ' + (_panEnabled ? 'on' : 'off');
}
function copyGraphConfig() {
if (!cy) return;
const settings = {
graph: {
layout: 'cola',
spacing: _colaSpacing,
strengthMult: _colaStrengthMult,
drag: _dragEnabled,
pan: _panEnabled,
},
cytoscape: {
zoom: Math.round(cy.zoom() * 100) / 100,
pan: cy.pan(),
},
api: {
graph_active: '/api/graph/active',
graph_list: '/api/graph/list',
test_status: '/api/test/status',
},
nodes: Object.fromEntries(cy.nodes().map(n => [n.id(), {x: Math.round(n.position('x')), y: Math.round(n.position('y'))}])),
};
navigator.clipboard.writeText(JSON.stringify(settings, null, 2)).then(() => {
const btn = document.getElementById('btn-copy');
btn.textContent = 'copied!';
setTimeout(() => btn.textContent = 'copy', 1000);
});
}
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);
}
function graphAnimate(event, node) {
if (!cy) return;
// Pulse the source node if it exists in the graph (handles v1 and v2 node names)
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') {
pulseNode(node); flashEdge(node, 'thinker');
} else {
// thinker decided
pulseNode(node || 'thinker');
flashEdge('thinker', 'output');
}
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':
case 'machine_state_added':
case 'machine_reset':
case 'machine_destroyed':
pulseNode('ui'); flashEdge('thinker', 'ui');
break;
case 'updated':
pulseNode('memorizer'); flashEdge('output', 'memorizer');
break;
case 'director_updated':
pulseNode('director'); flashEdge('memorizer', 'director');
break;
case 'director_plan':
pulseNode('director'); flashEdge('director', 'thinker');
break;
case 'tick':
pulseNode('sensor');
break;
case 'thinking':
if (node) pulseNode(node);
break;
case 'tool_call':
pulseNode(node || 'thinker'); flashEdge('thinker', 'ui');
break;
case 'tool_result':
if (cy.getElementById('interpreter').length) {
pulseNode('interpreter');
}
break;
case 'interpreted':
pulseNode('interpreter'); flashEdge('interpreter', 'output');
break;
case 's3_audit':
if (cy.getElementById('s3_audit').length) {
pulseNode('s3_audit'); flashEdge('thinker', 's3_audit'); flashEdge('s3_audit', 'thinker');
}
break;
}
}
// --- OIDC Auth ---
async function initAuth() {
try {
const resp = await fetch('/auth/config');
authConfig = await resp.json();
} catch { authConfig = { enabled: false }; }
if (!authConfig.enabled) { connect(); return; }
// Handle OIDC callback
if (location.pathname === '/callback') {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const verifier = sessionStorage.getItem('pkce_verifier');
if (code && verifier) {
const tokenResp = await fetch(authConfig.issuer + '/oauth/v2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: authConfig.clientId,
code,
redirect_uri: location.origin + '/callback',
code_verifier: verifier,
}),
});
const tokens = await tokenResp.json();
if (tokens.access_token) {
// Store access token for userinfo, id_token for JWT validation
localStorage.setItem('cog_access_token', tokens.access_token);
authToken = tokens.id_token || tokens.access_token;
localStorage.setItem('cog_token', authToken);
sessionStorage.removeItem('pkce_verifier');
}
}
history.replaceState(null, '', '/');
}
if (authToken) {
connect();
} else {
showLogin();
}
}
function showLogin() {
statusEl.textContent = 'not authenticated';
statusEl.style.color = '#f59e0b';
const btn = document.createElement('button');
btn.textContent = 'Log in with loop42';
btn.className = 'login-btn';
btn.onclick = startLogin;
document.getElementById('input-bar').replaceChildren(btn);
}
async function startLogin() {
// PKCE: generate code_verifier + code_challenge
const verifier = randomString(64);
sessionStorage.setItem('pkce_verifier', verifier);
const encoder = new TextEncoder();
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const params = new URLSearchParams({
response_type: 'code',
client_id: authConfig.clientId,
redirect_uri: location.origin + '/callback',
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
});
location.href = authConfig.issuer + '/oauth/v2/authorize?' + params;
}
function randomString(len) {
const arr = new Uint8Array(len);
crypto.getRandomValues(arr);
return btoa(String.fromCharCode(...arr)).replace(/[^a-zA-Z0-9]/g, '').slice(0, len);
}
// --- WebSocket ---
let _authFailed = false;
function connect() {
if (_authFailed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl = proto + '//' + location.host + '/ws';
if (authToken) {
const accessToken = localStorage.getItem('cog_access_token') || '';
wsUrl += '?token=' + encodeURIComponent(authToken) + '&access_token=' + encodeURIComponent(accessToken);
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
statusEl.textContent = 'connected';
statusEl.style.color = '#22c55e';
addTrace('runtime', 'connected', 'ws open');
connectDebugSockets();
};
ws.onerror = () => {}; // swallow — onclose handles it
ws.onclose = (e) => {
// 4001 = explicit auth rejection, 1006 = HTTP 403 before upgrade
if (e.code === 4001 || e.code === 1006) {
_authFailed = true;
localStorage.removeItem('cog_token');
localStorage.removeItem('cog_access_token');
authToken = null;
statusEl.textContent = 'session expired';
statusEl.style.color = '#ef4444';
addTrace('runtime', 'auth expired', 'please log in again');
showLogin();
return;
}
statusEl.textContent = 'disconnected';
statusEl.style.color = '#666';
addTrace('runtime', 'disconnected', 'ws closed');
setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'hud') {
handleHud(data);
} else if (data.type === 'delta') {
if (!currentEl) {
currentEl = addMsg('assistant', '');
currentEl.classList.add('streaming');
}
currentEl.textContent += data.content;
scroll(msgs);
} else if (data.type === 'done') {
if (currentEl) {
currentEl.classList.remove('streaming');
// Render markdown now that streaming is complete
currentEl.innerHTML = renderMarkdown(currentEl.textContent);
}
currentEl = null;
} else if (data.type === 'controls') {
dockControls(data.controls);
} else if (data.type === 'cleared') {
addTrace('runtime', 'cleared', 'session reset');
}
};
}
// --- Debug WebSockets: /ws/test and /ws/trace ---
let _testPollInterval = null;
let _lastTestResultCount = 0;
function connectDebugSockets() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const base = proto + '//' + location.host;
const tokenParam = authToken ? '?token=' + encodeURIComponent(authToken) : '';
// /ws/test — test runner progress (WS + polling fallback)
if (!wsTest || wsTest.readyState > 1) {
wsTest = new WebSocket(base + '/ws/test' + tokenParam);
wsTest.onopen = () => addTrace('runtime', 'ws/test', 'connected');
wsTest.onclose = () => {
addTrace('runtime', 'ws/test', 'disconnected');
setTimeout(() => connectDebugSockets(), 3000);
};
wsTest.onerror = () => {};
wsTest.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'test_status') updateTestStatus(data);
};
}
// Polling fallback for test status (WS may buffer through proxy)
if (!_testPollInterval) {
_testPollInterval = setInterval(async () => {
try {
const headers = authToken ? { 'Authorization': 'Bearer ' + authToken } : {};
const r = await fetch('/api/test/status', { headers });
const data = await r.json();
const count = (data.results || []).length;
if (count !== _lastTestResultCount || data.running) {
_lastTestResultCount = count;
updateTestStatus(data);
}
} catch (e) {}
}, 500);
}
// /ws/trace — HUD and frame trace events
if (!wsTrace || wsTrace.readyState > 1) {
wsTrace = new WebSocket(base + '/ws/trace' + tokenParam);
wsTrace.onopen = () => addTrace('runtime', 'ws/trace', 'connected');
wsTrace.onclose = () => {}; // reconnects via test socket
wsTrace.onerror = () => {};
wsTrace.onmessage = (e) => {
const data = JSON.parse(e.data);
// Frame trace summary
if (data.event === 'frame_trace' && data.trace) {
const t = data.trace;
const frames = t.frames || [];
const summary = frames.map(f => `F${f.frame}:${f.node}(${f.duration_ms}ms)`).join(' → ');
addTrace('frame_engine', 'trace', `${t.path} ${t.total_frames}F ${t.total_ms}ms`, 'instruction',
summary + '\n' + JSON.stringify(t, null, 2));
}
// All other HUD events go to trace panel
else if (data.node && data.event) {
handleHud(data);
}
};
}
}
function updateTestStatus(data) {
const el = document.getElementById('test-status');
if (!el) return;
const results = data.results || [];
const pass = results.filter(r => r.status === 'PASS').length;
const fail = results.filter(r => r.status === 'FAIL').length;
const done = results.length;
const expected = data.total_expected || done;
const totalMs = results.reduce((s, r) => s + (r.elapsed_ms || 0), 0);
const durStr = totalMs > 1000 ? `${(totalMs / 1000).toFixed(1)}s` : `${totalMs}ms`;
if (data.running) {
const current = data.current || '';
const lastMs = results.length ? results[results.length - 1].elapsed_ms || 0 : 0;
const lastStr = lastMs > 1000 ? `${(lastMs / 1000).toFixed(1)}s` : `${lastMs}ms`;
el.innerHTML = `<span class="ts-running">TESTING</span> `
+ `<span class="ts-pass">${done}</span>/<span>${expected}</span>`
+ (fail ? ` <span class="ts-fail">${fail}F</span>` : '')
+ ` <span style="color:#555">${durStr}</span>`
+ ` <span style="color:#888;max-width:20rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current)}</span>`
+ ` <span style="color:#555">${lastStr}</span>`;
} else if (done > 0) {
const allGreen = fail === 0;
el.innerHTML = `<span class="${allGreen ? 'ts-pass' : 'ts-fail'}">${pass}/${expected}</span>`
+ (fail ? ` <span class="ts-fail">${fail} failed</span>` : ' <span class="ts-pass">all green</span>')
+ ` <span style="color:#555">${durStr}</span>`;
}
}
function handleHud(data) {
const node = data.node || 'unknown';
const event = data.event || '';
// Animate pipeline graph
graphAnimate(event, node);
if (event === 'context') {
// Update node meter
if (data.tokens !== undefined) {
updateMeter(node, data.tokens, data.max_tokens, data.fill_pct);
}
// Expandable: show message count + token info
const count = (data.messages || []).length;
const tokenInfo = data.tokens ? ` [${data.tokens}/${data.max_tokens}t ${data.fill_pct}%]` : '';
const summary = count + ' msgs' + tokenInfo + ': ' + (data.messages || []).map(m =>
m.role[0].toUpperCase() + ':' + truncate(m.content, 30)
).join(' | ');
const detail = (data.messages || []).map((m, i) =>
i + ' [' + m.role + '] ' + m.content
).join('\n');
addTrace(node, 'context', summary, 'context', detail);
} else if (event === 'perceived') {
// v0.11: Input sends structured analysis, not prose instruction
const text = data.analysis
? Object.entries(data.analysis).map(([k,v]) => k + '=' + v).join(' ')
: (data.instruction || '');
const detail = data.analysis ? JSON.stringify(data.analysis, null, 2) : null;
addTrace(node, 'perceived', text, 'instruction', detail);
} else if (event === 'decided') {
addTrace(node, 'decided', data.instruction, 'instruction');
} else if (event === 'updated' && data.state) {
const pairs = Object.entries(data.state).map(([k, v]) => {
const val = Array.isArray(v) ? v.join(', ') : (v || 'null');
return k + '=' + truncate(val, 25);
}).join(' ');
const detail = JSON.stringify(data.state, null, 2);
addTrace(node, 'state', pairs, 'state', detail);
updateAwarenessState(data.state);
} else if (event === 'process_start') {
addTrace(node, 'run ' + (data.tool || 'python'), truncate(data.code || '', 80), 'instruction', data.code);
showAwarenessProcess(data.pid, data.tool || 'python', data.code || '');
} else if (event === 'process_done') {
addTrace(node, (data.exit_code === 0 ? 'done' : 'failed'), truncate(data.output || '', 80), data.exit_code === 0 ? '' : 'error', data.output);
updateAwarenessProcess(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed);
} else if (event === 'error') {
addTrace(node, 'error', data.detail || '', 'error');
} else if (event === 's3_audit') {
addTrace(node, 'S3* ' + (data.check || ''), data.detail || '', data.detail && data.detail.includes('failed') ? 'error' : 'instruction');
} else if (event === 'director_plan') {
const steps = (data.steps || []).join(' → ');
addTrace(node, 'plan', data.goal + ': ' + steps, 'instruction', JSON.stringify(data, null, 2));
} else if (event === 'tool_call') {
addTrace(node, 'tool: ' + (data.tool || '?'), data.input || '', 'instruction');
} else if (event === 'tool_result') {
const rows = data.rows !== undefined ? ` (${data.rows} rows)` : '';
addTrace(node, 'result: ' + (data.tool || '?'), truncate(data.output || '', 100) + rows, '', data.output);
} else if (event === 'thinking') {
addTrace(node, 'thinking', data.detail || '');
} else if (event === 'streaming') {
addTrace(node, 'streaming', '');
} else if (event === 'done') {
addTrace(node, 'done', '');
} else if (event === 'tick') {
// Update sensor meter with tick count
const meter = document.getElementById('meter-sensor');
if (meter) {
const text = meter.querySelector('.nm-text');
const deltas = Object.entries(data.deltas || {}).map(([k,v]) => k + '=' + v).join(' ');
text.textContent = 'tick #' + (data.tick || 0) + (deltas ? ' | ' + deltas : '');
}
if (data.deltas && Object.keys(data.deltas).length) {
const deltas = Object.entries(data.deltas).map(([k,v]) => k + '=' + truncate(String(v), 30)).join(' ');
addTrace(node, 'tick #' + data.tick, deltas);
}
updateAwarenessSensors(data.tick || 0, data.deltas || {});
} else if (event === 'started' || event === 'stopped') {
const meter = document.getElementById('meter-sensor');
if (meter) meter.querySelector('.nm-text').textContent = event;
addTrace(node, event, '');
} else {
// Generic fallback
const detail = JSON.stringify(data, null, 2);
addTrace(node, event, '', '', detail);
}
}
function addTrace(node, event, text, cls, detail) {
const line = document.createElement('div');
line.className = 'trace-line' + (detail ? ' expandable' : '');
const ts = new Date().toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 1 });
line.innerHTML =
'<span class="trace-ts">' + ts + '</span>' +
'<span class="trace-node ' + esc(node) + '">' + esc(node) + '</span>' +
'<span class="trace-event">' + esc(event) + '</span>' +
'<span class="trace-data' + (cls ? ' ' + cls : '') + '">' + esc(text) + '</span>';
traceEl.appendChild(line);
if (detail) {
const detailEl = document.createElement('div');
detailEl.className = 'trace-detail';
detailEl.textContent = detail;
traceEl.appendChild(detailEl);
line.addEventListener('click', () => detailEl.classList.toggle('open'));
}
scroll(traceEl);
}
function renderControls(controls) {
const container = document.createElement('div');
container.className = 'controls-container';
for (const ctrl of controls) {
if (ctrl.type === 'button') {
const btn = document.createElement('button');
btn.className = 'control-btn';
btn.textContent = ctrl.label;
btn.onclick = () => {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'action', action: ctrl.action, data: ctrl.payload || ctrl.data || {} }));
addTrace('runtime', 'action', ctrl.action);
}
};
container.appendChild(btn);
} else if (ctrl.type === 'table') {
const table = document.createElement('table');
table.className = 'control-table';
// Header
if (ctrl.columns) {
const thead = document.createElement('tr');
for (const col of ctrl.columns) {
const th = document.createElement('th');
th.textContent = col;
thead.appendChild(th);
}
table.appendChild(thead);
}
// Rows
for (const row of (ctrl.data || [])) {
const tr = document.createElement('tr');
if (Array.isArray(row)) {
for (const cell of row) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
} else if (typeof row === 'object') {
for (const col of (ctrl.columns || Object.keys(row))) {
const td = document.createElement('td');
td.textContent = row[col] ?? '';
tr.appendChild(td);
}
}
table.appendChild(tr);
}
container.appendChild(table);
} else if (ctrl.type === 'label') {
const lbl = document.createElement('div');
lbl.className = 'control-label';
lbl.innerHTML = '<span class="cl-text">' + esc(ctrl.text || '') + '</span><span class="cl-value">' + esc(String(ctrl.value ?? '')) + '</span>';
container.appendChild(lbl);
} else if (ctrl.type === 'process') {
const card = document.createElement('div');
card.className = 'process-card ' + (ctrl.status || 'running');
card.innerHTML =
'<span class="pc-tool">' + esc(ctrl.tool || 'python') + '</span>' +
'<span class="pc-status">' + esc(ctrl.status || 'running') + '</span>' +
(ctrl.status === 'running' ? '<button class="pc-stop" onclick="cancelProcess(' + (ctrl.pid || 0) + ')">Stop</button>' : '') +
'<pre class="pc-output">' + esc(ctrl.output || '') + '</pre>';
container.appendChild(card);
}
}
msgs.appendChild(container);
scroll(msgs);
}
function cancelProcess(pid) {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'cancel_process', pid }));
}
}
function showProcessCard(pid, tool, code) {
const card = document.createElement('div');
card.className = 'process-card running';
card.id = 'proc-' + pid;
card.innerHTML =
'<span class="pc-tool">' + esc(tool) + '</span>' +
'<span class="pc-status">running</span>' +
'<button class="pc-stop" onclick="cancelProcess(' + pid + ')">Stop</button>' +
'<pre class="pc-code">' + esc(truncate(code, 200)) + '</pre>' +
'<pre class="pc-output"></pre>';
msgs.appendChild(card);
scroll(msgs);
}
function updateProcessCard(pid, status, output, elapsed) {
const card = document.getElementById('proc-' + pid);
if (!card) return;
card.className = 'process-card ' + status;
const statusEl = card.querySelector('.pc-status');
if (statusEl) statusEl.textContent = status + (elapsed ? ' (' + elapsed + 's)' : '');
const stopBtn = card.querySelector('.pc-stop');
if (stopBtn) stopBtn.remove();
const outEl = card.querySelector('.pc-output');
if (outEl && output) outEl.textContent = output;
}
function updateMeter(node, tokens, maxTokens, fillPct) {
const meter = document.getElementById('meter-' + node);
if (!meter) return;
const fill = meter.querySelector('.nm-fill');
const text = meter.querySelector('.nm-text');
fill.style.width = fillPct + '%';
fill.style.backgroundColor = fillPct > 80 ? '#ef4444' : fillPct > 50 ? '#f59e0b' : '#22c55e';
text.textContent = tokens + ' / ' + maxTokens + 't (' + fillPct + '%)';
}
function scroll(el) { el.scrollTop = el.scrollHeight; }
function esc(s) { const d = document.createElement('span'); d.textContent = s; return d.innerHTML; }
function renderMarkdown(text) {
// Escape HTML first
let html = esc(text);
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => '<pre><code>' + code.trim() + '</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Headers
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// Unordered lists
html = html.replace(/^[*-] (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
// Line breaks (double newline = paragraph break)
html = html.replace(/\n\n/g, '<br><br>');
html = html.replace(/\n/g, '<br>');
return html;
}
function truncate(s, n) { return s.length > n ? s.slice(0, n) + '\u2026' : s; }
function addMsg(role, text) {
const el = document.createElement('div');
el.className = 'msg ' + role;
el.textContent = text;
msgs.appendChild(el);
scroll(msgs);
return el;
}
function send() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== 1) return;
addMsg('user', text);
addTrace('runtime', 'user_msg', truncate(text, 60));
// S3*: attach current workspace state so pipeline knows what user sees
ws.send(JSON.stringify({ text, dashboard: _currentDashboard }));
inputEl.value = '';
}
async function clearSession() {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
await fetch('/api/clear', { method: 'POST', headers });
// Clear UI
msgs.innerHTML = '';
traceEl.innerHTML = '';
_currentDashboard = [];
currentEl = null;
const dock = document.getElementById('dock');
if (dock) dock.innerHTML = '';
addTrace('runtime', 'cleared', 'session reset');
} catch (e) {
addTrace('runtime', 'error', 'clear failed: ' + e);
}
}
// --- Awareness panel updates ---
let _sensorReadings = {};
function updateAwarenessState(state) {
const body = document.getElementById('aw-state-body');
if (!body) return;
const display = [
['user', state.user_name],
['mood', state.user_mood],
['topic', state.topic],
['language', state.language],
['style', state.style_hint],
['situation', state.situation],
];
let html = '';
for (const [k, v] of display) {
if (!v) continue;
const moodCls = k === 'mood' ? ' mood-' + v : '';
html += '<div class="aw-row"><span class="aw-key">' + esc(k) + '</span><span class="aw-val' + moodCls + '">' + esc(String(v)) + '</span></div>';
}
const facts = state.facts || [];
if (facts.length) {
html += '<ul class="aw-facts">';
for (const f of facts) html += '<li>' + esc(f) + '</li>';
html += '</ul>';
}
body.innerHTML = html || '<span class="aw-empty">no state yet</span>';
}
function updateAwarenessSensors(tick, deltas) {
// Merge deltas into persistent readings
for (const [k, v] of Object.entries(deltas)) {
_sensorReadings[k] = { value: v, at: Date.now() };
}
const body = document.getElementById('aw-sensors-body');
if (!body) return;
const entries = Object.entries(_sensorReadings);
if (!entries.length) { body.innerHTML = '<span class="aw-empty">waiting for tick...</span>'; return; }
let html = '';
for (const [name, r] of entries) {
const age = Math.round((Date.now() - r.at) / 1000);
const ageStr = age < 5 ? 'now' : age < 60 ? age + 's' : Math.floor(age / 60) + 'm';
html += '<div class="aw-sensor"><span class="aw-sensor-name">' + esc(name) + '</span><span class="aw-sensor-val">' + esc(String(r.value)) + '</span><span class="aw-sensor-age">' + ageStr + '</span></div>';
}
body.innerHTML = html;
}
function showAwarenessProcess(pid, tool, code) {
const body = document.getElementById('aw-proc-body');
if (!body) return;
// Remove "idle" placeholder
const empty = body.querySelector('.aw-empty');
if (empty) empty.remove();
const el = document.createElement('div');
el.className = 'aw-proc running';
el.id = 'aw-proc-' + pid;
el.innerHTML =
'<div class="aw-proc-header"><span class="aw-proc-tool">' + esc(tool) + '</span><span class="aw-proc-status">running</span>' +
'<button class="aw-proc-stop" onclick="cancelProcess(' + pid + ')">Stop</button></div>' +
'<div class="aw-proc-code">' + esc(truncate(code, 150)) + '</div>' +
'<div class="aw-proc-output"></div>';
body.appendChild(el);
}
function updateAwarenessProcess(pid, status, output, elapsed) {
const el = document.getElementById('aw-proc-' + pid);
if (!el) return;
el.className = 'aw-proc ' + status;
const st = el.querySelector('.aw-proc-status');
if (st) st.textContent = status + (elapsed ? ' (' + elapsed + 's)' : '');
const stop = el.querySelector('.aw-proc-stop');
if (stop) stop.remove();
const out = el.querySelector('.aw-proc-output');
if (out && output) out.textContent = output;
// Auto-remove completed processes (done: 10s, failed: 30s)
const delay = status === 'done' ? 10000 : 30000;
setTimeout(() => {
el.remove();
const body = document.getElementById('aw-proc-body');
if (body && !body.children.length) body.innerHTML = '<span class="aw-empty">idle</span>';
}, delay);
}
function dockControls(controls) {
_currentDashboard = controls; // S3*: remember what's rendered
const body = document.getElementById('aw-ctrl-body');
if (!body) return;
// Replace previous controls with new ones
body.innerHTML = '';
const container = document.createElement('div');
container.className = 'controls-container';
for (const ctrl of controls) {
if (ctrl.type === 'button') {
const btn = document.createElement('button');
btn.className = 'control-btn';
btn.textContent = ctrl.label;
btn.onclick = () => {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'action', action: ctrl.action, data: ctrl.payload || ctrl.data || {} }));
addTrace('runtime', 'action', ctrl.action);
}
};
container.appendChild(btn);
} else if (ctrl.type === 'table') {
const table = document.createElement('table');
table.className = 'control-table';
if (ctrl.columns) {
const thead = document.createElement('tr');
for (const col of ctrl.columns) {
const th = document.createElement('th');
th.textContent = col;
thead.appendChild(th);
}
table.appendChild(thead);
}
for (const row of (ctrl.data || [])) {
const tr = document.createElement('tr');
if (Array.isArray(row)) {
for (const cell of row) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
} else if (typeof row === 'object') {
for (const col of (ctrl.columns || Object.keys(row))) {
const td = document.createElement('td');
td.textContent = row[col] ?? '';
tr.appendChild(td);
}
}
table.appendChild(tr);
}
container.appendChild(table);
} else if (ctrl.type === 'label') {
const lbl = document.createElement('div');
lbl.className = 'control-label';
lbl.innerHTML = '<span class="cl-text">' + esc(ctrl.text || '') + '</span><span class="cl-value">' + esc(String(ctrl.value ?? '')) + '</span>';
container.appendChild(lbl);
} else if (ctrl.type === 'display') {
const disp = document.createElement('div');
const dt = ctrl.display_type || 'text';
const style = ctrl.style ? ' display-' + ctrl.style : '';
disp.className = 'control-display display-' + dt + style;
if (dt === 'progress') {
const pct = Math.min(100, Math.max(0, Number(ctrl.value) || 0));
disp.innerHTML = '<span class="cd-label">' + esc(ctrl.label) + '</span>' +
'<div class="cd-bar"><div class="cd-fill" style="width:' + pct + '%"></div></div>' +
'<span class="cd-pct">' + pct + '%</span>';
} else if (dt === 'status') {
disp.innerHTML = '<span class="cd-icon">' + (ctrl.style === 'success' ? '✓' : ctrl.style === 'error' ? '✗' : ctrl.style === 'warning' ? '⚠' : '') + '</span>' +
'<span class="cd-label">' + esc(ctrl.label) + '</span>';
} else {
disp.innerHTML = '<span class="cd-label">' + esc(ctrl.label) + '</span>' +
(ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : '');
}
container.appendChild(disp);
}
}
body.appendChild(container);
}
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
window.addEventListener('load', initGraph);
initAuth();