/** * Bundled llama.cpp server lifecycle manager. * Used by main.cjs (Electron) and server.js so local AI starts whether the * app runs as a packaged desktop app or via `node server.js` in a browser. */ const { spawn, execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const http = require('http'); const net = require('net'); const os = require('os'); const DEFAULT_PORT = 8090; const EXPECTED_MODEL_BYTES = 2019377696; // Llama 3.2 3B Instruct Q4_K_M — the stage-speed default. // Also on disk: model-8b.gguf (Llama 3.1 8B, deeper but ~3x slower — swap filenames + update this // constant to switch back) and model-1b-backup.gguf (original tiny model). const STUCK_LOADING_MS = 90000; // 8B model takes longer to load than the old 1B — don't kill it prematurely const NO_LOG_STUCK_MS = 30000; const MAX_RESTART_ATTEMPTS = 12; const VC_REDIST_MISSING_CODE = 3221225781; // 0xC0000135 STATUS_DLL_NOT_FOUND let llamaServerProcess = null; let lastError = null; let restartTimer = null; let restartAttempts = 0; let processStartedAt = 0; let lastLogAt = 0; let readySince = 0; let lastHealthStatus = ''; let lastLogLines = []; let useSafeMode = false; let useCachedModel = false; let activePort = DEFAULT_PORT; let phase = 'idle'; // idle | copying | starting | loading | ready | error let modelCopyInProgress = false; let startPromise = null; let vcRedistInstallAttempted = false; function isMissingRuntimeExitCode(code) { return code === VC_REDIST_MISSING_CODE || code === -1073741515; } function formatExitError(code) { if (isMissingRuntimeExitCode(code)) { return 'Missing Microsoft Visual C++ runtime (required for local AI). Installing runtime...'; } return code ? `Local AI process exited unexpectedly (code ${code})` : 'Local AI process exited before becoming ready'; } function resolveVcRedistPath() { const execDir = path.dirname(process.execPath || ''); const candidates = [ path.join(process.resourcesPath || '', 'app.asar.unpacked', 'vendor', 'redist', 'vc_redist.x64.exe'), path.join(execDir, 'resources', 'app.asar.unpacked', 'vendor', 'redist', 'vc_redist.x64.exe'), path.join(__dirname, 'vendor', 'redist', 'vc_redist.x64.exe') ]; for (const candidate of candidates) { try { if (candidate && fs.existsSync(candidate)) return candidate; } catch (_) {} } return null; } function installVcRedistSync() { const redist = resolveVcRedistPath(); if (!redist) { lastError = 'Missing Microsoft Visual C++ runtime and bundled installer not found. Reinstall Bible App.'; return false; } try { pushLog('Installing Microsoft Visual C++ runtime (one-time)...'); console.log('[LLAMA] Running VC++ redistributable:', redist); execSync(`"${redist}" /install /quiet /norestart`, { stdio: 'ignore', windowsHide: true, timeout: 180000 }); pushLog('Visual C++ runtime installed'); return true; } catch (e) { lastError = 'Failed to install Microsoft Visual C++ runtime automatically. Reinstall Bible App or install "Microsoft Visual C++ 2015-2022 Redistributable (x64)".'; pushLog(lastError); return false; } } function handleMissingRuntimeExit() { if (vcRedistInstallAttempted) { lastError = 'Missing Microsoft Visual C++ runtime. Reinstall Bible App or install "Microsoft Visual C++ 2015-2022 Redistributable (x64)".'; return false; } vcRedistInstallAttempted = true; phase = 'starting'; if (installVcRedistSync()) { restartAttempts = 0; useSafeMode = true; useCachedModel = true; startPromise = null; scheduleRestart('Installed VC++ runtime, retrying local AI'); return true; } return false; } function ensureLlamaStarting() { if (startPromise) return startPromise; startPromise = startLlamaServer() .catch((e) => { startPromise = null; throw e; }); return startPromise; } function getLlamaPort() { return activePort || parseInt(process.env.BIBLE_APP_LLAMA_PORT || String(DEFAULT_PORT), 10); } function pushLog(line) { if (!line) return; lastLogAt = Date.now(); lastLogLines.push(String(line).slice(0, 500)); if (lastLogLines.length > 40) lastLogLines.shift(); } function resolveUserDataRoot() { const envDir = (process.env.BIBLE_APP_USER_DATA_DIR || process.env.BIBLE_APP_DATA_DIR || '').trim(); if (envDir) return envDir; if (process.platform === 'win32') { return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Bible App'); } if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', 'Bible App'); } return path.join(os.homedir(), '.config', 'Bible App'); } function resolveVendorLlamaDir() { const execDir = path.dirname(process.execPath || ''); const candidates = [ path.join(process.resourcesPath || '', 'app.asar.unpacked', 'vendor', 'llama'), path.join(execDir, 'resources', 'app.asar.unpacked', 'vendor', 'llama'), path.join(__dirname, 'vendor', 'llama') ]; for (const dir of candidates) { try { if (dir && fs.existsSync(path.join(dir, 'llama-server.exe'))) { return dir; } } catch (_) {} } return null; } function isPackagedInstall(vendorDir) { const normalized = String(vendorDir || '').toLowerCase(); return normalized.includes('app.asar.unpacked') || normalized.includes('program files') || normalized.includes('\\resources\\'); } function isDevInstall(vendorDir) { const normalized = path.resolve(vendorDir || '').toLowerCase(); const devRoot = path.resolve(__dirname).toLowerCase(); return normalized.startsWith(devRoot); } function getCachedModelPath() { return path.join(resolveUserDataRoot(), 'llama-cache', 'model.gguf'); } function isCachedModelReady() { try { const cachedModel = getCachedModelPath(); if (!fs.existsSync(cachedModel)) return false; const stat = fs.statSync(cachedModel); // Must MATCH the expected model size (±50 MB), not merely exceed it. A leftover cache from a // different model (e.g. the old 4.7 GB 8B build) would otherwise be treated as current and // silently loaded in place of the model this version ships with. return Math.abs(stat.size - EXPECTED_MODEL_BYTES) <= (50 * 1024 * 1024); } catch (_) { return false; } } async function copyModelToCache(sourceModel) { const cachedModel = getCachedModelPath(); fs.mkdirSync(path.dirname(cachedModel), { recursive: true }); phase = 'copying'; pushLog('Copying AI model to local cache (one-time, about 1 GB)...'); console.log('[LLAMA] Copying model to', cachedModel); // Async copy keeps the main process responsive during the ~1GB copy, so status probes // can actually observe the 'copying' phase (a blocking copyFileSync froze the event loop). await fs.promises.copyFile(sourceModel, cachedModel); pushLog('Model cache copy complete'); phase = 'starting'; return cachedModel; } function isProcessAlive(proc) { if (!proc || !proc.pid) return false; try { process.kill(proc.pid, 0); return true; } catch (_) { return false; } } function forceKillProcess(proc) { if (!proc || !proc.pid) return; try { if (process.platform === 'win32') { execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore', windowsHide: true }); } else if (isProcessAlive(proc)) { proc.kill('SIGKILL'); } } catch (_) { try { if (isProcessAlive(proc)) proc.kill(); } catch (_) {} } } function isPortAvailable(port) { return new Promise((resolve) => { const tester = net.createServer() .once('error', () => resolve(false)) .once('listening', () => { tester.close(() => resolve(true)); }) .listen(port, '127.0.0.1'); }); } async function pickPort() { for (let port = DEFAULT_PORT; port < DEFAULT_PORT + 12; port++) { // eslint-disable-next-line no-await-in-loop if (await isPortAvailable(port)) return port; } return DEFAULT_PORT; } async function resolveModelPath(vendorDir) { const sourceModel = path.join(vendorDir, 'model.gguf'); // A model sitting in user data always wins: it is either one installed by hand from // Settings > Local AI, or the fallback copy made when reading the install folder failed. if (isCachedModelReady()) return getCachedModelPath(); // Then the machine-wide copy the full one-file installer drops in ProgramData. if (isSharedModelReady()) return getSharedModelPath(); // Nothing bundled and nothing cached — the caller reports "model not installed". if (!fs.existsSync(sourceModel)) return sourceModel; // The model ships INSIDE the installer now, unpacked next to llama-server.exe, so read it // straight from there. Duplicating ~1.9 GB into user data is only worth it as a retry fallback // (useCachedModel), for the rare machine where antivirus blocks reads from the install folder. if (!useCachedModel) return sourceModel; return copyModelToCache(sourceModel); } /* * Machine-wide model location, written by the full one-file installer. * * That installer runs elevated (Windows treats any unmanifested "...Setup.exe" as an installer), and * an elevated process can end up with a DIFFERENT user's %APPDATA% if someone types another admin's * credentials at the UAC prompt. ProgramData is the same path for every account and readable by all * of them, so the model always lands where the app will actually look for it — and no app update * ever touches it. */ function getSharedModelPath() { if (process.platform !== 'win32') return null; const base = process.env.ProgramData || process.env.ALLUSERSPROFILE || 'C:\\ProgramData'; return path.join(base, 'Bible App', 'llama-cache', 'model.gguf'); } function isSharedModelReady() { try { const p = getSharedModelPath(); if (!p || !fs.existsSync(p)) return false; return Math.abs(fs.statSync(p).size - EXPECTED_MODEL_BYTES) <= (50 * 1024 * 1024); } catch (_) { return false; } } function getBundledModelPath() { try { const dir = resolveVendorLlamaDir(); return dir ? path.join(dir, 'model.gguf') : null; } catch (_) { return null; } } function isBundledModelReady() { try { const p = getBundledModelPath(); if (!p || !fs.existsSync(p)) return false; return Math.abs(fs.statSync(p).size - EXPECTED_MODEL_BYTES) <= (50 * 1024 * 1024); } catch (_) { return false; } } // True when neither a bundled nor a cached model exists — the app can run, the Ask tab just needs // the model installed once from Settings. function isModelMissing() { try { if (isCachedModelReady()) return false; if (isSharedModelReady()) return false; const dir = resolveVendorLlamaDir(); if (dir && fs.existsSync(path.join(dir, 'model.gguf'))) return false; return true; } catch (_) { return true; } } /* * What the Ask tab will ACTUALLY load, for Settings to report honestly. * The model ships inside the installer, so on a normal machine it is 'bundled' and there is nothing * to install. Settings used to look only at the user-data copy, which is why a machine with a * perfectly good bundled model still announced "AI model not installed yet". */ function getModelInfo() { const sizeOf = (p) => { try { return p && fs.existsSync(p) ? fs.statSync(p).size : 0; } catch (_) { return 0; } }; if (isCachedModelReady()) { const p = getCachedModelPath(); return { ready: true, source: 'installed', path: p, sizeMB: Math.round(sizeOf(p) / 1048576) }; } if (isSharedModelReady()) { const p = getSharedModelPath(); return { ready: true, source: 'shared', path: p, sizeMB: Math.round(sizeOf(p) / 1048576) }; } if (isBundledModelReady()) { const p = getBundledModelPath(); return { ready: true, source: 'bundled', path: p, sizeMB: Math.round(sizeOf(p) / 1048576) }; } // Not usable — report the biggest partial we can see so Settings can say "incomplete" vs "absent". const best = [getCachedModelPath(), getSharedModelPath(), getBundledModelPath()] .filter(Boolean) .reduce((a, b) => (sizeOf(a) >= sizeOf(b) ? a : b)); return { ready: false, source: 'none', path: best, sizeMB: Math.round(sizeOf(best) / 1048576) }; } function buildArgs(modelPath, port) { const cpuCount = os.cpus().length; const threadCount = useSafeMode ? Math.max(2, Math.min(4, cpuCount)) : Math.max(2, Math.min(8, cpuCount)); // 8B model benefits from more threads; leave headroom for the app const args = [ '-m', modelPath, '--host', '127.0.0.1', '--port', String(port), // 4096 ctx: scripture grounding + a few turns of history overflowed the old 1024/2048 // window ("exceeds the available context size" errors mid-service). KV cache for the // bundled ~1B model at 4096 is modest, safe even in safe mode. '--ctx-size', '4096', '--threads', String(threadCount), '--threads-batch', String(threadCount), '--batch-size', useSafeMode ? '128' : '256', '--ubatch-size', useSafeMode ? '128' : '256', '--parallel', '1', '--cont-batching' // (no --no-warmup: warm the model at BOOT so the first live question isn't the slow one) ]; if (useSafeMode) args.push('--cache-ram', '0'); return args; } function killLlamaProcess(reason) { if (!llamaServerProcess) return; pushLog(reason || 'Stopping local AI process'); console.warn('[LLAMA]', reason || 'Stopping local AI process'); const proc = llamaServerProcess; llamaServerProcess = null; forceKillProcess(proc); } function scheduleRestart(reason) { if (restartTimer) return; if (restartAttempts >= MAX_RESTART_ATTEMPTS) { phase = 'error'; lastError = reason || lastError || 'Local AI failed to start after multiple attempts'; console.error('[LLAMA]', lastError); return; } restartAttempts += 1; useSafeMode = true; useCachedModel = true; phase = 'starting'; const delayMs = Math.min(8000, 800 * restartAttempts); console.warn(`[LLAMA] Scheduling restart attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS} in ${delayMs}ms`); restartTimer = setTimeout(() => { restartTimer = null; startPromise = null; startLlamaServer({ isRetry: true }); }, delayMs); } function checkStuckAndRecover() { if (modelCopyInProgress) return false; if (!llamaServerProcess) return false; if (!isProcessAlive(llamaServerProcess)) { llamaServerProcess = null; scheduleRestart('Local AI process stopped unexpectedly'); return true; } if (readySince) return false; const elapsed = Date.now() - processStartedAt; const sinceLog = lastLogAt ? (Date.now() - lastLogAt) : elapsed; const stuckOnPort = elapsed >= STUCK_LOADING_MS; const silentTooLong = sinceLog >= NO_LOG_STUCK_MS && lastLogLines.length <= 1; if (!stuckOnPort && !silentTooLong) return false; const reason = silentTooLong ? `Local AI produced no output for ${Math.round(sinceLog / 1000)}s; retrying from local cache` : `Local AI stuck waiting for port after ${Math.round(elapsed / 1000)}s; retrying from local cache`; lastError = reason; killLlamaProcess(reason); scheduleRestart(reason); return true; } async function startLlamaServer(options = {}) { if (modelCopyInProgress) { return { started: false, preparing: true, phase: 'copying' }; } if (llamaServerProcess && isProcessAlive(llamaServerProcess)) { return { started: true, alreadyRunning: true }; } llamaServerProcess = null; try { const dir = resolveVendorLlamaDir(); if (!dir) { phase = 'error'; lastError = 'Bundled AI files not found (vendor/llama missing from install)'; console.warn('[LLAMA]', lastError); return { started: false, error: lastError }; } if (!options.isRetry) { restartAttempts = 0; // Start at FULL speed everywhere. Safe mode halves the threads, quarters the batch size // and turns off the prompt-cache RAM the Ask tab relies on — defaulting installed // machines into it made the stage PC noticeably slower than the dev box for no reason. // scheduleRestart() still switches it (and the cached-model copy) on if a start fails. useSafeMode = false; useCachedModel = false; } activePort = await pickPort(); process.env.BIBLE_APP_LLAMA_PORT = String(activePort); const exePath = path.join(dir, 'llama-server.exe'); let modelPath; try { modelCopyInProgress = useCachedModel && !isCachedModelReady(); modelPath = await resolveModelPath(dir); } finally { modelCopyInProgress = false; } if (!fs.existsSync(modelPath)) { phase = 'error'; lastError = 'AI model not installed yet. Open Settings > Local AI to install it (one time).'; console.warn('[LLAMA]', lastError, modelPath); return { started: false, error: lastError, modelMissing: true }; } const modelStat = fs.statSync(modelPath); if (modelStat.size < EXPECTED_MODEL_BYTES - (50 * 1024 * 1024)) { phase = 'error'; lastError = `AI model file looks incomplete (${Math.round(modelStat.size / (1024 * 1024))} MB)`; console.warn('[LLAMA]', lastError); return { started: false, error: lastError }; } const args = buildArgs(modelPath, activePort); processStartedAt = Date.now(); lastLogAt = processStartedAt; readySince = 0; lastHealthStatus = 'starting'; phase = 'loading'; console.log('[LLAMA] Spawning', exePath, args.join(' ')); pushLog(`Starting local AI on port ${activePort} (attempt ${restartAttempts + 1})`); llamaServerProcess = spawn(exePath, args, { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH || ''}`, LLAMA_LOG_COLORS: '0' } }); llamaServerProcess.stdout.on('data', (chunk) => { chunk.toString('utf-8').split(/\r?\n/).forEach((line) => { const trimmed = line.trim(); if (!trimmed) return; pushLog(trimmed); console.log('[LLAMA]', trimmed); if (/model loaded|server is listening/i.test(trimmed)) { lastHealthStatus = 'model loaded'; } }); }); llamaServerProcess.stderr.on('data', (chunk) => { chunk.toString('utf-8').split(/\r?\n/).forEach((line) => { const trimmed = line.trim(); if (!trimmed) return; pushLog(trimmed); console.log('[LLAMA]', trimmed); }); }); llamaServerProcess.on('exit', (code, sig) => { const wasReady = !!readySince; llamaServerProcess = null; readySince = 0; console.log('[LLAMA] llama-server exited code=' + code + ' signal=' + sig); if (wasReady && (!code || code === 0)) return; if (isMissingRuntimeExitCode(code) && handleMissingRuntimeExit()) return; phase = 'error'; lastError = formatExitError(code); scheduleRestart(lastError); }); llamaServerProcess.on('error', (err) => { phase = 'error'; lastError = `Failed to launch local AI: ${err.message}`; console.error('[LLAMA]', lastError); llamaServerProcess = null; scheduleRestart(lastError); }); lastError = null; return { started: true, port: activePort, dir, modelPath, safeMode: useSafeMode, cachedModel: useCachedModel, phase }; } catch (e) { phase = 'error'; lastError = e.message || String(e); console.error('[LLAMA] startLlamaServer error:', lastError); return { started: false, error: lastError }; } } function stopLlamaServer() { if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } killLlamaProcess('Stopping local AI on app exit'); phase = 'idle'; } function getLlamaStatus() { checkStuckAndRecover(); const alive = isProcessAlive(llamaServerProcess); const loadingSeconds = processStartedAt && !readySince ? Math.max(0, Math.round((Date.now() - processStartedAt) / 1000)) : 0; return { port: getLlamaPort(), dir: resolveVendorLlamaDir(), pid: alive && llamaServerProcess ? llamaServerProcess.pid : null, lastError, restartAttempts, loadingSeconds, safeMode: useSafeMode, cachedModel: useCachedModel, cacheReady: isCachedModelReady(), phase: modelCopyInProgress ? 'copying' : phase, lastHealthStatus, recentLog: lastLogLines.slice(-6).join(' | ') }; } function probeLlamaHealth() { checkStuckAndRecover(); const port = getLlamaPort(); return new Promise((resolve) => { const req2 = http.request({ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 4000 }, (r2) => { let body = ''; r2.on('data', (c) => { body += c.toString('utf-8'); }); r2.on('end', () => { let status = ''; try { const j = JSON.parse(body); status = (j && j.status) || ''; } catch (_) {} lastHealthStatus = status || `http-${r2.statusCode}`; const loading = status === 'loading model' || status === 'loading'; const ready = r2.statusCode === 200 && !loading && status !== 'error'; if (ready) { readySince = Date.now(); phase = 'ready'; } resolve({ ready, loading: !ready && (loading || !!llamaServerProcess), status: lastHealthStatus }); }); }); req2.on('timeout', () => { try { req2.destroy(); } catch (_) {} const alive = isProcessAlive(llamaServerProcess); resolve({ ready: false, loading: alive || modelCopyInProgress || phase === 'copying', status: modelCopyInProgress || phase === 'copying' ? 'copying-model' : (alive ? 'waiting-for-health' : 'not-running'), unreachable: true }); }); req2.on('error', (err) => { const alive = isProcessAlive(llamaServerProcess); resolve({ ready: false, loading: alive || modelCopyInProgress || phase === 'copying', status: modelCopyInProgress || phase === 'copying' ? 'copying-model' : (alive ? 'waiting-for-port' : 'not-running'), unreachable: true, error: err.code || err.message }); }); req2.end(); }); } module.exports = { startLlamaServer, ensureLlamaStarting, stopLlamaServer, probeLlamaHealth, getLlamaStatus, getLlamaPort, resolveVendorLlamaDir, checkStuckAndRecover, // Model provisioning (bundled with the installer; can also be installed by hand) getCachedModelPath, isCachedModelReady, getSharedModelPath, isSharedModelReady, getBundledModelPath, isBundledModelReady, getModelInfo, isModelMissing, EXPECTED_MODEL_BYTES };