Architecture: - Graph engine (engine.py) loads graph definitions, instantiates nodes - Versioned nodes: input_v1, thinker_v1, output_v1, memorizer_v1, director_v1 - NODE_REGISTRY for dynamic node lookup by name - Graph API: /api/graph/active, /api/graph/list, /api/graph/switch - Graph definition: graphs/v1_current.py (7 nodes, 13 edges, 3 edge types) S3* Audit system: - Workspace mismatch detection (server vs browser controls) - Code-without-tools retry (Thinker wrote code but no tool calls) - Intent-without-action retry (request intent but Thinker only produced text) - Dashboard feedback: browser sends workspace state on every message - Sensor continuous comparison on 5s tick State machines: - create_machine / add_state / reset_machine / destroy_machine via function calling - Local transitions (go:) resolve without LLM round-trip - Button persistence across turns Database tools: - query_db tool via pymysql to MariaDB K3s pod (eras2_production) - Table rendering in workspace (tab-separated parsing) - Director pre-planning with Opus for complex data requests - Error retry with corrected SQL Frontend: - Cytoscape.js pipeline graph with real-time node animations - Overlay scrollbars (CSS-only, no reflow) - Tool call/result trace events - S3* audit events in trace Testing: - 167 integration tests (11 test suites) - 22 node-level unit tests (test_nodes/) - Three test levels: node unit, graph integration, scenario Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
802 lines
31 KiB
JavaScript
802 lines
31 KiB
JavaScript
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 =
|
||
'<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();
|