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 = `TESTING` + `${pass}/${total}` + (fail ? `${fail}F` : '') + `${esc(current)}`; } else if (total > 0) { const lastGreen = data.last_green; const lastRed = data.last_red; let parts = [`TESTS`, `${pass}P`, fail ? `${fail}F` : '']; if (lastRed) parts.push(`last red: ${esc((lastRed.step || '') + ' ' + (lastRed.check || ''))}`); 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 = '' + ts + '' + '' + esc(node) + '' + '' + esc(event) + '' + '' + esc(text) + ''; 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 = '' + esc(ctrl.text || '') + '' + esc(String(ctrl.value ?? '')) + ''; container.appendChild(lbl); } else if (ctrl.type === 'process') { const card = document.createElement('div'); card.className = 'process-card ' + (ctrl.status || 'running'); card.innerHTML = '' + esc(ctrl.tool || 'python') + '' + '' + esc(ctrl.status || 'running') + '' + (ctrl.status === 'running' ? '' : '') + '
' + esc(ctrl.output || '') + '
'; 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 = '' + esc(tool) + '' + 'running' + '' + '
' + esc(truncate(code, 200)) + '
' + '
';
  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) => '
' + code.trim() + '
'); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // Italic html = html.replace(/\*(.+?)\*/g, '$1'); // Headers html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // Unordered lists html = html.replace(/^[*-] (.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>\n?)+/g, m => ''); // Line breaks (double newline = paragraph break) html = html.replace(/\n\n/g, '

    '); html = html.replace(/\n/g, '
    '); 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 += '
    ' + esc(k) + '' + esc(String(v)) + '
    '; } const facts = state.facts || []; if (facts.length) { html += ''; } body.innerHTML = html || 'no state yet'; } 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 = 'waiting for tick...'; 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 += '
    ' + esc(name) + '' + esc(String(r.value)) + '' + ageStr + '
    '; } 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 = '
    ' + esc(tool) + 'running' + '
    ' + '
    ' + esc(truncate(code, 150)) + '
    ' + '
    '; 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 = 'idle'; }, 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 = '' + esc(ctrl.text || '') + '' + esc(String(ctrl.value ?? '')) + ''; 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 = '' + esc(ctrl.label) + '' + '
    ' + '' + pct + '%'; } else if (dt === 'status') { disp.innerHTML = '' + (ctrl.style === 'success' ? '✓' : ctrl.style === 'error' ? '✗' : ctrl.style === 'warning' ? '⚠' : 'ℹ') + '' + '' + esc(ctrl.label) + ''; } else { disp.innerHTML = '' + esc(ctrl.label) + '' + (ctrl.value ? '' + esc(String(ctrl.value)) + '' : ''); } container.appendChild(disp); } } body.appendChild(container); } inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); window.addEventListener('load', initGraph); initAuth();