mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
Fixes #108 — leejet's latest sd.cpp release (master-587-b8bdffc) only ships Mac arm64, the Windows cudart runtime stub, and Linux ROCm. The old matcher only ever read `releases/latest` and excluded ROCm/Vulkan, so Ubuntu/Linux users hit "No binary found for this platform" with no recovery path. Windows users were also broken: the only Win asset in the latest release isn't an sd-cli build. - Walk the last 15 leejet releases until one ships a usable build for the current platform, so a partial latest release self-heals to the prior tag (master-586 onwards has the full 12-zip matrix). - Linux: prefer plain x86_64, then vulkan, then rocm (instead of rejecting both rocm and vulkan). - Windows: priority order avx2 > avx > avx512 > noavx > cuda12, and skip the standalone `cudart-sd-bin-win-cu12-x64.zip` runtime stub. - macOS Intel: surface a clear error — leejet only ships arm64. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
526 lines
22 KiB
JavaScript
526 lines
22 KiB
JavaScript
const { ipcMain, app, BrowserWindow } = require('electron');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const { spawn, execFile } = require('child_process');
|
|
const os = require('os');
|
|
|
|
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
const DATA_DIR = path.join(app.getPath('userData'), 'local-ai');
|
|
const BIN_DIR = path.join(DATA_DIR, 'bin');
|
|
const MODELS_DIR = path.join(DATA_DIR, 'models');
|
|
const TMP_DIR = path.join(DATA_DIR, 'tmp');
|
|
|
|
for (const dir of [BIN_DIR, MODELS_DIR, TMP_DIR]) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
const BINARY_NAME = process.platform === 'win32' ? 'sd-cli.exe' : 'sd-cli';
|
|
const BINARY_PATH = path.join(BIN_DIR, BINARY_NAME);
|
|
|
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
let activeProcess = null;
|
|
const activeDownloads = new Map(); // modelId → request object
|
|
|
|
// ─── GitHub release asset matcher per platform ───────────────────────────────
|
|
// Asset names look like: sd-master-44cca3d-bin-Darwin-macOS-15.7.4-arm64.zip
|
|
// We pick the best match in priority order so a single release that only
|
|
// ships e.g. avx512 still resolves cleanly.
|
|
function pickBinaryAsset(zipNames) {
|
|
const { platform, arch } = process;
|
|
|
|
// The "cudart" zip in recent leejet releases is just the CUDA runtime DLLs,
|
|
// not an sd-cli build, so it must never satisfy the Windows match.
|
|
const isSdCliZip = (n) => n.startsWith('sd-master-') || n.includes('-bin-');
|
|
const candidates = zipNames.filter(isSdCliZip);
|
|
|
|
if (platform === 'darwin') {
|
|
// leejet only publishes arm64 macOS builds. Mac Intel must use the
|
|
// hosted API instead — caller maps the empty result to a clear error.
|
|
if (arch !== 'arm64') return null;
|
|
return candidates.find(n => n.includes('Darwin') && n.includes('arm64')) || null;
|
|
}
|
|
if (platform === 'win32') {
|
|
// Priority: avx2 > avx > avx512 > noavx > cuda12. cuda needs the
|
|
// separate cudart runtime so we only fall back to it if nothing else.
|
|
const winCandidates = candidates.filter(n => /win-(avx2?|avx512|noavx|cuda12|cu12)-x64/.test(n));
|
|
const order = ['win-avx2-x64', 'win-avx-x64', 'win-avx512-x64', 'win-noavx-x64', 'win-cuda12-x64', 'win-cu12-x64'];
|
|
for (const tag of order) {
|
|
const hit = winCandidates.find(n => n.includes(tag));
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
// Linux: prefer plain x86_64, then vulkan, then rocm.
|
|
const linuxCandidates = candidates.filter(n => n.includes('Linux') && n.includes('x86_64'));
|
|
const plain = linuxCandidates.find(n => !n.includes('rocm') && !n.includes('vulkan'));
|
|
return plain
|
|
|| linuxCandidates.find(n => n.includes('vulkan'))
|
|
|| linuxCandidates.find(n => n.includes('rocm'))
|
|
|| null;
|
|
}
|
|
|
|
function fetchJson(url) {
|
|
return new Promise((resolve, reject) => {
|
|
https.get(url, { headers: { 'User-Agent': 'open-generative-ai' } }, (res) => {
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
return;
|
|
}
|
|
let body = '';
|
|
res.on('data', (d) => { body += d; });
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
|
});
|
|
res.on('error', reject);
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
// ─── Robust HTTPS download with redirect-following, range-resume, and retry ───
|
|
function downloadFile(url, destPath, onProgress) {
|
|
const tmp = destPath + '.part';
|
|
|
|
// Outer total so progress never goes backwards across retries/redirects
|
|
let knownTotal = 0;
|
|
|
|
const attempt = (requestUrl, redirectsLeft, retriesLeft) => new Promise((resolve, reject) => {
|
|
// Resume from however many bytes are already on disk
|
|
const alreadyDownloaded = fs.existsSync(tmp) ? fs.statSync(tmp).size : 0;
|
|
|
|
const parsed = new URL(requestUrl);
|
|
const mod = parsed.protocol === 'https:' ? https : http;
|
|
|
|
const reqHeaders = {
|
|
'User-Agent': 'Mozilla/5.0 (compatible; open-generative-ai/1.0)',
|
|
'Accept': '*/*',
|
|
'Connection': 'keep-alive',
|
|
};
|
|
if (alreadyDownloaded > 0) reqHeaders['Range'] = `bytes=${alreadyDownloaded}-`;
|
|
|
|
const req = mod.get({ hostname: parsed.hostname, path: parsed.pathname + parsed.search, headers: reqHeaders }, (res) => {
|
|
const { statusCode, headers } = res;
|
|
|
|
// Follow redirects
|
|
if ([301, 302, 303, 307, 308].includes(statusCode)) {
|
|
res.resume();
|
|
if (redirectsLeft <= 0) { reject(new Error('Too many redirects')); return; }
|
|
resolve(attempt(headers.location, redirectsLeft - 1, retriesLeft));
|
|
return;
|
|
}
|
|
|
|
// 206 Partial Content (range accepted) or 200 OK (server ignored Range)
|
|
if (statusCode !== 200 && statusCode !== 206) {
|
|
res.resume();
|
|
reject(new Error(`HTTP ${statusCode} from ${parsed.hostname}`));
|
|
return;
|
|
}
|
|
|
|
// content-length on a 206 is the remaining bytes; on 200 it's the full file
|
|
const chunkSize = parseInt(headers['content-length'] || '0', 10);
|
|
if (statusCode === 200) {
|
|
// Server ignored our Range header — restart the file
|
|
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
knownTotal = chunkSize;
|
|
} else {
|
|
// 206: total = already downloaded + remaining
|
|
knownTotal = alreadyDownloaded + chunkSize;
|
|
}
|
|
|
|
let received = alreadyDownloaded;
|
|
const out = fs.createWriteStream(tmp, { flags: statusCode === 206 ? 'a' : 'w' });
|
|
|
|
res.on('data', (chunk) => {
|
|
received += chunk.length;
|
|
if (knownTotal && onProgress) onProgress(received / knownTotal);
|
|
});
|
|
res.pipe(out);
|
|
out.on('finish', () => { fs.renameSync(tmp, destPath); resolve(); });
|
|
out.on('error', reject);
|
|
res.on('error', reject);
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
if (retriesLeft > 0) {
|
|
console.warn(`[download] ${err.message} — retrying in 3s (${retriesLeft} left)`);
|
|
setTimeout(() => resolve(attempt(requestUrl, redirectsLeft, retriesLeft - 1)), 3000);
|
|
} else {
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
req.setTimeout(60000, () => req.destroy(new Error('Request timed out')));
|
|
});
|
|
|
|
return attempt(url, 10, 5);
|
|
}
|
|
|
|
// ─── Extract zip on each platform ────────────────────────────────────────────
|
|
function extractZip(zipPath, destDir) {
|
|
return new Promise((resolve, reject) => {
|
|
let cmd, args;
|
|
if (process.platform === 'win32') {
|
|
cmd = 'powershell';
|
|
args = ['-NoProfile', '-Command', `Expand-Archive -Force -Path "${zipPath}" -DestinationPath "${destDir}"`];
|
|
} else {
|
|
cmd = 'unzip';
|
|
args = ['-o', zipPath, '-d', destDir];
|
|
}
|
|
execFile(cmd, args, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── Binary management ────────────────────────────────────────────────────────
|
|
// Recursively find a file by name under dir; returns full path or null.
|
|
function findFile(dir, name) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
const found = findFile(full, name);
|
|
if (found) return found;
|
|
} else if (entry.name === name) {
|
|
return full;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getBinaryStatus() {
|
|
const exists = fs.existsSync(BINARY_PATH);
|
|
return { exists, path: BINARY_PATH };
|
|
}
|
|
|
|
// Metal-enabled binaries hosted on our own release (macOS arm64 only).
|
|
// Other platforms fall back to the stock leejet release.
|
|
const CUSTOM_BINARIES = {
|
|
'darwin-arm64': 'https://github.com/Anil-matcha/Open-Generative-AI/releases/download/v1.0.3-binaries/sd-cli-metal-macos-arm64.zip',
|
|
};
|
|
|
|
async function downloadBinary(mainWindow) {
|
|
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id: '__binary__', ...data });
|
|
|
|
try {
|
|
send({ phase: 'fetching-release', progress: 0 });
|
|
|
|
const platformKey = `${process.platform}-${process.arch}`;
|
|
const customUrl = CUSTOM_BINARIES[platformKey];
|
|
|
|
let downloadUrl, zipName;
|
|
|
|
if (customUrl) {
|
|
downloadUrl = customUrl;
|
|
zipName = path.basename(customUrl);
|
|
} else {
|
|
// Walk recent releases until we find one that actually ships a
|
|
// build for this platform. leejet sometimes publishes a partial
|
|
// release (e.g. master-587 ships only Mac arm64 + Linux ROCm),
|
|
// so the very latest tag isn't always usable.
|
|
const releases = await fetchJson(
|
|
'https://api.github.com/repos/leejet/stable-diffusion.cpp/releases?per_page=15'
|
|
);
|
|
|
|
let chosen = null;
|
|
let lastSeen = [];
|
|
for (const release of releases) {
|
|
const zips = (release.assets || [])
|
|
.filter(a => a.name.endsWith('.zip'));
|
|
lastSeen = zips.map(a => a.name);
|
|
const pickedName = pickBinaryAsset(lastSeen);
|
|
if (pickedName) {
|
|
chosen = zips.find(a => a.name === pickedName);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!chosen) {
|
|
if (process.platform === 'darwin' && process.arch !== 'arm64') {
|
|
throw new Error('Local inference on macOS only supports Apple Silicon (M1/M2/M3/M4). Mac Intel is not supported by stable-diffusion.cpp upstream.');
|
|
}
|
|
const available = lastSeen.join(', ') || '(none)';
|
|
throw new Error(`No binary found for ${process.platform}-${process.arch} in the last 15 releases. Latest release assets: ${available}`);
|
|
}
|
|
downloadUrl = chosen.browser_download_url;
|
|
zipName = chosen.name;
|
|
}
|
|
|
|
send({ phase: 'downloading', progress: 0 });
|
|
const zipPath = path.join(BIN_DIR, zipName);
|
|
await downloadFile(downloadUrl, zipPath, (p) => {
|
|
send({ phase: 'downloading', progress: p });
|
|
});
|
|
|
|
send({ phase: 'extracting', progress: 0.95 });
|
|
await extractZip(zipPath, BIN_DIR);
|
|
fs.unlinkSync(zipPath);
|
|
|
|
// The zip may extract into a subdirectory — find the binary wherever it landed
|
|
const foundBinary = findFile(BIN_DIR, BINARY_NAME);
|
|
if (!foundBinary) throw new Error(`Extracted archive but could not find "${BINARY_NAME}" inside ${BIN_DIR}`);
|
|
|
|
// Move it to the expected root location if it's nested
|
|
if (foundBinary !== BINARY_PATH) {
|
|
fs.renameSync(foundBinary, BINARY_PATH);
|
|
}
|
|
|
|
// Make binary executable on Unix
|
|
if (process.platform !== 'win32') {
|
|
fs.chmodSync(BINARY_PATH, 0o755);
|
|
// Also chmod the dylib so it can be loaded
|
|
const dylib = findFile(BIN_DIR, 'libstable-diffusion.dylib');
|
|
if (dylib) fs.chmodSync(dylib, 0o755);
|
|
}
|
|
|
|
// macOS: strip Gatekeeper quarantine so the downloaded binary can run
|
|
if (process.platform === 'darwin') {
|
|
await new Promise((res) => execFile('xattr', ['-cr', BIN_DIR], () => res()));
|
|
}
|
|
|
|
send({ phase: 'done', progress: 1 });
|
|
return { ok: true };
|
|
} catch (err) {
|
|
send({ phase: 'error', error: err.message });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ─── Model management ─────────────────────────────────────────────────────────
|
|
function getModelState(model) {
|
|
const filePath = path.join(MODELS_DIR, model.filename);
|
|
const partPath = filePath + '.part';
|
|
if (fs.existsSync(filePath)) return 'downloaded';
|
|
if (fs.existsSync(partPath)) return 'partial';
|
|
return 'not-downloaded';
|
|
}
|
|
|
|
function getAuxState(aux) {
|
|
const filePath = path.join(MODELS_DIR, aux.filename);
|
|
return fs.existsSync(filePath) ? 'downloaded' : 'not-downloaded';
|
|
}
|
|
|
|
async function listModels() {
|
|
const { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY } = require('./modelCatalog');
|
|
const auxStatus = {
|
|
llm: getAuxState(ZIMAGE_AUXILIARY.llm),
|
|
vae: getAuxState(ZIMAGE_AUXILIARY.vae),
|
|
};
|
|
return LOCAL_MODEL_CATALOG.map(m => ({
|
|
...m,
|
|
state: getModelState(m),
|
|
path: path.join(MODELS_DIR, m.filename),
|
|
...(m.requiresAuxiliary ? { auxiliaryStatus: auxStatus } : {}),
|
|
}));
|
|
}
|
|
|
|
async function downloadModel(modelId, mainWindow) {
|
|
const { LOCAL_MODEL_CATALOG } = require('./modelCatalog');
|
|
const model = LOCAL_MODEL_CATALOG.find(m => m.id === modelId);
|
|
if (!model) throw new Error(`Unknown model: ${modelId}`);
|
|
|
|
const destPath = path.join(MODELS_DIR, model.filename);
|
|
if (fs.existsSync(destPath)) return { ok: true, path: destPath };
|
|
|
|
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id: modelId, ...data });
|
|
send({ phase: 'downloading', progress: 0 });
|
|
|
|
await downloadFile(model.downloadUrl, destPath, (p) => {
|
|
send({ phase: 'downloading', progress: p });
|
|
});
|
|
|
|
send({ phase: 'done', progress: 1 });
|
|
return { ok: true, path: destPath };
|
|
}
|
|
|
|
async function downloadAuxiliary(auxKey, mainWindow) {
|
|
const { ZIMAGE_AUXILIARY } = require('./modelCatalog');
|
|
const aux = ZIMAGE_AUXILIARY[auxKey];
|
|
if (!aux) throw new Error(`Unknown auxiliary file: ${auxKey}`);
|
|
|
|
const destPath = path.join(MODELS_DIR, aux.filename);
|
|
if (fs.existsSync(destPath)) return { ok: true, path: destPath };
|
|
|
|
const id = aux.id;
|
|
const send = (data) => mainWindow?.webContents.send('local-ai:download-progress', { id, ...data });
|
|
send({ phase: 'downloading', progress: 0 });
|
|
|
|
await downloadFile(aux.downloadUrl, destPath, (p) => {
|
|
send({ phase: 'downloading', progress: p });
|
|
});
|
|
|
|
send({ phase: 'done', progress: 1 });
|
|
return { ok: true, path: destPath };
|
|
}
|
|
|
|
async function deleteModel(modelId) {
|
|
const { LOCAL_MODEL_CATALOG } = require('./modelCatalog');
|
|
const model = LOCAL_MODEL_CATALOG.find(m => m.id === modelId);
|
|
if (!model) throw new Error(`Unknown model: ${modelId}`);
|
|
|
|
const filePath = path.join(MODELS_DIR, model.filename);
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
const partPath = filePath + '.part';
|
|
if (fs.existsSync(partPath)) fs.unlinkSync(partPath);
|
|
return { ok: true };
|
|
}
|
|
|
|
// ─── Generation ───────────────────────────────────────────────────────────────
|
|
function arToDimensions(ar, modelType) {
|
|
const base = (modelType === 'sdxl' || modelType === 'z-image') ? 1024 : 512;
|
|
const map = {
|
|
'1:1': [base, base],
|
|
'16:9': [Math.round(base * 16 / 9 / 64) * 64, base],
|
|
'9:16': [base, Math.round(base * 16 / 9 / 64) * 64],
|
|
'4:3': [Math.round(base * 4 / 3 / 64) * 64, base],
|
|
'3:4': [base, Math.round(base * 4 / 3 / 64) * 64],
|
|
};
|
|
return map[ar] || [base, base];
|
|
}
|
|
|
|
async function generate(params, mainWindow) {
|
|
const { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY } = require('./modelCatalog');
|
|
const send = (data) => mainWindow?.webContents.send('local-ai:progress', data);
|
|
|
|
if (!fs.existsSync(BINARY_PATH)) throw new Error('sd.cpp binary not installed. Download it in Settings > Local Models.');
|
|
|
|
const model = LOCAL_MODEL_CATALOG.find(m => m.id === params.model);
|
|
if (!model) throw new Error(`Unknown local model: ${params.model}`);
|
|
|
|
const modelPath = path.join(MODELS_DIR, model.filename);
|
|
if (!fs.existsSync(modelPath)) throw new Error(`Model file not found. Download "${model.name}" in Settings > Local Models.`);
|
|
|
|
if (model.requiresAuxiliary) {
|
|
const llmPath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.llm.filename);
|
|
const vaePath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.vae.filename);
|
|
if (!fs.existsSync(llmPath)) throw new Error('Text encoder (Qwen3-4B) not downloaded. Go to Settings > Local Models and download all required files for Z-Image.');
|
|
if (!fs.existsSync(vaePath)) throw new Error('VAE (ae.safetensors) not downloaded. Go to Settings > Local Models and download all required files for Z-Image.');
|
|
}
|
|
|
|
const [width, height] = arToDimensions(params.aspect_ratio || '1:1', model.type);
|
|
const seed = params.seed && params.seed !== -1 ? params.seed : Math.floor(Math.random() * 2147483647);
|
|
const outPath = path.join(TMP_DIR, `gen-${Date.now()}.png`);
|
|
|
|
const steps = model.defaultSteps || params.steps || 20;
|
|
const cfgScale = model.defaultGuidance !== undefined ? model.defaultGuidance : (params.guidance_scale || 7.5);
|
|
const sampler = model.sampler || 'euler_a';
|
|
|
|
// z-image GGUFs are standalone diffusion transformers loaded via --diffusion-model.
|
|
// -m triggers full-model SD version detection which fails for these files (0 KV metadata).
|
|
const modelFlag = (model.type === 'z-image' || model.type === 'flux')
|
|
? '--diffusion-model'
|
|
: '-m';
|
|
|
|
const args = [
|
|
modelFlag, modelPath,
|
|
'-p', params.prompt || '',
|
|
'-o', outPath,
|
|
'--steps', String(steps),
|
|
'-H', String(height),
|
|
'-W', String(width),
|
|
'--cfg-scale', String(cfgScale),
|
|
'--seed', String(seed),
|
|
'--sampling-method', sampler,
|
|
'-v',
|
|
];
|
|
|
|
if (params.negative_prompt) {
|
|
args.push('-n', params.negative_prompt);
|
|
}
|
|
|
|
if (model.type === 'z-image') {
|
|
const llmPath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.llm.filename);
|
|
const vaePath = path.join(MODELS_DIR, ZIMAGE_AUXILIARY.vae.filename);
|
|
args.push('--llm', llmPath);
|
|
args.push('--vae', vaePath);
|
|
if (model.scheduler) args.push('--scheduler', model.scheduler);
|
|
} else if (model.type === 'sdxl') {
|
|
args.push('--sd-version', 'sdxl');
|
|
} else if (model.type === 'sd2') {
|
|
args.push('--sd-version', 'sd2');
|
|
} else if (model.type === 'flux') {
|
|
args.push('--flux');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
send({ step: 0, totalSteps: params.steps || model.defaultSteps || 20, status: 'starting' });
|
|
|
|
console.log('[sd-cli] command:', BINARY_PATH, args.join(' '));
|
|
// DYLD_LIBRARY_PATH lets macOS find libstable-diffusion.dylib next to sd-cli
|
|
const spawnEnv = { ...process.env, DYLD_LIBRARY_PATH: BIN_DIR, LD_LIBRARY_PATH: BIN_DIR };
|
|
activeProcess = spawn(BINARY_PATH, args, { env: spawnEnv });
|
|
const stepRegex = /step\s+(\d+)\/(\d+)/i;
|
|
const outputLines = [];
|
|
|
|
const handleOutput = (data) => {
|
|
const line = data.toString();
|
|
outputLines.push(line.trimEnd());
|
|
const match = line.match(stepRegex);
|
|
if (match) {
|
|
const step = parseInt(match[1]);
|
|
const total = parseInt(match[2]);
|
|
send({ step, totalSteps: total, status: 'generating', progress: step / total });
|
|
}
|
|
};
|
|
|
|
activeProcess.stdout.on('data', handleOutput);
|
|
activeProcess.stderr.on('data', handleOutput);
|
|
|
|
activeProcess.on('close', (code) => {
|
|
activeProcess = null;
|
|
const allOutput = outputLines.filter(l => l.trim()).join('\n');
|
|
console.error('[sd-cli] full output:\n' + allOutput);
|
|
if (code !== 0) {
|
|
const tail = outputLines.filter(l => l.trim()).slice(-20).join('\n');
|
|
reject(new Error(`sd-cli exited (code ${code}):\n${tail}`));
|
|
return;
|
|
}
|
|
if (!fs.existsSync(outPath)) {
|
|
reject(new Error('sd.cpp finished but no output image found'));
|
|
return;
|
|
}
|
|
try {
|
|
const imgBuffer = fs.readFileSync(outPath);
|
|
const dataUrl = `data:image/png;base64,${imgBuffer.toString('base64')}`;
|
|
fs.unlinkSync(outPath);
|
|
send({ step: 1, totalSteps: 1, status: 'done', progress: 1 });
|
|
resolve({ url: dataUrl, seed });
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
activeProcess.on('error', (err) => {
|
|
activeProcess = null;
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
function cancelGeneration() {
|
|
if (activeProcess) {
|
|
activeProcess.kill('SIGTERM');
|
|
activeProcess = null;
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
// ─── IPC Registration ─────────────────────────────────────────────────────────
|
|
function getMainWindow() {
|
|
return BrowserWindow.getAllWindows()[0] || null;
|
|
}
|
|
|
|
function register() {
|
|
ipcMain.handle('local-ai:binary-status', () => getBinaryStatus());
|
|
ipcMain.handle('local-ai:download-binary', () => downloadBinary(getMainWindow()));
|
|
ipcMain.handle('local-ai:list-models', () => listModels());
|
|
ipcMain.handle('local-ai:download-model', (_, modelId) => downloadModel(modelId, getMainWindow()));
|
|
ipcMain.handle('local-ai:download-auxiliary', (_, auxKey) => downloadAuxiliary(auxKey, getMainWindow()));
|
|
ipcMain.handle('local-ai:delete-model', (_, modelId) => deleteModel(modelId));
|
|
ipcMain.handle('local-ai:generate', (_, params) => generate(params, getMainWindow()));
|
|
ipcMain.handle('local-ai:cancel-generation', () => cancelGeneration());
|
|
}
|
|
|
|
module.exports = { register };
|