feat: add local model inference via sd.cpp (Electron-only)

- Scaffold full IPC bridge: preload.js exposes localAI API to renderer
- electron/lib/localInference.js: binary download (Metal-enabled macOS build),
  model management, auxiliary file downloads (Qwen3-4B LLM + FLUX VAE for Z-Image),
  and generation via sd-cli with DYLD_LIBRARY_PATH + xattr quarantine stripping
- electron/lib/modelCatalog.js: Z-Image Turbo/Base (featured) + SD 1.5/SDXL models
- Z-Image requires 3 components loaded via --diffusion-model + --llm + --vae flags
- ImageStudio: Local/API toggle (Electron-only), local model selector, progress bar
- SettingsModal: tabbed UI with Local Models tab (hidden on web/hosted version)
- LocalModelManager: engine status bar, per-model download cards, auxiliary
  component download UI (text encoder + VAE) for Z-Image models
- localInferenceClient.js: safe wrapper with isLocalAIAvailable() guard
- All local features gated behind isLocalAIAvailable() — web version unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Anil Matcha 2026-04-22 17:31:34 +05:30
parent 5cbcd88733
commit 36d392ab78
10 changed files with 1355 additions and 70 deletions

View file

@ -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 };

View file

@ -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 };

View file

@ -1,5 +1,6 @@
const { app, BrowserWindow, shell } = require('electron'); const { app, BrowserWindow, shell } = require('electron');
const path = require('path'); const path = require('path');
const { register: registerLocalInference } = require('./lib/localInference');
// Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1 which // Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1 which
// blocks Chromium's user namespace sandbox. The .deb package ships an AppArmor // blocks Chromium's user namespace sandbox. The .deb package ships an AppArmor
@ -24,6 +25,7 @@ function createWindow() {
webSecurity: false, webSecurity: false,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
preload: path.join(__dirname, 'preload.js'),
}, },
...(isMac ? { titleBarStyle: 'hiddenInset' } : {}), ...(isMac ? { titleBarStyle: 'hiddenInset' } : {}),
backgroundColor: '#0d0d0d', backgroundColor: '#0d0d0d',
@ -57,6 +59,7 @@ function createWindow() {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
registerLocalInference();
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {

32
electron/preload.js Normal file
View file

@ -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);
}
});

View file

@ -17,6 +17,7 @@
"setup": "npm install && npm run build:studio", "setup": "npm install && npm run build:studio",
"vite:dev": "vite", "vite:dev": "vite",
"vite:build": "vite build", "vite:build": "vite build",
"electron:dev": "npm run vite:build && electron .",
"electron:build": "vite build && electron-builder --mac", "electron:build": "vite build && electron-builder --mac",
"electron:build:win": "vite build && electron-builder --win", "electron:build:win": "vite build && electron-builder --win",
"electron:build:linux": "vite build && electron-builder --linux", "electron:build:linux": "vite build && electron-builder --linux",

View file

@ -4,6 +4,8 @@ import {
i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel, i2iModels, getAspectRatiosForI2IModel, getResolutionsForI2IModel, getQualityFieldForI2IModel,
getMaxImagesForI2IModel getMaxImagesForI2IModel
} from '../lib/models.js'; } 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 { ENHANCE_TAGS, QUICK_PROMPTS } from '../lib/promptUtils.js';
import { AuthModal } from './AuthModal.js'; import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js'; import { createUploadPicker } from './UploadPicker.js';
@ -33,6 +35,11 @@ export function ImageStudio() {
let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support) let uploadedImageUrls = []; // array of uploaded image URLs (multi-image support)
let imageMode = false; // false = t2i models, true = i2i models 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; // 01
// Advanced parameters state // Advanced parameters state
let negativePrompt = ''; let negativePrompt = '';
let guidanceScale = 7.5; let guidanceScale = 7.5;
@ -185,6 +192,37 @@ export function ImageStudio() {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="opacity-60 text-secondary"><path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z"/></svg>
`, '720p', 'quality-btn', 'Set output quality'); `, '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(modelBtn);
controlsLeft.appendChild(arBtn); controlsLeft.appendChild(arBtn);
controlsLeft.appendChild(qualityBtn); controlsLeft.appendChild(qualityBtn);
@ -223,6 +261,32 @@ export function ImageStudio() {
inlineInstructions.classList.add('max-w-4xl', 'mt-8'); inlineInstructions.classList.add('max-w-4xl', 'mt-8');
container.appendChild(inlineInstructions); 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 = `
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-white/60">Generating locally...</span>
<span id="local-progress-pct" class="text-xs font-bold text-primary">0%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div id="local-progress-fill" class="h-full bg-primary transition-all duration-200" style="width:0%"></div>
</div>
<div class="flex justify-end">
<button id="local-cancel-btn" class="text-xs text-red-400 hover:text-red-300 transition-colors">Cancel</button>
</div>
`;
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) // 3. QUICK TOOLS PANEL (Prompt Enhancer + Quick Starters)
// ========================================== // ==========================================
@ -662,6 +726,48 @@ export function ImageStudio() {
const renderModels = (filter = '') => { const renderModels = (filter = '') => {
list.innerHTML = ''; 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 = `<div class="text-xs text-muted text-center py-4">No local models match</div>`;
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 = `
<div class="flex items-center gap-3.5">
<div class="w-10 h-10 ${m.featured ? 'bg-primary/10 text-primary' : 'bg-green-500/10 text-green-400'} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase">${m.featured ? '⚡' : m.name.charAt(0)}</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-white tracking-tight">${m.name}</span>
${m.featured ? '<span class="text-[9px] font-black px-1 py-0.5 rounded bg-primary/20 text-primary">FEATURED</span>' : ''}
</div>
<span class="text-[10px] text-muted">${m.sizeGB} GB · ${m.type.toUpperCase()}</span>
</div>
</div>
${selectedLocalModel === m.id ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
`;
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())); const filtered = getCurrentModels().filter(m => m.name.toLowerCase().includes(filter.toLowerCase()) || m.id.toLowerCase().includes(filter.toLowerCase()));
filtered.forEach(m => { 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 = `<span class="animate-spin inline-block mr-2 text-black">◌</span> 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 = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${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'); const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) { if (!apiKey) {
AuthModal(() => generateBtn.click()); AuthModal(() => generateBtn.click());

View file

@ -0,0 +1,302 @@
import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js';
// ─── Icons ────────────────────────────────────────────────────────────────────
const DownloadIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>`;
const TrashIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>`;
const CheckIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>`;
// ─── 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 = `
<span class="text-xs font-bold text-white">sd.cpp inference engine</span>
<span id="binary-status-text" class="text-[11px] text-muted">Checking...</span>
`;
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 = `<div id="binary-progress-fill" class="h-full bg-primary transition-all" style="width:0%"></div>`;
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 = `
<div class="flex items-center gap-2 min-w-0">
${isReady
? `<span class="text-green-400 shrink-0">${CheckIcon}</span>`
: `<span class="text-yellow-400 shrink-0">!</span>`}
<span class="text-[11px] text-white truncate">${label}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
${isReady
? `<span class="text-[10px] text-green-400">Ready</span>`
: `<button class="aux-dl-btn flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-bold bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 transition-all">${DownloadIcon} Get</button>`}
</div>
<div class="aux-progress hidden w-full col-span-2 mt-1">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div class="aux-fill h-full bg-primary transition-all" style="width:0%"></div>
</div>
<span class="aux-text text-[10px] text-muted block mt-0.5">Downloading...</span>
</div>
`;
const btn = row.querySelector('.aux-dl-btn');
if (btn) {
btn.onclick = async () => {
btn.disabled = true;
btn.innerHTML = `<span class="animate-spin">◌</span>`;
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 = `
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-bold text-white truncate">${model.name}</span>
${model.featured ? `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-black bg-primary/20 text-primary border border-primary/30">⚡ Featured</span>` : ''}
${fullyReady ? `<span class="text-green-400">${CheckIcon}</span>` : ''}
</div>
<p class="text-[11px] text-muted leading-relaxed">${model.description}</p>
<div class="flex items-center gap-1.5 flex-wrap mt-1">
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-primary/10 text-primary">${model.type.toUpperCase()}</span>
<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${fmtGB(model.sizeGB)}</span>
${(model.tags || []).filter(t => t !== 'featured').map(t => `<span class="px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted">${t}</span>`).join('')}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
${isDownloaded
? `<button class="delete-btn p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-all">${TrashIcon}</button>`
: `<button class="download-btn flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black hover:shadow-glow transition-all">${DownloadIcon} Download</button>`
}
</div>
</div>
<div class="progress-wrap hidden">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div class="progress-fill h-full bg-primary transition-all" style="width:0%"></div>
</div>
<span class="progress-text text-[10px] text-muted mt-1 block">Preparing...</span>
</div>
${model.requiresAuxiliary ? `<div class="aux-section flex flex-col gap-1.5 pt-1 border-t border-white/5"></div>` : ''}
`;
// 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 = `<span class="animate-spin">◌</span> 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 = `
<div class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm font-bold text-white">Local Models</p>
<p class="text-xs text-muted max-w-xs">Local model inference is only available in the desktop app (Electron build). Use <span class="text-primary font-bold">npm run electron:build</span> to build.</p>
</div>
`;
return root;
}
// ── Section: engine status
const engineSection = document.createElement('div');
engineSection.className = 'flex flex-col gap-2';
engineSection.innerHTML = `<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">Inference Engine</h3>`;
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 = `
<div class="flex items-center justify-between">
<h3 class="text-xs font-bold text-secondary uppercase tracking-wider">Local Models</h3>
<span class="text-[10px] text-muted">Stored in your app data folder</span>
</div>
<div id="local-model-list" class="flex flex-col gap-3"></div>
`;
root.appendChild(modelsSection);
const listEl = modelsSection.querySelector('#local-model-list');
const renderModels = async () => {
listEl.innerHTML = `<div class="text-xs text-muted text-center py-4">Loading...</div>`;
try {
const models = await localAI.listModels();
listEl.innerHTML = '';
models.forEach(m => {
listEl.appendChild(ModelCard(m, renderModels));
});
} catch (err) {
listEl.innerHTML = `<div class="text-xs text-red-400 text-center py-4">Error loading models: ${err.message}</div>`;
}
};
renderModels();
return root;
}

View file

@ -1,92 +1,117 @@
import { LocalModelManager } from './LocalModelManager.js';
import { isLocalAIAvailable } from '../lib/localInferenceClient.js';
export function SettingsModal(onClose) { export function SettingsModal(onClose) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50'; 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;';
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';
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'bg-card p-6 rounded-xl border border-border-color w-96 glass'; 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;';
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';
const title = document.createElement('h2'); // ── Header ────────────────────────────────────────────────────────────────
title.textContent = 'Settings'; const header = document.createElement('div');
title.className = 'text-xl font-bold mb-4'; 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;';
title.style.marginBottom = '1rem'; header.innerHTML = `
<h2 style="font-size:1rem;font-weight:800;color:#fff;margin:0;">Settings</h2>
<button id="settings-close-btn" style="color:rgba(255,255,255,0.4);background:none;border:none;cursor:pointer;padding:4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
`;
modal.appendChild(header);
const label = document.createElement('label'); // ── Tabs ──────────────────────────────────────────────────────────────────
label.textContent = 'Muapi API Key'; const TABS = [
label.className = 'block text-sm text-secondary mb-2'; { id: 'api', label: 'API Key' },
...(isLocalAIAvailable() ? [{ id: 'local', label: 'Local Models' }] : []),
];
const input = document.createElement('input'); let activeTab = 'api';
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';
const btnContainer = document.createElement('div'); const tabBar = document.createElement('div');
btnContainer.className = 'flex justify-end gap-2'; 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;';
btnContainer.style.display = 'flex';
btnContainer.style.justifyContent = 'flex-end';
btnContainer.style.gap = '0.5rem';
const cancelBtn = document.createElement('button'); const tabBtns = {};
cancelBtn.textContent = 'Cancel'; TABS.forEach(({ id, label }) => {
cancelBtn.className = 'px-4 py-2 rounded hover:bg-white/5'; const btn = document.createElement('button');
cancelBtn.onclick = () => { btn.textContent = label;
document.body.removeChild(overlay); 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 = `
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<div>
<label style="display:block;font-size:0.75rem;color:rgba(255,255,255,0.5);margin-bottom:0.4rem;font-weight:600;">Muapi API Key</label>
<input id="settings-api-key" type="password"
style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:0.75rem;padding:0.6rem 0.9rem;color:#fff;font-size:0.875rem;outline:none;"
placeholder="Enter your Muapi API key..."
value="${localStorage.getItem('muapi_key') || ''}">
</div>
<p style="font-size:0.7rem;color:rgba(255,255,255,0.3);margin:0;">
Your API key is stored locally and never sent anywhere except api.muapi.ai.
</p>
<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.5rem;">
<button id="settings-cancel-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:none;border:1px solid rgba(255,255,255,0.1);color:rgba(255,255,255,0.6);font-size:0.75rem;font-weight:700;cursor:pointer;">Cancel</button>
<button id="settings-save-btn" style="padding:0.5rem 1rem;border-radius:0.5rem;background:var(--color-primary,#d9ff00);color:#000;font-size:0.75rem;font-weight:700;cursor:pointer;border:none;">Save</button>
</div>
</div>
`;
// ── 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(); if (onClose) onClose();
}; };
const saveBtn = document.createElement('button'); apiPanel.querySelector('#settings-cancel-btn').onclick = close;
saveBtn.textContent = 'Save'; apiPanel.querySelector('#settings-save-btn').onclick = () => {
saveBtn.className = 'px-4 py-2 rounded bg-primary text-black font-medium'; const key = apiPanel.querySelector('#settings-api-key').value.trim();
saveBtn.style.backgroundColor = 'var(--color-primary)';
saveBtn.style.color = 'black';
saveBtn.style.fontWeight = '500';
saveBtn.onclick = () => {
const key = input.value.trim();
if (key) { if (key) {
localStorage.setItem('muapi_key', key); localStorage.setItem('muapi_key', key);
alert('API Key saved!'); close();
document.body.removeChild(overlay);
if (onClose) onClose();
} else { } else {
alert('Please enter a valid key'); alert('Please enter a valid API key.');
} }
}; };
modal.appendChild(title); header.querySelector('#settings-close-btn').onclick = close;
modal.appendChild(label); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
modal.appendChild(input);
btnContainer.appendChild(cancelBtn);
btnContainer.appendChild(saveBtn);
modal.appendChild(btnContainer);
overlay.appendChild(modal); overlay.appendChild(modal);
// Close on outside click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
document.body.removeChild(overlay);
if (onClose) onClose();
}
});
return overlay; return overlay;
} }

View file

@ -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();

84
src/lib/localModels.js Normal file
View file

@ -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;
}