FIX: QVAC Updates
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s

This commit is contained in:
Raven Scott
2026-07-30 15:23:43 -04:00
parent 79250e1a55
commit 4b5b78892f
25 changed files with 2886 additions and 345 deletions
+290 -75
View File
@@ -5,6 +5,7 @@ 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'
/**
* @param {{
@@ -160,7 +161,9 @@ export function createQvacView(opts) {
const sdkLine = env.checking
? '<li class="warn">QVAC SDK: checking…</li>'
: `<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${
env.sdkAvailable ? 'available' : 'not installed — tools-only mode'
env.sdkAvailable
? 'package found (loads only when you download a model)'
: 'not installed — tools-only mode'
}</li>`
body.innerHTML = `
<ul class="qvac-check-list">
@@ -228,7 +231,7 @@ export function createQvacView(opts) {
}
if (wizardStep === 1) {
// Always paint a complete card with actions first — never wait on SDK import.
// Paint immediately. Never import @qvac/sdk here — that freezes packaged Electron.
const card = stepCard(
'System check',
'<p class="muted">Preparing environment check…</p>',
@@ -245,27 +248,30 @@ export function createQvacView(opts) {
paintSystemCheck(card, quick)
bindSystemCheckActions(card)
engine
.checkEnvironment({ probeSdk: true, timeoutMs: 2500 })
.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,
// 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()
})
bindSystemCheckActions(card)
})
.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
}
@@ -300,7 +306,17 @@ export function createQvacView(opts) {
onClick: () => {
wizardStep = 3
renderWizard()
startLoad()
// 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)
})
},
},
]
@@ -450,31 +466,156 @@ export function createQvacView(opts) {
}
}
/**
* @param {string} role
* @param {string} content
* @param {{ tools?: any[], streaming?: boolean }} [meta]
*/
function appendMsg(role, content, meta = {}) {
messages.push({ role, content, tools: meta.tools })
const entry = { role, content, tools: meta.tools, thinking: '' }
messages.push(entry)
const list = opts.els.messages
if (!list) return
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'
body.innerHTML = formatMdLite(content)
if (role === 'assistant') {
body.innerHTML = renderAssistantHtml(content, formatMdLite, {
openThink: Boolean(meta.streaming),
})
} else {
body.innerHTML = formatMdLite(content)
}
div.appendChild(body)
if (meta.tools?.length) {
const chips = document.createElement('div')
chips.className = 'qvac-tool-chips'
for (const t of meta.tools) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
c.title = JSON.stringify(t.args || {}).slice(0, 200)
chips.appendChild(c)
}
div.appendChild(chips)
div.appendChild(renderToolChips(meta.tools))
}
list.appendChild(div)
list.scrollTop = list.scrollHeight
return body
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() {
@@ -485,49 +626,67 @@ export function createQvacView(opts) {
busy = true
opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
appendMsg('user', text)
const streamBody = appendMsg('assistant', '…')
const assistantUi = appendMsg('assistant', '…', { streaming: true })
const streamBody = assistantUi?.body
const toolLog = []
let acc = ''
let thinkingAcc = ''
setStatus('Thinking…', 'busy')
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) // drop placeholder assistant
.map((m) => ({ role: m.role, content: m.content }))
.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 })
const result = await engine.complete(hist, {
onToken: (t) => {
acc += t
if (streamBody) streamBody.innerHTML = formatMdLite(acc || '…')
opts.els.messages && (opts.els.messages.scrollTop = opts.els.messages.scrollHeight)
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
},
onThinking: (t) => {
thinkingAcc += t
// If model streams think separately, wrap for partitioner
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')
// Live tool chips while model works
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 (streamBody) streamBody.innerHTML = formatMdLite(acc)
// update last message in state
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') {
// Store full text (with think) for UI re-open; history path strips think
last.content = acc
last.thinking = parts.thinking
last.tools = toolLog
}
if (toolLog.length && streamBody?.parentElement) {
let chips = streamBody.parentElement.querySelector('.qvac-tool-chips')
if (!chips) {
chips = document.createElement('div')
chips.className = 'qvac-tool-chips'
streamBody.parentElement.appendChild(chips)
}
chips.innerHTML = ''
for (const t of toolLog) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
chips.appendChild(c)
}
if (toolLog.length && assistantUi?.div) {
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
if (chips) chips.remove()
assistantUi.div.appendChild(renderToolChips(toolLog))
}
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} catch (err) {
@@ -541,6 +700,14 @@ export function createQvacView(opts) {
}
}
/** 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 = ''
@@ -574,33 +741,81 @@ export function createQvacView(opts) {
opts.els.newChatBtn?.addEventListener('click', () => newChat())
}
/** @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() && !messages.length) {
// Auto warm fallback path; full model load is manual after onboarding
engine.tryLoadSdk().then(() => {
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
if (mode === 'qvac' && settings().qvacProfile) {
setStatus('Loading saved model…', 'busy')
engine.loadProfile(settings().qvacProfile).then(() => {
setStatus('Ready', 'ok')
syncModelChip()
})
} else {
syncModelChip()
}
})
appendMsg(
'assistant',
'QVAC ready. Try “Summarize host health” or pick a sample prompt.'
)
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: () => {