Frame Engine (v3-framed): - Tick-based deterministic pipeline: frames advance on completion, not timers - FrameRecord/FrameTrace dataclasses for structured per-message tracing - /api/frames endpoint: queryable frame trace history (last 20 messages) - frame_trace HUD event with full pipeline visibility - Reflex=2F, Director=4F, Director+Interpreter=5F deterministic frame counts Expert Architecture (v4-eras): - PA node (pa_v1): routes to domain experts, holds user context - ExpertNode base: stateless executor with plan+execute two-LLM-call pattern - ErasExpertNode: eras2_production DB specialist with DESCRIBE-first discipline - Schema caching: DESCRIBE results reused across queries within session - Progress streaming: PA streams thinking message, expert streams per-tool progress - PARouting type for structured routing decisions UI Controls Split: - Separate thinker_controls from machine controls (current_controls is now a property) - Machine buttons persist across Thinker responses - Machine state parser handles both dict and list formats from Director - Normalized button format with go/payload field mapping WebSocket Architecture: - /ws/test: dedicated debug socket for test runner progress - /ws/trace: dedicated debug socket for HUD/frame trace events - /ws (chat): cleaned up, only deltas/controls/done/cleared - WS survives graph switch (re-attaches to new runtime) - Pipeline result reset on clear Test Infrastructure: - Live test streaming: on_result callback fires per check during execution - Frontend polling fallback (500ms) for proxy-buffered WS - frame_trace-first trace assertion (fixes stale perceived event bug) - action_match supports "or" patterns and multi-pattern matching - Trace window increased to 40 events - Graph-agnostic assertions (has X or Y) Test Suites: - smoketest.md: 12 steps covering all categories (~2min) - fast.md: 10 quick checks (~1min) - fast_v4.md: 10 v4-eras specific checks - expert_eras.md: eras domain tests (routing, DB, schema, errors) - expert_progress.md: progress streaming tests Other: - Shared db.py extracted from thinker_v2 (reused by experts) - InputNode prompt: few-shot examples, history as context summary - Director prompt: full tool signatures for add_state/reset_machine/destroy_machine - nginx no-cache headers for static files during development - Cache-busted static file references Scores: v3 smoketest 39/40, v4-eras fast 28/28, expert_eras 23/23 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1121 lines
41 KiB
JavaScript
1121 lines
41 KiB
JavaScript
const msgs = document.getElementById('messages');
|
||
const inputEl = document.getElementById('input');
|
||
const statusEl = document.getElementById('status');
|
||
const traceEl = document.getElementById('trace');
|
||
let ws, wsTest, wsTrace, currentEl;
|
||
let _currentDashboard = []; // S3*: tracks what user sees in workspace
|
||
let authToken = localStorage.getItem('cog_token');
|
||
let authConfig = null;
|
||
let cy = null; // Cytoscape instance
|
||
|
||
// --- Pipeline Graph ---
|
||
|
||
// Node color palette by role
|
||
const NODE_COLORS = {
|
||
user: '#444', input: '#f59e0b', sensor: '#3b82f6',
|
||
director: '#a855f7', thinker: '#f97316', interpreter: '#06b6d4',
|
||
output: '#10b981', ui: '#10b981', memorizer: '#a855f7', s3_audit: '#ef4444',
|
||
};
|
||
|
||
// Layout columns: role -> column index
|
||
const NODE_COLUMNS = {
|
||
user: 0, input: 1, sensor: 1,
|
||
director: 2, thinker: 2, interpreter: 2, s3_audit: 2,
|
||
output: 3, ui: 3,
|
||
memorizer: 4,
|
||
};
|
||
|
||
function buildGraphElements(graph, mx, cw, mid, row1, row2) {
|
||
const elements = [];
|
||
const roles = Object.keys(graph.nodes);
|
||
|
||
// Always add user node
|
||
elements.push({ data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } });
|
||
|
||
// Group roles by column
|
||
const columns = {};
|
||
for (const role of roles) {
|
||
const col = NODE_COLUMNS[role] !== undefined ? NODE_COLUMNS[role] : 2;
|
||
if (!columns[col]) columns[col] = [];
|
||
columns[col].push(role);
|
||
}
|
||
|
||
// Position nodes within each column
|
||
for (const [col, colRoles] of Object.entries(columns)) {
|
||
const c = parseInt(col);
|
||
const count = colRoles.length;
|
||
for (let i = 0; i < count; i++) {
|
||
const role = colRoles[i];
|
||
const ySpread = (row2 - row1);
|
||
const y = count === 1 ? mid : row1 + (ySpread * i / (count - 1));
|
||
const label = role === 'memorizer' ? 'memo' : role.replace(/_v\d+$/, '');
|
||
elements.push({ data: { id: role, label }, position: { x: mx + cw * c, y } });
|
||
}
|
||
}
|
||
|
||
// Collect valid node IDs for edge filtering
|
||
const nodeIds = new Set(elements.map(e => e.data.id));
|
||
|
||
// Add edges from graph definition
|
||
const cytoEdges = graph.cytoscape ? graph.cytoscape.edges : [];
|
||
if (cytoEdges.length) {
|
||
for (const edge of cytoEdges) {
|
||
const d = edge.data;
|
||
if (!nodeIds.has(d.source) || !nodeIds.has(d.target)) continue;
|
||
const edgeData = { id: d.id, source: d.source, target: d.target };
|
||
if (d.condition === 'reflex') edgeData.reflex = true;
|
||
if (d.edge_type === 'context') edgeData.ctx = true;
|
||
elements.push({ data: edgeData });
|
||
}
|
||
} else {
|
||
// Build edges from graph.edges array
|
||
for (const edge of graph.edges) {
|
||
const targets = Array.isArray(edge.to) ? edge.to : [edge.to];
|
||
for (const tgt of targets) {
|
||
if (!nodeIds.has(edge.from) || !nodeIds.has(tgt)) continue;
|
||
const edgeData = { id: `e-${edge.from}-${tgt}`, source: edge.from, target: tgt };
|
||
if (edge.condition === 'reflex') edgeData.reflex = true;
|
||
if (edge.type === 'context') edgeData.ctx = true;
|
||
elements.push({ data: edgeData });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Always add user->input edge
|
||
elements.push({ data: { id: 'e-user-input', source: 'user', target: 'input' } });
|
||
|
||
return elements;
|
||
}
|
||
|
||
async function initGraph() {
|
||
const container = document.getElementById('pipeline-graph');
|
||
if (!container) { console.error('[graph] no #pipeline-graph container'); return; }
|
||
if (typeof cytoscape === 'undefined') { console.error('[graph] cytoscape not loaded'); return; }
|
||
|
||
// Force dimensions — flexbox may not have resolved yet
|
||
const rect = container.getBoundingClientRect();
|
||
const W = rect.width || container.offsetWidth || 900;
|
||
const H = rect.height || container.offsetHeight || 180;
|
||
console.log('[graph] init', W, 'x', H);
|
||
// Layout: group by real data flow
|
||
// Col 0: user (external)
|
||
// Col 1: input (perceive) + sensor (environment) — both feed INTO the core
|
||
// Col 2: director (plans) + thinker (executes) + S3* (audits) — the CORE
|
||
// Col 3: output (voice) + ui (dashboard) — RENDER to user
|
||
// Col 4: memorizer (remembers) — feeds BACK to core
|
||
const mx = W * 0.07;
|
||
const cw = (W - mx * 2) / 4;
|
||
const row1 = H * 0.25;
|
||
const mid = H * 0.5;
|
||
const row2 = H * 0.75;
|
||
|
||
// Fetch graph from API, fall back to v1 hardcoded layout
|
||
let graphElements = null;
|
||
try {
|
||
const resp = await fetch('/api/graph/active');
|
||
if (resp.ok) {
|
||
const graph = await resp.json();
|
||
graphElements = buildGraphElements(graph, mx, cw, mid, row1, row2);
|
||
console.log('[graph] loaded from API:', graph.name, graphElements.length, 'elements');
|
||
}
|
||
} catch (e) { console.warn('[graph] API fetch failed, using fallback:', e); }
|
||
|
||
if (!graphElements) {
|
||
graphElements = [
|
||
{ data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } },
|
||
{ data: { id: 'input', label: 'input' }, position: { x: mx + cw, y: row1 + 5 } },
|
||
{ data: { id: 'sensor', label: 'sensor' }, position: { x: mx + cw, y: row2 - 5 } },
|
||
{ data: { id: 'director', label: 'director' }, position: { x: mx + cw * 1.8, y: row1 - 10 } },
|
||
{ data: { id: 'thinker', label: 'thinker' }, position: { x: mx + cw * 2, y: mid } },
|
||
{ data: { id: 's3_audit', label: 'S3*' }, position: { x: mx + cw * 1.8, y: row2 + 10 } },
|
||
{ data: { id: 'output', label: 'output' }, position: { x: mx + cw * 3, y: row1 + 5 } },
|
||
{ data: { id: 'ui', label: 'ui' }, position: { x: mx + cw * 3, y: row2 - 5 } },
|
||
{ data: { id: 'memorizer', label: 'memo' }, position: { x: mx + cw * 4, y: mid } },
|
||
{ data: { id: 'e-user-input', source: 'user', target: 'input' } },
|
||
{ data: { id: 'e-input-thinker', source: 'input', target: 'thinker' } },
|
||
{ data: { id: 'e-input-output', source: 'input', target: 'output', reflex: true } },
|
||
{ data: { id: 'e-thinker-output', source: 'thinker', target: 'output' } },
|
||
{ data: { id: 'e-thinker-ui', source: 'thinker', target: 'ui' } },
|
||
{ data: { id: 'e-output-memo', source: 'output', target: 'memorizer' } },
|
||
{ data: { id: 'e-memo-director', source: 'memorizer', target: 'director' } },
|
||
{ data: { id: 'e-director-thinker', source: 'director', target: 'thinker' } },
|
||
{ data: { id: 'e-thinker-audit', source: 'thinker', target: 's3_audit' } },
|
||
{ data: { id: 'e-audit-thinker', source: 's3_audit', target: 'thinker', ctx: true } },
|
||
{ data: { id: 'e-sensor-thinker', source: 'sensor', target: 'thinker', ctx: true } },
|
||
{ data: { id: 'e-memo-sensor', source: 'memorizer', target: 'sensor', ctx: true } },
|
||
{ data: { id: 'e-ui-sensor', source: 'ui', target: 'sensor', ctx: true } },
|
||
];
|
||
}
|
||
|
||
cy = cytoscape({
|
||
container,
|
||
elements: graphElements,
|
||
style: [
|
||
{ selector: 'node', style: {
|
||
'label': 'data(label)',
|
||
'text-valign': 'center',
|
||
'text-halign': 'center',
|
||
'font-size': '18px',
|
||
'min-zoomed-font-size': 10,
|
||
'font-family': 'system-ui, sans-serif',
|
||
'font-weight': 700,
|
||
'color': '#aaa',
|
||
'background-color': '#181818',
|
||
'border-width': 1,
|
||
'border-opacity': 0.3,
|
||
'border-color': '#444',
|
||
'width': 48,
|
||
'height': 48,
|
||
'transition-property': 'background-color, border-color, width, height',
|
||
'transition-duration': '0.3s',
|
||
}},
|
||
// Node colors — dynamic from NODE_COLORS palette
|
||
...Object.entries(NODE_COLORS).map(([id, color]) => ({
|
||
selector: `#${id}`, style: { 'border-color': color, 'color': color }
|
||
})),
|
||
{ selector: '#user', style: { 'color': '#888' } },
|
||
{ selector: '#sensor', style: { 'width': 40, 'height': 40, 'font-size': '15px' } },
|
||
{ selector: '#s3_audit', style: { 'width': 36, 'height': 36, 'font-size': '14px', 'border-style': 'dashed', 'border-opacity': 0.5 } },
|
||
// Active node (pulsed)
|
||
{ selector: 'node.active', style: {
|
||
'background-color': '#333',
|
||
'border-width': 3,
|
||
'width': 56,
|
||
'height': 56,
|
||
}},
|
||
// Edges
|
||
{ selector: 'edge', style: {
|
||
'width': 1.5,
|
||
'line-color': '#333',
|
||
'target-arrow-color': '#333',
|
||
'target-arrow-shape': 'triangle',
|
||
'arrow-scale': 0.7,
|
||
'curve-style': 'bezier',
|
||
'transition-property': 'line-color, target-arrow-color, width',
|
||
'transition-duration': '0.3s',
|
||
}},
|
||
{ selector: 'edge[?reflex]', style: { 'line-style': 'dashed', 'line-dash-pattern': [4, 4], 'line-color': '#2a2a2a' } },
|
||
{ selector: 'edge[?ctx]', style: { 'line-style': 'dotted', 'line-color': '#1a1a2e', 'width': 1 } },
|
||
{ selector: 'edge.active', style: { 'line-color': '#888', 'target-arrow-color': '#888', 'width': 2.5 } },
|
||
],
|
||
layout: { name: 'preset' },
|
||
userZoomingEnabled: true,
|
||
userPanningEnabled: true,
|
||
wheelSensitivity: 0.3,
|
||
boxSelectionEnabled: false,
|
||
autoungrabify: false, // drag on by default
|
||
selectionType: 'single',
|
||
});
|
||
|
||
// Re-enable right-click
|
||
container.addEventListener('contextmenu', e => e.stopPropagation(), true);
|
||
|
||
// Register cola + start physics
|
||
if (typeof cytoscapeCola !== 'undefined') cytoscape.use(cytoscapeCola);
|
||
startPhysics();
|
||
|
||
// Keep font size constant regardless of zoom
|
||
cy.on('zoom', () => {
|
||
const z = cy.zoom();
|
||
const fontSize = Math.round(12 / z);
|
||
const sensorSize = Math.round(10 / z);
|
||
const auditSize = Math.round(9 / z);
|
||
cy.nodes().style('font-size', fontSize + 'px');
|
||
cy.getElementById('sensor').style('font-size', sensorSize + 'px');
|
||
cy.getElementById('s3_audit').style('font-size', auditSize + 'px');
|
||
});
|
||
}
|
||
|
||
// --- Graph controls ---
|
||
let _dragEnabled = true;
|
||
let _physicsRunning = false;
|
||
let _physicsLayout = null;
|
||
let _colaSpacing = 25;
|
||
let _colaStrengthMult = 1.0;
|
||
|
||
function adjustCola(param, delta) {
|
||
if (!cy) return;
|
||
if (param === 'spacing') {
|
||
_colaSpacing = Math.max(5, Math.min(80, _colaSpacing + delta));
|
||
} else if (param === 'strength') {
|
||
_colaStrengthMult = Math.max(0.1, Math.min(3.0, _colaStrengthMult + delta * 0.2));
|
||
}
|
||
startPhysics();
|
||
}
|
||
|
||
function toggleDrag() {
|
||
if (!cy) return;
|
||
_dragEnabled = !_dragEnabled;
|
||
cy.autoungrabify(!_dragEnabled);
|
||
document.getElementById('btn-drag').textContent = 'drag: ' + (_dragEnabled ? 'on' : 'off');
|
||
}
|
||
|
||
function togglePhysics() {
|
||
if (!cy) return;
|
||
if (_physicsRunning) {
|
||
stopPhysics();
|
||
} else {
|
||
startPhysics();
|
||
}
|
||
}
|
||
|
||
function startPhysics() {
|
||
if (!cy) return;
|
||
stopPhysics();
|
||
try {
|
||
const rect = document.getElementById('pipeline-graph').getBoundingClientRect();
|
||
_physicsLayout = cy.layout({
|
||
name: 'cola',
|
||
animate: true,
|
||
infinite: true,
|
||
fit: false, // don't fight zoom
|
||
nodeSpacing: _colaSpacing,
|
||
nodeWeight: n => {
|
||
const w = { thinker: 80, input: 50, output: 50, memorizer: 40, director: 40, ui: 30, sensor: 20, s3_audit: 10, user: 60 };
|
||
return w[n.id()] || 30;
|
||
},
|
||
edgeElasticity: e => {
|
||
const base = e.data('ctx') ? 0.1 : e.data('reflex') ? 0.2 : 0.6;
|
||
return base * _colaStrengthMult;
|
||
},
|
||
boundingBox: { x1: 0, y1: 0, w: rect.width, h: rect.height },
|
||
});
|
||
_physicsLayout.run();
|
||
_physicsRunning = true;
|
||
} catch (e) {
|
||
console.log('[graph] physics failed:', e);
|
||
}
|
||
}
|
||
|
||
function stopPhysics() {
|
||
if (_physicsLayout) {
|
||
try { _physicsLayout.stop(); } catch(e) {}
|
||
_physicsLayout = null;
|
||
}
|
||
_physicsRunning = false;
|
||
}
|
||
|
||
let _panEnabled = true;
|
||
|
||
function togglePan() {
|
||
if (!cy) return;
|
||
_panEnabled = !_panEnabled;
|
||
cy.userPanningEnabled(_panEnabled);
|
||
cy.userZoomingEnabled(_panEnabled);
|
||
document.getElementById('btn-pan').textContent = 'pan: ' + (_panEnabled ? 'on' : 'off');
|
||
}
|
||
|
||
function copyGraphConfig() {
|
||
if (!cy) return;
|
||
const settings = {
|
||
graph: {
|
||
layout: 'cola',
|
||
spacing: _colaSpacing,
|
||
strengthMult: _colaStrengthMult,
|
||
drag: _dragEnabled,
|
||
pan: _panEnabled,
|
||
},
|
||
cytoscape: {
|
||
zoom: Math.round(cy.zoom() * 100) / 100,
|
||
pan: cy.pan(),
|
||
},
|
||
api: {
|
||
graph_active: '/api/graph/active',
|
||
graph_list: '/api/graph/list',
|
||
test_status: '/api/test/status',
|
||
},
|
||
nodes: Object.fromEntries(cy.nodes().map(n => [n.id(), {x: Math.round(n.position('x')), y: Math.round(n.position('y'))}])),
|
||
};
|
||
navigator.clipboard.writeText(JSON.stringify(settings, null, 2)).then(() => {
|
||
const btn = document.getElementById('btn-copy');
|
||
btn.textContent = 'copied!';
|
||
setTimeout(() => btn.textContent = 'copy', 1000);
|
||
});
|
||
}
|
||
|
||
function pulseNode(id) {
|
||
if (!cy) return;
|
||
const node = cy.getElementById(id);
|
||
if (!node.length) return;
|
||
node.addClass('active');
|
||
setTimeout(() => node.removeClass('active'), 1500);
|
||
}
|
||
|
||
function flashEdge(sourceId, targetId) {
|
||
if (!cy) return;
|
||
const edge = cy.edges().filter(e => e.data('source') === sourceId && e.data('target') === targetId);
|
||
if (!edge.length) return;
|
||
edge.addClass('active');
|
||
setTimeout(() => edge.removeClass('active'), 1000);
|
||
}
|
||
|
||
function graphAnimate(event, node) {
|
||
if (!cy) return;
|
||
// Pulse the source node if it exists in the graph (handles v1 and v2 node names)
|
||
if (node && cy.getElementById(node).length) pulseNode(node);
|
||
|
||
switch (event) {
|
||
case 'perceived':
|
||
pulseNode('input'); flashEdge('user', 'input');
|
||
break;
|
||
case 'decided':
|
||
if (node === 'director_v2' || node === 'director') {
|
||
pulseNode(node); flashEdge(node, 'thinker');
|
||
} else {
|
||
// thinker decided
|
||
pulseNode(node || 'thinker');
|
||
flashEdge('thinker', 'output');
|
||
}
|
||
break;
|
||
case 'reflex_path':
|
||
pulseNode('input'); flashEdge('input', 'output');
|
||
break;
|
||
case 'streaming':
|
||
if (node === 'output') pulseNode('output');
|
||
break;
|
||
case 'controls':
|
||
case 'machine_created':
|
||
case 'machine_transition':
|
||
case 'machine_state_added':
|
||
case 'machine_reset':
|
||
case 'machine_destroyed':
|
||
pulseNode('ui'); flashEdge('thinker', 'ui');
|
||
break;
|
||
case 'updated':
|
||
pulseNode('memorizer'); flashEdge('output', 'memorizer');
|
||
break;
|
||
case 'director_updated':
|
||
pulseNode('director'); flashEdge('memorizer', 'director');
|
||
break;
|
||
case 'director_plan':
|
||
pulseNode('director'); flashEdge('director', 'thinker');
|
||
break;
|
||
case 'tick':
|
||
pulseNode('sensor');
|
||
break;
|
||
case 'thinking':
|
||
if (node) pulseNode(node);
|
||
break;
|
||
case 'tool_call':
|
||
pulseNode(node || 'thinker'); flashEdge('thinker', 'ui');
|
||
break;
|
||
case 'tool_result':
|
||
if (cy.getElementById('interpreter').length) {
|
||
pulseNode('interpreter');
|
||
}
|
||
break;
|
||
case 'interpreted':
|
||
pulseNode('interpreter'); flashEdge('interpreter', 'output');
|
||
break;
|
||
case 's3_audit':
|
||
if (cy.getElementById('s3_audit').length) {
|
||
pulseNode('s3_audit'); flashEdge('thinker', 's3_audit'); flashEdge('s3_audit', 'thinker');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// --- OIDC Auth ---
|
||
|
||
async function initAuth() {
|
||
try {
|
||
const resp = await fetch('/auth/config');
|
||
authConfig = await resp.json();
|
||
} catch { authConfig = { enabled: false }; }
|
||
|
||
if (!authConfig.enabled) { connect(); return; }
|
||
|
||
// Handle OIDC callback
|
||
if (location.pathname === '/callback') {
|
||
const params = new URLSearchParams(location.search);
|
||
const code = params.get('code');
|
||
const verifier = sessionStorage.getItem('pkce_verifier');
|
||
if (code && verifier) {
|
||
const tokenResp = await fetch(authConfig.issuer + '/oauth/v2/token', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({
|
||
grant_type: 'authorization_code',
|
||
client_id: authConfig.clientId,
|
||
code,
|
||
redirect_uri: location.origin + '/callback',
|
||
code_verifier: verifier,
|
||
}),
|
||
});
|
||
const tokens = await tokenResp.json();
|
||
if (tokens.access_token) {
|
||
// Store access token for userinfo, id_token for JWT validation
|
||
localStorage.setItem('cog_access_token', tokens.access_token);
|
||
authToken = tokens.id_token || tokens.access_token;
|
||
localStorage.setItem('cog_token', authToken);
|
||
sessionStorage.removeItem('pkce_verifier');
|
||
}
|
||
}
|
||
history.replaceState(null, '', '/');
|
||
}
|
||
|
||
if (authToken) {
|
||
connect();
|
||
} else {
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
function showLogin() {
|
||
statusEl.textContent = 'not authenticated';
|
||
statusEl.style.color = '#f59e0b';
|
||
const btn = document.createElement('button');
|
||
btn.textContent = 'Log in with loop42';
|
||
btn.className = 'login-btn';
|
||
btn.onclick = startLogin;
|
||
document.getElementById('input-bar').replaceChildren(btn);
|
||
}
|
||
|
||
async function startLogin() {
|
||
// PKCE: generate code_verifier + code_challenge
|
||
const verifier = randomString(64);
|
||
sessionStorage.setItem('pkce_verifier', verifier);
|
||
const encoder = new TextEncoder();
|
||
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier));
|
||
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||
|
||
const params = new URLSearchParams({
|
||
response_type: 'code',
|
||
client_id: authConfig.clientId,
|
||
redirect_uri: location.origin + '/callback',
|
||
scope: 'openid profile email',
|
||
code_challenge: challenge,
|
||
code_challenge_method: 'S256',
|
||
});
|
||
location.href = authConfig.issuer + '/oauth/v2/authorize?' + params;
|
||
}
|
||
|
||
function randomString(len) {
|
||
const arr = new Uint8Array(len);
|
||
crypto.getRandomValues(arr);
|
||
return btoa(String.fromCharCode(...arr)).replace(/[^a-zA-Z0-9]/g, '').slice(0, len);
|
||
}
|
||
|
||
// --- WebSocket ---
|
||
|
||
let _authFailed = false;
|
||
|
||
function connect() {
|
||
if (_authFailed) return;
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
let wsUrl = proto + '//' + location.host + '/ws';
|
||
if (authToken) {
|
||
const accessToken = localStorage.getItem('cog_access_token') || '';
|
||
wsUrl += '?token=' + encodeURIComponent(authToken) + '&access_token=' + encodeURIComponent(accessToken);
|
||
}
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = () => {
|
||
statusEl.textContent = 'connected';
|
||
statusEl.style.color = '#22c55e';
|
||
addTrace('runtime', 'connected', 'ws open');
|
||
connectDebugSockets();
|
||
};
|
||
|
||
ws.onerror = () => {}; // swallow — onclose handles it
|
||
|
||
ws.onclose = (e) => {
|
||
// 4001 = explicit auth rejection, 1006 = HTTP 403 before upgrade
|
||
if (e.code === 4001 || e.code === 1006) {
|
||
_authFailed = true;
|
||
localStorage.removeItem('cog_token');
|
||
localStorage.removeItem('cog_access_token');
|
||
authToken = null;
|
||
statusEl.textContent = 'session expired';
|
||
statusEl.style.color = '#ef4444';
|
||
addTrace('runtime', 'auth expired', 'please log in again');
|
||
showLogin();
|
||
return;
|
||
}
|
||
statusEl.textContent = 'disconnected';
|
||
statusEl.style.color = '#666';
|
||
addTrace('runtime', 'disconnected', 'ws closed');
|
||
setTimeout(connect, 2000);
|
||
};
|
||
|
||
ws.onmessage = (e) => {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (data.type === 'hud') {
|
||
handleHud(data);
|
||
|
||
} else if (data.type === 'delta') {
|
||
if (!currentEl) {
|
||
currentEl = addMsg('assistant', '');
|
||
currentEl.classList.add('streaming');
|
||
}
|
||
currentEl.textContent += data.content;
|
||
scroll(msgs);
|
||
|
||
} else if (data.type === 'done') {
|
||
if (currentEl) {
|
||
currentEl.classList.remove('streaming');
|
||
// Render markdown now that streaming is complete
|
||
currentEl.innerHTML = renderMarkdown(currentEl.textContent);
|
||
}
|
||
currentEl = null;
|
||
|
||
} else if (data.type === 'controls') {
|
||
dockControls(data.controls);
|
||
} else if (data.type === 'cleared') {
|
||
addTrace('runtime', 'cleared', 'session reset');
|
||
}
|
||
};
|
||
}
|
||
|
||
// --- Debug WebSockets: /ws/test and /ws/trace ---
|
||
|
||
let _testPollInterval = null;
|
||
let _lastTestResultCount = 0;
|
||
|
||
function connectDebugSockets() {
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const base = proto + '//' + location.host;
|
||
const tokenParam = authToken ? '?token=' + encodeURIComponent(authToken) : '';
|
||
|
||
// /ws/test — test runner progress (WS + polling fallback)
|
||
if (!wsTest || wsTest.readyState > 1) {
|
||
wsTest = new WebSocket(base + '/ws/test' + tokenParam);
|
||
wsTest.onopen = () => addTrace('runtime', 'ws/test', 'connected');
|
||
wsTest.onclose = () => {
|
||
addTrace('runtime', 'ws/test', 'disconnected');
|
||
setTimeout(() => connectDebugSockets(), 3000);
|
||
};
|
||
wsTest.onerror = () => {};
|
||
wsTest.onmessage = (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'test_status') updateTestStatus(data);
|
||
};
|
||
}
|
||
|
||
// Polling fallback for test status (WS may buffer through proxy)
|
||
if (!_testPollInterval) {
|
||
_testPollInterval = setInterval(async () => {
|
||
try {
|
||
const headers = authToken ? { 'Authorization': 'Bearer ' + authToken } : {};
|
||
const r = await fetch('/api/test/status', { headers });
|
||
const data = await r.json();
|
||
const count = (data.results || []).length;
|
||
if (count !== _lastTestResultCount || data.running) {
|
||
_lastTestResultCount = count;
|
||
updateTestStatus(data);
|
||
}
|
||
} catch (e) {}
|
||
}, 500);
|
||
}
|
||
|
||
// /ws/trace — HUD and frame trace events
|
||
if (!wsTrace || wsTrace.readyState > 1) {
|
||
wsTrace = new WebSocket(base + '/ws/trace' + tokenParam);
|
||
wsTrace.onopen = () => addTrace('runtime', 'ws/trace', 'connected');
|
||
wsTrace.onclose = () => {}; // reconnects via test socket
|
||
wsTrace.onerror = () => {};
|
||
wsTrace.onmessage = (e) => {
|
||
const data = JSON.parse(e.data);
|
||
// Frame trace summary
|
||
if (data.event === 'frame_trace' && data.trace) {
|
||
const t = data.trace;
|
||
const frames = t.frames || [];
|
||
const summary = frames.map(f => `F${f.frame}:${f.node}(${f.duration_ms}ms)`).join(' → ');
|
||
addTrace('frame_engine', 'trace', `${t.path} ${t.total_frames}F ${t.total_ms}ms`, 'instruction',
|
||
summary + '\n' + JSON.stringify(t, null, 2));
|
||
}
|
||
// All other HUD events go to trace panel
|
||
else if (data.node && data.event) {
|
||
handleHud(data);
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
function updateTestStatus(data) {
|
||
const el = document.getElementById('test-status');
|
||
if (!el) return;
|
||
const results = data.results || [];
|
||
const pass = results.filter(r => r.status === 'PASS').length;
|
||
const fail = results.filter(r => r.status === 'FAIL').length;
|
||
const done = results.length;
|
||
const expected = data.total_expected || done;
|
||
|
||
const totalMs = results.reduce((s, r) => s + (r.elapsed_ms || 0), 0);
|
||
const durStr = totalMs > 1000 ? `${(totalMs / 1000).toFixed(1)}s` : `${totalMs}ms`;
|
||
|
||
if (data.running) {
|
||
const current = data.current || '';
|
||
const lastMs = results.length ? results[results.length - 1].elapsed_ms || 0 : 0;
|
||
const lastStr = lastMs > 1000 ? `${(lastMs / 1000).toFixed(1)}s` : `${lastMs}ms`;
|
||
el.innerHTML = `<span class="ts-running">TESTING</span> `
|
||
+ `<span class="ts-pass">${done}</span>/<span>${expected}</span>`
|
||
+ (fail ? ` <span class="ts-fail">${fail}F</span>` : '')
|
||
+ ` <span style="color:#555">${durStr}</span>`
|
||
+ ` <span style="color:#888;max-width:20rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current)}</span>`
|
||
+ ` <span style="color:#555">${lastStr}</span>`;
|
||
} else if (done > 0) {
|
||
const allGreen = fail === 0;
|
||
el.innerHTML = `<span class="${allGreen ? 'ts-pass' : 'ts-fail'}">${pass}/${expected}</span>`
|
||
+ (fail ? ` <span class="ts-fail">${fail} failed</span>` : ' <span class="ts-pass">all green</span>')
|
||
+ ` <span style="color:#555">${durStr}</span>`;
|
||
}
|
||
}
|
||
|
||
function handleHud(data) {
|
||
const node = data.node || 'unknown';
|
||
const event = data.event || '';
|
||
|
||
// Animate pipeline graph
|
||
graphAnimate(event, node);
|
||
|
||
if (event === 'context') {
|
||
// Update node meter
|
||
if (data.tokens !== undefined) {
|
||
updateMeter(node, data.tokens, data.max_tokens, data.fill_pct);
|
||
}
|
||
// Expandable: show message count + token info
|
||
const count = (data.messages || []).length;
|
||
const tokenInfo = data.tokens ? ` [${data.tokens}/${data.max_tokens}t ${data.fill_pct}%]` : '';
|
||
const summary = count + ' msgs' + tokenInfo + ': ' + (data.messages || []).map(m =>
|
||
m.role[0].toUpperCase() + ':' + truncate(m.content, 30)
|
||
).join(' | ');
|
||
const detail = (data.messages || []).map((m, i) =>
|
||
i + ' [' + m.role + '] ' + m.content
|
||
).join('\n');
|
||
addTrace(node, 'context', summary, 'context', detail);
|
||
|
||
} else if (event === 'perceived') {
|
||
// v0.11: Input sends structured analysis, not prose instruction
|
||
const text = data.analysis
|
||
? Object.entries(data.analysis).map(([k,v]) => k + '=' + v).join(' ')
|
||
: (data.instruction || '');
|
||
const detail = data.analysis ? JSON.stringify(data.analysis, null, 2) : null;
|
||
addTrace(node, 'perceived', text, 'instruction', detail);
|
||
|
||
} else if (event === 'decided') {
|
||
addTrace(node, 'decided', data.instruction, 'instruction');
|
||
|
||
} else if (event === 'updated' && data.state) {
|
||
const pairs = Object.entries(data.state).map(([k, v]) => {
|
||
const val = Array.isArray(v) ? v.join(', ') : (v || 'null');
|
||
return k + '=' + truncate(val, 25);
|
||
}).join(' ');
|
||
const detail = JSON.stringify(data.state, null, 2);
|
||
addTrace(node, 'state', pairs, 'state', detail);
|
||
updateAwarenessState(data.state);
|
||
|
||
} else if (event === 'process_start') {
|
||
addTrace(node, 'run ' + (data.tool || 'python'), truncate(data.code || '', 80), 'instruction', data.code);
|
||
showAwarenessProcess(data.pid, data.tool || 'python', data.code || '');
|
||
|
||
} else if (event === 'process_done') {
|
||
addTrace(node, (data.exit_code === 0 ? 'done' : 'failed'), truncate(data.output || '', 80), data.exit_code === 0 ? '' : 'error', data.output);
|
||
updateAwarenessProcess(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed);
|
||
|
||
} else if (event === 'error') {
|
||
addTrace(node, 'error', data.detail || '', 'error');
|
||
|
||
} else if (event === 's3_audit') {
|
||
addTrace(node, 'S3* ' + (data.check || ''), data.detail || '', data.detail && data.detail.includes('failed') ? 'error' : 'instruction');
|
||
|
||
} else if (event === 'director_plan') {
|
||
const steps = (data.steps || []).join(' → ');
|
||
addTrace(node, 'plan', data.goal + ': ' + steps, 'instruction', JSON.stringify(data, null, 2));
|
||
|
||
} else if (event === 'tool_call') {
|
||
addTrace(node, 'tool: ' + (data.tool || '?'), data.input || '', 'instruction');
|
||
|
||
} else if (event === 'tool_result') {
|
||
const rows = data.rows !== undefined ? ` (${data.rows} rows)` : '';
|
||
addTrace(node, 'result: ' + (data.tool || '?'), truncate(data.output || '', 100) + rows, '', data.output);
|
||
|
||
} else if (event === 'thinking') {
|
||
addTrace(node, 'thinking', data.detail || '');
|
||
|
||
} else if (event === 'streaming') {
|
||
addTrace(node, 'streaming', '');
|
||
|
||
} else if (event === 'done') {
|
||
addTrace(node, 'done', '');
|
||
|
||
} else if (event === 'tick') {
|
||
// Update sensor meter with tick count
|
||
const meter = document.getElementById('meter-sensor');
|
||
if (meter) {
|
||
const text = meter.querySelector('.nm-text');
|
||
const deltas = Object.entries(data.deltas || {}).map(([k,v]) => k + '=' + v).join(' ');
|
||
text.textContent = 'tick #' + (data.tick || 0) + (deltas ? ' | ' + deltas : '');
|
||
}
|
||
if (data.deltas && Object.keys(data.deltas).length) {
|
||
const deltas = Object.entries(data.deltas).map(([k,v]) => k + '=' + truncate(String(v), 30)).join(' ');
|
||
addTrace(node, 'tick #' + data.tick, deltas);
|
||
}
|
||
updateAwarenessSensors(data.tick || 0, data.deltas || {});
|
||
|
||
} else if (event === 'started' || event === 'stopped') {
|
||
const meter = document.getElementById('meter-sensor');
|
||
if (meter) meter.querySelector('.nm-text').textContent = event;
|
||
addTrace(node, event, '');
|
||
|
||
} else {
|
||
// Generic fallback
|
||
const detail = JSON.stringify(data, null, 2);
|
||
addTrace(node, event, '', '', detail);
|
||
}
|
||
}
|
||
|
||
function addTrace(node, event, text, cls, detail) {
|
||
const line = document.createElement('div');
|
||
line.className = 'trace-line' + (detail ? ' expandable' : '');
|
||
|
||
const ts = new Date().toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 1 });
|
||
|
||
line.innerHTML =
|
||
'<span class="trace-ts">' + ts + '</span>' +
|
||
'<span class="trace-node ' + esc(node) + '">' + esc(node) + '</span>' +
|
||
'<span class="trace-event">' + esc(event) + '</span>' +
|
||
'<span class="trace-data' + (cls ? ' ' + cls : '') + '">' + esc(text) + '</span>';
|
||
|
||
traceEl.appendChild(line);
|
||
|
||
if (detail) {
|
||
const detailEl = document.createElement('div');
|
||
detailEl.className = 'trace-detail';
|
||
detailEl.textContent = detail;
|
||
traceEl.appendChild(detailEl);
|
||
line.addEventListener('click', () => detailEl.classList.toggle('open'));
|
||
}
|
||
|
||
scroll(traceEl);
|
||
}
|
||
|
||
function renderControls(controls) {
|
||
const container = document.createElement('div');
|
||
container.className = 'controls-container';
|
||
|
||
for (const ctrl of controls) {
|
||
if (ctrl.type === 'button') {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'control-btn';
|
||
btn.textContent = ctrl.label;
|
||
btn.onclick = () => {
|
||
if (ws && ws.readyState === 1) {
|
||
ws.send(JSON.stringify({ type: 'action', action: ctrl.action, data: ctrl.payload || ctrl.data || {} }));
|
||
addTrace('runtime', 'action', ctrl.action);
|
||
}
|
||
};
|
||
container.appendChild(btn);
|
||
|
||
} else if (ctrl.type === 'table') {
|
||
const table = document.createElement('table');
|
||
table.className = 'control-table';
|
||
// Header
|
||
if (ctrl.columns) {
|
||
const thead = document.createElement('tr');
|
||
for (const col of ctrl.columns) {
|
||
const th = document.createElement('th');
|
||
th.textContent = col;
|
||
thead.appendChild(th);
|
||
}
|
||
table.appendChild(thead);
|
||
}
|
||
// Rows
|
||
for (const row of (ctrl.data || [])) {
|
||
const tr = document.createElement('tr');
|
||
if (Array.isArray(row)) {
|
||
for (const cell of row) {
|
||
const td = document.createElement('td');
|
||
td.textContent = cell;
|
||
tr.appendChild(td);
|
||
}
|
||
} else if (typeof row === 'object') {
|
||
for (const col of (ctrl.columns || Object.keys(row))) {
|
||
const td = document.createElement('td');
|
||
td.textContent = row[col] ?? '';
|
||
tr.appendChild(td);
|
||
}
|
||
}
|
||
table.appendChild(tr);
|
||
}
|
||
container.appendChild(table);
|
||
|
||
} else if (ctrl.type === 'label') {
|
||
const lbl = document.createElement('div');
|
||
lbl.className = 'control-label';
|
||
lbl.innerHTML = '<span class="cl-text">' + esc(ctrl.text || '') + '</span><span class="cl-value">' + esc(String(ctrl.value ?? '')) + '</span>';
|
||
container.appendChild(lbl);
|
||
|
||
} else if (ctrl.type === 'process') {
|
||
const card = document.createElement('div');
|
||
card.className = 'process-card ' + (ctrl.status || 'running');
|
||
card.innerHTML =
|
||
'<span class="pc-tool">' + esc(ctrl.tool || 'python') + '</span>' +
|
||
'<span class="pc-status">' + esc(ctrl.status || 'running') + '</span>' +
|
||
(ctrl.status === 'running' ? '<button class="pc-stop" onclick="cancelProcess(' + (ctrl.pid || 0) + ')">Stop</button>' : '') +
|
||
'<pre class="pc-output">' + esc(ctrl.output || '') + '</pre>';
|
||
container.appendChild(card);
|
||
}
|
||
}
|
||
|
||
msgs.appendChild(container);
|
||
scroll(msgs);
|
||
}
|
||
|
||
function cancelProcess(pid) {
|
||
if (ws && ws.readyState === 1) {
|
||
ws.send(JSON.stringify({ type: 'cancel_process', pid }));
|
||
}
|
||
}
|
||
|
||
function showProcessCard(pid, tool, code) {
|
||
const card = document.createElement('div');
|
||
card.className = 'process-card running';
|
||
card.id = 'proc-' + pid;
|
||
card.innerHTML =
|
||
'<span class="pc-tool">' + esc(tool) + '</span>' +
|
||
'<span class="pc-status">running</span>' +
|
||
'<button class="pc-stop" onclick="cancelProcess(' + pid + ')">Stop</button>' +
|
||
'<pre class="pc-code">' + esc(truncate(code, 200)) + '</pre>' +
|
||
'<pre class="pc-output"></pre>';
|
||
msgs.appendChild(card);
|
||
scroll(msgs);
|
||
}
|
||
|
||
function updateProcessCard(pid, status, output, elapsed) {
|
||
const card = document.getElementById('proc-' + pid);
|
||
if (!card) return;
|
||
card.className = 'process-card ' + status;
|
||
const statusEl = card.querySelector('.pc-status');
|
||
if (statusEl) statusEl.textContent = status + (elapsed ? ' (' + elapsed + 's)' : '');
|
||
const stopBtn = card.querySelector('.pc-stop');
|
||
if (stopBtn) stopBtn.remove();
|
||
const outEl = card.querySelector('.pc-output');
|
||
if (outEl && output) outEl.textContent = output;
|
||
}
|
||
|
||
function updateMeter(node, tokens, maxTokens, fillPct) {
|
||
const meter = document.getElementById('meter-' + node);
|
||
if (!meter) return;
|
||
const fill = meter.querySelector('.nm-fill');
|
||
const text = meter.querySelector('.nm-text');
|
||
fill.style.width = fillPct + '%';
|
||
fill.style.backgroundColor = fillPct > 80 ? '#ef4444' : fillPct > 50 ? '#f59e0b' : '#22c55e';
|
||
text.textContent = tokens + ' / ' + maxTokens + 't (' + fillPct + '%)';
|
||
}
|
||
|
||
function scroll(el) { el.scrollTop = el.scrollHeight; }
|
||
function esc(s) { const d = document.createElement('span'); d.textContent = s; return d.innerHTML; }
|
||
|
||
function renderMarkdown(text) {
|
||
// Escape HTML first
|
||
let html = esc(text);
|
||
// Code blocks (``` ... ```)
|
||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => '<pre><code>' + code.trim() + '</code></pre>');
|
||
// Inline code
|
||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
// Bold
|
||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
// Italic
|
||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||
// Headers
|
||
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
||
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
|
||
html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
||
// Unordered lists
|
||
html = html.replace(/^[*-] (.+)$/gm, '<li>$1</li>');
|
||
html = html.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
|
||
// Line breaks (double newline = paragraph break)
|
||
html = html.replace(/\n\n/g, '<br><br>');
|
||
html = html.replace(/\n/g, '<br>');
|
||
return html;
|
||
}
|
||
function truncate(s, n) { return s.length > n ? s.slice(0, n) + '\u2026' : s; }
|
||
|
||
function addMsg(role, text) {
|
||
const el = document.createElement('div');
|
||
el.className = 'msg ' + role;
|
||
el.textContent = text;
|
||
msgs.appendChild(el);
|
||
scroll(msgs);
|
||
return el;
|
||
}
|
||
|
||
function send() {
|
||
const text = inputEl.value.trim();
|
||
if (!text || !ws || ws.readyState !== 1) return;
|
||
addMsg('user', text);
|
||
addTrace('runtime', 'user_msg', truncate(text, 60));
|
||
// S3*: attach current workspace state so pipeline knows what user sees
|
||
ws.send(JSON.stringify({ text, dashboard: _currentDashboard }));
|
||
inputEl.value = '';
|
||
}
|
||
|
||
// --- 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();
|