Nico b6ca02f864 v0.9.2: dedicated UI node, strict node roles, markdown rendering
6-node pipeline: Input -> Thinker -> Output (voice) + UI (screen) in parallel

- Output: text only (markdown, emoji). Never emits HTML or controls.
- UI: dedicated node for labels, buttons, tables. Tracks workspace state.
  Replaces entire workspace on each update. Runs parallel with Output.
- Input: strict one-sentence perception. No more hallucinating responses.
- Thinker: controls removed from prompt, focuses on reasoning + tools.
- Frontend: markdown rendered in chat (bold, italic, code blocks, lists).
  Label control type added. UI node meter in top bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:12:15 +01:00

558 lines
20 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 authToken = localStorage.getItem('cog_token');
let authConfig = null;
// --- 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 ---
function connect() {
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.onclose = () => {
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') {
renderControls(data.controls);
dockControls(data.controls);
}
};
}
function handleHud(data) {
const node = data.node || 'unknown';
const event = data.event || '';
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') {
addTrace(node, 'perceived', data.instruction, 'instruction');
} 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);
showProcessCard(data.pid, data.tool || 'python', 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);
updateProcessCard(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed);
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 === '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));
ws.send(JSON.stringify({ text }));
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 done processes after 10s
if (status === 'done') {
setTimeout(() => {
el.remove();
const body = document.getElementById('aw-proc-body');
if (body && !body.children.length) body.innerHTML = '<span class="aw-empty">idle</span>';
}, 10000);
}
}
function dockControls(controls) {
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);
}
}
body.appendChild(container);
}
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
initAuth();