Nico 5f447dfd53 v0.14.0: v2 Director-drives architecture + 3-pod K8s split
Architecture:
- director_v2: always-on brain, produces DirectorPlan with tool_sequence
- thinker_v2: pure executor, runs tools from DirectorPlan
- interpreter_v1: factual result summarizer, no hallucination
- v2_director_drives graph: Input -> Director -> Thinker -> Output

Infrastructure:
- Split into 3 pods: cog-frontend (nginx), cog-runtime (FastAPI), cog-mcp (SSE proxy)
- MCP survives runtime restarts (separate pod, proxies via HTTP)
- Async send pipeline: /api/send/check -> /api/send -> /api/result with progress
- Zero-downtime rolling updates (maxUnavailable: 0)
- Dynamic graph visualization (fetched from API, not hardcoded)

Tests: 22 new mocked unit tests (director_v2: 7, thinker_v2: 8, interpreter_v1: 7)

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

1051 lines
38 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, 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':
case 'tool_exec':
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');
};
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 === 'test_status') {
updateTestStatus(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 total = results.length;
if (data.running) {
const current = data.current || '';
el.innerHTML = `<span class="ts-running">TESTING</span>`
+ `<span class="ts-pass">${pass}</span>/<span>${total}</span>`
+ (fail ? `<span class="ts-fail">${fail}F</span>` : '')
+ `<span style="color:#888;max-width:20rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current)}</span>`;
} else if (total > 0) {
const lastGreen = data.last_green;
const lastRed = data.last_red;
let parts = [`<span class="ts-idle">TESTS</span>`,
`<span class="ts-pass">${pass}P</span>`,
fail ? `<span class="ts-fail">${fail}F</span>` : ''];
if (lastRed) parts.push(`<span class="ts-fail" title="${esc(lastRed.detail || '')}">last red: ${esc((lastRed.step || '') + ' ' + (lastRed.check || ''))}</span>`);
el.innerHTML = parts.filter(Boolean).join(' ');
}
}
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 = '';
}
// --- 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();