This commit is contained in:
+41
-7
@@ -281,12 +281,31 @@ export function createQvacView(opts) {
|
||||
})
|
||||
}
|
||||
|
||||
const swarm = createSwarmPanel(opts.els.agentBar, {
|
||||
// Swarm mounts into each assistant message (transcript), not a sticky chrome bar
|
||||
const swarm = createSwarmPanel(null, {
|
||||
onStop: () => stopInference(),
|
||||
})
|
||||
|
||||
function clearAgentBar() {
|
||||
swarm.hide()
|
||||
/** Release live swarm controller only; past message nodes stay in the transcript. */
|
||||
function releaseLiveSwarm() {
|
||||
if (typeof swarm.release === 'function') swarm.release()
|
||||
else swarm.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a swarm host inside the assistant message (above answer body).
|
||||
* @param {{ div: HTMLElement, body: HTMLElement }|null|undefined} assistantUi
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function ensureSwarmSlot(assistantUi) {
|
||||
if (!assistantUi?.div) return null
|
||||
let slot = assistantUi.div.querySelector('.qvac-swarm-slot')
|
||||
if (slot) return /** @type {HTMLElement} */ (slot)
|
||||
slot = document.createElement('div')
|
||||
slot.className = 'qvac-swarm-slot'
|
||||
// Insert before message body so the answer streams below the swarm card
|
||||
assistantUi.div.insertBefore(slot, assistantUi.body || assistantUi.div.firstChild)
|
||||
return slot
|
||||
}
|
||||
|
||||
/** Finish onboarding in tools-only mode (no model download). */
|
||||
@@ -814,7 +833,10 @@ export function createQvacView(opts) {
|
||||
let acc = ''
|
||||
let thinkingAcc = ''
|
||||
setStatus('Thinking…', 'busy')
|
||||
clearAgentBar()
|
||||
releaseLiveSwarm()
|
||||
// Hide legacy sticky bar if still in DOM
|
||||
opts.els.agentBar?.classList.add('hidden')
|
||||
opts.els.agentBar && (opts.els.agentBar.innerHTML = '')
|
||||
try {
|
||||
// History for the model: clean answers only (no think tags)
|
||||
const hist = messages
|
||||
@@ -833,18 +855,19 @@ export function createQvacView(opts) {
|
||||
subAgents: settings().qvacSubAgents !== false,
|
||||
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
|
||||
}
|
||||
|
||||
if (
|
||||
shouldUseSubAgents(text, prefs) &&
|
||||
opts.isConnected?.() &&
|
||||
!engine.isAborted?.()
|
||||
) {
|
||||
setStatus('Multi-agent swarm…', 'busy')
|
||||
const swarmSlot = ensureSwarmSlot(assistantUi)
|
||||
if (streamBody) {
|
||||
streamBody.innerHTML = formatMdLite(
|
||||
'_Swarm is running — watch specialists above while tools gather live data…_'
|
||||
'_Specialists are gathering live data in this turn…_'
|
||||
)
|
||||
}
|
||||
followMessagesScroll({ force: true })
|
||||
const pack = await runSubAgents({
|
||||
tools,
|
||||
query: text,
|
||||
@@ -857,8 +880,10 @@ export function createQvacView(opts) {
|
||||
label: s.label,
|
||||
description: s.description,
|
||||
})),
|
||||
text
|
||||
text,
|
||||
{ mount: swarmSlot }
|
||||
)
|
||||
followMessagesScroll({ force: true })
|
||||
},
|
||||
onAgent: (ev) => {
|
||||
if (engine.isAborted?.()) return
|
||||
@@ -866,6 +891,7 @@ export function createQvacView(opts) {
|
||||
if (ev.status === 'start' || ev.status === 'running') {
|
||||
setStatus(`${ev.label}…`, 'busy')
|
||||
}
|
||||
followMessagesScroll()
|
||||
},
|
||||
})
|
||||
if (engine.isAborted?.()) {
|
||||
@@ -876,6 +902,10 @@ export function createQvacView(opts) {
|
||||
return
|
||||
}
|
||||
swarm.complete('All agents done — synthesizing answer…')
|
||||
// Collapse into transcript so the answer can stream below without covering chat
|
||||
swarm.collapse(
|
||||
`${pack.agents?.length || 0} specialist${(pack.agents?.length || 0) === 1 ? '' : 's'} · complete`
|
||||
)
|
||||
completeOpts.agentBriefing = pack.contextText
|
||||
completeOpts.agentFallbackText = synthesizeFromAgents(text, pack)
|
||||
completeOpts.agentToolCalls = pack.agents.map((a) => ({
|
||||
@@ -896,6 +926,7 @@ export function createQvacView(opts) {
|
||||
assistantUi.div.appendChild(renderToolChips(toolLog))
|
||||
}
|
||||
setStatus('Synthesizing…', 'busy')
|
||||
followMessagesScroll({ force: true })
|
||||
}
|
||||
|
||||
if (engine.isAborted?.()) {
|
||||
@@ -1005,8 +1036,11 @@ export function createQvacView(opts) {
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
releaseLiveSwarm()
|
||||
messages = []
|
||||
if (opts.els.messages) opts.els.messages.innerHTML = ''
|
||||
opts.els.agentBar?.classList.add('hidden')
|
||||
if (opts.els.agentBar) opts.els.agentBar.innerHTML = ''
|
||||
appendMsg(
|
||||
'assistant',
|
||||
'New chat. Ask about host health, metrics, anomalies, or processes.'
|
||||
|
||||
+209
-40
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Multi-agent swarm panel — animated cards, live activity, progress.
|
||||
* 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 }>} */
|
||||
@@ -13,12 +16,14 @@ export const AGENT_VISUAL = {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement|null|undefined} host
|
||||
* @param {HTMLElement|null|undefined} defaultHost
|
||||
* @param {{
|
||||
* onStop?: () => void,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function createSwarmPanel(host, opts = {}) {
|
||||
export function createSwarmPanel(defaultHost = null, opts = {}) {
|
||||
/** @type {HTMLElement|null|undefined} */
|
||||
let host = defaultHost
|
||||
/** @type {Map<string, {
|
||||
* el: HTMLElement,
|
||||
* status: string,
|
||||
@@ -34,20 +39,25 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
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 }
|
||||
}
|
||||
|
||||
function ensureShell() {
|
||||
if (!host) return null
|
||||
if (host.querySelector('.qvac-swarm')) return host.querySelector('.qvac-swarm')
|
||||
host.innerHTML = `
|
||||
/**
|
||||
* @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">
|
||||
<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>
|
||||
@@ -59,32 +69,39 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
<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 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-grid" role="list"></div>
|
||||
<div class="qvac-swarm-log" aria-live="polite">
|
||||
<div class="qvac-swarm-log-inner"></div>
|
||||
<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>
|
||||
<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')
|
||||
return mount.querySelector('.qvac-swarm')
|
||||
}
|
||||
|
||||
function startTick() {
|
||||
stopTick()
|
||||
tickTimer = setInterval(() => {
|
||||
for (const [id, st] of cards) {
|
||||
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')
|
||||
@@ -100,19 +117,58 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
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 = '') {
|
||||
function begin(agents, query = '', options = {}) {
|
||||
if (options.mount) host = options.mount
|
||||
if (!host) return
|
||||
host.classList.remove('hidden')
|
||||
unbindExpand()
|
||||
panelOpen = true
|
||||
cards.clear()
|
||||
log = []
|
||||
total = agents.length
|
||||
done = 0
|
||||
const shell = ensureShell()
|
||||
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')
|
||||
@@ -169,7 +225,6 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
description: a.description,
|
||||
activity: 'Waiting in queue…',
|
||||
})
|
||||
// Stagger entrance
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => el.classList.add('is-in'))
|
||||
})
|
||||
@@ -194,12 +249,48 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
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)
|
||||
// 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
|
||||
|
||||
@@ -233,7 +324,9 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
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)
|
||||
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)
|
||||
@@ -271,6 +364,58 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
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')
|
||||
@@ -287,14 +432,30 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
}
|
||||
}
|
||||
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()
|
||||
if (!host) return
|
||||
host.classList.add('hidden')
|
||||
host.innerHTML = ''
|
||||
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 = []
|
||||
}
|
||||
@@ -345,14 +506,22 @@ export function createSwarmPanel(host, opts = {}) {
|
||||
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 }
|
||||
return {
|
||||
begin,
|
||||
update,
|
||||
complete,
|
||||
collapse,
|
||||
fail,
|
||||
hide,
|
||||
release,
|
||||
isOpen: () => panelOpen,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatus(s) {
|
||||
|
||||
Reference in New Issue
Block a user