First Try: QVAC (QuantumVerse Automatic Computer)
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* @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,
|
||||
* 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) => void,
|
||||
* onOpenView?: (view: string) => void,
|
||||
* 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,
|
||||
confirmAction: (msg) => {
|
||||
try {
|
||||
return typeof confirm === 'function' ? confirm(msg) : true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const engine = createQvacEngine({
|
||||
tools,
|
||||
getContext: () => ({
|
||||
peerAlias: opts.getPeerLabel?.() || '',
|
||||
peerId: opts.manager.active?.publicKeyHex || '',
|
||||
role: opts.getRole?.() || 'viewer',
|
||||
connected: Boolean(opts.isConnected?.()),
|
||||
}),
|
||||
getCatalog: () => opts.getCatalog?.() || {},
|
||||
getPrefs: () => ({
|
||||
rag: settings().qvacRag !== false,
|
||||
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
|
||||
}),
|
||||
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 syncModelChip() {
|
||||
const chip = opts.els.modelChip
|
||||
if (!chip) return
|
||||
const st = engine.getStatus()
|
||||
const profile = getProfile(settings().qvacProfile || selectedProfile)
|
||||
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 === 'ready') {
|
||||
chip.textContent = profile.chatModel
|
||||
chip.dataset.mode = 'ready'
|
||||
} 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'
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
renderWizard()
|
||||
}
|
||||
}
|
||||
|
||||
function renderWizard() {
|
||||
const host = opts.els.setupSteps
|
||||
if (!host) 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) {
|
||||
const card = stepCard('System check', '<p class="muted">Checking environment…</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()
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
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}">
|
||||
<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>`,
|
||||
[
|
||||
{
|
||||
label: 'Back',
|
||||
onClick: () => {
|
||||
wizardStep = 1
|
||||
renderWizard()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Download & load',
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
wizardStep = 3
|
||||
renderWizard()
|
||||
startLoad()
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
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>`,
|
||||
[]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
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 agent’s health, metrics, or processes.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function appendMsg(role, content, meta = {}) {
|
||||
messages.push({ role, content, tools: meta.tools })
|
||||
const list = opts.els.messages
|
||||
if (!list) return
|
||||
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)
|
||||
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)
|
||||
}
|
||||
list.appendChild(div)
|
||||
list.scrollTop = list.scrollHeight
|
||||
return body
|
||||
}
|
||||
|
||||
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 streamBody = appendMsg('assistant', '…')
|
||||
const toolLog = []
|
||||
let acc = ''
|
||||
setStatus('Thinking…', 'busy')
|
||||
try {
|
||||
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 }))
|
||||
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)
|
||||
},
|
||||
onTool: (name, args, res) => {
|
||||
toolLog.push({ name, args, result: res })
|
||||
},
|
||||
})
|
||||
acc = result.contentText || acc
|
||||
if (streamBody) streamBody.innerHTML = formatMdLite(acc)
|
||||
// update last message in state
|
||||
const last = messages[messages.length - 1]
|
||||
if (last?.role === 'assistant') {
|
||||
last.content = acc
|
||||
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)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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', () => resetOnboarding())
|
||||
opts.els.unloadBtn?.addEventListener('click', async () => {
|
||||
await engine.unload()
|
||||
setStatus('Model unloaded', 'ok')
|
||||
syncModelChip()
|
||||
})
|
||||
opts.els.newChatBtn?.addEventListener('click', () => newChat())
|
||||
}
|
||||
|
||||
function enter() {
|
||||
showPane()
|
||||
renderSamples()
|
||||
syncModelChip()
|
||||
if (isOnboarded() && !messages.length) {
|
||||
// Auto warm fallback path; full model load is manual after onboarding
|
||||
engine.tryLoadSdk().then(() => {
|
||||
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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
bind()
|
||||
|
||||
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, '"')
|
||||
}
|
||||
Reference in New Issue
Block a user