- Thinker tool results stream directly to user, skipping Output node (halves latency) - ProcessManager process_start/process_done events render as live cards in chat - UI controls sent before response text, not after - Button clicks route to handle_action(), skip Input, go straight to Thinker - Fix Thinker model: gemini-2.5-flash-preview -> gemini-2.5-flash (old ID expired) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
375 lines
13 KiB
JavaScript
375 lines
13 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');
|
|
currentEl = null;
|
|
|
|
} else if (data.type === 'controls') {
|
|
renderControls(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);
|
|
|
|
} 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 || '');
|
|
|
|
} 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);
|
|
|
|
} 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);
|
|
}
|
|
|
|
} 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.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 === '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 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 = '';
|
|
}
|
|
|
|
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
|
|
initAuth();
|