311 lines
7.9 KiB
JavaScript
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,
|
|
}
|