/** * QVAC tab — onboarding + local AI chat for PearData. */ import { createQvacEngine } from './engine.js' import { createToolRunner } from './tools.js' import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js' import { SAMPLE_PROMPTS } from './prompts.js' import { partitionThink, renderAssistantHtml } from './think.js' import { shouldUseSubAgents, runSubAgents, synthesizeFromAgents, } from './agents.js' import { chartIdsFromToolLog, mountChartEmbeds } from './chart-embed.js' import { createSwarmPanel } from './swarm-ui.js' import { appConfirm } from '../confirm-modal.js' /** * @param {{ * els: { * root: HTMLElement|null, * setup: HTMLElement|null, * chat: HTMLElement|null, * messages: HTMLElement|null, * input: HTMLTextAreaElement|null, * sendBtn: HTMLElement|null, * stopBtn?: HTMLElement|null, * status: HTMLElement|null, * modelChip: HTMLElement|null, * samples: HTMLElement|null, * setupSteps: HTMLElement|null, * agentBar?: HTMLElement|null, * settingsBtn?: HTMLElement|null, * settingsPanel?: HTMLElement|null, * resetBtn?: HTMLElement|null, * unloadBtn?: HTMLElement|null, * newChatBtn?: HTMLElement|null, * }, * manager: { request: Function, active: any }, * getRole: () => string, * isConnected: () => boolean, * getSettings: () => object, * saveSettings: (patch: object) => void, * getPeerLabel: () => string, * getCatalog?: () => Record, * onOpenChart?: (id: string, ts?: number, opts?: object) => void, * onOpenView?: (view: string) => void, * charts?: object, * log?: (msg: string) => void, * }} opts */ export function createQvacView(opts) { function settings() { return opts.getSettings?.() || {} } function persist(patch) { opts.saveSettings?.(patch) } const tools = createToolRunner({ manager: opts.manager, getRole: opts.getRole, isConnected: opts.isConnected, getCatalog: () => opts.getCatalog?.() || {}, onOpenChart: opts.onOpenChart, onOpenView: opts.onOpenView, charts: opts.charts, dashboards: opts.dashboards, getAutoNavigate: () => settings().qvacAutoNavigate || 'ask', confirmAction: (msg) => { const text = String(msg || 'Continue?') const danger = /silence|acknowledge|ack |run job|delete|forget|prune|remove|kill/i.test(text) const isNav = /open|switch|view|navigate/i.test(text) return appConfirm({ title: danger ? 'Confirm agent action' : isNav ? 'Switch view' : 'Confirm', message: text, confirmLabel: danger ? 'Allow' : isNav ? 'Open' : 'Continue', cancelLabel: 'Not now', danger, icon: danger ? '!' : isNav ? '↗' : '?', }) }, }) const engine = createQvacEngine({ tools, getContext: () => ({ peerAlias: opts.getPeerLabel?.() || '', peerId: opts.manager.active?.publicKeyHex || '', role: opts.getRole?.() || 'viewer', connected: Boolean(opts.isConnected?.()), subAgents: settings().qvacSubAgents !== false, }), getCatalog: () => opts.getCatalog?.() || {}, getPrefs: () => ({ rag: settings().qvacRag !== false, idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0, subAgents: settings().qvacSubAgents !== false, maxSubAgents: Number(settings().qvacMaxSubAgents) || 3, toolDepth: settings().qvacToolDepth || 'auto', autoNavigate: settings().qvacAutoNavigate || 'ask', }), log: opts.log, }) /** @type {Array<{ role: string, content: string, tools?: any[] }>} */ let messages = [] let busy = false let wizardStep = 0 /** @type {string} */ let selectedProfile = 'recommended' function isOnboarded() { return Boolean(settings().qvacOnboarded) } function setStatus(text, kind = '') { const el = opts.els.status if (!el) return el.textContent = text || '' el.dataset.kind = kind } function isModelLoaded() { const st = engine.getStatus() return st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac' || settings().qvacMode === 'qvac') } function syncModelChip() { const chip = opts.els.modelChip if (!chip) return const st = engine.getStatus() const profile = getProfile(settings().qvacProfile || selectedProfile) if (st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac')) { chip.textContent = profile.chatModel chip.dataset.mode = 'ready' chip.title = 'Model loaded' } else if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) { chip.textContent = 'tools-only' chip.dataset.mode = 'fallback' chip.title = st.sdkError || 'Install @qvac/sdk for full local LLM' } else if (st.status === 'downloading' || st.status === 'loading') { const pct = st.progress?.percentage chip.textContent = pct != null ? `${st.status} ${Number(pct).toFixed(0)}%` : st.status chip.dataset.mode = 'busy' } else { chip.textContent = st.status || 'idle' chip.dataset.mode = st.status || 'idle' } syncHeaderActions() syncSettingsForm() } /** * Header: always Settings when onboarded. * Setup only when model is NOT loaded (first-time / tools-only). * When loaded, re-run setup lives inside the settings panel. */ function syncHeaderActions() { const reset = opts.els.resetBtn const settingsBtn = opts.els.settingsBtn const unload = opts.els.unloadBtn if (!isOnboarded()) { // Still in wizard — Setup is the primary path; Settings optional if present reset?.classList.add('hidden') settingsBtn?.classList.add('hidden') unload?.classList.add('hidden') return } settingsBtn?.classList.remove('hidden') const loaded = isModelLoaded() if (loaded) { reset?.classList.add('hidden') unload?.classList.remove('hidden') } else { reset?.classList.remove('hidden') unload?.classList.add('hidden') } } function showPane() { const setup = opts.els.setup const chat = opts.els.chat if (isOnboarded()) { setup?.classList.add('hidden') chat?.classList.remove('hidden') } else { setup?.classList.remove('hidden') chat?.classList.add('hidden') closeSettings() renderWizard() } syncHeaderActions() } function openSettings() { const panel = opts.els.settingsPanel const btn = opts.els.settingsBtn if (!panel) return panel.classList.remove('hidden') btn?.setAttribute('aria-expanded', 'true') syncSettingsForm() } function closeSettings() { const panel = opts.els.settingsPanel const btn = opts.els.settingsBtn panel?.classList.add('hidden') btn?.setAttribute('aria-expanded', 'false') } function toggleSettings() { const panel = opts.els.settingsPanel if (!panel || panel.classList.contains('hidden')) openSettings() else closeSettings() } function syncSettingsForm() { const s = settings() const root = opts.els.root if (!root) return const st = engine.getStatus() const profile = getProfile(s.qvacProfile || selectedProfile) const statusEl = root.querySelector('#qvac-settings-model-status') if (statusEl) { const loaded = isModelLoaded() const multi = s.qvacSubAgents !== false statusEl.textContent = [ `Status: ${st.status || 'idle'}`, loaded ? `model=${profile.chatModel}` : 'no model loaded', s.qvacMode ? `mode=${s.qvacMode}` : '', multi ? 'multi-agent=on' : 'multi-agent=off', `nav=${s.qvacAutoNavigate || 'ask'}`, ] .filter(Boolean) .join(' · ') } const set = (sel, val, isCheck) => { const el = root.querySelector(sel) if (!el) return if (isCheck) el.checked = Boolean(val) else el.value = String(val ?? '') } set('#qvac-set-profile', s.qvacProfile || 'recommended') set('#qvac-set-rag', s.qvacRag !== false, true) set('#qvac-set-idle', s.qvacIdleUnloadMin ?? 30) set('#qvac-set-tool-depth', s.qvacToolDepth || 'auto') set('#qvac-set-subagents', s.qvacSubAgents !== false, true) set('#qvac-set-max-agents', s.qvacMaxSubAgents ?? 3) set('#qvac-set-autonav', s.qvacAutoNavigate || 'ask') } function readSettingsForm() { const root = opts.els.root if (!root) return const profile = root.querySelector('#qvac-set-profile')?.value || 'recommended' const rag = root.querySelector('#qvac-set-rag')?.checked !== false const idle = Number(root.querySelector('#qvac-set-idle')?.value) || 0 const toolDepth = root.querySelector('#qvac-set-tool-depth')?.value || 'auto' const subAgents = root.querySelector('#qvac-set-subagents')?.checked !== false let maxAgents = Number(root.querySelector('#qvac-set-max-agents')?.value) || 3 maxAgents = Math.min(6, Math.max(1, maxAgents)) const autoNavRaw = root.querySelector('#qvac-set-autonav')?.value || 'ask' const autoNav = ['off', 'ask', 'on'].includes(autoNavRaw) ? autoNavRaw : 'ask' selectedProfile = profile persist({ qvacProfile: profile, qvacRag: rag, qvacIdleUnloadMin: idle, qvacToolDepth: toolDepth, qvacSubAgents: subAgents, qvacMaxSubAgents: maxAgents, qvacAutoNavigate: autoNav, }) } // Swarm mounts into each assistant message (transcript), not a sticky chrome bar const swarm = createSwarmPanel(null, { onStop: () => stopInference(), }) /** 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). */ function finishToolsOnly(note = '') { persist({ qvacOnboarded: true, qvacProfile: selectedProfile || settings().qvacProfile || 'recommended', qvacMode: 'fallback', }) setStatus('Ready (tools-only)', 'ok') syncModelChip() showPane() renderSamples() if (!messages.length) { appendMsg( 'assistant', note || 'Tools-only mode is ready. Ask about host health, charts, or anomalies — answers use live agent RPCs.\n\nInstall `@qvac/sdk` and re-run setup for full local Qwen chat.' ) } } function paintSystemCheck(card, env) { const body = card.querySelector('.qvac-step-body') if (!body) return const ramGb = env.totalRamBytes ? (env.totalRamBytes / 1e9).toFixed(1) : '?' if (env.totalRamBytes) { selectedProfile = settings().qvacProfile || suggestProfile({ totalRamBytes: env.totalRamBytes }) } const sdkLine = env.checking ? '
  • QVAC SDK: checking…
  • ' : `
  • QVAC SDK: ${ env.sdkAvailable ? 'package found (loads only when you download a model)' : 'not installed — tools-only mode' }
  • ` body.innerHTML = `

    Suggested profile: ${escapeHtml(getProfile(selectedProfile).label)}

    You can use tools-only mode immediately (live agent RPCs, no model download), or choose a local model if the SDK is available.

    ` } function bindSystemCheckActions(card) { const actions = card.querySelector('.qvac-step-actions') if (!actions) return actions.innerHTML = '' actions.appendChild( btn('Back', false, () => { wizardStep = 0 renderWizard() }) ) actions.appendChild( btn('Tools-only', false, () => { finishToolsOnly() }) ) actions.appendChild( btn('Choose model', true, () => { wizardStep = 2 renderWizard() }) ) } function renderWizard() { const host = opts.els.setupSteps if (!host) { opts.log?.('QVAC setup steps container missing') return } host.innerHTML = '' if (wizardStep === 0) { host.appendChild( stepCard( 'Welcome to QVAC', `

    Local-first AI for PearData — models run on your desktop, not the agent and not a cloud API.

    The assistant uses live tools (getHostSnapshot, charts, processes) so answers stay grounded in agent data.

    Powered by QVAC (Tether).

    `, [ { label: 'Continue', primary: true, onClick: () => { wizardStep = 1 renderWizard() }, }, ] ) ) return } if (wizardStep === 1) { // Paint immediately. Never import @qvac/sdk here — that freezes packaged Electron. const card = stepCard( 'System check', '

    Preparing environment check…

    ', [] ) host.appendChild(card) const quick = { checking: true, sdkAvailable: false, platform: typeof process !== 'undefined' ? process.platform : 'unknown', arch: typeof process !== 'undefined' ? process.arch : 'unknown', totalRamBytes: null, } paintSystemCheck(card, quick) bindSystemCheckActions(card) // Yield a frame so the card paints before any async work requestAnimationFrame(() => { engine .checkEnvironment({ probeSdk: false }) .then((env) => { if (wizardStep !== 1) return if (!card.isConnected) return paintSystemCheck(card, { ...env, checking: false }) bindSystemCheckActions(card) syncModelChip() }) .catch((err) => { if (wizardStep !== 1 || !card.isConnected) return paintSystemCheck(card, { checking: false, sdkAvailable: false, sdkError: err?.message || String(err), platform: quick.platform, arch: quick.arch, totalRamBytes: null, }) bindSystemCheckActions(card) }) }) return } if (wizardStep === 2) { const profilesHtml = PROFILE_LIST.map((p) => { const active = p.id === selectedProfile ? ' active' : '' return `` }).join('') const card = stepCard( 'Model profile', `
    ${profilesHtml}

    Skip model download and use grounded tools-only answers instead.

    `, [ { label: 'Back', onClick: () => { wizardStep = 1 renderWizard() }, }, { label: 'Tools-only', onClick: () => finishToolsOnly(), }, { label: 'Download & load', primary: true, onClick: () => { wizardStep = 3 renderWizard() // Yield so the loading card paints before main-process model load starts requestAnimationFrame(() => { setTimeout(() => { startLoad().catch((err) => { opts.log?.(`QVAC startLoad: ${err?.message || err}`) finishToolsOnly( `Model load failed (${err?.message || err}). Continuing in tools-only mode.` ) }) }, 50) }) }, }, ] ) host.appendChild(card) host.querySelectorAll('[data-profile]').forEach((el) => { el.addEventListener('click', () => { selectedProfile = el.getAttribute('data-profile') || 'recommended' renderWizard() }) }) return } if (wizardStep === 3) { host.appendChild( stepCard( 'Loading model', `

    Starting…

    If this stalls, use tools-only mode below.

    `, [ { label: 'Skip to tools-only', onClick: () => { engine.unload().catch(() => {}) finishToolsOnly('Skipped model load. Tools-only mode is ready.') }, }, ] ) ) return } // Safety net — never leave the setup pane empty host.appendChild( stepCard( 'Setup', '

    Unexpected setup step. Restart onboarding.

    ', [ { label: 'Restart', primary: true, onClick: () => { wizardStep = 0 renderWizard() }, }, { label: 'Tools-only', onClick: () => finishToolsOnly(), }, ] ) ) } async function startLoad() { const fill = opts.els.root?.querySelector('#qvac-progress-fill') const label = opts.els.root?.querySelector('#qvac-progress-label') persist({ qvacProfile: selectedProfile }) setStatus('Loading model…', 'busy') try { const result = await engine.loadProfile(selectedProfile, { onProgress: (p) => { const pct = Math.min(100, Number(p?.percentage) || 0) if (fill) fill.style.width = `${pct}%` if (label) { const mb = (n) => ((Number(n) || 0) / 1e6).toFixed(1) label.textContent = pct >= 100 ? 'Loading into memory…' : `Downloading ${pct.toFixed(0)}% (${mb(p.downloaded)} / ${mb(p.total)} MB)` } syncModelChip() }, }) persist({ qvacOnboarded: true, qvacProfile: selectedProfile, qvacMode: result.mode || 'fallback', }) setStatus( result.ok && result.mode === 'qvac' ? 'Ready' : `Ready (tools-only${result.error ? `: ${result.error}` : ''})`, result.mode === 'qvac' ? 'ok' : 'warn' ) syncModelChip() showPane() renderSamples() if (!messages.length) { appendMsg( 'assistant', result.mode === 'fallback' ? 'Tools-only mode is ready. Ask about host health, charts, or anomalies — answers use live agent RPCs.\n\nInstall `@qvac/sdk` and re-run setup for full local Qwen chat.' : `Model **${getProfile(selectedProfile).chatModel}** is loaded. Ask about this agent’s health, metrics, or processes.` ) } } catch (err) { const msg = err?.message || String(err) if (label) label.textContent = `Load failed: ${msg}` setStatus(msg, 'error') opts.log?.(`QVAC load error: ${msg}`) // Don't leave user stuck on a blank progress step finishToolsOnly(`Model load failed (${msg}). Continuing in tools-only mode.`) } } function stepCard(title, bodyHtml, actions) { const el = document.createElement('div') el.className = 'qvac-step-card' el.innerHTML = `

    ${escapeHtml(title)}

    ${bodyHtml}
    ` const act = el.querySelector('.qvac-step-actions') for (const a of actions) { act?.appendChild(btn(a.label, a.primary, a.onClick)) } return el } function btn(label, primary, onClick) { const b = document.createElement('button') b.type = 'button' b.className = primary ? 'btn' : 'btn btn-ghost' b.textContent = label b.addEventListener('click', onClick) return b } function renderSamples() { const host = opts.els.samples if (!host) return host.innerHTML = '' for (const s of SAMPLE_PROMPTS) { const b = document.createElement('button') b.type = 'button' b.className = 'qvac-sample' b.textContent = s b.addEventListener('click', () => { if (opts.els.input) opts.els.input.value = s send() }) host.appendChild(b) } } /** * @param {string} role * @param {string} content * @param {{ tools?: any[], streaming?: boolean }} [meta] */ function appendMsg(role, content, meta = {}) { const entry = { role, content, tools: meta.tools, thinking: '' } messages.push(entry) const list = opts.els.messages if (!list) return null const div = document.createElement('div') div.className = `qvac-msg qvac-msg-${role}` const body = document.createElement('div') body.className = 'qvac-msg-body' if (role === 'assistant') { body.innerHTML = renderAssistantHtml(content, formatMdLite, { openThink: Boolean(meta.streaming), }) } else { body.innerHTML = formatMdLite(content) } div.appendChild(body) if (meta.tools?.length) { div.appendChild(renderToolChips(meta.tools)) } list.appendChild(div) list.scrollTop = list.scrollHeight return { div, body, entry } } function renderToolChips(tools) { const chips = document.createElement('div') chips.className = 'qvac-tool-chips' for (const t of tools) { const c = document.createElement('span') c.className = 'qvac-tool-chip' c.textContent = t.name const preview = t.result != null ? JSON.stringify(t.result).slice(0, 280) : JSON.stringify(t.args || {}).slice(0, 200) c.title = preview chips.appendChild(c) } return chips } /** Near bottom of a scroll container? (for stick-to-bottom while streaming) */ function isNearBottom(el, threshold = 56) { if (!el) return true return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold } /** * Keep the think panel pinned to the latest tokens while streaming. * Uses rAF so rapid token events coalesce into one smooth follow. * @param {HTMLElement|null|undefined} thinkBody * @param {{ force?: boolean }} [opts] */ function followThinkScroll(thinkBody, { force = true } = {}) { if (!thinkBody) return if (!force && !isNearBottom(thinkBody)) return const run = () => { thinkBody._qvacScrollRaf = 0 // Instant pin each frame — feels continuous as text grows (smooth // scroll-behavior fights high-frequency stream updates). thinkBody.scrollTop = thinkBody.scrollHeight } if (thinkBody._qvacScrollRaf) cancelAnimationFrame(thinkBody._qvacScrollRaf) thinkBody._qvacScrollRaf = requestAnimationFrame(run) } /** Keep the chat list following the live assistant message. */ function followMessagesScroll({ force = true } = {}) { const list = opts.els.messages if (!list) return if (!force && !isNearBottom(list)) return if (list._qvacScrollRaf) cancelAnimationFrame(list._qvacScrollRaf) list._qvacScrollRaf = requestAnimationFrame(() => { list._qvacScrollRaf = 0 list.scrollTop = list.scrollHeight }) } /** * Paint / update assistant HTML. While streaming with an open think panel, * updates the think body in place so scroll position can stay pinned. * @param {HTMLElement|null|undefined} bodyEl * @param {string} raw * @param {boolean} streaming */ function paintAssistant(bodyEl, raw, streaming) { if (!bodyEl) return const parts = partitionThink(raw) let details = bodyEl.querySelector('details.qvac-think') let thinkBody = bodyEl.querySelector('.qvac-think-body') let answerEl = bodyEl.querySelector('.qvac-msg-answer') // Fast path: structure already exists and we still have thinking text — // update DOM in place so the think scroller doesn't jump to top each token. if (parts.thinking && details && thinkBody) { details.open = true const summary = details.querySelector('.qvac-think-summary') let live = summary?.querySelector('.qvac-think-live') const showLive = Boolean(streaming || parts.thinkingOpen) if (showLive && summary && !live) { live = document.createElement('span') live.className = 'qvac-think-live' live.textContent = 'live' summary.appendChild(live) } else if (!showLive && live) { live.remove() } const stick = streaming || isNearBottom(thinkBody) thinkBody.innerHTML = formatMdLite(parts.thinking) if (stick) followThinkScroll(thinkBody, { force: true }) if (parts.answer) { if (!answerEl) { answerEl = document.createElement('div') answerEl.className = 'qvac-msg-answer' bodyEl.appendChild(answerEl) } answerEl.className = 'qvac-msg-answer' answerEl.innerHTML = formatMdLite(parts.answer) } else if (streaming || parts.thinkingOpen) { if (!answerEl) { answerEl = document.createElement('div') bodyEl.appendChild(answerEl) } answerEl.className = 'qvac-msg-answer qvac-msg-pending muted' answerEl.textContent = 'Working…' } else if (answerEl) { answerEl.remove() } if (streaming) followMessagesScroll({ force: true }) return } // Full re-render (first paint, or no think block) bodyEl.innerHTML = renderAssistantHtml(raw, formatMdLite, { openThink: streaming }) thinkBody = bodyEl.querySelector('.qvac-think-body') if (streaming || parts.thinkingOpen) { followThinkScroll(thinkBody, { force: true }) followMessagesScroll({ force: true }) } else if (thinkBody && isNearBottom(thinkBody)) { followThinkScroll(thinkBody, { force: true }) } } function setBusyUi(on) { busy = on const send = opts.els.sendBtn const stop = opts.els.stopBtn if (send) { send.disabled = on send.classList.toggle('hidden', on) } if (stop) { stop.classList.toggle('hidden', !on) stop.disabled = false } } async function stopInference() { if (!busy) return setStatus('Stopping…', 'busy') if (swarm.isOpen?.()) swarm.fail('Stopped by user') try { await engine.stop?.() } catch (err) { opts.log?.(`QVAC stop: ${err?.message || err}`) } } async function send() { const input = opts.els.input const text = (input?.value || '').trim() if (!text || busy) return if (input) input.value = '' setBusyUi(true) appendMsg('user', text) const assistantUi = appendMsg('assistant', '…', { streaming: true }) const streamBody = assistantUi?.body const toolLog = [] let acc = '' let thinkingAcc = '' setStatus('Thinking…', 'busy') 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 .filter((m) => m.role === 'user' || m.role === 'assistant') .slice(0, -1) .map((m) => { if (m.role !== 'assistant') return { role: m.role, content: m.content } const { answer } = partitionThink(m.content) return { role: 'assistant', content: answer || m.content } }) hist.push({ role: 'user', content: text }) /** @type {{ agentBriefing?: string, agentFallbackText?: string, agentToolCalls?: any[] }} */ const completeOpts = {} const prefs = { 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( '_Specialists are gathering live data in this turn…_' ) } followMessagesScroll({ force: true }) 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, { mount: swarmSlot } ) followMessagesScroll({ force: true }) }, onAgent: (ev) => { if (engine.isAborted?.()) return swarm.update(ev) if (ev.status === 'start' || ev.status === 'running') { setStatus(`${ev.label}…`, 'busy') } followMessagesScroll() }, }) 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…') // 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) => ({ name: `agent:${a.id}`, args: {}, result: { summary: a.summary, status: a.status }, })) for (const a of pack.agents) { toolLog.push({ name: `agent:${a.id}`, args: {}, result: { summary: a.summary, status: a.status }, }) } if (assistantUi?.div) { let chips = assistantUi.div.querySelector('.qvac-tool-chips') if (chips) chips.remove() assistantUi.div.appendChild(renderToolChips(toolLog)) } setStatus('Synthesizing…', 'busy') followMessagesScroll({ force: true }) } if (engine.isAborted?.()) { acc = '_(Stopped.)_' paintAssistant(streamBody, acc, false) setStatus('Stopped', 'warn') return } const result = await engine.complete(hist, { ...completeOpts, onToken: (t) => { acc += t paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true) }, onThinking: (t) => { thinkingAcc += t const raw = thinkingAcc ? `\n${thinkingAcc}\n\n${acc}` : acc paintAssistant(streamBody, raw, true) }, onTool: (name, args, res) => { if (engine.isAborted?.()) return toolLog.push({ name, args, result: res }) setStatus(`Tool: ${name}…`, 'busy') if (assistantUi?.div) { let chips = assistantUi.div.querySelector('.qvac-tool-chips') if (chips) chips.remove() assistantUi.div.appendChild(renderToolChips(toolLog)) followMessagesScroll({ force: true }) } }, }) acc = result.contentText || acc if (thinkingAcc && !/\n${thinkingAcc}\n\n${acc}` } paintAssistant(streamBody, acc, false) followMessagesScroll({ force: true }) const parts = partitionThink(acc) const last = messages[messages.length - 1] if (last?.role === 'assistant') { last.content = acc last.thinking = parts.thinking last.tools = toolLog } if (toolLog.length && assistantUi?.div) { let chips = assistantUi.div.querySelector('.qvac-tool-chips') if (chips) chips.remove() assistantUi.div.appendChild(renderToolChips(toolLog)) } // Live sparklines under the answer for chart-related tools if (!result.stopped) { const embedIds = chartIdsFromToolLog(toolLog) if (embedIds.length && assistantUi?.div && opts.manager?.request) { mountChartEmbeds(assistantUi.div, embedIds, { request: (m, a) => opts.manager.request(m, a || {}), catalog: opts.getCatalog?.() || {}, onOpen: (id) => { opts.onOpenView?.('charts') if (opts.charts?.showCharts) { opts.charts.showCharts({ charts: [id], pin: true, boardOnly: true, focus: id, openFocus: true, }) } else { opts.onOpenChart?.(id) } }, }).catch(() => {}) } } if (result.stopped || result.mode === 'stopped') { setStatus('Stopped', 'warn') } else { setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok') } } catch (err) { const msg = err?.message || String(err) if (/cancel|abort|stopped/i.test(msg)) { if (streamBody && !acc) { paintAssistant(streamBody, '_(Stopped.)_', false) } else if (streamBody && acc) { paintAssistant(streamBody, acc + '\n\n_(Stopped.)_', false) } setStatus('Stopped', 'warn') } else { if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`) setStatus(msg, 'error') } } finally { setBusyUi(false) syncModelChip() } } /** Merge separate thinking stream + content stream for progressive UI. */ function mergeThinkStream(thinking, content) { if (thinking && !/\n${thinking}\n\n${content}` } return content || (thinking ? `\n${thinking}` : '…') } 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.' ) } function resetOnboarding() { persist({ qvacOnboarded: false, qvacMode: '' }) wizardStep = 0 engine.unload().catch(() => {}) showPane() } async function unloadModel() { await engine.unload() persist({ qvacMode: settings().qvacMode === 'qvac' ? 'fallback' : settings().qvacMode }) setStatus('Model unloaded', 'ok') syncModelChip() } async function reloadModelFromSettings() { readSettingsForm() const profile = settings().qvacProfile || selectedProfile || 'recommended' selectedProfile = profile setStatus('Loading model…', 'busy') syncModelChip() try { const result = await engine.loadProfile(profile, { onProgress: (p) => { const pct = p?.percentage if (pct != null) setStatus(`Loading model ${Number(pct).toFixed(0)}%…`, 'busy') syncModelChip() }, }) persist({ qvacOnboarded: true, qvacProfile: profile, qvacMode: result.mode || 'fallback', }) setStatus( result.ok && result.mode === 'qvac' ? 'Ready' : `Ready (tools-only${result.error ? `: ${result.error}` : ''})`, result.mode === 'qvac' ? 'ok' : 'warn' ) } catch (err) { setStatus(`Load failed: ${err?.message || err}`, 'error') } syncModelChip() showPane() } function bind() { opts.els.sendBtn?.addEventListener('click', () => send()) opts.els.stopBtn?.addEventListener('click', () => stopInference()) opts.els.input?.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && busy) { ev.preventDefault() stopInference() return } if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault() send() } }) // Global Esc while QVAC tab is active opts.els.root?.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && busy) { ev.preventDefault() stopInference() } }) opts.els.resetBtn?.addEventListener('click', () => { closeSettings() resetOnboarding() }) opts.els.unloadBtn?.addEventListener('click', () => unloadModel()) opts.els.newChatBtn?.addEventListener('click', () => newChat()) opts.els.settingsBtn?.addEventListener('click', () => toggleSettings()) const root = opts.els.root root?.querySelector('#qvac-settings-close')?.addEventListener('click', () => { readSettingsForm() closeSettings() setStatus('Settings saved', 'ok') syncModelChip() }) root?.querySelector('#qvac-set-rerun-setup')?.addEventListener('click', () => { readSettingsForm() closeSettings() resetOnboarding() }) root?.querySelector('#qvac-set-unload')?.addEventListener('click', () => { readSettingsForm() unloadModel() }) root?.querySelector('#qvac-set-reload')?.addEventListener('click', () => { reloadModelFromSettings() }) // Live-persist toggles for (const sel of [ '#qvac-set-profile', '#qvac-set-rag', '#qvac-set-idle', '#qvac-set-tool-depth', '#qvac-set-subagents', '#qvac-set-max-agents', '#qvac-set-autonav', ]) { root?.querySelector(sel)?.addEventListener('change', () => { readSettingsForm() syncSettingsForm() }) } } /** @type {Promise|null} */ let restorePromise = null /** * If onboarding finished with a full model, reload it via main-process IPC * (never require SDK in the renderer). */ async function restoreSavedModel() { const mode = settings().qvacMode const profile = settings().qvacProfile || 'recommended' if (mode !== 'qvac' || !profile) return const st = engine.getStatus() if (st.status === 'ready' && st.sdkLoaded) return if (st.status === 'downloading' || st.status === 'loading') return setStatus('Loading saved model…', 'busy') syncModelChip() try { const result = await engine.loadProfile(profile, { onProgress: (p) => { const pct = p?.percentage if (pct != null) setStatus(`Loading model ${Number(pct).toFixed(0)}%…`, 'busy') syncModelChip() }, }) if (result.mode === 'qvac' && result.ok !== false) { persist({ qvacMode: 'qvac', qvacProfile: profile }) setStatus('Ready', 'ok') } else { persist({ qvacMode: result.mode || 'fallback' }) setStatus( result.error ? `Ready (tools-only: ${result.error})` : 'Ready (tools-only)', 'warn' ) } } catch (err) { setStatus(`Ready (tools-only: ${err?.message || err})`, 'warn') } syncModelChip() } function enter() { showPane() renderSamples() syncModelChip() if (isOnboarded()) { // Restore model in background if user previously completed Download & load if (!restorePromise) { restorePromise = restoreSavedModel().finally(() => { restorePromise = null }) } if (!messages.length) { const mode = settings().qvacMode appendMsg( 'assistant', mode === 'qvac' ? 'QVAC ready — restoring your saved model if needed. Ask about host health, metrics, or processes.' : 'QVAC ready (tools-only). Use **Setup → Download & load** for full local chat, or ask for live metrics now.' ) } } } bind() // Warm model as soon as the view is constructed (if already onboarded) if (isOnboarded() && settings().qvacMode === 'qvac') { restorePromise = restoreSavedModel().finally(() => { restorePromise = null }) } return { enter, leave: () => { // Keep weights on disk; optional idle unload still runs via engine timer engine.touchActivity?.() }, engine, newChat, resetOnboarding, } } function formatMdLite(text) { const esc = escapeHtml(String(text || '')) return esc .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\n/g, '
    ') } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') }