Files
bible-app/llama-launcher.cjs
T

665 lines
25 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/**
* 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
};