Agent UI
Release rolling / release (push) Canceled after 3m10s

This commit is contained in:
Raven Scott
2026-07-30 17:32:59 -04:00
parent 6abc5999f2
commit e3d0f70b29
4 changed files with 987 additions and 62 deletions
+48 -10
View File
@@ -11,6 +11,7 @@
* id: string,
* label: string,
* description: string,
* activity?: string,
* match?: (q: string) => boolean,
* run: (tools: { run: (name: string, args?: object) => Promise<any> }) => Promise<any>,
* summarize: (result: any) => string,
@@ -22,7 +23,8 @@ export const AGENT_SPECS = [
{
id: 'host',
label: 'Host investigator',
description: 'KPIs, findings, health, top processes pack',
description: 'KPIs, findings, health pack',
activity: 'Calling investigate_host…',
match: (q) =>
!q ||
/health|wrong|diagnos|investigat|summar|status|load|cpu|ram|memory|host|alert|anomal/.test(
@@ -42,7 +44,8 @@ export const AGENT_SPECS = [
{
id: 'hot',
label: 'Hot metrics',
description: 'Charts with strongest recent change',
description: 'Spiking / changing charts',
activity: 'Scanning hot_metrics window…',
match: (q) =>
!q ||
/hot|spik|unusual|metric|chart|cpu|disk|io|net|redis|docker|postgres|nginx/.test(q),
@@ -58,7 +61,8 @@ export const AGENT_SPECS = [
{
id: 'processes',
label: 'Process scout',
description: 'Top CPU processes',
description: 'Top CPU / RSS processes',
activity: 'Listing top processes by CPU…',
match: (q) =>
!q || /process|top|cpu|load|rss|memory|pid|runaway|hung/.test(q),
run: (tools) => tools.run('list_processes', { sort: 'cpu', limit: 12, filter: 'all' }),
@@ -74,7 +78,8 @@ export const AGENT_SPECS = [
{
id: 'anomalies',
label: 'Anomaly / alerts',
description: 'Recent anomalies and open alerts',
description: 'Anomalies + open alerts',
activity: 'Fetching anomalies & alerts…',
match: (q) =>
!q || /anomal|alert|warn|crit|threshold|silence|page/.test(q),
run: async (tools) => {
@@ -99,7 +104,8 @@ export const AGENT_SPECS = [
{
id: 'fleet',
label: 'Fleet scout',
description: 'Parent/child fleet health',
description: 'Parent / child fleet health',
activity: 'Querying fleet_health…',
match: (q) => /fleet|child|peer|parent|cluster|all hosts/.test(q),
run: async (tools) => {
const [health, children] = await Promise.all([
@@ -117,7 +123,8 @@ export const AGENT_SPECS = [
{
id: 'storage',
label: 'Storage / retention',
description: 'Agent disk and retention config',
description: 'Disk usage & retention config',
activity: 'Reading storage_info…',
match: (q) => /storage|retention|prune|disk|warm|history|size|hyperdb/.test(q),
run: (tools) => tools.run('storage_info', {}),
summarize: (r) => {
@@ -228,10 +235,13 @@ export function pickAgents(userText, maxAgents = 3) {
* query: string,
* maxAgents?: number,
* isAborted?: () => boolean,
* onPlan?: (agents: AgentSpec[]) => void,
* onAgent?: (ev: {
* id: string,
* label: string,
* status: 'start'|'done'|'error',
* description?: string,
* status: 'queued'|'start'|'running'|'done'|'error'|'stopped',
* activity?: string,
* summary?: string,
* error?: string,
* }) => void,
@@ -242,6 +252,8 @@ export async function runSubAgents(opts) {
const tools = opts.tools
const aborted = () => Boolean(opts.isAborted?.())
opts.onPlan?.(specs)
/** @type {Array<{ id: string, label: string, status: string, summary: string, result?: any, error?: string }>} */
const agents = []
@@ -251,23 +263,45 @@ export async function runSubAgents(opts) {
agents.push({
id: spec.id,
label: spec.label,
status: 'error',
status: 'stopped',
summary: 'stopped',
error: 'stopped',
})
opts.onAgent?.({
id: spec.id,
label: spec.label,
description: spec.description,
status: 'stopped',
activity: 'Stopped',
error: 'stopped',
})
return
}
opts.onAgent?.({ id: spec.id, label: spec.label, status: 'start' })
opts.onAgent?.({
id: spec.id,
label: spec.label,
description: spec.description,
status: 'start',
activity: spec.activity || `Running ${spec.label}`,
})
try {
const result = await spec.run(tools)
if (aborted()) {
agents.push({
id: spec.id,
label: spec.label,
status: 'error',
status: 'stopped',
summary: 'stopped',
error: 'stopped',
})
opts.onAgent?.({
id: spec.id,
label: spec.label,
description: spec.description,
status: 'stopped',
activity: 'Stopped',
error: 'stopped',
})
return
}
const summary = spec.summarize(result)
@@ -283,7 +317,9 @@ export async function runSubAgents(opts) {
opts.onAgent?.({
id: spec.id,
label: spec.label,
description: spec.description,
status: row.status === 'error' ? 'error' : 'done',
activity: row.status === 'error' ? 'Failed' : 'Complete',
summary,
error: row.error,
})
@@ -299,7 +335,9 @@ export async function runSubAgents(opts) {
opts.onAgent?.({
id: spec.id,
label: spec.label,
description: spec.description,
status: 'error',
activity: 'Failed',
error: msg,
})
}
+26 -37
View File
@@ -12,6 +12,7 @@ import {
synthesizeFromAgents,
} from './agents.js'
import { chartIdsFromToolLog, mountChartEmbeds } from './chart-embed.js'
import { createSwarmPanel } from './swarm-ui.js'
/**
* @param {{
@@ -269,41 +270,12 @@ export function createQvacView(opts) {
})
}
/** @type {Map<string, HTMLElement>} */
const agentChipEls = new Map()
const swarm = createSwarmPanel(opts.els.agentBar, {
onStop: () => stopInference(),
})
function clearAgentBar() {
const bar = opts.els.agentBar
if (!bar) return
bar.innerHTML = ''
bar.classList.add('hidden')
agentChipEls.clear()
}
/**
* @param {{ id: string, label: string, status: string, summary?: string, error?: string }} ev
*/
function paintAgentEvent(ev) {
const bar = opts.els.agentBar
if (!bar) return
bar.classList.remove('hidden')
let chip = agentChipEls.get(ev.id)
if (!chip) {
chip = document.createElement('span')
chip.className = 'qvac-agent-chip'
chip.dataset.agent = ev.id
bar.appendChild(chip)
agentChipEls.set(ev.id, chip)
}
chip.dataset.status = ev.status
const tip = ev.summary || ev.error || ''
chip.title = tip
chip.textContent =
ev.status === 'start'
? `${ev.label}`
: ev.status === 'error'
? `${ev.label}`
: `${ev.label}`
swarm.hide()
}
/** Finish onboarding in tools-only mode (no model download). */
@@ -810,6 +782,7 @@ export function createQvacView(opts) {
async function stopInference() {
if (!busy) return
setStatus('Stopping…', 'busy')
if (swarm.isOpen?.()) swarm.fail('Stopped by user')
try {
await engine.stop?.()
} catch (err) {
@@ -855,27 +828,43 @@ export function createQvacView(opts) {
opts.isConnected?.() &&
!engine.isAborted?.()
) {
setStatus('Spinning up agents…', 'busy')
setStatus('Multi-agent swarm…', 'busy')
if (streamBody) {
streamBody.innerHTML = formatMdLite('_Dispatching multi-agent investigation…_')
streamBody.innerHTML = formatMdLite(
'_Swarm is running — watch specialists above while tools gather live data…_'
)
}
const pack = await runSubAgents({
tools,
query: text,
maxAgents: prefs.maxSubAgents,
isAborted: () => Boolean(engine.isAborted?.()),
onPlan: (specs) => {
swarm.begin(
specs.map((s) => ({
id: s.id,
label: s.label,
description: s.description,
})),
text
)
},
onAgent: (ev) => {
if (engine.isAborted?.()) return
paintAgentEvent(ev)
setStatus(`Agent: ${ev.label}`, 'busy')
swarm.update(ev)
if (ev.status === 'start' || ev.status === 'running') {
setStatus(`${ev.label}`, 'busy')
}
},
})
if (engine.isAborted?.()) {
swarm.fail('Stopped')
acc = '_(Stopped during multi-agent investigation.)_'
paintAssistant(streamBody, acc, false)
setStatus('Stopped', 'warn')
return
}
swarm.complete('All agents done — synthesizing answer…')
completeOpts.agentBriefing = pack.contextText
completeOpts.agentFallbackText = synthesizeFromAgents(text, pack)
completeOpts.agentToolCalls = pack.agents.map((a) => ({
+386
View File
@@ -0,0 +1,386 @@
/**
* Multi-agent swarm panel — animated cards, live activity, progress.
*/
/** @type {Record<string, { icon: string, hue: number, short: string }>} */
export const AGENT_VISUAL = {
host: { icon: '◎', hue: 168, short: 'Host' },
hot: { icon: '⚡', hue: 28, short: 'Hot' },
processes: { icon: '⧉', hue: 210, short: 'Procs' },
anomalies: { icon: '⚑', hue: 0, short: 'Alerts' },
fleet: { icon: '⬡', hue: 265, short: 'Fleet' },
storage: { icon: '▣', hue: 45, short: 'Store' },
}
/**
* @param {HTMLElement|null|undefined} host
* @param {{
* onStop?: () => void,
* }} [opts]
*/
export function createSwarmPanel(host, opts = {}) {
/** @type {Map<string, {
* el: HTMLElement,
* status: string,
* label: string,
* description?: string,
* activity: string,
* summary?: string,
* startedAt?: number,
* finishedAt?: number,
* }>} */
const cards = new Map()
/** @type {Array<{ t: number, text: string, kind: string }>} */
let log = []
let total = 0
let done = 0
let panelOpen = false
/** @type {ReturnType<typeof setInterval>|null} */
let tickTimer = null
function vis(id) {
return AGENT_VISUAL[id] || { icon: '◆', hue: 200, short: id }
}
function ensureShell() {
if (!host) return null
if (host.querySelector('.qvac-swarm')) return host.querySelector('.qvac-swarm')
host.innerHTML = `
<div class="qvac-swarm" data-state="idle">
<header class="qvac-swarm-head">
<div class="qvac-swarm-brand">
<span class="qvac-swarm-orb" aria-hidden="true">
<span class="qvac-swarm-orb-core"></span>
<span class="qvac-swarm-orb-ring"></span>
<span class="qvac-swarm-orb-ring qvac-swarm-orb-ring-2"></span>
</span>
<div class="qvac-swarm-titles">
<strong class="qvac-swarm-title">Multi-agent swarm</strong>
<span class="qvac-swarm-subtitle muted">Dispatching specialists…</span>
</div>
</div>
<div class="qvac-swarm-progress-wrap" title="Agents finished">
<svg class="qvac-swarm-ring" viewBox="0 0 36 36" aria-hidden="true">
<path class="qvac-swarm-ring-bg" d="M18 2.5a15.5 15.5 0 1 1 0 31 15.5 15.5 0 0 1 0-31z"/>
<path class="qvac-swarm-ring-fg" d="M18 2.5a15.5 15.5 0 1 1 0 31 15.5 15.5 0 0 1 0-31z"
stroke-dasharray="0 100" pathLength="100"/>
</svg>
<span class="qvac-swarm-count">0/0</span>
</div>
</header>
<div class="qvac-swarm-grid" role="list"></div>
<div class="qvac-swarm-log" aria-live="polite">
<div class="qvac-swarm-log-inner"></div>
</div>
<footer class="qvac-swarm-foot muted">
<span class="qvac-swarm-foot-status">Idle</span>
<span class="qvac-swarm-foot-hint">Live tool specialists · parallel</span>
</footer>
</div>
`
return host.querySelector('.qvac-swarm')
}
function startTick() {
stopTick()
tickTimer = setInterval(() => {
for (const [id, st] of cards) {
if (st.status !== 'running' && st.status !== 'start') continue
const el = st.el
const timer = el?.querySelector('.qvac-agent-card-timer')
if (timer && st.startedAt) {
timer.textContent = `${((Date.now() - st.startedAt) / 1000).toFixed(1)}s`
}
}
}, 120)
}
function stopTick() {
if (tickTimer) clearInterval(tickTimer)
tickTimer = null
}
/**
* @param {Array<{ id: string, label: string, description?: string }>} agents
* @param {string} [query]
*/
function begin(agents, query = '') {
if (!host) return
host.classList.remove('hidden')
panelOpen = true
cards.clear()
log = []
total = agents.length
done = 0
const shell = ensureShell()
if (!shell) return
shell.dataset.state = 'running'
const grid = shell.querySelector('.qvac-swarm-grid')
const logInner = shell.querySelector('.qvac-swarm-log-inner')
if (grid) grid.innerHTML = ''
if (logInner) logInner.innerHTML = ''
const sub = shell.querySelector('.qvac-swarm-subtitle')
if (sub) {
sub.textContent = query
? `Investigating: ${String(query).slice(0, 72)}${query.length > 72 ? '…' : ''}`
: 'Dispatching specialists…'
}
updateProgress(shell)
setFoot(shell, 'Spawning agents…', true)
pushLog(shell, 'swarm', `Launching ${agents.length} specialist${agents.length === 1 ? '' : 's'}`)
agents.forEach((a, i) => {
const v = vis(a.id)
const el = document.createElement('article')
el.className = 'qvac-agent-card'
el.dataset.status = 'queued'
el.dataset.agent = a.id
el.style.setProperty('--agent-hue', String(v.hue))
el.style.setProperty('--agent-delay', `${i * 60}ms`)
el.setAttribute('role', 'listitem')
el.innerHTML = `
<div class="qvac-agent-card-glow" aria-hidden="true"></div>
<header class="qvac-agent-card-head">
<span class="qvac-agent-avatar" aria-hidden="true">
<span class="qvac-agent-avatar-ico">${v.icon}</span>
<span class="qvac-agent-pulse"></span>
</span>
<div class="qvac-agent-card-meta">
<strong class="qvac-agent-card-name">${esc(a.label)}</strong>
<span class="qvac-agent-card-desc muted">${esc(a.description || v.short)}</span>
</div>
<span class="qvac-agent-card-badge">queued</span>
</header>
<div class="qvac-agent-card-activity">
<span class="qvac-agent-activity-dot"></span>
<span class="qvac-agent-activity-text">Waiting in queue…</span>
</div>
<div class="qvac-agent-card-result muted hidden"></div>
<footer class="qvac-agent-card-foot">
<span class="qvac-agent-card-timer">—</span>
<span class="qvac-agent-card-state-ico" aria-hidden="true"></span>
</footer>
`
grid?.appendChild(el)
cards.set(a.id, {
el,
status: 'queued',
label: a.label,
description: a.description,
activity: 'Waiting in queue…',
})
// Stagger entrance
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('is-in'))
})
})
startTick()
}
/**
* @param {{
* id: string,
* label?: string,
* description?: string,
* status: string,
* activity?: string,
* summary?: string,
* error?: string,
* }} ev
*/
function update(ev) {
if (!host || !panelOpen) return
const shell = host.querySelector('.qvac-swarm')
if (!shell) return
let st = cards.get(ev.id)
if (!st) {
// Late card
begin(
[{ id: ev.id, label: ev.label || ev.id, description: ev.description }],
''
)
st = cards.get(ev.id)
}
if (!st) return
const prev = st.status
st.status = ev.status
if (ev.activity) st.activity = ev.activity
if (ev.summary) st.summary = ev.summary
if (ev.error) st.summary = ev.error
const el = st.el
el.dataset.status = normalizeStatus(ev.status)
if (ev.status === 'start' || ev.status === 'running') {
if (!st.startedAt) st.startedAt = Date.now()
el.dataset.status = 'running'
setCardActivity(el, ev.activity || `Running ${ev.label || st.label}`)
setBadge(el, 'running')
if (prev !== 'running' && prev !== 'start') {
pushLog(shell, ev.id, `${st.label} started`)
}
} else if (ev.status === 'done') {
st.finishedAt = Date.now()
done = Math.min(total, done + (prev === 'done' ? 0 : 1))
setCardActivity(el, ev.activity || 'Complete')
setBadge(el, 'done')
setResult(el, ev.summary || 'ok')
const ms = st.startedAt ? Date.now() - st.startedAt : 0
const timer = el.querySelector('.qvac-agent-card-timer')
if (timer) timer.textContent = ms ? `${(ms / 1000).toFixed(1)}s` : 'ok'
el.classList.add('is-done-pop')
pushLog(shell, ev.id, `${st.label} finished${ev.summary ? ` · ${clip(ev.summary, 80)}` : ''}`)
} else if (ev.status === 'error' || ev.status === 'stopped') {
st.finishedAt = Date.now()
if (prev !== 'done' && prev !== 'error') done = Math.min(total, done + 1)
setCardActivity(el, ev.error || ev.summary || 'Failed')
setBadge(el, ev.status === 'stopped' ? 'stopped' : 'error')
setResult(el, ev.error || ev.summary || 'error', true)
pushLog(
shell,
ev.id,
`${st.label} ${ev.status === 'stopped' ? 'stopped' : 'error'}${ev.error ? ` · ${clip(ev.error, 60)}` : ''}`
)
}
updateProgress(shell)
const running = [...cards.values()].filter(
(c) => c.status === 'start' || c.status === 'running'
).length
if (done >= total && total > 0) {
shell.dataset.state = 'complete'
setFoot(shell, 'Swarm complete — synthesizing…', false)
stopTick()
} else {
setFoot(
shell,
running ? `${running} agent${running === 1 ? '' : 's'} active` : 'Waiting…',
true
)
}
}
function complete(note = 'Synthesizing final answer…') {
if (!host) return
const shell = host.querySelector('.qvac-swarm')
if (!shell) return
shell.dataset.state = 'complete'
setFoot(shell, note, false)
pushLog(shell, 'swarm', note)
stopTick()
}
function fail(note = 'Stopped') {
if (!host) return
const shell = host.querySelector('.qvac-swarm')
if (!shell) return
shell.dataset.state = 'stopped'
setFoot(shell, note, false)
for (const st of cards.values()) {
if (st.status === 'running' || st.status === 'start' || st.status === 'queued') {
update({
id: st.el.dataset.agent || '',
status: 'stopped',
summary: 'stopped',
})
}
}
stopTick()
}
function hide() {
panelOpen = false
stopTick()
if (!host) return
host.classList.add('hidden')
host.innerHTML = ''
cards.clear()
log = []
}
function setCardActivity(el, text) {
const t = el.querySelector('.qvac-agent-activity-text')
if (t) t.textContent = text
}
function setBadge(el, status) {
const b = el.querySelector('.qvac-agent-card-badge')
if (!b) return
b.textContent = status
b.dataset.status = status
}
function setResult(el, text, isErr = false) {
const r = el.querySelector('.qvac-agent-card-result')
if (!r) return
r.classList.remove('hidden')
r.classList.toggle('is-error', isErr)
r.textContent = clip(text, 160)
}
function updateProgress(shell) {
const fg = shell.querySelector('.qvac-swarm-ring-fg')
const count = shell.querySelector('.qvac-swarm-count')
const pct = total ? Math.round((done / total) * 100) : 0
if (fg) fg.setAttribute('stroke-dasharray', `${pct} 100`)
if (count) count.textContent = `${done}/${total}`
}
function setFoot(shell, status, live) {
const s = shell.querySelector('.qvac-swarm-foot-status')
if (s) {
s.textContent = status
s.classList.toggle('is-live', Boolean(live))
}
}
function pushLog(shell, kind, text) {
log.push({ t: Date.now(), text, kind })
const inner = shell.querySelector('.qvac-swarm-log-inner')
if (!inner) return
const line = document.createElement('div')
line.className = 'qvac-swarm-log-line'
line.dataset.kind = kind
const v = vis(kind)
line.innerHTML = `<span class="qvac-swarm-log-ico" style="--agent-hue:${v.hue}">${kind === 'swarm' ? '✦' : v.icon}</span><span class="qvac-swarm-log-text">${esc(text)}</span><span class="qvac-swarm-log-time muted">${timeNow()}</span>`
inner.appendChild(line)
// Keep last ~40 lines
while (inner.children.length > 40) inner.removeChild(inner.firstChild)
const logEl = shell.querySelector('.qvac-swarm-log')
if (logEl) logEl.scrollTop = logEl.scrollHeight
line.classList.add('is-in')
}
return { begin, update, complete, fail, hide, isOpen: () => panelOpen }
}
function normalizeStatus(s) {
if (s === 'start') return 'running'
return s
}
function clip(s, n) {
const t = String(s || '')
return t.length > n ? t.slice(0, n - 1) + '…' : t
}
function timeNow() {
try {
return new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return ''
}
}
function esc(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}