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 --- 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; cy = cytoscape({ container, elements: [ // Col 0 — external { data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } }, // Col 1 — perception { data: { id: 'input', label: 'input' }, position: { x: mx + cw, y: row1 + 5 } }, { data: { id: 'sensor', label: 'sensor' }, position: { x: mx + cw, y: row2 - 5 } }, // Col 2 — core (plan + execute + audit) { 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 } }, // Col 3 — render { 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 } }, // Col 4 — memory (feedback) { data: { id: 'memorizer', label: 'memo' }, position: { x: mx + cw * 4, y: mid } }, // Edges — main pipeline { 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' } }, // Memory feedback loop { data: { id: 'e-output-memo', source: 'output', target: 'memorizer' } }, { data: { id: 'e-memo-director', source: 'memorizer', target: 'director' } }, // Director plans, Thinker executes { data: { id: 'e-director-thinker', source: 'director', target: 'thinker' } }, // S3* audit loop { data: { id: 'e-thinker-audit', source: 'thinker', target: 's3_audit' } }, { data: { id: 'e-audit-thinker', source: 's3_audit', target: 'thinker', ctx: true } }, // Context feeds { data: { id: 'e-sensor-ctx', source: 'sensor', target: 'thinker', ctx: true } }, ], style: [ { selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '10px', 'font-family': 'system-ui, sans-serif', 'font-weight': 700, 'color': '#aaa', 'background-color': '#222', 'border-width': 2, 'border-color': '#444', 'width': 48, 'height': 48, 'transition-property': 'background-color, border-color, width, height', 'transition-duration': '0.3s', }}, // Node colors { selector: '#user', style: { 'border-color': '#666', 'color': '#888' } }, { selector: '#input', style: { 'border-color': '#f59e0b', 'color': '#f59e0b' } }, { selector: '#thinker', style: { 'border-color': '#f97316', 'color': '#f97316' } }, { selector: '#output', style: { 'border-color': '#10b981', 'color': '#10b981' } }, { selector: '#ui', style: { 'border-color': '#10b981', 'color': '#10b981' } }, { selector: '#memorizer', style: { 'border-color': '#a855f7', 'color': '#a855f7' } }, { selector: '#director', style: { 'border-color': '#a855f7', 'color': '#a855f7' } }, { selector: '#sensor', style: { 'border-color': '#3b82f6', 'color': '#3b82f6', 'width': 36, 'height': 36, 'font-size': '9px' } }, { selector: '#s3_audit', style: { 'border-color': '#ef4444', 'color': '#ef4444', 'width': 32, 'height': 32, 'font-size': '8px', 'border-style': 'dashed' } }, // Active node (pulsed) { selector: 'node.active', style: { 'background-color': '#333', 'border-width': 3, 'width': 56, 'height': 56, }}, { selector: '#input.active', style: { 'background-color': '#3d2800', 'border-color': '#fbbf24' } }, { selector: '#thinker.active', style: { 'background-color': '#3d1f00', 'border-color': '#fb923c' } }, { selector: '#output.active', style: { 'background-color': '#003d2a', 'border-color': '#34d399' } }, { selector: '#ui.active', style: { 'background-color': '#003d2a', 'border-color': '#34d399' } }, { selector: '#memorizer.active', style: { 'background-color': '#2a003d', 'border-color': '#c084fc' } }, { selector: '#director.active', style: { 'background-color': '#2a003d', 'border-color': '#c084fc' } }, { selector: '#sensor.active', style: { 'background-color': '#00203d', 'border-color': '#60a5fa', 'width': 44, 'height': 44 } }, { selector: '#s3_audit.active', style: { 'background-color': '#3d0000', 'border-color': '#f87171', 'width': 40, 'height': 40 } }, // 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: false, userPanningEnabled: false, boxSelectionEnabled: false, autoungrabify: true, }); } 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; switch (event) { case 'perceived': pulseNode('input'); flashEdge('user', 'input'); break; case 'decided': pulseNode('thinker'); flashEdge('input', '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': pulseNode('thinker'); break; case 'tool_call': pulseNode('thinker'); flashEdge('thinker', 'ui'); break; case 's3_audit': 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); } }; } 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();