665 lines
25 KiB
JavaScript
665 lines
25 KiB
JavaScript
/**
|
|||
|
|
* 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
|
||
|
|
};
|