FIX: QVAC Updates
This commit is contained in:
+290
-75
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user