Files
peardata/ui/qvac/index.js
T
Raven Scott 79971cc345
Release rolling / release (push) Canceled after 6m30s
CI / test (push) Canceled after 6m32s
Ask Mode and No More Auto Driven AI Confirm
2026-07-30 16:51:58 -04:00

1158 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'
/**
* @param {{
* els: {
* root: HTMLElement|null,
* setup: HTMLElement|null,
* chat: HTMLElement|null,
* messages: HTMLElement|null,
* input: HTMLTextAreaElement|null,
* sendBtn: 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<string, object>,
* 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) => {
try {
// Must be strict true — Cancel returns false
return typeof confirm === 'function' ? confirm(msg) : false
} catch {
return false
}
},
})
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,
})
}
/** @type {Map<string, HTMLElement>} */
const agentChipEls = new Map()
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}`
}
/** 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
? '<li class="warn">QVAC SDK: checking…</li>'
: `<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${
env.sdkAvailable
? 'package found (loads only when you download a model)'
: 'not installed — tools-only mode'
}</li>`
body.innerHTML = `
<ul class="qvac-check-list">
${sdkLine}
<li class="ok">Platform: ${escapeHtml(env.platform || 'unknown')} / ${escapeHtml(env.arch || 'unknown')}</li>
<li class="ok">RAM (est.): ${escapeHtml(String(ramGb))} GB</li>
${env.sdkError && !env.checking ? `<li class="warn">SDK note: ${escapeHtml(env.sdkError)}</li>` : ''}
</ul>
<p class="muted">Suggested profile: <strong>${escapeHtml(getProfile(selectedProfile).label)}</strong></p>
<p class="muted">You can use tools-only mode immediately (live agent RPCs, no model download), or choose a local model if the SDK is available.</p>
`
}
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',
`<p>Local-first AI for PearData — models run on <strong>your desktop</strong>, not the agent and not a cloud API.</p>
<p class="muted">The assistant uses live tools (<code>getHostSnapshot</code>, charts, processes) so answers stay grounded in agent data.</p>
<p class="muted">Powered by <a href="https://github.com/tetherto/qvac" target="_blank" rel="noreferrer">QVAC</a> (Tether).</p>`,
[
{
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',
'<p class="muted">Preparing environment check…</p>',
[]
)
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 `<button type="button" class="qvac-profile-card${active}" data-profile="${escapeHtml(p.id)}">
<strong>${escapeHtml(p.label)}</strong>
<span class="muted">${escapeHtml(p.description)}</span>
<span class="qvac-profile-meta">${escapeHtml(p.chatModel)} · ~${p.approxDownloadGb} GB · tools ${p.tools ? 'on' : 'off'}</span>
</button>`
}).join('')
const card = stepCard(
'Model profile',
`<div class="qvac-profile-grid">${profilesHtml}</div>
<p class="muted" style="margin-top:12px">Skip model download and use grounded tools-only answers instead.</p>`,
[
{
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',
`<div class="qvac-progress-wrap">
<div class="qvac-progress-bar"><i id="qvac-progress-fill" style="width:0%"></i></div>
<p class="muted" id="qvac-progress-label">Starting…</p>
</div>
<p class="muted">If this stalls, use tools-only mode below.</p>`,
[
{
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',
'<p class="muted">Unexpected setup step. Restart onboarding.</p>',
[
{
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 agents 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 = `<h3>${escapeHtml(title)}</h3><div class="qvac-step-body">${bodyHtml}</div><div class="qvac-step-actions"></div>`
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 })
}
}
async function send() {
const input = opts.els.input
const text = (input?.value || '').trim()
if (!text || busy) return
if (input) input.value = ''
busy = true
opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
appendMsg('user', text)
const assistantUi = appendMsg('assistant', '…', { streaming: true })
const streamBody = assistantUi?.body
const toolLog = []
let acc = ''
let thinkingAcc = ''
setStatus('Thinking…', 'busy')
clearAgentBar()
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?.()) {
setStatus('Spinning up agents…', 'busy')
if (streamBody) {
streamBody.innerHTML = formatMdLite('_Dispatching multi-agent investigation…_')
}
const pack = await runSubAgents({
tools,
query: text,
maxAgents: prefs.maxSubAgents,
onAgent: (ev) => {
paintAgentEvent(ev)
setStatus(`Agent: ${ev.label}…`, 'busy')
},
})
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')
}
const result = await engine.complete(hist, {
...completeOpts,
onToken: (t) => {
acc += t
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
},
onThinking: (t) => {
thinkingAcc += t
const raw = thinkingAcc
? `<think>\n${thinkingAcc}\n</think>\n${acc}`
: acc
paintAssistant(streamBody, raw, true)
},
onTool: (name, args, res) => {
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 && !/<think/i.test(acc)) {
acc = `<think>\n${thinkingAcc}\n</think>\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
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(() => {})
}
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} catch (err) {
const msg = err?.message || String(err)
if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`)
setStatus(msg, 'error')
} finally {
busy = false
if (opts.els.sendBtn) opts.els.sendBtn.disabled = false
syncModelChip()
}
}
/** Merge separate thinking stream + content stream for progressive UI. */
function mergeThinkStream(thinking, content) {
if (thinking && !/<think/i.test(content)) {
return `<think>\n${thinking}\n</think>\n${content}`
}
return content || (thinking ? `<think>\n${thinking}` : '…')
}
function newChat() {
messages = []
if (opts.els.messages) opts.els.messages.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.input?.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault()
send()
}
})
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<void>|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, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}