1158 lines
38 KiB
JavaScript
1158 lines
38 KiB
JavaScript
/**
|
||
* 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 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 = `<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|