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 = ` +
+ Generating locally... + 0% +
+
+
+
+
+ +
+ `; + container.appendChild(localProgressWrap); + + localProgressWrap.querySelector('#local-cancel-btn')?.addEventListener('click', () => { + localAI.cancelGeneration(); + localProgressWrap.classList.remove('flex'); + localProgressWrap.classList.add('hidden'); + generateBtn.disabled = false; + generateBtn.innerHTML = `Generate ✨`; + }); + // ========================================== // 3. QUICK TOOLS PANEL (Prompt Enhancer + Quick Starters) // ========================================== @@ -662,6 +726,48 @@ export function ImageStudio() { const renderModels = (filter = '') => { list.innerHTML = ''; + + if (useLocalModel) { + // ── Local model list ────────────────────────────────────── + const filtered = LOCAL_MODEL_CATALOG.filter(m => + m.name.toLowerCase().includes(filter.toLowerCase()) || + m.id.toLowerCase().includes(filter.toLowerCase()) + ); + if (filtered.length === 0) { + list.innerHTML = `
No local models match
`; + return; + } + filtered.forEach(m => { + const item = document.createElement('div'); + item.className = `flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${selectedLocalModel === m.id ? 'bg-white/5 border-white/5' : ''}`; + item.innerHTML = ` +
+
${m.featured ? '⚡' : m.name.charAt(0)}
+
+
+ ${m.name} + ${m.featured ? 'FEATURED' : ''} +
+ ${m.sizeGB} GB · ${m.type.toUpperCase()} +
+
+ ${selectedLocalModel === m.id ? '' : ''} + `; + item.onclick = (e) => { + e.stopPropagation(); + selectedLocalModel = m.id; + document.getElementById('model-btn-label').textContent = m.name; + selectedAr = m.aspectRatios[0]; + document.getElementById('ar-btn-label').textContent = selectedAr; + qualityBtn.style.display = 'none'; + closeDropdown(); + }; + list.appendChild(item); + }); + return; + } + + // ── Remote (API) model list ─────────────────────────────────── const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase())); filtered.forEach(m => { @@ -1056,6 +1162,74 @@ export function ImageStudio() { } } + // ── Local inference path ────────────────────────────────────────────── + if (useLocalModel) { + const lm = getLocalModelById(selectedLocalModel); + if (!lm) { alert('No local model selected.'); return; } + + hero.classList.add('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none'); + generateBtn.disabled = true; + generateBtn.innerHTML = ` Generating...`; + + const progressWrap = document.getElementById('local-progress-wrap'); + const progressFill = document.getElementById('local-progress-fill'); + const progressPct = document.getElementById('local-progress-pct'); + progressWrap.classList.remove('hidden'); + progressWrap.classList.add('flex'); + + const unsub = localAI.onProgress(({ step, totalSteps, progress, status }) => { + const pct = Math.round((progress || (step / totalSteps)) * 100); + if (progressFill) progressFill.style.width = `${pct}%`; + if (progressPct) progressPct.textContent = `${pct}%`; + generateBtn.innerHTML = ` ${pct}%`; + }); + + let hadError = false; + try { + const res = await localAI.generate({ + model: selectedLocalModel, + prompt, + negative_prompt: negativePrompt || undefined, + aspect_ratio: selectedAr, + steps: steps, + guidance_scale: guidanceScale, + seed, + }); + unsub(); + progressWrap.classList.replace('flex', 'hidden'); + progressWrap.classList.add('hidden'); + + if (res?.url) { + addToHistory({ + id: Date.now().toString(), + url: res.url, + prompt, + model: `local:${selectedLocalModel}`, + aspect_ratio: selectedAr, + seed: res.seed, + timestamp: new Date().toISOString() + }); + showImageInCanvas(res.url); + } else { + throw new Error('No image returned from local generation'); + } + } catch (e) { + hadError = true; + unsub(); + progressWrap.classList.add('hidden'); + console.error('[Local] generation error:', e); + hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none'); + console.error('[Local] full error:', e.message); + generateBtn.innerHTML = `Error: ${e.message.slice(0, 120)}`; + setTimeout(() => { generateBtn.innerHTML = `Generate ✨`; }, 6000); + } finally { + generateBtn.disabled = false; + if (!hadError) generateBtn.innerHTML = `Generate ✨`; + } + return; + } + + // ── Remote API path ─────────────────────────────────────────────────── const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) { AuthModal(() => generateBtn.click()); diff --git a/src/components/LocalModelManager.js b/src/components/LocalModelManager.js new file mode 100644 index 0000000..83df068 --- /dev/null +++ b/src/components/LocalModelManager.js @@ -0,0 +1,302 @@ +import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js'; + +// ─── Icons ──────────────────────────────────────────────────────────────────── +const DownloadIcon = ``; +const TrashIcon = ``; +const CheckIcon = ``; + +// ─── Helpers ───────────────────────────────────────────────────────────────── +function fmtGB(gb) { + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(gb * 1024).toFixed(0)} MB`; +} + +function tagEl(text) { + const span = document.createElement('span'); + span.className = 'px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted'; + span.textContent = text; + return span; +} + +// ─── Binary Status Bar ──────────────────────────────────────────────────────── +function BinaryStatusBar(onStatusChange) { + const bar = document.createElement('div'); + bar.className = 'flex items-center justify-between gap-3 p-3 rounded-xl bg-white/3 border border-white/5'; + + const label = document.createElement('div'); + label.className = 'flex flex-col gap-0.5'; + label.innerHTML = ` + sd.cpp inference engine + Checking... + `; + + const btn = document.createElement('button'); + btn.id = 'binary-action-btn'; + btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold transition-all hidden'; + btn.textContent = 'Install'; + + bar.appendChild(label); + bar.appendChild(btn); + + const progressBar = document.createElement('div'); + progressBar.className = 'h-1 rounded-full bg-white/5 mt-2 hidden overflow-hidden'; + progressBar.id = 'binary-progress-bar'; + progressBar.innerHTML = `
`; + bar.appendChild(progressBar); + + const refresh = async () => { + const status = await localAI.getBinaryStatus(); + const text = bar.querySelector('#binary-status-text'); + if (status.exists) { + text.textContent = 'Installed and ready'; + text.className = 'text-[11px] text-green-400'; + btn.classList.add('hidden'); + } else { + text.textContent = 'Not installed — required for local generation'; + text.className = 'text-[11px] text-yellow-400'; + btn.textContent = 'Install Engine'; + btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black transition-all'; + btn.classList.remove('hidden'); + } + if (onStatusChange) onStatusChange(status.exists); + }; + + btn.onclick = async () => { + btn.disabled = true; + btn.textContent = 'Downloading...'; + progressBar.classList.remove('hidden'); + + const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => { + if (id !== '__binary__') return; + const fill = document.getElementById('binary-progress-fill'); + const text = bar.querySelector('#binary-status-text'); + if (fill) fill.style.width = `${Math.round(progress * 100)}%`; + if (text) text.textContent = phase === 'extracting' ? 'Extracting...' : `Downloading... ${Math.round(progress * 100)}%`; + }); + + try { + await localAI.downloadBinary(); + unsub(); + progressBar.classList.add('hidden'); + await refresh(); + } catch (err) { + unsub(); + const text = bar.querySelector('#binary-status-text'); + if (text) text.textContent = `Error: ${err.message}`; + btn.disabled = false; + btn.textContent = 'Retry'; + } + }; + + if (isLocalAIAvailable()) refresh(); + + return bar; +} + +// ─── Auxiliary file row (text encoder / VAE for Z-Image) ───────────────────── +function AuxRow(label, auxKey, initStatus, onStateChange) { + const row = document.createElement('div'); + row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-white/3 border border-white/5'; + + const isReady = initStatus === 'downloaded'; + + row.innerHTML = ` +
+ ${isReady + ? `${CheckIcon}` + : `!`} + ${label} +
+
+ ${isReady + ? `Ready` + : ``} +
+ + `; + + const btn = row.querySelector('.aux-dl-btn'); + if (btn) { + btn.onclick = async () => { + btn.disabled = true; + btn.innerHTML = ``; + const progWrap = row.querySelector('.aux-progress'); + const progFill = row.querySelector('.aux-fill'); + const progText = row.querySelector('.aux-text'); + progWrap.classList.remove('hidden'); + + const auxId = auxKey === 'llm' ? '__llm__' : '__vae__'; + const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => { + if (id !== auxId) return; + progFill.style.width = `${Math.round(progress * 100)}%`; + progText.textContent = phase === 'done' ? 'Complete!' : `Downloading... ${Math.round(progress * 100)}%`; + }); + + try { + await localAI.downloadAuxiliary(auxKey); + unsub(); + if (onStateChange) onStateChange(); + } catch (err) { + unsub(); + progText.textContent = `Error: ${err.message}`; + btn.disabled = false; + btn.innerHTML = `${DownloadIcon} Retry`; + } + }; + } + + return row; +} + +// ─── Model Card ─────────────────────────────────────────────────────────────── +function ModelCard(model, onStateChange) { + const card = document.createElement('div'); + card.className = 'flex flex-col gap-3 p-4 rounded-xl border border-white/5 bg-white/3 hover:border-white/10 transition-all'; + + const isDownloaded = model.state === 'downloaded'; + const auxStatus = model.auxiliaryStatus || {}; + const auxReady = !model.requiresAuxiliary || (auxStatus.llm === 'downloaded' && auxStatus.vae === 'downloaded'); + const fullyReady = isDownloaded && auxReady; + + card.innerHTML = ` +
+
+
+ ${model.name} + ${model.featured ? `⚡ Featured` : ''} + ${fullyReady ? `${CheckIcon}` : ''} +
+

${model.description}

+
+ ${model.type.toUpperCase()} + ${fmtGB(model.sizeGB)} + ${(model.tags || []).filter(t => t !== 'featured').map(t => `${t}`).join('')} +
+
+
+ ${isDownloaded + ? `` + : `` + } +
+
+ + ${model.requiresAuxiliary ? `
` : ''} + `; + + // Auxiliary files section for Z-Image + if (model.requiresAuxiliary) { + const auxSection = card.querySelector('.aux-section'); + auxSection.appendChild(document.createElement('span')).className = 'text-[10px] text-muted uppercase tracking-wider font-bold'; + auxSection.querySelector('span').textContent = 'Required components'; + auxSection.appendChild(AuxRow('Qwen3-4B Text Encoder (2.4 GB)', 'llm', auxStatus.llm, onStateChange)); + auxSection.appendChild(AuxRow('FLUX VAE (335 MB)', 'vae', auxStatus.vae, onStateChange)); + } + + const progressWrap = card.querySelector('.progress-wrap'); + const progressFill = card.querySelector('.progress-fill'); + const progressText = card.querySelector('.progress-text'); + + const downloadBtn = card.querySelector('.download-btn'); + if (downloadBtn) { + downloadBtn.onclick = async () => { + downloadBtn.disabled = true; + downloadBtn.innerHTML = ` Starting...`; + progressWrap.classList.remove('hidden'); + + const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => { + if (id !== model.id) return; + progressFill.style.width = `${Math.round(progress * 100)}%`; + progressText.textContent = phase === 'done' ? 'Complete!' : `Downloading... ${Math.round(progress * 100)}%`; + }); + + try { + await localAI.downloadModel(model.id); + unsub(); + if (onStateChange) onStateChange(); + } catch (err) { + unsub(); + progressText.textContent = `Error: ${err.message}`; + downloadBtn.disabled = false; + downloadBtn.innerHTML = `${DownloadIcon} Retry`; + } + }; + } + + const deleteBtn = card.querySelector('.delete-btn'); + if (deleteBtn) { + deleteBtn.onclick = async () => { + if (!confirm(`Delete "${model.name}"? You'll need to re-download it to use it again.`)) return; + await localAI.deleteModel(model.id); + if (onStateChange) onStateChange(); + }; + } + + return card; +} + +// ─── Main component ─────────────────────────────────────────────────────────── +export function LocalModelManager() { + const root = document.createElement('div'); + root.className = 'flex flex-col gap-5'; + + if (!isLocalAIAvailable()) { + root.innerHTML = ` +
+

Local Models

+

Local model inference is only available in the desktop app (Electron build). Use npm run electron:build to build.

+
+ `; + return root; + } + + // ── Section: engine status + const engineSection = document.createElement('div'); + engineSection.className = 'flex flex-col gap-2'; + engineSection.innerHTML = `

Inference Engine

`; + + let binaryReady = false; + const binaryBar = BinaryStatusBar((ready) => { binaryReady = ready; }); + engineSection.appendChild(binaryBar); + root.appendChild(engineSection); + + // ── Section: models + const modelsSection = document.createElement('div'); + modelsSection.className = 'flex flex-col gap-3'; + modelsSection.innerHTML = ` +
+

Local Models

+ Stored in your app data folder +
+
+ `; + root.appendChild(modelsSection); + + const listEl = modelsSection.querySelector('#local-model-list'); + + const renderModels = async () => { + listEl.innerHTML = `
Loading...
`; + try { + const models = await localAI.listModels(); + listEl.innerHTML = ''; + models.forEach(m => { + listEl.appendChild(ModelCard(m, renderModels)); + }); + } catch (err) { + listEl.innerHTML = `
Error loading models: ${err.message}
`; + } + }; + + renderModels(); + + return root; +} diff --git a/src/components/SettingsModal.js b/src/components/SettingsModal.js index 2f85065..e81b756 100644 --- a/src/components/SettingsModal.js +++ b/src/components/SettingsModal.js @@ -1,92 +1,117 @@ +import { LocalModelManager } from './LocalModelManager.js'; +import { isLocalAIAvailable } from '../lib/localInferenceClient.js'; + export function SettingsModal(onClose) { const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50'; - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.right = '0'; - overlay.style.bottom = '0'; - overlay.style.backgroundColor = 'rgba(0,0,0,0.8)'; - overlay.style.display = 'flex'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.zIndex = '100'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:100;'; const modal = document.createElement('div'); - modal.className = 'bg-card p-6 rounded-xl border border-border-color w-96 glass'; - modal.style.background = 'var(--bg-card)'; - modal.style.padding = '1.5rem'; - modal.style.borderRadius = 'var(--border-radius-xl)'; - modal.style.border = '1px solid var(--border-color)'; - modal.style.width = '24rem'; + modal.style.cssText = 'background:var(--bg-card,#111);border-radius:1rem;border:1px solid rgba(255,255,255,0.08);width:min(90vw,36rem);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;'; - const title = document.createElement('h2'); - title.textContent = 'Settings'; - title.className = 'text-xl font-bold mb-4'; - title.style.marginBottom = '1rem'; + // ── Header ──────────────────────────────────────────────────────────────── + const header = document.createElement('div'); + header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:1.25rem 1.5rem;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;'; + header.innerHTML = ` +

Settings

+ + `; + modal.appendChild(header); - const label = document.createElement('label'); - label.textContent = 'Muapi API Key'; - label.className = 'block text-sm text-secondary mb-2'; + // ── Tabs ────────────────────────────────────────────────────────────────── + const TABS = [ + { id: 'api', label: 'API Key' }, + ...(isLocalAIAvailable() ? [{ id: 'local', label: 'Local Models' }] : []), + ]; - const input = document.createElement('input'); - input.type = 'password'; - input.className = 'w-full mb-4 p-2 rounded bg-input border border-border-color'; - input.value = localStorage.getItem('muapi_key') || ''; - input.placeholder = 'Enter your Muapi API key...'; - input.style.width = '100%'; - input.style.marginBottom = '1rem'; + let activeTab = 'api'; - const btnContainer = document.createElement('div'); - btnContainer.className = 'flex justify-end gap-2'; - btnContainer.style.display = 'flex'; - btnContainer.style.justifyContent = 'flex-end'; - btnContainer.style.gap = '0.5rem'; + const tabBar = document.createElement('div'); + tabBar.style.cssText = 'display:flex;gap:0.25rem;padding:0.75rem 1.5rem 0;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0;'; - const cancelBtn = document.createElement('button'); - cancelBtn.textContent = 'Cancel'; - cancelBtn.className = 'px-4 py-2 rounded hover:bg-white/5'; - cancelBtn.onclick = () => { - document.body.removeChild(overlay); + const tabBtns = {}; + TABS.forEach(({ id, label }) => { + const btn = document.createElement('button'); + btn.textContent = label; + btn.style.cssText = 'padding:0.4rem 0.75rem;border-radius:0.5rem 0.5rem 0 0;font-size:0.75rem;font-weight:700;border:none;cursor:pointer;transition:all 0.15s;'; + btn.onclick = () => switchTab(id); + tabBtns[id] = btn; + tabBar.appendChild(btn); + }); + modal.appendChild(tabBar); + + // ── Body ────────────────────────────────────────────────────────────────── + const body = document.createElement('div'); + body.style.cssText = 'flex:1;overflow-y:auto;padding:1.5rem;'; + modal.appendChild(body); + + // ── Tab: API Key ────────────────────────────────────────────────────────── + const apiPanel = document.createElement('div'); + apiPanel.innerHTML = ` +
+
+ + +
+

+ Your API key is stored locally and never sent anywhere except api.muapi.ai. +

+
+ + +
+
+ `; + + // ── Tab: Local Models ───────────────────────────────────────────────────── + const localPanel = LocalModelManager(); + + // ── Tab switching ───────────────────────────────────────────────────────── + const switchTab = (id) => { + activeTab = id; + body.innerHTML = ''; + + TABS.forEach(({ id: tid }) => { + const btn = tabBtns[tid]; + if (tid === id) { + btn.style.background = 'rgba(255,255,255,0.08)'; + btn.style.color = '#fff'; + } else { + btn.style.background = 'transparent'; + btn.style.color = 'rgba(255,255,255,0.4)'; + } + }); + + if (id === 'api') body.appendChild(apiPanel); + if (id === 'local') body.appendChild(localPanel); + }; + + switchTab('api'); + + // ── API key save/cancel handlers ────────────────────────────────────────── + const close = () => { + if (document.body.contains(overlay)) document.body.removeChild(overlay); if (onClose) onClose(); }; - const saveBtn = document.createElement('button'); - saveBtn.textContent = 'Save'; - saveBtn.className = 'px-4 py-2 rounded bg-primary text-black font-medium'; - saveBtn.style.backgroundColor = 'var(--color-primary)'; - saveBtn.style.color = 'black'; - saveBtn.style.fontWeight = '500'; - - saveBtn.onclick = () => { - const key = input.value.trim(); + apiPanel.querySelector('#settings-cancel-btn').onclick = close; + apiPanel.querySelector('#settings-save-btn').onclick = () => { + const key = apiPanel.querySelector('#settings-api-key').value.trim(); if (key) { localStorage.setItem('muapi_key', key); - alert('API Key saved!'); - document.body.removeChild(overlay); - if (onClose) onClose(); + close(); } else { - alert('Please enter a valid key'); + alert('Please enter a valid API key.'); } }; - modal.appendChild(title); - modal.appendChild(label); - modal.appendChild(input); - - btnContainer.appendChild(cancelBtn); - btnContainer.appendChild(saveBtn); - modal.appendChild(btnContainer); + header.querySelector('#settings-close-btn').onclick = close; + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.appendChild(modal); - - // Close on outside click - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - document.body.removeChild(overlay); - if (onClose) onClose(); - } - }); - return overlay; } diff --git a/src/lib/localInferenceClient.js b/src/lib/localInferenceClient.js new file mode 100644 index 0000000..fccd786 --- /dev/null +++ b/src/lib/localInferenceClient.js @@ -0,0 +1,71 @@ +// Frontend client for local inference — wraps window.localAI (Electron IPC). +// Falls back gracefully when running in browser/dev mode. + +export const isLocalAIAvailable = () => typeof window !== 'undefined' && !!window.localAI?.isElectron; + +class LocalInferenceClient { + async getBinaryStatus() { + if (!isLocalAIAvailable()) return { exists: false }; + return window.localAI.getBinaryStatus(); + } + + async downloadBinary() { + if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.'); + return window.localAI.downloadBinary(); + } + + async listModels() { + if (!isLocalAIAvailable()) return []; + return window.localAI.listModels(); + } + + async downloadModel(modelId) { + if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.'); + return window.localAI.downloadModel(modelId); + } + + async downloadAuxiliary(auxKey) { + if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.'); + return window.localAI.downloadAuxiliary(auxKey); + } + + async deleteModel(modelId) { + if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.'); + return window.localAI.deleteModel(modelId); + } + + /** + * Generate an image locally using sd.cpp. + * Returns { url: 'data:image/png;base64,...', seed } + */ + async generate(params) { + if (!isLocalAIAvailable()) throw new Error('Local AI only available in the desktop app.'); + return window.localAI.generate(params); + } + + cancelGeneration() { + if (isLocalAIAvailable()) window.localAI.cancelGeneration(); + } + + /** + * Subscribe to generation progress events. + * @param {function} callback - ({ step, totalSteps, progress, status }) => void + * @returns unsubscribe function + */ + onProgress(callback) { + if (!isLocalAIAvailable()) return () => {}; + return window.localAI.onProgress(callback); + } + + /** + * Subscribe to download progress events. + * @param {function} callback - ({ id, phase, progress }) => void + * @returns unsubscribe function + */ + onDownloadProgress(callback) { + if (!isLocalAIAvailable()) return () => {}; + return window.localAI.onDownloadProgress(callback); + } +} + +export const localAI = new LocalInferenceClient(); diff --git a/src/lib/localModels.js b/src/lib/localModels.js new file mode 100644 index 0000000..4e6b87f --- /dev/null +++ b/src/lib/localModels.js @@ -0,0 +1,84 @@ +// Frontend-side local model catalog (mirrors electron/lib/modelCatalog.js) +export const LOCAL_MODEL_CATALOG = [ + // ── Z-Image (Tongyi-MAI) ──────────────────────────────────────────────── + { + id: 'z-image-turbo', + name: 'Z-Image Turbo', + description: 'WaveSpeed\'s featured local model — 6B params, ultra-fast 8-step generation. No API key needed.', + type: 'z-image', + filename: 'z_image_turbo-Q4_K.gguf', + sizeGB: 3.4, + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultSteps: 8, + defaultGuidance: 1.0, + tags: ['turbo', 'fast', 'local', 'featured'], + featured: true, + }, + { + id: 'z-image-base', + name: 'Z-Image Base', + description: 'Full-quality 6B parameter model from Tongyi-MAI — higher detail, 50-step generation.', + type: 'z-image', + filename: 'Z-Image-Q4_K_M.gguf', + sizeGB: 3.5, + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultSteps: 50, + defaultGuidance: 7.5, + tags: ['high-quality', 'local', 'detailed'], + featured: true, + }, + // ── Classic SD 1.5 ────────────────────────────────────────────────────── + { + 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, + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultSteps: 20, + defaultGuidance: 7.5, + 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, + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultSteps: 25, + defaultGuidance: 7, + 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, + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultSteps: 20, + defaultGuidance: 7, + 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, + aspectRatios: ['1:1', '4:3', '3:4', '16:9', '9:16'], + defaultSteps: 30, + defaultGuidance: 7.5, + tags: ['sdxl', 'high-quality', 'versatile'], + }, +]; + +export function getLocalModelById(id) { + return LOCAL_MODEL_CATALOG.find(m => m.id === id) || null; +}