69eb87ebc9
Targeted UI/UX polish on the Agents page, keeping the existing dark aesthetic and both Overview/Live view modes: - Add a readable --text-mute token (dark + light) and apply it to the summary chips, lane meta, and idle/offline status, which previously used the near-invisible --text-dim. - Event feed: demote the generic "Span Started/Completed" label to a quiet mono category tag and promote the tool name, with a left-edge accent by event kind (run/span/error/session). Scoped to #agents-content so other pages' feeds are unaffected. - Active-op pills: add a per-kind left accent bar (tool/subagent/run). - Lane sparkline: raise opacity and add a gradient so it actually reads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
949 lines
38 KiB
JavaScript
949 lines
38 KiB
JavaScript
// ── agents.js — Agents page ───────────────────────────────
|
|
|
|
import {
|
|
escapeHTML,
|
|
formatDuration,
|
|
formatCount,
|
|
formatCost,
|
|
formatTokenCount,
|
|
formatElapsed,
|
|
getEnvelopeType,
|
|
getEnvelopePayload,
|
|
getEnvelopeAttributes,
|
|
getEnvelopeCorrelation,
|
|
getEnvelopeTS,
|
|
getRecordID,
|
|
getEventIcon,
|
|
getEventLabel,
|
|
getEventBody,
|
|
getEventDetails,
|
|
isAgentTimelineEvent,
|
|
isCurrentPath,
|
|
getAgentIdentity,
|
|
normalizeAgentKey,
|
|
agentsSkeleton,
|
|
} from '../utils.js';
|
|
|
|
import { subscribeWS } from '../ws.js';
|
|
|
|
import {
|
|
agentsState,
|
|
resetAgentsState,
|
|
mergeOpenClawEvents,
|
|
getVMStatus,
|
|
isOpenClawVM,
|
|
isAgentOnline,
|
|
openclawState,
|
|
} from '../state.js';
|
|
|
|
import { app, navigate, renderBreadcrumbs, isRouteCurrent } from '../router.js';
|
|
import { api } from '../api.js';
|
|
|
|
// ── Module-level state ───────────────────────────────────
|
|
|
|
let agentsUnsubscribe = null;
|
|
let _agentsRenderTimer = null;
|
|
|
|
// ── Private helpers ──────────────────────────────────────
|
|
|
|
function eventKindClass(eventType) {
|
|
if (eventType === 'run.start' || eventType === 'run.end') return 'evt-run';
|
|
if (eventType === 'span.start' || eventType === 'span.end') return 'evt-span';
|
|
if (eventType === 'error') return 'evt-error';
|
|
if (eventType === 'session.start' || eventType === 'session.end') return 'evt-session';
|
|
return 'evt-other';
|
|
}
|
|
|
|
function ensureAgentBucket(evt) {
|
|
const identity = getAgentIdentity(evt);
|
|
if (!identity.key) return null;
|
|
|
|
if (!agentsState.agents[identity.key]) {
|
|
agentsState.agents[identity.key] = {
|
|
key: identity.key,
|
|
name: identity.name,
|
|
framework: identity.framework,
|
|
host: identity.host,
|
|
clientID: identity.clientID,
|
|
sessions: {},
|
|
operations: {},
|
|
events: [],
|
|
eventIDs: new Set(),
|
|
lastSeenAt: 0,
|
|
liveLoaded: false,
|
|
liveLoading: false,
|
|
};
|
|
}
|
|
|
|
const agent = agentsState.agents[identity.key];
|
|
agent.name = identity.name || agent.name || identity.key;
|
|
agent.framework = identity.framework || agent.framework;
|
|
agent.host = identity.host || agent.host;
|
|
agent.clientID = identity.clientID || agent.clientID;
|
|
return agent;
|
|
}
|
|
|
|
function getSortedAgentKeys() {
|
|
return Object.keys(agentsState.agents).sort((a, b) => {
|
|
const left = agentsState.agents[a];
|
|
const right = agentsState.agents[b];
|
|
const leftOnline = isAgentOnline(left);
|
|
const rightOnline = isAgentOnline(right);
|
|
|
|
if (leftOnline !== rightOnline) return leftOnline ? -1 : 1;
|
|
return (left.name || left.key).localeCompare(right.name || right.key);
|
|
});
|
|
}
|
|
|
|
function ensureSelectedAgentKey() {
|
|
const keys = getSortedAgentKeys();
|
|
if (keys.length === 0) {
|
|
agentsState.selectedAgentKey = '';
|
|
return '';
|
|
}
|
|
if (!agentsState.selectedAgentKey || !agentsState.agents[agentsState.selectedAgentKey]) {
|
|
agentsState.selectedAgentKey = keys[0];
|
|
}
|
|
return agentsState.selectedAgentKey;
|
|
}
|
|
|
|
function setAgentsViewMode(mode) {
|
|
agentsState.viewMode = mode === 'live' ? 'live' : 'overview';
|
|
renderAgentsContent();
|
|
if (agentsState.viewMode === 'live') {
|
|
void loadSelectedAgentLiveData();
|
|
}
|
|
}
|
|
|
|
function getAgentBucket(evt) {
|
|
return ensureAgentBucket(evt);
|
|
}
|
|
|
|
function processAgentEvent(evt) {
|
|
const agent = getAgentBucket(evt);
|
|
if (!agent) return;
|
|
|
|
const eventType = getEnvelopeType(evt);
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
const ts = new Date(getEnvelopeTS(evt)).getTime();
|
|
agent.lastSeenAt = Number.isFinite(ts) ? ts : Date.now();
|
|
|
|
if (eventType === 'session.start' && correlation.session_id) {
|
|
agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
|
|
}
|
|
if (eventType === 'session.end' && correlation.session_id) {
|
|
delete agent.sessions[correlation.session_id];
|
|
}
|
|
|
|
if (eventType === 'span.start' && correlation.span_id) {
|
|
const payload = getEnvelopePayload(evt);
|
|
agent.operations['s:' + correlation.span_id] = {
|
|
type: 'span',
|
|
name: attrs.name || attrs.span_kind || 'unknown',
|
|
kind: attrs.span_kind || '',
|
|
subType: attrs.type || '',
|
|
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
|
promptPreview: payload.prompt_preview || '',
|
|
inputPreview: payload.input ? (typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input)) : '',
|
|
spanID: correlation.span_id,
|
|
runID: correlation.run_id || '',
|
|
};
|
|
}
|
|
if (eventType === 'span.end' && correlation.span_id) {
|
|
const op = agent.operations['s:' + correlation.span_id];
|
|
if (op) {
|
|
const payload = getEnvelopePayload(evt);
|
|
op.resultPreview = payload.result_preview || '';
|
|
op.status = payload.status || '';
|
|
op.durationMS = payload.duration_ms || 0;
|
|
op.endedAt = new Date(getEnvelopeTS(evt)).getTime() || Date.now();
|
|
op.usage = payload.usage || null;
|
|
setTimeout(() => {
|
|
delete agent.operations['s:' + correlation.span_id];
|
|
refreshThinkingStream(agent);
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
if (eventType === 'run.start' && correlation.run_id) {
|
|
const payload = getEnvelopePayload(evt);
|
|
agent.operations['r:' + correlation.run_id] = {
|
|
type: 'run',
|
|
name: 'Thinking…',
|
|
kind: 'run',
|
|
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
|
promptPreview: payload.prompt_preview || payload.message_preview || payload.message || '',
|
|
runID: correlation.run_id,
|
|
};
|
|
}
|
|
if (eventType === 'run.end' && correlation.run_id) {
|
|
const op = agent.operations['r:' + correlation.run_id];
|
|
if (op) {
|
|
const payload = getEnvelopePayload(evt);
|
|
op.endedAt = new Date(getEnvelopeTS(evt)).getTime() || Date.now();
|
|
op.status = payload.status || '';
|
|
op.usage = payload.usage || null;
|
|
op.model = payload.model || '';
|
|
op.thinkingTokens = (payload.usage && payload.usage.thinking_tokens) || 0;
|
|
setTimeout(() => {
|
|
delete agent.operations['r:' + correlation.run_id];
|
|
refreshThinkingStream(agent);
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
const id = getRecordID(evt);
|
|
if (id && !agent.eventIDs.has(id)) {
|
|
agent.eventIDs.add(id);
|
|
agent.events.push(evt);
|
|
while (agent.events.length > 100) {
|
|
const removed = agent.events.shift();
|
|
agent.eventIDs.delete(getRecordID(removed));
|
|
}
|
|
}
|
|
}
|
|
|
|
function getAgentDisplayOps(agent) {
|
|
const now = Date.now();
|
|
const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000);
|
|
const hasSpecificSpans = ops.some(op => op.kind && op.kind !== 'run');
|
|
return hasSpecificSpans ? ops.filter(op => op.kind && op.kind !== 'run') : ops;
|
|
}
|
|
|
|
function buildAgentActivityBars(agent, bucketCount) {
|
|
const events = agent.events || [];
|
|
if (events.length === 0) return '';
|
|
const count = bucketCount || 20;
|
|
const now = Date.now();
|
|
const windowMS = 3600000; // 1 hour
|
|
const bucketMS = windowMS / count;
|
|
const buckets = new Array(count).fill(0);
|
|
|
|
for (const evt of events) {
|
|
const ts = new Date(getEnvelopeTS(evt)).getTime();
|
|
const age = now - ts;
|
|
if (age > windowMS || age < 0) continue;
|
|
const idx = Math.min(count - 1, Math.floor((windowMS - age) / bucketMS));
|
|
buckets[idx]++;
|
|
}
|
|
|
|
const max = Math.max(...buckets, 1);
|
|
return `<div class="agent-lane-sparkline">${buckets.map(b => {
|
|
const pct = (b / max * 100).toFixed(0);
|
|
return `<div class="agent-lane-sparkline-bar" style="height:${Math.max(pct, 3)}%"></div>`;
|
|
}).join('')}</div>`;
|
|
}
|
|
|
|
function renderAgentLanes() {
|
|
const contentEl = document.getElementById('agents-content');
|
|
if (!contentEl) return;
|
|
contentEl.innerHTML = '<div class="agent-lanes" id="agents-lanes"></div>';
|
|
|
|
const lanesEl = document.getElementById('agents-lanes');
|
|
if (!lanesEl) return;
|
|
|
|
const agentKeys = getSortedAgentKeys();
|
|
|
|
if (agentKeys.length === 0) {
|
|
lanesEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
|
return;
|
|
}
|
|
|
|
lanesEl.innerHTML = agentKeys.map(key => {
|
|
const agent = agentsState.agents[key];
|
|
const isOnline = isAgentOnline(agent);
|
|
const sessionCount = Object.keys(agent.sessions).length;
|
|
const ops = getAgentDisplayOps(agent);
|
|
const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
|
|
|
|
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
|
|
const statusText = !isOnline ? 'offline'
|
|
: subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '')
|
|
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
|
|
: 'idle';
|
|
|
|
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
|
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
|
const stale = elapsed > 300;
|
|
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent'
|
|
: op.kind === 'run' ? ' run' : '';
|
|
return `
|
|
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
|
<span class="active-op-dot"></span>
|
|
<span class="active-op-name">${escapeHTML(op.name)}</span>
|
|
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
|
${stale ? '<span class="active-op-stale">(stale?)</span>' : ''}
|
|
</div>`;
|
|
}).join('')}</div>` : '';
|
|
|
|
const recent = agent.events.slice(-40).reverse();
|
|
const eventsHTML = recent.length > 0 ? recent.map(evt => {
|
|
const eventType = getEnvelopeType(evt);
|
|
const details = getEventDetails(evt);
|
|
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
|
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
|
|
|
return `
|
|
<div class="timeline-event ${eventKindClass(eventType)}">
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(eventType)}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
${expandHTML}
|
|
${detailHTML}
|
|
</div>`;
|
|
}).join('') : '<p class="empty-state">No recent activity</p>';
|
|
|
|
return `
|
|
<div class="agent-lane" data-agent-key="${escapeHTML(key)}">
|
|
<div class="agent-lane-header">
|
|
<div>
|
|
<div class="agent-lane-name">
|
|
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
|
|
${escapeHTML(agent.name || key)}
|
|
</div>
|
|
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
|
|
${buildAgentActivityBars(agent)}
|
|
</div>
|
|
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
|
</div>
|
|
${opsHTML}
|
|
<div class="agent-lane-events">${eventsHTML}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
lanesEl.querySelectorAll('.agent-lane[data-agent-key]').forEach(lane => {
|
|
lane.addEventListener('click', () => {
|
|
selectAgent(lane.dataset.agentKey || '', 'live');
|
|
});
|
|
});
|
|
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
|
button.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
button.parentElement.classList.toggle('expanded');
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderAgentSummary() {
|
|
const el = document.getElementById('agents-summary');
|
|
if (!el) return;
|
|
const s = agentsState.dbStats;
|
|
const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length;
|
|
const liveSubagents = getSortedAgentKeys().reduce((count, key) => {
|
|
const agent = agentsState.agents[key];
|
|
return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
|
|
}, 0);
|
|
el.innerHTML = `
|
|
<div class="agents-summary-stat">Live Agents <span class="value">${liveAgents}</span></div>
|
|
<div class="agents-summary-stat">Active Subagents <span class="value">${liveSubagents}</span></div>
|
|
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
|
|
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
|
|
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
|
|
`;
|
|
}
|
|
|
|
function getAgentLabel(agent) {
|
|
if (!agent) return 'Unknown';
|
|
return agent.name || agent.host || agent.framework || agent.key || 'Unknown';
|
|
}
|
|
|
|
function getAgentLiveSummary(agent) {
|
|
const recent = agent.events.slice().reverse();
|
|
const activeOps = getAgentDisplayOps(agent);
|
|
const sessionIDs = Object.keys(agent.sessions);
|
|
const live = {
|
|
sessionIDs,
|
|
activeOps,
|
|
activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'),
|
|
activeTools: activeOps.filter(op => op.kind === 'tool'),
|
|
latestPrompt: '',
|
|
latestRunStatus: '',
|
|
latestModel: '',
|
|
latestError: '',
|
|
latestUsage: null,
|
|
latestContextWindow: null,
|
|
};
|
|
|
|
for (const evt of recent) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const payload = getEnvelopePayload(evt);
|
|
if (!live.latestPrompt && eventType === 'run.start') {
|
|
live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || '';
|
|
}
|
|
if (!live.latestRunStatus && eventType === 'run.end') {
|
|
live.latestRunStatus = payload.status || '';
|
|
live.latestModel = payload.model || '';
|
|
live.latestUsage = payload.usage || null;
|
|
live.latestContextWindow = payload.context_window || null;
|
|
}
|
|
if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) {
|
|
live.latestUsage = payload.metrics.usage || null;
|
|
live.latestModel = live.latestModel || payload.metrics.model || '';
|
|
}
|
|
if (!live.latestError && eventType === 'error') {
|
|
const errPayload = payload.error || {};
|
|
live.latestError = errPayload.message || payload.message || '';
|
|
}
|
|
if (live.latestPrompt && live.latestRunStatus && live.latestError) break;
|
|
}
|
|
|
|
return live;
|
|
}
|
|
|
|
function buildLiveEventContext(evt) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const payload = getEnvelopePayload(evt);
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
const parts = [];
|
|
|
|
if ((eventType === 'span.start' || eventType === 'span.end') && attrs.span_kind === 'tool') {
|
|
if (payload.input) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">input</span><span class="v">${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}</span></div>`);
|
|
}
|
|
if (payload.result_preview) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">result</span><span class="v">${escapeHTML(String(payload.result_preview))}</span></div>`);
|
|
}
|
|
}
|
|
if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) {
|
|
if (payload.prompt_preview) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(payload.prompt_preview))}</span></div>`);
|
|
}
|
|
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
|
}
|
|
if (payload.usage && payload.usage.total_cost !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.usage.total_cost))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'run.start') {
|
|
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
|
if (preview) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(preview))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'run.end') {
|
|
if (payload.model) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.model))}</span></div>`);
|
|
}
|
|
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
|
}
|
|
if (payload.duration_ms !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">duration</span><span class="v">${escapeHTML(formatDuration(payload.duration_ms))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'metric.snapshot' && payload.metrics) {
|
|
if (payload.metrics.model) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.metrics.model))}</span></div>`);
|
|
}
|
|
if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}</span></div>`);
|
|
}
|
|
if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.metrics.usage.total_cost))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'error') {
|
|
const errPayload = payload.error || {};
|
|
if (errPayload.type) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">type</span><span class="v">${escapeHTML(String(errPayload.type))}</span></div>`);
|
|
}
|
|
}
|
|
|
|
const ids = [];
|
|
if (correlation.session_id) ids.push(`session ${correlation.session_id}`);
|
|
if (correlation.run_id) ids.push(`run ${correlation.run_id}`);
|
|
if (correlation.span_id) ids.push(`span ${correlation.span_id}`);
|
|
if (ids.length > 0) {
|
|
parts.push(`<div class="live-detail-row ids"><span class="k">ids</span><span class="v">${escapeHTML(ids.join(' · '))}</span></div>`);
|
|
}
|
|
|
|
return parts.join('');
|
|
}
|
|
|
|
function getRunGroupLabel(runID, events) {
|
|
const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start');
|
|
if (!runStart) {
|
|
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
|
|
}
|
|
const payload = getEnvelopePayload(runStart);
|
|
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
|
if (preview) {
|
|
return preview.length > 72 ? preview.slice(0, 72) + '...' : preview;
|
|
}
|
|
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
|
|
}
|
|
|
|
function groupAgentEventsByRun(events) {
|
|
const groups = [];
|
|
const byRun = new Map();
|
|
|
|
for (const evt of events) {
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
const runID = correlation.run_id || '';
|
|
const key = runID || `session:${correlation.session_id || 'unknown'}`;
|
|
if (!byRun.has(key)) {
|
|
const group = {
|
|
key,
|
|
runID,
|
|
sessionID: correlation.session_id || '',
|
|
events: [],
|
|
subagents: new Set(),
|
|
tools: new Set(),
|
|
};
|
|
byRun.set(key, group);
|
|
groups.push(group);
|
|
}
|
|
|
|
const group = byRun.get(key);
|
|
group.events.push(evt);
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
if (attrs.span_kind === 'agent' || attrs.type === 'subagent') {
|
|
group.subagents.add(attrs.name || 'unknown');
|
|
}
|
|
if (attrs.span_kind === 'tool' && attrs.name) {
|
|
group.tools.add(attrs.name);
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
function refreshThinkingStream(agent) {
|
|
if (!agent) return;
|
|
const selectedKey = agentsState.selectedAgentKey;
|
|
if (agent.key !== selectedKey) return;
|
|
const streamEl = document.getElementById('thinking-stream-' + selectedKey);
|
|
if (streamEl) {
|
|
streamEl.innerHTML = renderThinkingStream(agent);
|
|
}
|
|
}
|
|
|
|
function renderThinkingStream(agent) {
|
|
const now = Date.now();
|
|
const ops = Object.values(agent.operations).filter(op => {
|
|
if (op.endedAt) return (now - op.endedAt) < 3000;
|
|
return (now - op.startedAt) < 300000;
|
|
});
|
|
|
|
if (ops.length === 0) {
|
|
return '<div class="thinking-stream-empty">Idle — waiting for activity</div>';
|
|
}
|
|
|
|
return ops.map(op => {
|
|
const elapsed = op.endedAt
|
|
? Math.floor((op.endedAt - op.startedAt) / 1000)
|
|
: Math.floor((now - op.startedAt) / 1000);
|
|
const isEnded = !!op.endedAt;
|
|
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
|
|
const isRun = op.kind === 'run';
|
|
const isTool = op.kind === 'tool';
|
|
|
|
let icon, kindLabel, kindClass;
|
|
if (isRun) {
|
|
icon = isEnded ? '✓' : '◌';
|
|
kindLabel = isEnded ? (op.status === 'success' ? 'Done' : op.status || 'Done') : 'Thinking';
|
|
kindClass = 'thinking-op-run' + (isEnded ? ' ended' : ' active');
|
|
} else if (isSubagent) {
|
|
icon = isEnded ? '✓' : '◎';
|
|
kindLabel = isEnded ? (op.status === 'success' ? 'Subagent done' : 'Subagent ' + (op.status || 'done')) : 'Subagent';
|
|
kindClass = 'thinking-op-subagent' + (isEnded ? ' ended' : ' active');
|
|
} else if (isTool) {
|
|
icon = isEnded ? '✓' : '▸';
|
|
kindLabel = isEnded ? (op.status === 'success' ? 'Tool done' : 'Tool ' + (op.status || 'done')) : 'Tool';
|
|
kindClass = 'thinking-op-tool' + (isEnded ? ' ended' : ' active');
|
|
} else {
|
|
icon = '·';
|
|
kindLabel = op.name;
|
|
kindClass = 'thinking-op-other' + (isEnded ? ' ended' : ' active');
|
|
}
|
|
|
|
const preview = op.promptPreview || op.inputPreview || '';
|
|
const result = op.resultPreview || '';
|
|
const usage = op.usage || {};
|
|
const thinkingToks = op.thinkingTokens || usage.thinking_tokens || 0;
|
|
const totalToks = usage.total_tokens || 0;
|
|
|
|
const navigableRunID = isRun ? op.runID : (isSubagent ? op.runID : '');
|
|
const clickable = navigableRunID ? ` clickable" data-run-id="${escapeHTML(navigableRunID)}` : '';
|
|
|
|
return `
|
|
<div class="thinking-op ${kindClass}${clickable ? ' thinking-op-link' : ''}"${clickable ? ` data-run-id="${escapeHTML(navigableRunID)}"` : ''}>
|
|
<div class="thinking-op-header">
|
|
<span class="thinking-op-icon${isRun && !isEnded ? ' spin' : ''}">${icon}</span>
|
|
<span class="thinking-op-kind">${escapeHTML(kindLabel)}</span>
|
|
<span class="thinking-op-name">${escapeHTML(op.name)}</span>
|
|
<span class="thinking-op-elapsed${isEnded ? '' : ' live'}" data-start="${op.startedAt}" data-ended="${op.endedAt || ''}">
|
|
${isEnded ? formatElapsed(elapsed) : `<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>`}
|
|
</span>
|
|
${navigableRunID ? '<span class="thinking-op-arrow">→</span>' : ''}
|
|
</div>
|
|
${preview ? `<div class="thinking-op-preview">${escapeHTML(preview.length > 180 ? preview.slice(0, 180) + '…' : preview)}</div>` : ''}
|
|
${result ? `<div class="thinking-op-result">${escapeHTML(result.length > 180 ? result.slice(0, 180) + '…' : result)}</div>` : ''}
|
|
${(thinkingToks || totalToks) ? `<div class="thinking-op-tokens">${thinkingToks ? `<span class="thinking-tok-badge">🧠 ${formatTokenCount(thinkingToks)} thinking</span>` : ''}${totalToks ? `<span class="thinking-tok-badge">⚡ ${formatTokenCount(totalToks)} total</span>` : ''}</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderAgentsLive() {
|
|
const contentEl = document.getElementById('agents-content');
|
|
if (!contentEl) return;
|
|
|
|
const agentKeys = getSortedAgentKeys();
|
|
const selectedKey = ensureSelectedAgentKey();
|
|
if (!selectedKey || agentKeys.length === 0) {
|
|
contentEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
|
return;
|
|
}
|
|
|
|
const selected = agentsState.agents[selectedKey];
|
|
const summary = getAgentLiveSummary(selected);
|
|
const recent = selected.events.slice(-80).reverse();
|
|
const runGroups = groupAgentEventsByRun(recent);
|
|
|
|
contentEl.innerHTML = `
|
|
<div class="agents-live-layout">
|
|
<aside class="agents-live-sidebar">
|
|
<div class="section-title">Agents</div>
|
|
<div class="agent-picker" id="agent-picker">
|
|
${agentKeys.map(key => {
|
|
const agent = agentsState.agents[key];
|
|
const active = key === selectedKey ? ' active' : '';
|
|
const online = isAgentOnline(agent) ? 'online' : 'offline';
|
|
const sessions = Object.keys(agent.sessions).length;
|
|
const ops = getAgentDisplayOps(agent).length;
|
|
return `
|
|
<button class="agent-picker-item${active}" data-agent-key="${escapeHTML(key)}" type="button">
|
|
<span class="agent-picker-dot ${online}"></span>
|
|
<span class="agent-picker-main">
|
|
<span class="agent-picker-name">${escapeHTML(getAgentLabel(agent))}</span>
|
|
<span class="agent-picker-meta">${escapeHTML(agent.framework || 'unknown')} · ${sessions} sessions · ${ops} ops</span>
|
|
</span>
|
|
</button>`;
|
|
}).join('')}
|
|
</div>
|
|
</aside>
|
|
<section class="agents-live-main">
|
|
<div class="agents-live-header">
|
|
<div>
|
|
<div class="agent-lane-name"><span class="agent-lane-dot ${isAgentOnline(selected) ? 'online' : 'offline'}"></span>${escapeHTML(getAgentLabel(selected))}</div>
|
|
<div class="agent-lane-meta">${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}</div>
|
|
</div>
|
|
<div class="agents-live-badges">
|
|
<span class="agents-live-badge">${summary.sessionIDs.length} sessions</span>
|
|
<span class="agents-live-badge">${summary.activeSubagents.length} subagents</span>
|
|
<span class="agents-live-badge">${summary.activeTools.length} tools</span>
|
|
</div>
|
|
</div>
|
|
<div class="agents-live-cards">
|
|
<div class="agents-live-card thinking-stream-card">
|
|
<div class="agents-live-card-title">
|
|
Live Operations
|
|
${summary.activeOps.length > 0 ? `<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>${summary.activeOps.length} active</span>` : ''}
|
|
</div>
|
|
<div class="thinking-stream" id="thinking-stream-${escapeHTML(selectedKey)}">
|
|
${renderThinkingStream(selected)}
|
|
</div>
|
|
</div>
|
|
<div class="agents-live-card">
|
|
<div class="agents-live-card-title">Last Run</div>
|
|
<div class="live-kv"><span>Status</span><strong>${escapeHTML(summary.latestRunStatus || '—')}</strong></div>
|
|
<div class="live-kv"><span>Model</span><strong>${escapeHTML(summary.latestModel || '—')}</strong></div>
|
|
<div class="live-kv"><span>Tokens</span><strong>${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.total_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Thinking</span><strong class="thinking-toks">${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.thinking_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Cost</span><strong>${escapeHTML(formatCost(summary.latestUsage ? summary.latestUsage.total_cost : null))}</strong></div>
|
|
${summary.latestError ? `<div class="live-kv error-kv"><span>Error</span><strong>${escapeHTML(summary.latestError)}</strong></div>` : ''}
|
|
</div>
|
|
<div class="agents-live-card">
|
|
<div class="agents-live-card-title">Context Window</div>
|
|
<div class="live-kv"><span>Input</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.input_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Output</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.output_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Used</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.used_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Remaining</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.tokens_remaining : null))}</strong></div>
|
|
${summary.latestContextWindow && summary.latestContextWindow.max_tokens ? `
|
|
<div class="context-bar">
|
|
<div class="context-bar-fill" style="width:${Math.min(100, ((summary.latestContextWindow.used_tokens || 0) / summary.latestContextWindow.max_tokens * 100)).toFixed(1)}%"></div>
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="agents-live-timeline">
|
|
${runGroups.length > 0 ? runGroups.map(group => `
|
|
<section class="live-run-group">
|
|
<div class="live-run-group-header">
|
|
<div class="live-run-group-title">${escapeHTML(getRunGroupLabel(group.runID, group.events))}</div>
|
|
<div class="live-run-group-meta">
|
|
<span>${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')}</span>
|
|
<span>${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')}</span>
|
|
<span>${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')}</span>
|
|
</div>
|
|
</div>
|
|
<div class="live-run-events">
|
|
${group.events.map(evt => `
|
|
<div class="timeline-event live-event ${eventKindClass(getEnvelopeType(evt))}">
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(getEnvelopeType(evt))}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
<div class="live-detail-grid">${buildLiveEventContext(evt)}</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
</section>
|
|
`).join('') : '<p class="empty-state">No recent activity</p>'}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
|
|
contentEl.querySelectorAll('[data-agent-key]').forEach(button => {
|
|
button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live'));
|
|
});
|
|
|
|
const mainSection = contentEl.querySelector('.agents-live-main');
|
|
if (mainSection) {
|
|
mainSection.addEventListener('click', e => {
|
|
const op = e.target.closest('.thinking-op[data-run-id]');
|
|
if (op && op.dataset.runId) {
|
|
navigate('/runs/' + op.dataset.runId);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function scheduleAgentsRender() {
|
|
if (_agentsRenderTimer) return;
|
|
_agentsRenderTimer = requestAnimationFrame(() => {
|
|
_agentsRenderTimer = null;
|
|
renderAgentsContent();
|
|
});
|
|
}
|
|
|
|
function handleAgentsWS(msg) {
|
|
if (msg.type !== 'message') return;
|
|
|
|
const eventType = getEnvelopeType(msg.data);
|
|
if (eventType === 'openclaw.snapshot') {
|
|
mergeOpenClawEvents([msg.data]);
|
|
scheduleAgentsRender();
|
|
return;
|
|
}
|
|
if (!isAgentTimelineEvent(msg.data)) return;
|
|
|
|
if (eventType === 'run.start') agentsState.dbStats.messages++;
|
|
else if (eventType === 'span.end') {
|
|
const attrs = getEnvelopeAttributes(msg.data);
|
|
if (attrs.span_kind === 'tool') agentsState.dbStats.tools++;
|
|
} else if (eventType === 'error') agentsState.dbStats.errors++;
|
|
|
|
addAgentEvents([msg.data]);
|
|
scheduleAgentsRender();
|
|
}
|
|
|
|
function updateAgentTimers() {
|
|
document.querySelectorAll('.active-op-time[data-start]').forEach(el => {
|
|
const start = parseInt(el.dataset.start, 10);
|
|
if (!start) return;
|
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
el.textContent = formatElapsed(elapsed);
|
|
|
|
const op = el.closest('.active-op');
|
|
if (op && elapsed > 300 && !op.classList.contains('stale')) {
|
|
op.classList.add('stale');
|
|
if (!op.querySelector('.active-op-stale')) {
|
|
op.insertAdjacentHTML('beforeend', '<span class="active-op-stale">(stale?)</span>');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function addAgentEvents(events) {
|
|
let changed = false;
|
|
|
|
for (const evt of events) {
|
|
const id = getRecordID(evt);
|
|
const agent = getAgentBucket(evt);
|
|
if (!id || !agent || agent.eventIDs.has(id)) continue;
|
|
processAgentEvent(evt);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
for (const agent of Object.values(agentsState.agents)) {
|
|
agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
|
|
}
|
|
recomputeAgentStats();
|
|
}
|
|
}
|
|
|
|
function recomputeAgentStats() {
|
|
const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
|
|
|
|
for (const agent of Object.values(agentsState.agents)) {
|
|
for (const evt of agent.events) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
|
|
if (eventType === 'run.start' || eventType === 'run.end') stats.messages++;
|
|
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
|
|
stats.tools++;
|
|
const toolName = attrs.name || 'unknown';
|
|
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
|
|
}
|
|
if (eventType === 'error') stats.errors++;
|
|
}
|
|
}
|
|
|
|
agentsState.stats = stats;
|
|
}
|
|
|
|
function bindAgentViewToggle() {
|
|
const root = document.getElementById('agents-view-toggle');
|
|
if (!root) return;
|
|
root.querySelectorAll('[data-mode]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
setAgentsViewMode(button.dataset.mode || 'overview');
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateAgentViewToggle() {
|
|
const root = document.getElementById('agents-view-toggle');
|
|
if (!root) return;
|
|
root.querySelectorAll('[data-mode]').forEach(button => {
|
|
button.classList.toggle('active', button.dataset.mode === agentsState.viewMode);
|
|
});
|
|
}
|
|
|
|
function renderAgentsContent() {
|
|
renderAgentSummary();
|
|
updateAgentViewToggle();
|
|
if (agentsState.viewMode === 'live') {
|
|
renderAgentsLive();
|
|
return;
|
|
}
|
|
renderAgentLanes();
|
|
}
|
|
|
|
async function loadSelectedAgentLiveData() {
|
|
const selectedKey = ensureSelectedAgentKey();
|
|
if (!selectedKey) return;
|
|
|
|
const agent = agentsState.agents[selectedKey];
|
|
if (!agent || agent.liveLoaded || agent.liveLoading || !agent.clientID || !agent.framework) {
|
|
return;
|
|
}
|
|
|
|
agent.liveLoading = true;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('client_id', agent.clientID);
|
|
params.set('framework', agent.framework);
|
|
params.set('limit', '250');
|
|
const data = await api('/v1/agents/live?' + params.toString());
|
|
addAgentEvents((data.events || []).slice().reverse());
|
|
agent.liveLoaded = true;
|
|
} catch (err) {
|
|
console.error('Failed to load live agent context:', err);
|
|
} finally {
|
|
agent.liveLoading = false;
|
|
if (isCurrentPath('/agents') && agentsState.viewMode === 'live' && agentsState.selectedAgentKey === selectedKey) {
|
|
renderAgentsContent();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Exports ──────────────────────────────────────────────
|
|
|
|
export async function renderAgents(initialKey, routeToken) {
|
|
resetAgentsState();
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="agents-toolbar">
|
|
<div class="view-toggle" id="agents-view-toggle">
|
|
<button class="view-toggle-btn active" data-mode="overview" type="button">Overview</button>
|
|
<button class="view-toggle-btn" data-mode="live" type="button">Live</button>
|
|
</div>
|
|
</div>
|
|
<div class="agents-summary-row" id="agents-summary"></div>
|
|
<div id="agents-content">${agentsSkeleton()}</div>
|
|
`;
|
|
|
|
bindAgentViewToggle();
|
|
|
|
try {
|
|
const [snapshots, events, summaryData] = await Promise.all([
|
|
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
|
api('/v1/events?limit=300'),
|
|
api('/v1/stats/summary').catch(() => null),
|
|
]);
|
|
|
|
if ((routeToken && !isRouteCurrent(routeToken)) || !isCurrentPath('/agents')) return;
|
|
|
|
if (summaryData) {
|
|
agentsState.dbStats.messages = summaryData.runs_today || 0;
|
|
agentsState.dbStats.tools = summaryData.tool_calls_today || 0;
|
|
agentsState.dbStats.errors = summaryData.errors_today || 0;
|
|
}
|
|
|
|
mergeOpenClawEvents(snapshots.events || []);
|
|
addAgentEvents((events.events || []).filter(isAgentTimelineEvent).slice().reverse());
|
|
|
|
if (initialKey && agentsState.agents[initialKey]) {
|
|
agentsState.selectedAgentKey = initialKey;
|
|
renderBreadcrumbs();
|
|
}
|
|
|
|
renderAgentsContent();
|
|
} catch (e) {
|
|
if (routeToken && !isRouteCurrent(routeToken)) return;
|
|
document.getElementById('agents-content').innerHTML =
|
|
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
|
}
|
|
|
|
if (!routeToken || isRouteCurrent(routeToken)) {
|
|
agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
|
|
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
|
}
|
|
}
|
|
|
|
export function selectAgent(key, nextMode) {
|
|
if (!key || !agentsState.agents[key]) return;
|
|
agentsState.selectedAgentKey = key;
|
|
if (nextMode) {
|
|
agentsState.viewMode = nextMode;
|
|
}
|
|
const newPath = '/agents/' + encodeURIComponent(key);
|
|
if (window.location.pathname !== newPath) {
|
|
history.pushState(null, '', newPath);
|
|
renderBreadcrumbs();
|
|
}
|
|
renderAgentsContent();
|
|
if (agentsState.viewMode === 'live') {
|
|
void loadSelectedAgentLiveData();
|
|
}
|
|
}
|
|
|
|
export function cleanup() {
|
|
if (agentsState.timerInterval) {
|
|
clearInterval(agentsState.timerInterval);
|
|
agentsState.timerInterval = null;
|
|
}
|
|
if (agentsUnsubscribe) {
|
|
agentsUnsubscribe();
|
|
agentsUnsubscribe = null;
|
|
}
|
|
if (_agentsRenderTimer) {
|
|
cancelAnimationFrame(_agentsRenderTimer);
|
|
_agentsRenderTimer = null;
|
|
}
|
|
}
|