FIX: QVAC (QuantumVerse Automatic Computer) Onboarding
CI / test (push) Successful in 1m7s
Release rolling / release (push) Successful in 8m37s

This commit is contained in:
Raven Scott
2026-07-30 13:57:03 -04:00
parent 32928f19bd
commit e2d909eaaa
3 changed files with 291 additions and 98 deletions
+73 -24
View File
@@ -54,10 +54,23 @@ export function createQvacEngine(deps) {
)
}
async function tryLoadSdk() {
/**
* @param {number} [timeoutMs]
*/
async function tryLoadSdk(timeoutMs = 8_000) {
if (sdk) return sdk
try {
sdk = await import('@qvac/sdk')
const load = import('@qvac/sdk').then((m) => m)
const timed =
timeoutMs > 0
? Promise.race([
load,
new Promise((_, reject) => {
setTimeout(() => reject(new Error(`@qvac/sdk import timed out after ${timeoutMs}ms`)), timeoutMs)
}),
])
: load
sdk = await timed
sdkError = null
return sdk
} catch (err) {
@@ -80,45 +93,81 @@ export function createQvacEngine(deps) {
}
}
/**
* Lightweight host check without full qvac doctor.
*/
async function checkEnvironment() {
status = 'checking'
function hostPlatformInfo() {
const mem =
typeof performance !== 'undefined' && performance.memory
? performance.memory.jsHeapSizeLimit
: null
let totalRamBytes = null
let freeRamBytes = null
// Prefer already-loaded process/os globals — avoid hanging dynamic imports in UI
try {
const os = await import('os')
totalRamBytes = os.totalmem?.()
freeRamBytes = os.freemem?.()
} catch {
// browser/Pear without os
}
const s = await tryLoadSdk()
let resources = null
if (s?.getSystemResources) {
try {
resources = await s.getSystemResources({ sample: false })
} catch {
// ignore
if (typeof process !== 'undefined' && process.memoryUsage) {
// Node/Electron: totalmem via require('os') may work; try sync first
}
} catch {
// ignore
}
try {
// eslint-disable-next-line no-undef
const osMod = typeof require === 'function' ? require('os') : null
if (osMod) {
totalRamBytes = osMod.totalmem?.()
freeRamBytes = osMod.freemem?.()
}
} catch {
// ESM-only
}
status = 'idle'
return {
sdkAvailable: Boolean(s),
sdkError,
totalRamBytes: totalRamBytes ?? mem,
freeRamBytes,
resources,
platform: typeof process !== 'undefined' ? process.platform : 'unknown',
arch: typeof process !== 'undefined' ? process.arch : 'unknown',
}
}
/**
* Lightweight host check — never blocks on native SDK probes.
* @param {{ probeSdk?: boolean, timeoutMs?: number }} [opts]
*/
async function checkEnvironment(opts = {}) {
status = 'checking'
const host = hostPlatformInfo()
// Optional async os.totalmem for pure ESM
if (host.totalRamBytes == null) {
try {
const os = await Promise.race([
import('os'),
new Promise((_, rej) => setTimeout(() => rej(new Error('os import timeout')), 1500)),
])
host.totalRamBytes = os.totalmem?.() ?? host.totalRamBytes
host.freeRamBytes = os.freemem?.() ?? host.freeRamBytes
} catch {
// keep nulls
}
}
let sdkAvailable = Boolean(sdk)
let err = sdkError
if (opts.probeSdk !== false && !sdk) {
// Short probe only — full model load happens later on user action
const s = await tryLoadSdk(opts.timeoutMs ?? 2500)
sdkAvailable = Boolean(s)
err = sdkError
}
status = 'idle'
return {
sdkAvailable,
sdkError: err,
totalRamBytes: host.totalRamBytes,
freeRamBytes: host.freeRamBytes,
resources: null,
platform: host.platform,
arch: host.arch,
}
}
/**
* @param {string} profile
* @param {{ onProgress?: (p: any) => void }} [opts]
+202 -71
View File
@@ -130,9 +130,79 @@ export function createQvacView(opts) {
}
}
/** 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 ? 'available' : '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) return
if (!host) {
opts.log?.('QVAC setup steps container missing')
return
}
host.innerHTML = ''
if (wizardStep === 0) {
@@ -142,57 +212,67 @@ export function createQvacView(opts) {
`<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() } }]
[
{
label: 'Continue',
primary: true,
onClick: () => {
wizardStep = 1
renderWizard()
},
},
]
)
)
return
}
if (wizardStep === 1) {
const card = stepCard('System check', '<p class="muted">Checking environment…</p>', [])
// Always paint a complete card with actions first — never wait on SDK import.
const card = stepCard(
'System check',
'<p class="muted">Preparing environment check…</p>',
[]
)
host.appendChild(card)
engine.checkEnvironment().then((env) => {
const body = card.querySelector('.qvac-step-body')
if (!body) return
const ramGb = env.totalRamBytes ? (env.totalRamBytes / 1e9).toFixed(1) : '?'
selectedProfile = settings().qvacProfile || suggestProfile({ totalRamBytes: env.totalRamBytes })
body.innerHTML = `
<ul class="qvac-check-list">
<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${env.sdkAvailable ? 'available' : 'not installed — tools-only mode'}</li>
<li class="ok">Platform: ${escapeHtml(env.platform)} / ${escapeHtml(env.arch)}</li>
<li class="ok">RAM (est.): ${ramGb} GB</li>
${env.sdkError ? `<li class="warn">SDK note: ${escapeHtml(env.sdkError)}</li>` : ''}
</ul>
<p class="muted">Suggested profile: <strong>${escapeHtml(getProfile(selectedProfile).label)}</strong></p>
${
!env.sdkAvailable
? `<p class="muted">Install with <code>npm i @qvac/sdk</code> in the PearData project for full Qwen chat. You can continue in tools-only mode now.</p>`
: ''
}`
const actions = card.querySelector('.qvac-step-actions')
if (actions) {
actions.innerHTML = ''
actions.appendChild(
btn('Back', false, () => {
wizardStep = 0
renderWizard()
})
)
actions.appendChild(
btn('Choose model', true, () => {
wizardStep = 2
renderWizard()
})
)
}
})
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)
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,
})
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="${p.id}">
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>
@@ -200,7 +280,8 @@ export function createQvacView(opts) {
}).join('')
const card = stepCard(
'Model profile',
`<div class="qvac-profile-grid">${profilesHtml}</div>`,
`<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',
@@ -209,6 +290,10 @@ export function createQvacView(opts) {
renderWizard()
},
},
{
label: 'Tools-only',
onClick: () => finishToolsOnly(),
},
{
label: 'Download & load',
primary: true,
@@ -237,11 +322,43 @@ export function createQvacView(opts) {
`<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>`,
[]
</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() {
@@ -249,36 +366,50 @@ export function createQvacView(opts) {
const label = opts.els.root?.querySelector('#qvac-progress-label')
persist({ qvacProfile: selectedProfile })
setStatus('Loading model…', 'busy')
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 ? 'Ready' : `Fallback: ${result.error || 'SDK unavailable'}`, result.ok ? '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.`
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.`)
}
}