/** * 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|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} */ 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 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, }