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:
parent
5cbcd88733
commit
36d392ab78
10 changed files with 1355 additions and 70 deletions
462
electron/lib/localInference.js
Normal file
462
electron/lib/localInference.js
Normal 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 };
|
||||
131
electron/lib/modelCatalog.js
Normal file
131
electron/lib/modelCatalog.js
Normal 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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
32
electron/preload.js
Normal file
32
electron/preload.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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');
|
||||
|
||||
// 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 = `
|
||||
<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)
|
||||
// ==========================================
|
||||
|
|
@ -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 = `<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()));
|
||||
|
||||
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');
|
||||
if (!apiKey) {
|
||||
AuthModal(() => generateBtn.click());
|
||||
|
|
|
|||
302
src/components/LocalModelManager.js
Normal file
302
src/components/LocalModelManager.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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 = `
|
||||
<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');
|
||||
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 = `
|
||||
<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();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
71
src/lib/localInferenceClient.js
Normal file
71
src/lib/localInferenceClient.js
Normal 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
84
src/lib/localModels.js
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue