Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the Windows machine. Build outputs, node_modules and the AI models stay out (see .gitignore); everything the installer build needs is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
Reference in New Issue
Block a user