Files
peardata/electron/qvac-service.cjs
T
Raven Scott 4b5b78892f
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s
FIX: QVAC Updates
2026-07-30 15:23:43 -04:00

311 lines
7.9 KiB
JavaScript

/**
* QVAC SDK host for the Electron **main** process.
*
* Loading @qvac/sdk (and Bare natives) in the renderer freezes/blanks the UI.
* All loadModel / completion / unload traffic goes through IPC from the GUI.
*/
'use strict'
const path = require('path')
const fs = require('fs')
/** @type {any} */
let sdk = null
/** @type {Promise<any>|null} */
let sdkLoadPromise = null
/** @type {string|null} */
let modelId = null
/** @type {string|null} */
let loadedChatModel = null
/** @type {Error|string|null} */
let lastError = null
function appRoot() {
try {
const { app } = require('electron')
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..')
} catch {
return path.resolve(__dirname, '..')
}
}
function detectInstalled() {
try {
// resolve only — @qvac/sdk is ESM, do not require()
const resolved = require.resolve('@qvac/sdk', { paths: [appRoot(), __dirname] })
return { installed: true, path: resolved, error: null }
} catch (err) {
const pkg = path.join(appRoot(), 'node_modules', '@qvac', 'sdk', 'package.json')
if (fs.existsSync(pkg)) return { installed: true, path: pkg, error: null }
return { installed: false, path: null, error: err?.message || String(err) }
}
}
/**
* @qvac/sdk is pure ESM — use dynamic import() from this CJS host.
* @returns {Promise<any>}
*/
async function ensureSdk() {
if (sdk) return sdk
if (sdkLoadPromise) return sdkLoadPromise
sdkLoadPromise = (async () => {
const det = detectInstalled()
if (!det.installed) {
const e = new Error(det.error || '@qvac/sdk not installed')
lastError = e
throw e
}
// Prefer package name so Node/Electron resolves exports (import condition).
// Fall back to file URL if bare specifier fails in packaged layout.
let mod
try {
mod = await import('@qvac/sdk')
} catch (nameErr) {
try {
const resolved =
det.path && det.path.endsWith('.js')
? det.path
: require.resolve('@qvac/sdk', {
paths: [appRoot(), process.cwd(), __dirname],
})
const href = path.isAbsolute(resolved)
? require('url').pathToFileURL(resolved).href
: resolved
mod = await import(href)
} catch (fileErr) {
const msg = [nameErr?.message, fileErr?.message].filter(Boolean).join(' | ')
lastError = new Error(msg)
throw lastError
}
}
// Support both namespace and default exports
sdk = mod?.default && typeof mod.default === 'object' ? { ...mod, ...mod.default } : mod
lastError = null
return sdk
})()
try {
return await sdkLoadPromise
} catch (err) {
sdkLoadPromise = null
throw err
}
}
/**
* @param {{ chatModel: string, tools?: boolean, ctxSize?: number }} opts
* @param {(p: any) => void} [onProgress]
*/
async function loadModel(opts, onProgress) {
const s = await ensureSdk()
const chatModel = String(opts.chatModel || 'QWEN3_1_7B_INST_Q4')
const modelSrc = s[chatModel] || chatModel
const ctxSize = Math.max(2048, Math.min(32768, Number(opts.ctxSize) || 8192))
if (modelId && s.unloadModel) {
try {
await s.unloadModel({ modelId })
} catch {
// ignore
}
modelId = null
}
const id = await s.loadModel({
modelSrc,
modelType: 'llm',
modelConfig: {
tools: Boolean(opts.tools),
ctx_size: ctxSize,
},
onProgress: (prog) => {
try {
onProgress?.(prog)
} catch {
// ignore UI callback errors
}
},
})
modelId = id
loadedChatModel = chatModel
lastError = null
return { ok: true, modelId: id, chatModel, ctxSize }
}
async function unloadModel() {
if (sdk && modelId && sdk.unloadModel) {
try {
await sdk.unloadModel({ modelId })
} catch {
// ignore
}
}
modelId = null
loadedChatModel = null
return { ok: true }
}
/**
* One completion round (tool loop stays in the renderer).
* @param {{ history: any[], tools?: any[] }} opts
* @param {(token: string) => void} [onToken]
*/
/**
* @param {{ history: any[], tools?: any[] }} opts
* @param {(token: string) => void} [onToken]
* @param {(token: string) => void} [onThinking]
*/
async function complete(opts, onToken, onThinking) {
const s = await ensureSdk()
if (!modelId) throw new Error('No model loaded')
const run = s.completion({
modelId,
history: opts.history || [],
stream: true,
tools: opts.tools,
// Best-effort: emit thinkingDelta when model uses <think> blocks
captureThinking: true,
})
let content = ''
let thinking = ''
/** @type {any[]} */
const toolCalls = []
if (run.events) {
for await (const ev of run.events) {
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
const t = ev.text || ev.delta || ''
thinking += t
onThinking?.(t)
} else if (ev.type === 'contentDelta' && ev.text) {
content += ev.text
onToken?.(ev.text)
} else if (ev.type === 'toolCall') {
// Prefer structured call; also support nested toolCall
const call = ev.call || ev.toolCall || ev
toolCalls.push({
name: call.name || ev.name,
arguments: call.arguments || ev.arguments || {},
id: call.id || ev.id,
})
} else if (ev.type === 'toolCallError') {
// surface as content note; tool loop may continue
const msg = ev.error?.message || 'tool call error'
content += `\n[tool error: ${msg}]\n`
}
}
} else if (run.tokenStream) {
for await (const token of run.tokenStream) {
content += token
onToken?.(token)
}
}
const final = run.final ? await run.final : { contentText: content, toolCalls }
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
// Prefer full text that may still include think tags if captureThinking missed
const contentText = final.contentText || final.raw?.fullText || content
const thinkingText =
thinking ||
final.thinking ||
final.raw?.thinking ||
''
return {
contentText,
thinkingText,
toolCalls: calls,
stats: final.stats,
mode: 'qvac',
}
}
function getStatus() {
const det = detectInstalled()
return {
installed: det.installed,
sdkError: lastError ? String(lastError.message || lastError) : det.error,
modelId,
loadedChatModel,
ready: Boolean(modelId),
}
}
/**
* @param {import('electron').IpcMain} ipcMain
*/
function registerIpc(ipcMain) {
ipcMain.handle('peardata:qvac-detect', async () => detectInstalled())
ipcMain.handle('peardata:qvac-status', async () => getStatus())
ipcMain.handle('peardata:qvac-load', async (evt, args) => {
try {
const result = await loadModel(args || {}, (prog) => {
try {
evt.sender.send('peardata:qvac-progress', prog)
} catch {
// window gone
}
})
return result
} catch (err) {
lastError = err
return { ok: false, error: err?.message || String(err), mode: 'fallback' }
}
})
ipcMain.handle('peardata:qvac-unload', async () => {
try {
return await unloadModel()
} catch (err) {
return { ok: false, error: err?.message || String(err) }
}
})
ipcMain.handle('peardata:qvac-complete', async (evt, args) => {
try {
return await complete(
args || {},
(token) => {
try {
evt.sender.send('peardata:qvac-token', token)
} catch {
// ignore
}
},
(tok) => {
try {
evt.sender.send('peardata:qvac-thinking', tok)
} catch {
// ignore
}
}
)
} catch (err) {
lastError = err
return {
contentText: '',
thinkingText: '',
toolCalls: [],
error: err?.message || String(err),
mode: 'error',
}
}
})
}
module.exports = {
detectInstalled,
ensureSdk,
loadModel,
unloadModel,
complete,
getStatus,
registerIpc,
}