diff --git a/electron/lib/localInference.js b/electron/lib/localInference.js new file mode 100644 index 0000000..3f4ff37 --- /dev/null +++ b/electron/lib/localInference.js @@ -0,0 +1,462 @@ +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 +// Returns a predicate that returns true when the asset name matches this platform. +function getBinaryAssetMatcher() { + const { platform, arch } = process; + if (platform === 'darwin') { + const archToken = arch === 'arm64' ? 'arm64' : 'x86_64'; + return (name) => name.includes('Darwin') && name.includes(archToken); + } + if (platform === 'win32') { + // Prefer avx2 (best balance); fall back to noavx + return (name) => name.includes('win-avx2-x64') || name.includes('win-noavx-x64'); + } + // Linux: prefer plain build over rocm/vulkan + return (name) => name.includes('Linux') && name.includes('x86_64') && !name.includes('rocm') && !name.includes('vulkan'); +} + +// ─── 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 }; +} + +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 releaseData = await new Promise((resolve, reject) => { + https.get( + 'https://api.github.com/repos/leejet/stable-diffusion.cpp/releases/latest', + { headers: { 'User-Agent': 'open-generative-ai' } }, + (res) => { + 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); + }); + + const matches = getBinaryAssetMatcher(); + const allZips = releaseData.assets?.filter(a => a.name.endsWith('.zip')) || []; + const asset = allZips.find(a => matches(a.name)); + if (!asset) { + const available = allZips.map(a => a.name).join(', '); + throw new Error(`No binary found for this platform. Available: ${available}`); + } + + send({ phase: 'downloading', progress: 0 }); + const zipPath = path.join(BIN_DIR, asset.name); + await downloadFile(asset.browser_download_url, 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 }; diff --git a/electron/lib/modelCatalog.js b/electron/lib/modelCatalog.js new file mode 100644 index 0000000..3a7df60 --- /dev/null +++ b/electron/lib/modelCatalog.js @@ -0,0 +1,131 @@ +// Curated local model catalog for sd.cpp +// All models must be publicly available (no auth required) + +// Shared auxiliary files needed by Z-Image type models +const ZIMAGE_AUXILIARY = { + llm: { + id: '__llm__', + filename: 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf', + displayName: 'Qwen3-4B Text Encoder', + sizeGB: 2.4, + downloadUrl: 'https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf', + }, + vae: { + id: '__vae__', + filename: 'ae.safetensors', + displayName: 'FLUX VAE', + sizeGB: 0.33, + downloadUrl: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors', + }, +}; + +const LOCAL_MODEL_CATALOG = [ + // ── Z-Image (Tongyi-MAI) — native sd.cpp GGUF support ────────────────── + // Requires auxiliary files: Qwen3-4B LLM text encoder + FLUX VAE + { + id: 'z-image-turbo', + name: 'Z-Image Turbo', + description: 'WaveSpeed\'s featured local model — ultra-fast 8-step generation. No API key needed. Requires text encoder + VAE (~2.7 GB extra).', + type: 'z-image', + filename: 'z_image_turbo-Q4_K.gguf', + sizeGB: 2.5, + downloadUrl: 'https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf', + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 8, + defaultGuidance: 1.0, + sampler: 'euler', + scheduler: 'simple', + tags: ['turbo', 'fast', 'local', 'featured'], + featured: true, + requiresAuxiliary: true, + }, + { + id: 'z-image-base', + name: 'Z-Image Base', + description: 'Full-quality model from Tongyi-MAI — higher detail, 50-step generation. Requires text encoder + VAE (~2.7 GB extra).', + type: 'z-image', + filename: 'Z-Image-Q4_K_M.gguf', + sizeGB: 3.5, + downloadUrl: 'https://huggingface.co/unsloth/Z-Image-GGUF/resolve/main/Z-Image-Q4_K_M.gguf', + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 50, + defaultGuidance: 7.5, + sampler: 'euler', + scheduler: 'simple', + tags: ['high-quality', 'local', 'detailed'], + featured: true, + requiresAuxiliary: true, + }, + // ── Classic SD 1.5 models ─────────────────────────────────────────────── + { + id: 'dreamshaper-8', + name: 'Dreamshaper 8', + description: 'Versatile SD 1.5 model — great for portraits, landscapes, and artistic styles.', + type: 'sd1', + filename: 'DreamShaper_8_pruned.safetensors', + sizeGB: 2.1, + downloadUrl: 'https://huggingface.co/Lykon/dreamshaper-8/resolve/main/DreamShaper_8_pruned.safetensors', + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultWidth: 512, + defaultHeight: 512, + defaultSteps: 20, + defaultGuidance: 7.5, + sampler: 'euler_a', + tags: ['photorealistic', 'artistic', 'versatile'], + }, + { + id: 'realistic-vision-v51', + name: 'Realistic Vision v5.1', + description: 'Highly photorealistic people and scenes, based on SD 1.5.', + type: 'sd1', + filename: 'realisticVisionV51_v51VAE.safetensors', + sizeGB: 2.1, + downloadUrl: 'https://huggingface.co/SG161222/Realistic_Vision_V5.1_noVAE/resolve/main/Realistic_Vision_V5.1_fp16-no-ema.safetensors', + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultWidth: 512, + defaultHeight: 768, + defaultSteps: 25, + defaultGuidance: 7, + sampler: 'euler_a', + tags: ['photorealistic', 'portraits', 'people'], + }, + { + id: 'anything-v5', + name: 'Anything v5', + description: 'High quality anime and illustration style image generation.', + type: 'sd1', + filename: 'anything-v5-PrtRE.safetensors', + sizeGB: 2.1, + downloadUrl: 'https://huggingface.co/stablediffusionapi/anything-v5/resolve/main/anything-v5-PrtRE.safetensors', + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultWidth: 512, + defaultHeight: 768, + defaultSteps: 20, + defaultGuidance: 7, + sampler: 'euler_a', + tags: ['anime', 'illustration', 'artistic'], + }, + // ── SDXL ─────────────────────────────────────────────────────────────── + { + id: 'stable-diffusion-xl-base', + name: 'SDXL Base 1.0', + description: 'Official Stable Diffusion XL base model — higher resolution, excellent quality.', + type: 'sdxl', + filename: 'sd_xl_base_1.0.safetensors', + sizeGB: 6.9, + downloadUrl: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors', + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 30, + defaultGuidance: 7.5, + sampler: 'dpmpp2m', + tags: ['sdxl', 'high-quality', 'versatile'], + }, +]; + +module.exports = { LOCAL_MODEL_CATALOG, ZIMAGE_AUXILIARY }; diff --git a/electron/main.js b/electron/main.js index c4fc5be..2bb2eb6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,6 @@ const { app, BrowserWindow, shell } = require('electron'); const path = require('path'); +const { register: registerLocalInference } = require('./lib/localInference'); // Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1 which // blocks Chromium's user namespace sandbox. The .deb package ships an AppArmor @@ -24,6 +25,7 @@ function createWindow() { webSecurity: false, contextIsolation: true, nodeIntegration: false, + preload: path.join(__dirname, 'preload.js'), }, ...(isMac ? { titleBarStyle: 'hiddenInset' } : {}), backgroundColor: '#0d0d0d', @@ -57,6 +59,7 @@ function createWindow() { app.whenReady().then(() => { createWindow(); + registerLocalInference(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..8767ef3 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,32 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('localAI', { + isElectron: true, + + // Binary management + getBinaryStatus: () => ipcRenderer.invoke('local-ai:binary-status'), + downloadBinary: () => ipcRenderer.invoke('local-ai:download-binary'), + + // Model management + listModels: () => ipcRenderer.invoke('local-ai:list-models'), + downloadModel: (modelId) => ipcRenderer.invoke('local-ai:download-model', modelId), + downloadAuxiliary: (auxKey) => ipcRenderer.invoke('local-ai:download-auxiliary', auxKey), + deleteModel: (modelId) => ipcRenderer.invoke('local-ai:delete-model', modelId), + cancelDownload: (modelId) => ipcRenderer.invoke('local-ai:cancel-download', modelId), + + // Generation + generate: (params) => ipcRenderer.invoke('local-ai:generate', params), + cancelGeneration: () => ipcRenderer.invoke('local-ai:cancel-generation'), + + // Progress events — returns an unsubscribe function + onProgress: (callback) => { + const listener = (_, data) => callback(data); + ipcRenderer.on('local-ai:progress', listener); + return () => ipcRenderer.removeListener('local-ai:progress', listener); + }, + onDownloadProgress: (callback) => { + const listener = (_, data) => callback(data); + ipcRenderer.on('local-ai:download-progress', listener); + return () => ipcRenderer.removeListener('local-ai:download-progress', listener); + } +}); diff --git a/package.json b/package.json index 84d1060..0ee104a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "setup": "npm install && npm run build:studio", "vite:dev": "vite", "vite:build": "vite build", + "electron:dev": "npm run vite:build && electron .", "electron:build": "vite build && electron-builder --mac", "electron:build:win": "vite build && electron-builder --win", "electron:build:linux": "vite build && electron-builder --linux", diff --git a/src/components/ImageStudio.js b/src/components/ImageStudio.js index 6ce5f3f..a8f49d7 100644 --- a/src/components/ImageStudio.js +++ b/src/components/ImageStudio.js @@ -4,6 +4,8 @@ import { i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel, getMaxImagesForI2IModel } from '../lib/models.js'; +import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js'; +import { LOCAL_MODEL_CATALOG, getLocalModelById } from '../lib/localModels.js'; import { ENHANCE_TAGS, QUICK_PROMPTS } from '../lib/promptUtils.js'; import { AuthModal } from './AuthModal.js'; import { createUploadPicker } from './UploadPicker.js'; @@ -33,6 +35,11 @@ export function ImageStudio() { let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support) let imageMode = false; // false = t2i models, true = i2i models + // Local inference state + let useLocalModel = false; + let selectedLocalModel = LOCAL_MODEL_CATALOG[0]?.id || null; + let localGenProgress = 0; // 0–1 + // Advanced parameters state let negativePrompt = ''; let guidanceScale = 7.5; @@ -185,6 +192,37 @@ export function ImageStudio() { `, '720p', 'quality-btn', 'Set output quality'); + // Local / API source toggle (only shown in Electron) + let localToggleBtn = null; + if (isLocalAIAvailable()) { + localToggleBtn = document.createElement('button'); + localToggleBtn.id = 'local-toggle-btn'; + localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap'; + const updateLocalToggleStyle = () => { + if (useLocalModel) { + localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap bg-primary/20 border-primary/40 text-primary'; + localToggleBtn.textContent = '⚡ Local'; + } else { + localToggleBtn.className = 'flex items-center gap-1.5 px-3 py-2 rounded-xl transition-all border text-xs font-bold whitespace-nowrap bg-white/5 border-white/5 text-white/60 hover:bg-white/10'; + localToggleBtn.textContent = '☁ API'; + } + }; + updateLocalToggleStyle(); + localToggleBtn.onclick = (e) => { + e.stopPropagation(); + useLocalModel = !useLocalModel; + updateLocalToggleStyle(); + // Reflect active model in the button label + if (useLocalModel) { + const lm = getLocalModelById(selectedLocalModel); + if (lm) document.getElementById('model-btn-label').textContent = lm.name; + } else { + document.getElementById('model-btn-label').textContent = selectedModelName; + } + }; + controlsLeft.appendChild(localToggleBtn); + } + controlsLeft.appendChild(modelBtn); controlsLeft.appendChild(arBtn); controlsLeft.appendChild(qualityBtn); @@ -223,6 +261,32 @@ export function ImageStudio() { inlineInstructions.classList.add('max-w-4xl', 'mt-8'); container.appendChild(inlineInstructions); + // Local generation progress bar (hidden until active) + const localProgressWrap = document.createElement('div'); + localProgressWrap.className = 'w-full max-w-4xl mt-4 hidden flex-col gap-2'; + localProgressWrap.id = 'local-progress-wrap'; + localProgressWrap.innerHTML = ` +
${model.description}
+Local Models
+Local model inference is only available in the desktop app (Electron build). Use npm run electron:build to build.
++ Your API key is stored locally and never sent anywhere except api.muapi.ai. +
+