diff --git a/api/routers/generation.py b/api/routers/generation.py index ea8b25e0..6fb1b051 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -131,6 +131,64 @@ async def generate_from_image( +@router.post("/from-text") +async def generate_from_text( + background_tasks: BackgroundTasks, + prompt: str = Form(...), + model_id: str = Form("sf3d"), + collection: str = Form("Default"), + remesh: str = Form("quad"), + enable_texture: bool = Form(False), + texture_resolution: int = Form(1024), + params: str = Form("{}"), +): + if not prompt or not prompt.strip(): + raise HTTPException(400, "Prompt is required") + + if remesh not in VALID_REMESH_MODES: + raise HTTPException(400, "remesh must be 'quad', 'triangle', or 'none'") + + collection = sanitize_collection(collection) + + # Verify the requested model exists in the registry + try: + generator_registry.get_generator(model_id) + except ValueError as e: + raise HTTPException(400, str(e)) + + generator_registry.switch_model(model_id) + + # Parse model-specific params from JSON and merge with common fields + try: + model_params = json.loads(params) + except (json.JSONDecodeError, TypeError): + model_params = {} + + job_id = str(uuid.uuid4()) + # Use a 1x1 transparent PNG as placeholder for text-to-image + import base64 + placeholder_b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + image_bytes = base64.b64decode(placeholder_b64) + full_params = { + "remesh": remesh, + "enable_texture": enable_texture, + "texture_resolution": texture_resolution, + "prompt": prompt, + "text": prompt, + **model_params, + } + + _purge_old_jobs() + + job = JobStatus(job_id=job_id, status="pending", progress=0) + _jobs[job_id] = job + _cancel_events[job_id] = threading.Event() + + background_tasks.add_task(_run_generation, job_id, image_bytes, full_params, collection) + + return {"job_id": job_id} + + @router.get("/status/{job_id}") async def job_status(job_id: str): job = _jobs.get(job_id) diff --git a/api/routers/model.py b/api/routers/model.py index 4f04718b..d0a04c39 100644 --- a/api/routers/model.py +++ b/api/routers/model.py @@ -70,6 +70,53 @@ async def model_params(model_id: Optional[str] = None): raise HTTPException(404, f"Unknown model ID: {model_id}") +@router.get("/readiness/{model_id}") +async def model_readiness(model_id: str): + """ + Check if a model's runtime is ready (weights downloaded, venv set up, etc.). + Returns: { ready: boolean, status: string, details?: object } + """ + try: + gen = generator_registry.get_generator(model_id) + except ValueError: + raise HTTPException(404, f"Unknown model ID: {model_id}") + + manifest = generator_registry.get_manifest(model_id) + ext_id = manifest.get("ext_id", model_id.split("/")[0]) + + # Check if weights are downloaded + weights_downloaded = gen.is_downloaded() + + # Check if setup is needed (for subprocess extensions) + setup_needed = False + if hasattr(gen, '_proc') and gen._proc is None: + # ExtensionProcess - check if venv exists + import os + from pathlib import Path + ext_dir = Path(os.environ.get("EXTENSIONS_DIR", "")) / ext_id + if ext_dir.exists(): + venv_python = ext_dir / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + setup_needed = not venv_python.exists() + + # Determine readiness + ready = weights_downloaded and not setup_needed + + status = "ready" + if not weights_downloaded: + status = "weights_missing" + elif setup_needed: + status = "setup_required" + + return { + "model_id": model_id, + "ready": ready, + "status": status, + "weights_downloaded": weights_downloaded, + "setup_needed": setup_needed, + "loaded": gen.is_loaded(), + } + + @router.post("/switch") async def switch_model(model_id: str): """Switch the active model.""" @@ -129,6 +176,7 @@ async def hf_download( skip_prefixes: Optional[str] = None, include_prefixes: Optional[str] = None, token: Optional[str] = None, + weight_owner_id: Optional[str] = None, ): """ Streams a HuggingFace Hub model download via SSE. @@ -138,13 +186,16 @@ async def hf_download( skip_prefixes: JSON-encoded list of path prefixes to exclude. include_prefixes: JSON-encoded list of path prefixes to include (whitelist). token: HuggingFace access token for gated repos (from Electron settings). + weight_owner_id: If set, download into the owner's directory instead of model_id's. All three fall back to the extension's manifest / environment when not supplied. SSE format: data: {"percent": 0-100, "file": "...", "status": "..."} """ import json as _json import os - dest_dir = str(MODELS_DIR / model_id) + # Use weight_owner_id if provided, otherwise use model_id + effective_model_id = weight_owner_id or model_id + dest_dir = str(MODELS_DIR / effective_model_id) # Prefer skip_prefixes passed directly from the client (authoritative, no registry dep) if skip_prefixes: try: diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py index ecb6bb6c..09e0f046 100644 --- a/api/services/generator_registry.py +++ b/api/services/generator_registry.py @@ -533,6 +533,7 @@ def _discover_extensions( "download_check": node.get("download_check", ""), "hf_skip_prefixes": node.get("hf_skip_prefixes", []), "hf_include_prefixes": node.get("hf_include_prefixes", []), + "weight_owner_id": node.get("weight_owner_id"), "params_schema": node.get("params_schema", manifest.get("params_schema", [])), "input": node.get("input", "image"), "output": node.get("output", "mesh"), diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 005f1f78..a6de5379 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -3,7 +3,7 @@ import { buildSync } from 'esbuild' import { autoUpdater } from 'electron-updater' import { join } from 'path' import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp, symlink, lstat } from 'fs/promises' -import { existsSync, mkdirSync, readdirSync, statSync } from 'fs' +import { existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import axios from 'axios' import * as tar from 'tar' import * as os from 'os' @@ -108,6 +108,623 @@ function detectGpuInfo(): Promise { }) } +// ─── Bundle detection ────────────────────────────────────────────────────────── + +interface BundleExtension { + id: string + path: string + manifest: ParsedManifest +} + +function detectBundleExtensions(extractDir: string): BundleExtension[] { + const extensionsDir = join(extractDir, 'extensions') + if (!existsSync(extensionsDir)) return [] + + const children = readdirSync(extensionsDir, { withFileTypes: true }) + const bundles: BundleExtension[] = [] + + for (const child of children) { + if (!child.isDirectory()) continue + const childPath = join(extensionsDir, child.name) + const manifestPath = join(childPath, 'manifest.json') + if (!existsSync(manifestPath)) continue + + try { + const raw = readFileSync(manifestPath, 'utf-8') + const manifest = JSON.parse(raw) as ParsedManifest + if (!manifest.id) continue + + // Validate folder name matches manifest ID + if (manifest.id !== child.name) { + console.warn(`[Bundle] Skipping ${child.name}: folder name doesn't match manifest.id (${manifest.id})`) + continue + } + + bundles.push({ id: manifest.id, path: childPath, manifest }) + } catch { + console.warn(`[Bundle] Failed to parse manifest in ${child.name}`) + } + } + + return bundles +} + +function detectSingleExtension(extractDir: string): BundleExtension | null { + const manifestPath = join(extractDir, 'manifest.json') + if (!existsSync(manifestPath)) return null + + try { + const raw = readFileSync(manifestPath, 'utf-8') + const manifest = JSON.parse(raw) as ParsedManifest + if (!manifest.id) return null + + return { id: manifest.id, path: extractDir, manifest } + } catch { + return null + } +} + +// ─── Run an extension's setup.py directly (no FastAPI needed) ───────────────── + +function runExtensionSetup( + extDir: string, + gpuSm: number, + cudaVersion: number, + onLog?: (line: string) => void, +): Promise { + return new Promise((resolve, reject) => { + const userData = app.getPath('userData') + ensureSslPatch(userData) + const pythonExe = getVenvPythonExe(userData) + const setupPy = join(extDir, 'setup.py') + + // Shared pip wheel cache: a failed setup retried later reuses the multi-GB + // torch wheels instead of re-downloading them (see issue #223). Extension + // setup.py scripts that pass --no-cache-dir get it stripped by the launcher. + const pipCacheDir = join(getSettings(userData).dependenciesDir, 'pip-cache') + try { mkdirSync(pipCacheDir, { recursive: true }) } catch { /* pip creates it too */ } + + const accelerator = process.platform === 'darwin' && process.arch === 'arm64' ? 'mps' : gpuSm > 0 ? 'cuda' : 'cpu' + const args = JSON.stringify({ + python_exe: pythonExe, + ext_dir: extDir, + gpu_sm: gpuSm, + cuda_version: cudaVersion, + accelerator, + platform: process.platform, + arch: process.arch, + }) + const launcher = ` +import runpy +import subprocess +import sys + +setup_py = sys.argv[1] +setup_args = sys.argv[2:] + +_original_run = subprocess.run +_original_check_call = subprocess.check_call +_original_check_output = subprocess.check_output + +def _is_cuda_torch_index(value): + return isinstance(value, str) and value.startswith("https://download.pytorch.org/whl/cu") + +def _mentions_torch(command): + if not isinstance(command, (list, tuple)): + return False + return any(str(part).startswith(("torch==", "torchvision==", "torchaudio==")) for part in command) + +def _rewrite_command(command): + if sys.platform != "darwin" || !_mentions_torch(command): + return command + if not isinstance(command, (list, tuple)): + return command + + rewritten = [] + changed = false + i = 0 + while i < len(command): + part = command[i] + text = str(part) + if text in ("--index-url", "-i", "--extra-index-url") and i + 1 < len(command) and _is_cuda_torch_index(str(command[i + 1])): + changed = true + i += 2 + continue + if text.startswith("--index-url=") or text.startswith("--extra-index-url="): + value = text.split("=", 1)[1] + if _is_cuda_torch_index(value): + changed = true + i += 1 + continue + rewritten.append(part) + i += 1 + + if changed: + print("[Modly setup compat] Removed CUDA-only PyTorch index on macOS; pip will use macOS wheels.", file=sys.stderr) + return rewritten + return command + +def _is_pip_command(command): + if not isinstance(command, (list, tuple)): + return false + return any("pip" in str(part).lower() for part in command[:3]) + +def _strip_no_cache(command): + # Extension setup scripts often hardcode --no-cache-dir, which forces pip to + # re-download multi-GB wheels on every retry. Modly provides a shared cache + # via PIP_CACHE_DIR, so drop the flag and let pip use it. + if not _is_pip_command(command): + return command + if not any(str(part) == "--no-cache-dir" for part in command): + return command + print("[Modly setup compat] Removed --no-cache-dir so pip reuses the shared wheel cache.", file=sys.stderr) + return [part for part in command if str(part) != "--no-cache-dir"] + +def _transform_command(command): + return _strip_no_cache(_rewrite_command(command)) + +def _patched_run(*args, **kwargs): + args = list(args) + if args: + args[0] = _transform_command(args[0]) + return _original_run(*args, **kwargs) + +def _patched_check_call(*args, **kwargs): + args = list(args) + if args: + args[0] = _transform_command(args[0]) + return _original_check_call(*args, **kwargs) + +def _patched_check_output(*args, **kwargs): + args = list(args) + if args: + args[0] = _transform_command(args[0]) + return _original_check_output(*args, **kwargs) + +subprocess.run = _patched_run +subprocess.check_call = _patched_check_call +subprocess.check_output = _patched_check_output + +sys.argv = [setup_py] + setup_args +runpy.run_path(setup_py, run_name="__main__") +` + const proc = spawn(pythonExe, ['-c', launcher, setupPy, args], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PIP_CACHE_DIR: pipCacheDir }, + }) + + const handleLine = (line: string) => { if (line) onLog?.(line) } + + let stderr = '' + proc.stdout?.on('data', (d: Buffer) => d.toString().split('\n').forEach(handleLine)) + proc.stderr?.on('data', (d: Buffer) => { + const s = d.toString() + stderr += s + s.split('\n').forEach(handleLine) + }) + + proc.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`setup.py exited with code ${code}\n${stderr.slice(-2000)}`)) + }) + proc.on('error', reject) + }) +} + +// ─── Install a single extension from extracted directory ─────────────────────── + +async function installSingleExtension( + bundle: BundleExtension, + owner: string, + repo: string, + extensionsDir: string, + emit: (data: object) => void, + trackedIds: string[], + activeInstalls: Set, +): Promise<{ success: boolean; error?: string; extensionId?: string; extension?: AnyExtension }> { + const { id: extensionId, path: extPath, manifest } = bundle + + try { + // Validate manifest + const { isProcess, entryFile, isPythonProcess, hasNodes } = validateInstallManifest( + manifest, + { + hasEntryFile: (candidate) => existsSync(join(extPath, candidate)), + hasGeneratorFile: () => existsSync(join(extPath, 'generator.py')), + }, + `repository (extension: ${extensionId})`, + ) + if (!hasNodes) throw new Error(`manifest.json: required field "nodes" missing or empty for ${extensionId}`) + const safeExtensionId = assertSafeExtensionId(extensionId) + manifest.id = safeExtensionId + + // Override source field with the actual GitHub URL so trust is based on origin + manifest.source = `https://github.com/${owner}/${repo}` + const manifestPath = join(extPath, 'manifest.json') + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8') + + trackedIds.push(safeExtensionId) + activeInstalls.add(safeExtensionId) + + // Stage into a fresh, unique dir next to the final location + const installSuffix = String(Date.now()) + const destDir = resolveExtensionPathWithinRoot(extensionsDir, safeExtensionId) + const stagingDir = buildExtensionStagingPath(extensionsDir, safeExtensionId, installSuffix) + + if (existsSync(destDir)) { + const currentManifestPath = join(destDir, 'manifest.json') + let currentManifestJson: string + try { + currentManifestJson = await readFile(currentManifestPath, 'utf-8') + } catch (error) { + throw new Error( + `Cannot safely replace extension "${safeExtensionId}" because its existing ` + + `manifest.json is missing or unreadable. Uninstall it first. ${String(error)}`, + ) + } + validateExistingExtensionReplacement( + currentManifestJson, + manifest, + { + hasEntryFile: (candidate) => existsSync(join(destDir, candidate)), + hasGeneratorFile: () => existsSync(join(destDir, 'generator.py')), + }, + 'existing extension folder', + ) + } + + try { + await cp(extPath, stagingDir, { recursive: true }) + // Reserved recovery files are host-owned transaction state, never + // repository content. Strip packaged/stale copies before activation. + await rmAsync( + join(stagingDir, EXT_REGISTRATION_PENDING_MARKER), + { recursive: true, force: true }, + ) + await rmAsync( + join(stagingDir, EXT_VALIDATED_MARKER), + { recursive: true, force: true }, + ) + await writeFile(join(stagingDir, EXT_INCOMPLETE_MARKER), new Date().toISOString(), 'utf-8') + + // Compile TypeScript entry to JS at install time (once, no runtime overhead) + if (isProcess && entryFile.endsWith('.ts')) { + emit({ step: 'setting_up', message: `Compiling TypeScript entry for ${safeExtensionId}…` }) + const compiledEntry = entryFile.replace(/\.ts$/, '.js') + buildSync({ + entryPoints: [join(stagingDir, entryFile)], + outfile: join(stagingDir, compiledEntry), + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + }) + manifest.entry = compiledEntry + await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8') + } + } catch (stageErr) { + await rmWithRetry(stagingDir, 'ext-install') + throw stageErr + } + + // Swap into place (backup the previous version first) + terminateProcessRunner(safeExtensionId) + const backupDir = existsSync(destDir) + ? buildExtensionBackupPath(extensionsDir, safeExtensionId, installSuffix) + : null + if (backupDir) { + const parked = await renameWithRetry(destDir, backupDir, 'ext-install') + if (!parked.ok) { + await rmWithRetry(stagingDir, 'ext-install') + throw new Error(`The current extension folder "${safeExtensionId}" is locked (antivirus or a running process) — close what might be using it and try again.`) + } + } + const activated = await renameWithRetry(stagingDir, destDir, 'ext-install') + if (!activated.ok) { + await rmWithRetry(stagingDir, 'ext-install') + if (backupDir) await renameWithRetry(backupDir, destDir, 'ext-install') + throw new Error(`Could not move the staged extension "${safeExtensionId}" into place — the folder is locked. Try again.`) + } + + // Persist transaction state beside extension folders + const pending = await beginExtensionRegistrationTransaction( + extensionsDir, + safeExtensionId, + installSuffix, + logger, + ) + if (!pending.ok) { + if (backupDir) { + await rollbackFailedExtensionUpdate( + extensionsDir, + destDir, + backupDir, + safeExtensionId, + `Could not mark the update as pending: ${String(pending.error)}`, + ) + } else { + await rmWithRetry(destDir, 'ext-install') + } + throw new Error(`Could not mark extension registration as pending for "${safeExtensionId}": ${String(pending.error)}`) + } + + // Setup runs in the final folder; a failure restores the previous version + try { + if (isPythonProcess) { + if (existsSync(join(destDir, 'setup.py'))) { + emit({ step: 'setting_up', message: `Setting up Python environment for ${safeExtensionId}…` }) + const { sm: gpuSm, cudaVersion } = await detectGpuInfo() + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) + } + } else if (isProcess) { + if (existsSync(join(destDir, 'package.json'))) { + emit({ step: 'setting_up', message: `Installing dependencies for ${safeExtensionId}…` }) + await new Promise((resolve, reject) => { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const child = spawn(npm, ['install', '--omit=dev', '--no-audit', '--no-fund', '--ignore-scripts=false'], { + cwd: destDir, + stdio: 'pipe', + }) + let buf = '' + const onData = (chunk: Buffer) => { + buf += chunk.toString() + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const raw of lines) { + const line = raw.replace(/\x1b\[[0-9;]*m/g, '').trim() + if (line) emit({ step: 'setting_up', message: line }) + } + } + child.stdout?.on('data', onData) + child.stderr?.on('data', onData) + child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install failed (exit ${code})`))) + child.on('error', reject) + }) + } + } else { + if (existsSync(join(destDir, 'setup.py'))) { + emit({ step: 'setting_up', message: `Setting up Python environment for ${safeExtensionId}…` }) + const { sm: gpuSm, cudaVersion } = await detectGpuInfo() + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) + } + } + } catch (setupErr) { + if (backupDir) { + const restored = await restoreExtensionBackup(destDir, backupDir, logger) + if (!restored.ok) { + throw new Error( + `Extension "${safeExtensionId}" setup failed, and Modly could not restore the previous version ` + + `during ${restored.stage}: ${String(restored.error)}. ` + + `Restart Modly to retry recovery. Original failure: ${String(setupErr)}`, + ) + } + const cleared = await clearExtensionRegistrationTransaction( + extensionsDir, + safeExtensionId, + logger, + ) + if (!cleared.ok) { + throw new Error( + `Extension "${safeExtensionId}" setup failed and the previous version was restored, but Modly ` + + `could not clear recovery state during ${cleared.stage}: ${String(cleared.error)}.`, + ) + } + } else { + const removed = await rmWithRetry(destDir, 'ext-install') + if (removed.ok) { + const cleared = await clearExtensionRegistrationTransaction( + extensionsDir, + safeExtensionId, + logger, + ) + if (!cleared.ok) { + throw new Error( + `Extension "${safeExtensionId}" setup failed and its incomplete folder was removed, but Modly ` + + `could not clear recovery state during ${cleared.stage}: ${String(cleared.error)}.`, + ) + } + } + } + throw setupErr + } + + // Runtime validation passed. Mark cleanup as validated before deleting + // rollback copies so startup can safely retry an interrupted deletion. + try { + await validateExtensionDestinationRegistration( + destDir, + async () => { + if (!isProcess) { + await reloadAndValidateModelExtension( + manifest, + safeExtensionId, + pending.validationCapability, + ) + } + }, + 'ext-install', + logger, + ) + } catch (registrationError) { + if (backupDir) { + await rollbackFailedExtensionUpdate( + extensionsDir, + destDir, + backupDir, + safeExtensionId, + registrationError, + ) + } else { + const quarantined = await quarantineExtensionRegistrationFailure( + extensionsDir, + safeExtensionId, + logger, + ) + if (!quarantined.ok) { + throw new Error( + `Runtime registration failed for "${safeExtensionId}", and Modly could not quarantine the extension ` + + `during ${quarantined.stage}: ${String(quarantined.error)}. ` + + `Restart Modly to retry recovery. Original failure: ${String(registrationError)}`, + ) + } + if (!isProcess) { + try { + await quarantineModelExtensionRuntime(manifest, safeExtensionId) + } catch (runtimeQuarantineError) { + throw new Error( + `Runtime registration failed for "${safeExtensionId}" and filesystem quarantine was preserved, ` + + `but Modly could not evict partially registered model state: ` + + `${String(runtimeQuarantineError)}. ` + + `Original failure: ${String(registrationError)}`, + ) + } + } + } + throw registrationError + } + + await cleanupValidatedBackupsOrThrow(extensionsDir, safeExtensionId) + + emit({ step: 'done', extensionId: safeExtensionId }) + + const trustedRepos = await fetchTrustedRepos() + const ext = parseExtensionManifest(manifest, safeExtensionId, trustedRepos) + return { success: true, extensionId: safeExtensionId, extension: ext } + } catch (err) { + return { success: false, error: String(err) } + } +} + +// ─── Install an extension from a GitHub repo URL ──────────────────────────────── + ipcMain.handle('extensions:installFromGitHub', async (event, githubUrl: string) => { + const win = getWindow() + const emit = (data: object) => win?.webContents.send('extensions:installProgress', data) + const tmpDir = app.getPath('temp') + + let tarPath = '' + let extractDir = '' + const trackedExtensionIds: string[] = [] + + try { + // 1. Parse and validate GitHub URL + const parsed = new URL(githubUrl.trim()) + if (parsed.hostname !== 'github.com') throw new Error('Invalid URL: must be a GitHub repository (github.com)') + const parts = parsed.pathname.split('/').filter(Boolean) + if (parts.length < 2) throw new Error('Invalid URL: expected format https://github.com/owner/repo') + const [owner, repo] = parts + + emit({ step: 'downloading', percent: 0 }) + + // 2. Download tarball via GitHub API + const tarballUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/HEAD` + tarPath = join(tmpDir, `modly-ext-${Date.now()}.tar.gz`) + extractDir = join(tmpDir, `modly-ext-extract-${Date.now()}`) + + const response = await axios.get(tarballUrl, { + responseType: 'arraybuffer', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Modly-App', + }, + onDownloadProgress: (evt) => { + const pct = evt.total ? Math.round((evt.loaded / evt.total) * 80) : 40 + emit({ step: 'downloading', percent: pct }) + }, + }) + + await writeFile(tarPath, Buffer.from(response.data as ArrayBuffer)) + + // 3. Extract tarball (GitHub wraps contents in a top-level {owner}-{repo}-{sha}/ folder) + emit({ step: 'extracting' }) + await mkdir(extractDir, { recursive: true }) + await tar.x({ file: tarPath, cwd: extractDir, strip: 1 }) + + // 4. Detect bundle vs single extension + const bundles = detectBundleExtensions(extractDir) + const extensionsDir = getSettings(app.getPath('userData')).extensionsDir + await mkdir(extensionsDir, { recursive: true }) + + let results: Array<{ success: boolean; error?: string; extensionId?: string; extension?: any }> = [] + + if (bundles.length > 0) { + // Bundle install: process each child extension + emit({ step: 'validating', message: `Found ${bundles.length} extension(s) in bundle` }) + + // Check for duplicate extension IDs + const seenIds = new Set() + for (const bundle of bundles) { + if (seenIds.has(bundle.id)) { + throw new Error(`Duplicate extension ID in bundle: "${bundle.id}". Each extension in a bundle must have a unique ID.`) + } + seenIds.add(bundle.id) + } + + // Install each extension + let completed = 0 + for (const bundle of bundles) { + emit({ step: 'validating', message: `Installing ${bundle.id} (${++completed}/${bundles.length})` }) + const result = await installSingleExtension(bundle, owner, repo, extensionsDir, emit, trackedExtensionIds, activeExtensionInstalls) + results.push(result) + } + + // Check if any extensions failed + const failed = results.filter(r => !r.success) + const succeeded = results.filter(r => r.success) + + if (failed.length > 0 && succeeded.length > 0) { + // Partial success + emit({ step: 'error', message: `${failed.length} of ${bundles.length} extensions failed to install` }) + return { + success: false, + error: `${failed.length} extension(s) failed: ${failed.map(f => f.error).join('; ')}`, + partialResults: results, + } + } else if (failed.length > 0) { + // All failed + emit({ step: 'error', message: `All ${bundles.length} extensions failed to install` }) + return { + success: false, + error: `All extensions failed: ${failed.map(f => f.error).join('; ')}`, + partialResults: results, + } + } else { + // All succeeded - reload extensions once + await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) + emit({ step: 'done', extensionId: results.map(r => r.extensionId).join(', ') }) + return { success: true, partialResults: results } + } + } else { + // Legacy single extension install + const single = detectSingleExtension(extractDir) + if (!single) throw new Error('manifest.json missing from repository') + + const result = await installSingleExtension(single, owner, repo, extensionsDir, emit, trackedExtensionIds, activeExtensionInstalls) + results = [result] + + if (!result.success) { + return { success: false, error: result.error } + } + + return { success: true, extensionId: result.extensionId, extension: result.extension } + } + + } catch (err) { + emit({ step: 'error', message: String(err) }) + return { success: false, error: String(err) } + } finally { + for (const id of trackedExtensionIds) activeExtensionInstalls.delete(id) + // Cleanup temp files + if (tarPath && existsSync(tarPath)) rmAsync(tarPath, { force: true }).catch(() => {}) + if (extractDir && existsSync(extractDir)) rmAsync(extractDir, { recursive: true, force: true }).catch(() => {}) + } + }) + // ─── Run an extension's setup.py directly (no FastAPI needed) ───────────────── function runExtensionSetup( @@ -498,9 +1115,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return listDownloadedModels(modelsDir) }) - ipcMain.handle('model:isDownloaded', (_, modelId: string, downloadCheck?: string): boolean => { + ipcMain.handle('model:isDownloaded', (_, modelId: string, downloadCheck?: string, weightOwnerId?: string): boolean => { const modelsDir = getSettings(app.getPath('userData')).modelsDir - return isModelDownloaded(modelsDir, modelId, downloadCheck) + return isModelDownloaded(modelsDir, modelId, downloadCheck, weightOwnerId) }) ipcMain.handle('model:activeDownloads', () => @@ -509,8 +1126,30 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('model:download', async ( event, - { repoId, modelId, skipPrefixes, includePrefixes }: { repoId: string; modelId: string; skipPrefixes?: string[]; includePrefixes?: string[] }, + { repoId, modelId, skipPrefixes, includePrefixes, weightOwnerId }: { repoId: string; modelId: string; skipPrefixes?: string[]; includePrefixes?: string[]; weightOwnerId?: string }, ) => { + // If weightOwnerId not provided, try to resolve from extension manifest + let effectiveWeightOwnerId = weightOwnerId + if (!effectiveWeightOwnerId) { + const parts = modelId.split('/') + if (parts.length === 2) { + const [extId, nodeId] = parts + const extensionsDir = getSettings(app.getPath('userData')).extensionsDir + const extDir = resolveExtensionPathWithinRoot(extensionsDir, extId) + const manifestPath = join(extDir, 'manifest.json') + if (existsSync(manifestPath)) { + try { + const manifestRaw = await readFile(manifestPath, 'utf-8') + const manifest = JSON.parse(manifestRaw) as ParsedManifest + const node = manifest.nodes?.find((n: any) => n.id === nodeId) + if (node?.weight_owner_id) { + effectiveWeightOwnerId = node.weight_owner_id + } + } catch { /* ignore parse errors */ } + } + } + } + if (activeDownloads.has(modelId)) { return { success: false, error: 'Download already in progress' } } @@ -519,7 +1158,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe await downloadModelFromHF(repoId, modelId, (progress) => { activeDownloads.set(modelId, progress) event.sender.send('model:downloadProgress', { modelId, ...progress }) - }, skipPrefixes, includePrefixes) + }, skipPrefixes, includePrefixes, effectiveWeightOwnerId) return { success: true } } catch (err: any) { const message = err?.message ?? String(err) @@ -537,6 +1176,15 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } }) + ipcMain.handle('model:readiness', async (_, modelId: string) => { + try { + const response = await axios.get(`${API_BASE_URL}/model/readiness/${encodeURIComponent(modelId)}`, { timeout: 10000 }) + return response.data + } catch (err) { + return { ready: false, status: 'error', error: String(err) } + } + }) + ipcMain.handle('model:pauseDownload', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => { try { await axios.post(`${API_BASE_URL}/model/hf-download/pause`, null, { @@ -629,6 +1277,29 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return { total, used: total - free, available: free } }) + // GPU memory (VRAM) — NVIDIA only via nvidia-smi + ipcMain.handle('system:gpuMemory', async () => { + if (process.platform === 'darwin' && process.arch === 'arm64') { + // Apple Silicon: unified memory, no separate VRAM + return null + } + try { + const { stdout } = await pExecFile('nvidia-smi', [ + '--query-gpu=memory.total,memory.used', + '--format=csv,noheader,nounits' + ]) + const line = stdout.trim().split('\n')[0].trim() + const [totalMiB, usedMiB] = line.split(',').map(s => parseInt(s.trim(), 10)) + if (isNaN(totalMiB) || isNaN(usedMiB) || totalMiB === 0) return null + const total = totalMiB * 1024 * 1024 + const used = usedMiB * 1024 * 1024 + const available = total - used + return { total, used, available } + } catch { + return null + } + }) + ipcMain.handle('app:info', () => ({ version: app.getVersion(), userData: app.getPath('userData'), @@ -874,6 +1545,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe download_check?: string hf_skip_prefixes?: string[] hf_include_prefixes?: string[] + weight_owner_id?: string }[] } @@ -902,6 +1574,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe downloadCheck: n.download_check, hfSkipPrefixes: n.hf_skip_prefixes, hfIncludePrefixes: n.hf_include_prefixes, + weightOwnerId: n.weight_owner_id, })) if (parsed.type === 'process') { @@ -1818,6 +2491,59 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } }) + // Generation API (proxy to FastAPI) + ipcMain.handle('generation:from-image', async (_event, formData: FormData) => { + try { + const response = await axios.post(`${API_BASE_URL}/generate/from-image`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, + }) + return response.data + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('generation:from-text', async (_event, { prompt, modelId, params, collection, remesh, enableTexture, textureResolution }: { + prompt: string; modelId: string; params?: Record; collection?: string; remesh?: string; enableTexture?: boolean; textureResolution?: number + }) => { + try { + const formData = new FormData() + formData.append('prompt', prompt) + formData.append('model_id', modelId) + formData.append('collection', collection ?? 'Default') + formData.append('remesh', remesh ?? 'quad') + formData.append('enable_texture', String(enableTexture ?? false)) + formData.append('texture_resolution', String(textureResolution ?? 1024)) + formData.append('params', JSON.stringify(params ?? {})) + const response = await axios.post(`${API_BASE_URL}/generate/from-text`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, + }) + return response.data + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('generation:status', async (_event, jobId: string) => { + try { + const response = await axios.get(`${API_BASE_URL}/generate/status/${jobId}`, { timeout: 10000 }) + return response.data + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('generation:cancel', async (_event, jobId: string) => { + try { + const response = await axios.post(`${API_BASE_URL}/generate/cancel/${jobId}`, {}, { timeout: 5000 }) + return response.data + } catch (err) { + return { success: false, error: String(err) } + } + }) + // ── Workflows ──────────────────────────────────────────────────────────── function workflowsDir(): string { diff --git a/electron/main/model-downloader.ts b/electron/main/model-downloader.ts index 8c5571d9..6baeccaf 100644 --- a/electron/main/model-downloader.ts +++ b/electron/main/model-downloader.ts @@ -21,14 +21,41 @@ export type ProgressCallback = (progress: DownloadProgress) => void const PYTHON_API_URL = process.env['PYTHON_API_URL'] ?? 'http://127.0.0.1:8765' -// ------------------------------------------------------------------ -// Public API -// ------------------------------------------------------------------ +// ─── Weight owner utilities ──────────────────────────────────────────────────── /** - * Check if a model is already downloaded (directory exists and is non-empty). + * Resolves the canonical owner directory for a model capability. + * If the capability declares a weight_owner_id, returns the owner's directory path. + * Otherwise returns the capability's own directory path. + */ +export function resolveModelOwnerDir(modelsDir: string, modelId: string, weightOwnerId?: string): string { + if (weightOwnerId) { + return join(modelsDir, weightOwnerId) + } + return join(modelsDir, modelId) +} + +/** + * Checks if a model is downloaded, considering weight ownership. + * Checks both the capability directory and the owner directory (if different). */ -export function isModelDownloaded(modelsDir: string, modelId: string, downloadCheck?: string): boolean { +export function isModelDownloaded(modelsDir: string, modelId: string, downloadCheck?: string, weightOwnerId?: string): boolean { + // First check the owner directory if specified + if (weightOwnerId) { + const ownerDir = join(modelsDir, weightOwnerId) + if (existsSync(ownerDir)) { + if (downloadCheck && downloadCheck.trim()) { + return existsSync(join(ownerDir, downloadCheck)) + } + try { + return readdirSync(ownerDir).length > 0 + } catch { + return false + } + } + } + + // Fall back to capability's own directory const modelDir = join(modelsDir, modelId) if (!existsSync(modelDir)) return false if (downloadCheck && downloadCheck.trim()) { @@ -41,6 +68,34 @@ export function isModelDownloaded(modelsDir: string, modelId: string, downloadCh } } +/** + * Gets the effective download directory for a model, considering weight ownership. + * Returns the owner directory if weight_owner_id is set, otherwise the capability directory. + */ +export function getEffectiveModelDir(modelsDir: string, modelId: string, weightOwnerId?: string): string { + return resolveModelOwnerDir(modelsDir, modelId, weightOwnerId) +} + +/** + * Counts how many capabilities reference the same weight owner. + * Used to determine if weights can be safely deleted. + */ +export function countWeightOwnerReferences(modelsDir: string, weightOwnerId: string, allCapabilities: Array<{ modelId: string; weightOwnerId?: string }>): number { + return allCapabilities.filter(c => c.weightOwnerId === weightOwnerId || c.modelId === weightOwnerId).length +} + +// ------------------------------------------------------------------ +// Public API +// ------------------------------------------------------------------ + +/** + * Check if a model is already downloaded (directory exists and is non-empty). + * @deprecated Use isModelDownloaded with weightOwnerId parameter + */ +export function isModelDownloadedLegacy(modelsDir: string, modelId: string, downloadCheck?: string): boolean { + return isModelDownloaded(modelsDir, modelId, downloadCheck) +} + /** * Recursively compute the total size in bytes of a directory. */ @@ -119,10 +174,13 @@ export async function downloadModelFromHF( onProgress: ProgressCallback, skipPrefixes?: string[], includePrefixes?: string[], + weightOwnerId?: string, ): Promise { const { net } = require('electron') const STALL_TIMEOUT_MS = 120_000 - let url = `${PYTHON_API_URL}/model/hf-download?repo_id=${encodeURIComponent(repoId)}&model_id=${encodeURIComponent(modelId)}` + // Use the effective model directory (owner directory if weight_owner_id is set) + const effectiveModelId = weightOwnerId ?? modelId + let url = `${PYTHON_API_URL}/model/hf-download?repo_id=${encodeURIComponent(repoId)}&model_id=${encodeURIComponent(effectiveModelId)}` if (skipPrefixes && skipPrefixes.length > 0) { url += `&skip_prefixes=${encodeURIComponent(JSON.stringify(skipPrefixes))}` } diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 89fa69ec..dd6d088b 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -47,6 +47,8 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra system: { memory: (): Promise<{ total: number; used: number; available: number }> => ipcRenderer.invoke('system:memory') as Promise<{ total: number; used: number; available: number }>, + gpuMemory: (): Promise<{ total: number; used: number; available: number } | null> => + ipcRenderer.invoke('system:gpuMemory') as Promise<{ total: number; used: number; available: number } | null>, }, // Python / FastAPI bridge @@ -113,18 +115,32 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra ipcRenderer.invoke('api:updatePaths', patch) as Promise<{ success: boolean; error?: string }>, }, + // Generation API (direct FastAPI calls) + generation: { + fromImage: (formData: FormData): Promise<{ job_id: string }> => + ipcRenderer.invoke('generation:from-image', formData) as Promise<{ job_id: string }>, + fromText: (prompt: string, modelId: string, params?: Record, collection?: string, remesh?: string, enableTexture?: boolean, textureResolution?: number): Promise<{ job_id: string }> => + ipcRenderer.invoke('generation:from-text', { prompt, modelId, params, collection, remesh, enableTexture, textureResolution }) as Promise<{ job_id: string }>, + status: (jobId: string): Promise<{ job_id: string; status: string; progress: number; step?: string; output_url?: string; error?: string }> => + ipcRenderer.invoke('generation:status', jobId) as Promise<{ job_id: string; status: string; progress: number; step?: string; output_url?: string; error?: string }>, + cancel: (jobId: string): Promise<{ cancelled: boolean }> => + ipcRenderer.invoke('generation:cancel', jobId) as Promise<{ cancelled: boolean }>, + }, + // Model management model: { export: (args: { outputUrl: string; format: string }) => ipcRenderer.invoke('model:export', args), listDownloaded: () => ipcRenderer.invoke('model:listDownloaded'), - isDownloaded: (modelId: string, downloadCheck?: string) => ipcRenderer.invoke('model:isDownloaded', modelId, downloadCheck), - download: (repoId: string, modelId: string, skipPrefixes?: string[], includePrefixes?: string[]) => - ipcRenderer.invoke('model:download', { repoId, modelId, skipPrefixes, includePrefixes }), + isDownloaded: (modelId: string, downloadCheck?: string, weightOwnerId?: string) => ipcRenderer.invoke('model:isDownloaded', modelId, downloadCheck, weightOwnerId), + download: (repoId: string, modelId: string, skipPrefixes?: string[], includePrefixes?: string[], weightOwnerId?: string) => + ipcRenderer.invoke('model:download', { repoId, modelId, skipPrefixes, includePrefixes, weightOwnerId }), pauseDownload: (modelId: string) => ipcRenderer.invoke('model:pauseDownload', modelId), cancelDownload: (modelId: string) => ipcRenderer.invoke('model:cancelDownload', modelId), delete: (modelId: string) => ipcRenderer.invoke('model:delete', modelId), unloadAll: () => ipcRenderer.invoke('model:unloadAll'), showInFolder: (modelId: string) => ipcRenderer.invoke('model:showInFolder', modelId), + readiness: (modelId: string): Promise<{ ready: boolean; status: string; weights_downloaded: boolean; setup_needed: boolean; loaded: boolean; error?: string }> => + ipcRenderer.invoke('model:readiness', modelId) as Promise<{ ready: boolean; status: string; weights_downloaded: boolean; setup_needed: boolean; loaded: boolean; error?: string }>, activeDownloads: (): Promise<{ modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number }[]> => ipcRenderer.invoke('model:activeDownloads') as Promise<{ modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number }[]>, onProgress: (cb: (data: { @@ -207,10 +223,16 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra success: boolean; error?: string; cancelled?: boolean extensionId?: string extension?: unknown + partialResults?: Array<{ + success: boolean; error?: string; extensionId?: string; extension?: unknown + }> }> => ipcRenderer.invoke('extensions:installFromGitHub', url) as Promise<{ success: boolean; error?: string; cancelled?: boolean extensionId?: string extension?: unknown + partialResults?: Array<{ + success: boolean; error?: string; extensionId?: string; extension?: unknown + }> }>, installFromLocal: (): Promise<{ diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 895d4e8f..5c48e2ab 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -163,7 +163,7 @@ export default function ModelsPage(): JSX.Element { function handleInstallNode(node: ExtensionNode, fullId: string) { if (!node.hfRepo) return setDownloading((prev) => ({ ...prev, [fullId]: { ...(prev[fullId] ?? { percent: 0 }), paused: false, status: 'Starting…' } })) - window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { + window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes, node.weight_owner_id).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { if (!result.success && !result.paused && !result.cancelled) { setGhErr('Download failed') setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) @@ -205,7 +205,27 @@ export default function ModelsPage(): JSX.Element { setGhErr(null) clearInstall() const result = await installFromGH(url) - if (result.success) { + + // Handle bundle install with partial results + if (result.partialResults && result.partialResults.length > 0) { + const successful = result.partialResults.filter(r => r.success) + const failed = result.partialResults.filter(r => !r.success) + + if (failed.length > 0 && successful.length > 0) { + // Partial success - show error but keep the form open to show details + setGhErr(`${failed.length} of ${result.partialResults.length} extensions failed: ${failed.map(f => f.error).join('; ')}`) + // Still close the form since successful extensions were installed + setShowGHForm(false) + setGhUrl('') + } else if (failed.length > 0) { + // All failed + setGhErr(`All extensions failed: ${failed.map(f => f.error).join('; ')}`) + } else { + // All succeeded + setShowGHForm(false) + setGhUrl('') + } + } else if (result.success) { setShowGHForm(false) setGhUrl('') } else { diff --git a/src/areas/models/components/ExtensionCard.tsx b/src/areas/models/components/ExtensionCard.tsx index 7b0da394..03293d91 100644 --- a/src/areas/models/components/ExtensionCard.tsx +++ b/src/areas/models/components/ExtensionCard.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import type { AnyExtension } from '@shared/types/electron.d' import type { ExtensionNode } from '@shared/types/electron.d' export type { AnyExtension as Extension } @@ -35,6 +36,29 @@ export function ExtensionCard({ const isLocal = typeof ext.source === 'string' && ext.source.startsWith('local://') const { total, done, installing, hasAvailable } = extInstallSummary(ext, installedIds, downloading) + // Readiness state for model nodes + const [readiness, setReadiness] = useState>({}) + + useEffect(() => { + if (!isModel) return + let mounted = true + const checkReadiness = async () => { + for (const node of ext.nodes) { + if (!node.hfRepo) continue + const fullId = `${ext.id}/${node.id}` + try { + const result = await window.electron.model.readiness(fullId) + if (mounted) { + setReadiness(prev => ({ ...prev, [fullId]: { ready: result.ready, status: result.status } })) + } + } catch { /* ignore */ } + } + } + checkReadiness() + const interval = setInterval(checkReadiness, 30000) // Refresh every 30s + return () => { mounted = false; clearInterval(interval) } + }, [ext.id, ext.nodes, isModel]) + const handleOpen = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button')) return onOpen(ext) @@ -119,6 +143,7 @@ export function ExtensionCard({ {ext.nodes.map((node) => { const fullId = `${ext.id}/${node.id}` const state = getNodeState(ext.id, node, installedIds, downloading) + const nodeReadiness = readiness[fullId] return (
{isModel && ( -
+
+ {nodeReadiness && ( + + {nodeReadiness.ready ? 'Ready' : nodeReadiness.status === 'weights_missing' ? 'Weights missing' : 'Setup required'} + + )} @@ -13,6 +13,12 @@ export function ApplicationSection(): JSX.Element { > + + + ) diff --git a/src/areas/workflows/nodes/PreviewSingleImageNode.tsx b/src/areas/workflows/nodes/PreviewSingleImageNode.tsx new file mode 100644 index 00000000..6bc18b73 --- /dev/null +++ b/src/areas/workflows/nodes/PreviewSingleImageNode.tsx @@ -0,0 +1,64 @@ +import { Handle, Position, useReactFlow } from '@xyflow/react' +import { useWorkflowRunStore } from '../workflowRunStore' +import BaseNode from './BaseNode' + +const INPUT_COLOR = '#38bdf8' + +/** + * Preview node for single image outputs. + * Displays the image filling the node while preserving aspect ratio. + */ +export default function PreviewSingleImageNode({ id, selected }: { id: string; selected?: boolean }) { + const nodeImageOutputs = useWorkflowRunStore((s) => s.nodeImageOutputs) + const { getEdges } = useReactFlow() + + // Find the image URL fed into this node (first matching incoming edge) + const incomingEdge = getEdges().find((e) => e.target === id) + const imageUrl = incomingEdge ? nodeImageOutputs[incomingEdge.source] : undefined + + return ( + + + + + + } + subheader={ +
+ image + → preview +
+ } + handles={ + + } + > +
+ {imageUrl ? ( +
+ Preview +
+ ) : ( +

+ Connect an image to preview. +

+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 4ae35b3a..a383bd57 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -363,21 +363,6 @@ async function executeExtensionNode( throw new Error('No input image selected for model node') } - let blob: Blob - let fname: string - if (isTextInput || (selectedImageData && nodeInputPath === undefined)) { - const base64 = selectedImageData && nodeInputPath === undefined - ? selectedImageData - : 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' // 1x1 transparent PNG - fname = 'placeholder.png' - blob = new Blob([Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))], { type: 'image/png' }) - } else { - const base64 = await window.electron.fs.readFileBase64(activeImagePath as string) - const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) - blob = new Blob([bytes], { type: 'image/png' }) - fname = activeImagePath?.split(/[\\/]/).pop() ?? 'image.png' - } - const extraParams: Record = {} if (nodeInputMeshPath) { const norm = nodeInputMeshPath.replace(/\\/g, '/') @@ -398,19 +383,49 @@ async function executeExtensionNode( ) const effectiveParams = { ...schemaDefaults, ...liveParams } - const fd = new FormData() - fd.append('image', blob, fname) - fd.append('model_id', node.data.extensionId ?? '') - fd.append('collection', 'Workflows') - fd.append('remesh', 'none') - fd.append('enable_texture', 'false') - fd.append('texture_resolution', '1024') - fd.append('params', JSON.stringify({ ...effectiveParams, ...extraParams })) + let endpoint = '/generate/from-image' + let fd: FormData + + if (isTextInput) { + // Use text-to-image endpoint + endpoint = '/generate/from-text' + fd = new FormData() + fd.append('prompt', nodeInputText ?? '') + fd.append('model_id', node.data.extensionId ?? '') + fd.append('collection', 'Workflows') + fd.append('remesh', 'none') + fd.append('enable_texture', 'false') + fd.append('texture_resolution', '1024') + fd.append('params', JSON.stringify({ ...effectiveParams, ...extraParams })) + } else { + // Use image-to-image endpoint + let blob: Blob + let fname: string + if (selectedImageData && nodeInputPath === undefined) { + const base64 = selectedImageData + fname = 'placeholder.png' + blob = new Blob([Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))], { type: 'image/png' }) + } else { + const base64 = await window.electron.fs.readFileBase64(activeImagePath as string) + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) + blob = new Blob([bytes], { type: 'image/png' }) + fname = activeImagePath?.split(/[\\/]/).pop() ?? 'image.png' + } + + fd = new FormData() + fd.append('image', blob, fname) + fd.append('model_id', node.data.extensionId ?? '') + fd.append('collection', 'Workflows') + fd.append('remesh', 'none') + fd.append('enable_texture', 'false') + fd.append('texture_resolution', '1024') + fd.append('params', JSON.stringify({ ...effectiveParams, ...extraParams })) + } setRunState((s) => ({ ...s, blockProgress: 5, blockStep: 'Submitting to model…' })) const { data } = await client.post<{ job_id: string }>( - '/generate/from-image', fd, + endpoint, fd, { headers: { 'Content-Type': 'multipart/form-data' } }, ) _activeJobId.current = data.job_id diff --git a/src/index.html b/src/index.html index 6e778cf0..d0293507 100644 --- a/src/index.html +++ b/src/index.html @@ -3,7 +3,7 @@ - + Modly diff --git a/src/shared/components/layout/MemoryIndicator.tsx b/src/shared/components/layout/MemoryIndicator.tsx index fe09eac1..f30f8978 100644 --- a/src/shared/components/layout/MemoryIndicator.tsx +++ b/src/shared/components/layout/MemoryIndicator.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useAppStore } from '@shared/stores/appStore' const GB = 1024 ** 3 @@ -6,15 +7,31 @@ function fmtGB(bytes: number): string { return (bytes / GB).toFixed(1) } +function getBarColors(pct: number): { barColor: string; textColor: string } { + if (pct >= 90) return { barColor: 'bg-red-500', textColor: 'text-red-300' } + if (pct >= 75) return { barColor: 'bg-amber-500', textColor: 'text-amber-300' } + return { barColor: 'bg-emerald-500', textColor: 'text-zinc-300' } +} + export default function MemoryIndicator(): JSX.Element | null { - const [mem, setMem] = useState<{ total: number; used: number; available: number } | null>(null) + const [ram, setRam] = useState<{ total: number; used: number; available: number } | null>(null) + const [vram, setVram] = useState<{ total: number; used: number; available: number } | null>(null) + const platform = useAppStore(s => s.platform) + const isMac = platform === 'darwin' + const showVramIndicator = useAppStore(s => s.showVramIndicator) useEffect(() => { let cancelled = false const tick = async () => { try { - const next = await window.electron.system.memory() - if (!cancelled) setMem(next) + const [ramNext, vramNext] = await Promise.all([ + window.electron.system.memory(), + isMac ? Promise.resolve(null) : window.electron.system.gpuMemory(), + ]) + if (!cancelled) { + setRam(ramNext) + setVram(vramNext) + } } catch { // Renderer should not break if memory sampling fails. } @@ -25,42 +42,59 @@ export default function MemoryIndicator(): JSX.Element | null { cancelled = true clearInterval(id) } - }, []) + }, [isMac]) - if (!mem) return null + if (!ram) return null - const pct = mem.total > 0 ? Math.min(100, Math.round((mem.used / mem.total) * 100)) : 0 + const ramPct = ram.total > 0 ? Math.min(100, Math.round((ram.used / ram.total) * 100)) : 0 + const ramColors = getBarColors(ramPct) - let barColor = 'bg-emerald-500' - let textColor = 'text-zinc-300' - if (pct >= 90) { - barColor = 'bg-red-500' - textColor = 'text-red-300' - } else if (pct >= 75) { - barColor = 'bg-amber-500' - textColor = 'text-amber-300' - } + const ramTooltip = + `RAM:\n` + + ` Used: ${fmtGB(ram.used)} GB\n` + + ` Available: ${fmtGB(ram.available)} GB\n` + + ` Total: ${fmtGB(ram.total)} GB` - const tooltip = - `Used: ${fmtGB(mem.used)} GB\n` + - `Available: ${fmtGB(mem.available)} GB\n` + - `Total: ${fmtGB(mem.total)} GB` + const vramBar = showVramIndicator && !isMac && vram && vram.total > 0 ? (() => { + const vramPct = Math.min(100, Math.round((vram.used / vram.total) * 100)) + const vramColors = getBarColors(vramPct) + const vramTooltip = + `VRAM:\n` + + ` Used: ${fmtGB(vram.used)} GB\n` + + ` Available: ${fmtGB(vram.available)} GB\n` + + ` Total: ${fmtGB(vram.total)} GB` + return ( +
+ VRAM +
+
+
+ + {fmtGB(vram.used)} / {fmtGB(vram.total)} GB + +
+ ) + })() : null return (
RAM
- - {fmtGB(mem.used)} / {fmtGB(mem.total)} GB + + {fmtGB(ram.used)} / {fmtGB(ram.total)} GB + {vramBar}
) -} +} \ No newline at end of file diff --git a/src/shared/stores/appStore.ts b/src/shared/stores/appStore.ts index d06a5211..05281eb2 100644 --- a/src/shared/stores/appStore.ts +++ b/src/shared/stores/appStore.ts @@ -137,6 +137,8 @@ interface AppState { // UI preferences showRamIndicator: boolean setShowRamIndicator: (v: boolean) => void + showVramIndicator: boolean + setShowVramIndicator: (v: boolean) => void // Accessibility useAtkinsonFont: boolean @@ -241,6 +243,8 @@ export const useAppStore = create()( showRamIndicator: true, setShowRamIndicator: (v) => set({ showRamIndicator: v }), + showVramIndicator: true, + setShowVramIndicator: (v) => set({ showVramIndicator: v }), useAtkinsonFont: false, setUseAtkinsonFont: (v) => set({ useAtkinsonFont: v }), @@ -301,6 +305,7 @@ export const useAppStore = create()( partialize: (state) => ({ generationOptions: state.generationOptions, showRamIndicator: state.showRamIndicator, + showVramIndicator: state.showVramIndicator, useAtkinsonFont: state.useAtkinsonFont, uiScale: state.uiScale, lightSettings: state.lightSettings, diff --git a/src/shared/stores/extensionsStore.ts b/src/shared/stores/extensionsStore.ts index 91657a0a..d9f8e586 100644 --- a/src/shared/stores/extensionsStore.ts +++ b/src/shared/stores/extensionsStore.ts @@ -16,6 +16,20 @@ export interface InstallProgress { } // ─── Store ──────────────────────────────────────────────────────────────────── +type InstallResult = { + success: boolean + error?: string + extension?: AnyExtension + extensionId?: string + needsRepair?: boolean + cancelled?: boolean + partialResults?: Array<{ + success: boolean + error?: string + extension?: AnyExtension + extensionId?: string + }> +} interface ExtensionsStore { modelExtensions: ModelExtension[] @@ -26,8 +40,8 @@ interface ExtensionsStore { loadErrors: Record loadExtensions: () => Promise - installFromGitHub: (url: string) => Promise<{ success: boolean; error?: string }> - installFromLocal: () => Promise<{ success: boolean; error?: string; cancelled?: boolean; needsRepair?: boolean }> + installFromGitHub: (url: string) => Promise + installFromLocal: () => Promise uninstall: (extensionId: string) => Promise<{ success: boolean; error?: string }> reload: () => Promise clearInstallState: () => void @@ -120,13 +134,7 @@ export const useExtensionsStore = create((set, get) => ({ })) async function installExtension( - invoke: () => Promise<{ - success: boolean - error?: string - extension?: AnyExtension - extensionId?: string - needsRepair?: boolean - }>, + invoke: () => Promise, set: (partial: Partial | ((state: ExtensionsStore) => Partial)) => void, ) { set({ installProgress: { step: 'downloading', percent: 0 }, installError: null }) @@ -142,6 +150,52 @@ async function installExtension( try { const result = await invoke() + // Handle bundle install with partial results + if (result.partialResults && result.partialResults.length > 0) { + const successful = result.partialResults.filter(r => r.success && r.extension) + const failed = result.partialResults.filter(r => !r.success) + + // Add successful extensions + for (const res of successful) { + const ext = res.extension as AnyExtension + set((state) => { + if (ext.type === 'process') { + const filtered = state.processExtensions.filter((e) => e.id !== ext.id) + return { + processExtensions: [...filtered, ext], + modelExtensions: state.modelExtensions.filter((e) => e.id !== ext.id), + } + } else { + const filtered = state.modelExtensions.filter((e) => e.id !== ext.id) + return { + modelExtensions: [...filtered, ext], + processExtensions: state.processExtensions.filter((e) => e.id !== ext.id), + } + } + }) + } + + // Report partial success/failure + if (failed.length > 0 && successful.length > 0) { + set({ + installProgress: { step: 'done', extensionId: successful.map(s => s.extensionId).join(', ') }, + installError: `${failed.length} of ${result.partialResults.length} extensions failed: ${failed.map(f => f.error).join('; ')}`, + }) + } else if (failed.length > 0) { + set({ + installProgress: null, + installError: `All extensions failed: ${failed.map(f => f.error).join('; ')}`, + }) + } else { + set({ + installProgress: { step: 'done', extensionId: successful.map(s => s.extensionId).join(', ') }, + installError: null, + }) + } + return result + } + + // Handle single extension (legacy) if (result.success && result.extension) { const ext = result.extension as AnyExtension set((state) => { @@ -189,4 +243,4 @@ async function installExtension( } finally { window.electron.extensions.offInstallProgress() } -} + } diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 1a4d6fde..2a412006 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -24,6 +24,8 @@ export interface ExtensionNode { downloadCheck?: string hfSkipPrefixes?: string[] hfIncludePrefixes?: string[] + /** If set, this capability shares weights with the specified owner capability (format: "ext_id/node_id"). Downloads will go to the owner's directory. */ + weight_owner_id?: string } export interface ModelExtension {