556 lines
18 KiB
JavaScript
556 lines
18 KiB
JavaScript
/**
|
|
* Multi-agent swarm panel — animated cards as an in-chat message artifact.
|
|
*
|
|
* Mount into a host inside #qvac-messages (not a sticky chrome bar).
|
|
* After agents finish, call collapse() so it shrinks into the transcript.
|
|
*/
|
|
|
|
/** @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} defaultHost
|
|
* @param {{
|
|
* onStop?: () => void,
|
|
* }} [opts]
|
|
*/
|
|
export function createSwarmPanel(defaultHost = null, opts = {}) {
|
|
/** @type {HTMLElement|null|undefined} */
|
|
let host = defaultHost
|
|
/** @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
|
|
/** Live run (accepting updates) */
|
|
let panelOpen = false
|
|
/** @type {ReturnType<typeof setInterval>|null} */
|
|
let tickTimer = null
|
|
/** @type {(() => void)|null} */
|
|
let expandHandler = null
|
|
|
|
function vis(id) {
|
|
return AGENT_VISUAL[id] || { icon: '◆', hue: 200, short: id }
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} mount
|
|
*/
|
|
function ensureShell(mount) {
|
|
// Always rebuild for a fresh turn (old hosts stay frozen in transcript)
|
|
mount.innerHTML = `
|
|
<div class="qvac-swarm" data-state="idle">
|
|
<header class="qvac-swarm-head" data-swarm-toggle>
|
|
<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-head-right">
|
|
<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>
|
|
<button type="button" class="qvac-swarm-expand-btn hidden" data-swarm-expand aria-expanded="false" title="Expand or collapse details">
|
|
Details
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div class="qvac-swarm-body">
|
|
<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>
|
|
</div>
|
|
`
|
|
return mount.querySelector('.qvac-swarm')
|
|
}
|
|
|
|
function startTick() {
|
|
stopTick()
|
|
tickTimer = setInterval(() => {
|
|
for (const [, 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
|
|
}
|
|
|
|
function unbindExpand() {
|
|
if (!expandHandler || !host) {
|
|
expandHandler = null
|
|
return
|
|
}
|
|
const shell = host.querySelector('.qvac-swarm')
|
|
const btn = shell?.querySelector('[data-swarm-expand]')
|
|
const head = shell?.querySelector('[data-swarm-toggle]')
|
|
if (btn) btn.removeEventListener('click', expandHandler)
|
|
if (head) head.removeEventListener('click', expandHandler)
|
|
expandHandler = null
|
|
}
|
|
|
|
function bindExpand(shell) {
|
|
unbindExpand()
|
|
const btn = shell.querySelector('[data-swarm-expand]')
|
|
const head = shell.querySelector('[data-swarm-toggle]')
|
|
expandHandler = (ev) => {
|
|
// Only toggle when collapsed (or expanded after collapse)
|
|
if (shell.dataset.state !== 'collapsed' && shell.dataset.state !== 'expanded') return
|
|
// Don't steal clicks from nested buttons except expand
|
|
const t = /** @type {HTMLElement} */ (ev.target)
|
|
if (t.closest('button') && !t.closest('[data-swarm-expand]')) return
|
|
const next =
|
|
shell.dataset.state === 'collapsed' ? 'expanded' : 'collapsed'
|
|
shell.dataset.state = next
|
|
if (btn) {
|
|
btn.setAttribute('aria-expanded', next === 'expanded' ? 'true' : 'false')
|
|
btn.textContent = next === 'expanded' ? 'Hide' : 'Details'
|
|
}
|
|
}
|
|
btn?.addEventListener('click', expandHandler)
|
|
head?.addEventListener('click', expandHandler)
|
|
}
|
|
|
|
/**
|
|
* @param {Array<{ id: string, label: string, description?: string }>} agents
|
|
* @param {string} [query]
|
|
* @param {{ mount?: HTMLElement|null }} [options]
|
|
*/
|
|
function begin(agents, query = '', options = {}) {
|
|
if (options.mount) host = options.mount
|
|
if (!host) return
|
|
unbindExpand()
|
|
panelOpen = true
|
|
cards.clear()
|
|
log = []
|
|
total = agents.length
|
|
done = 0
|
|
host.classList.remove('hidden')
|
|
host.classList.add('qvac-swarm-slot')
|
|
const shell = ensureShell(host)
|
|
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…',
|
|
})
|
|
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 without wiping an in-progress host: inject one card
|
|
const v = vis(ev.id)
|
|
const grid = shell.querySelector('.qvac-swarm-grid')
|
|
const el = document.createElement('article')
|
|
el.className = 'qvac-agent-card is-in'
|
|
el.dataset.status = 'queued'
|
|
el.dataset.agent = ev.id
|
|
el.style.setProperty('--agent-hue', String(v.hue))
|
|
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(ev.label || ev.id)}</strong>
|
|
<span class="qvac-agent-card-desc muted">${esc(ev.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…</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)
|
|
total += 1
|
|
st = {
|
|
el,
|
|
status: 'queued',
|
|
label: ev.label || ev.id,
|
|
description: ev.description,
|
|
activity: 'Waiting…',
|
|
}
|
|
cards.set(ev.id, st)
|
|
updateProgress(shell)
|
|
}
|
|
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' && prev !== 'stopped') {
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* Shrink into a compact transcript row (stays in chat). Expandable for details.
|
|
* @param {string} [note]
|
|
*/
|
|
function collapse(note = 'Investigation complete') {
|
|
if (!host) return
|
|
const shell = host.querySelector('.qvac-swarm')
|
|
if (!shell) return
|
|
panelOpen = false
|
|
stopTick()
|
|
shell.dataset.state = 'collapsed'
|
|
const sub = shell.querySelector('.qvac-swarm-subtitle')
|
|
if (sub) {
|
|
const labels = [...cards.values()]
|
|
.map((c) => c.label)
|
|
.filter(Boolean)
|
|
.slice(0, 4)
|
|
const more = cards.size > labels.length ? ` +${cards.size - labels.length}` : ''
|
|
sub.textContent =
|
|
total > 0
|
|
? `${done}/${total} specialists · ${labels.join(', ')}${more}`
|
|
: note
|
|
}
|
|
setFoot(shell, note, false)
|
|
const btn = shell.querySelector('[data-swarm-expand]')
|
|
if (btn) {
|
|
btn.classList.remove('hidden')
|
|
btn.setAttribute('aria-expanded', 'false')
|
|
btn.textContent = 'Details'
|
|
}
|
|
// Mini chips of agent icons for collapsed glance
|
|
let chips = shell.querySelector('.qvac-swarm-mini-chips')
|
|
if (!chips) {
|
|
chips = document.createElement('div')
|
|
chips.className = 'qvac-swarm-mini-chips'
|
|
const brand = shell.querySelector('.qvac-swarm-titles')
|
|
brand?.appendChild(chips)
|
|
}
|
|
chips.innerHTML = ''
|
|
for (const [id, st] of cards) {
|
|
const v = vis(id)
|
|
const chip = document.createElement('span')
|
|
chip.className = 'qvac-swarm-mini-chip'
|
|
chip.dataset.status = normalizeStatus(st.status)
|
|
chip.style.setProperty('--agent-hue', String(v.hue))
|
|
chip.title = `${st.label}: ${st.status}`
|
|
chip.textContent = v.icon
|
|
chips.appendChild(chip)
|
|
}
|
|
bindExpand(shell)
|
|
}
|
|
|
|
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()
|
|
// Still collapse so chat is usable
|
|
collapse(note)
|
|
}
|
|
|
|
/** Detach live controller only — does not remove past transcript nodes. */
|
|
function release() {
|
|
panelOpen = false
|
|
stopTick()
|
|
unbindExpand()
|
|
cards.clear()
|
|
log = []
|
|
host = null
|
|
}
|
|
|
|
/** @deprecated Prefer leave transcript nodes; release() for controller reset */
|
|
function hide() {
|
|
panelOpen = false
|
|
stopTick()
|
|
unbindExpand()
|
|
if (host && host.classList.contains('qvac-agent-bar')) {
|
|
host.classList.add('hidden')
|
|
host.innerHTML = ''
|
|
}
|
|
// In-chat slots are left in the message tree
|
|
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)
|
|
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,
|
|
collapse,
|
|
fail,
|
|
hide,
|
|
release,
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|