Initial commit

This commit is contained in:
techjarves committed 2026-06-04 09:15:28 +05:30
commit a9460556e4
24 files changed
+9226

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Local AI Image Generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2028
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "tauri-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.17.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-react": "^4.6.0",
"vite": "^7.0.4"
}
}
File diff suppressed because it is too large. Load diff
+516
View File
@@ -0,0 +1,516 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Sidebar from "./components/Sidebar";
import TopStatusBar from "./components/TopStatusBar";
import Generator from "./components/Generator";
import ModelManager from "./components/ModelManager";
import ImageConstraints from "./components/ImageConstraints";
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, stopServer } from "./services/api";
import "./App.css";
function App() {
const dialogResolverRef = useRef(null);
const [dialog, setDialog] = useState(null);
const closeDialog = useCallback((value) => {
const resolver = dialogResolverRef.current;
dialogResolverRef.current = null;
setDialog(null);
if (resolver) resolver(value);
}, []);
const showConfirm = useCallback(({ title = "Confirm Action", message, confirmLabel = "OK", cancelLabel = "Cancel", danger = false }) => {
return new Promise((resolve) => {
dialogResolverRef.current = resolve;
setDialog({ type: "confirm", title, message, confirmLabel, cancelLabel, danger });
});
}, []);
const showAlert = useCallback(({ title = "Notice", message, confirmLabel = "OK", danger = false }) => {
return new Promise((resolve) => {
dialogResolverRef.current = resolve;
setDialog({ type: "alert", title, message, confirmLabel, danger });
});
}, []);
// Theme State
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem("theme");
if (saved) return saved;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
// Navigation
const [activeTab, setActiveTab] = useState("generator");
// Prompts
const [prompt, setPrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [inputImage, setInputImage] = useState(null); // base64 for Image-to-Image
// Model & Server Status
const [activeModel, setActiveModel] = useState(null);
const [serverRunning, setServerRunning] = useState(false);
const [isStoppingServer, setIsStoppingServer] = useState(false);
const [backendOptions, setBackendOptions] = useState({
options: [{ id: "cpu", label: "CPU", available: true }],
cudaAvailable: false,
vulkanAvailable: false,
defaultBackendType: "cpu",
});
const [health, setHealth] = useState(null);
const [cleanupItems, setCleanupItems] = useState([]);
const [isReadinessBusy, setIsReadinessBusy] = useState(false);
const [diagnosticsCopied, setDiagnosticsCopied] = useState(false);
// Gallery History
const [generatedImages, setGeneratedImages] = useState([]);
// Generation status
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState(0);
// System Specifications & Telemetry
const [specs, setSpecs] = useState({
os_name: "Loading Specs...",
cpu_name: "Loading Specs...",
cpu_cores_physical: 4,
cpu_cores_logical: 8,
ram_total_gb: 0,
gpu_name: "Loading Specs...",
});
const [telemetry, setTelemetry] = useState({
cpu_usage: 0,
ram_used_gb: 0,
ram_total_gb: 0,
gpu_name: "Detecting...",
vram_used_gb: 0,
vram_total_gb: 0,
});
// Default Image Constraints
const [constraints, setConstraints] = useState({
width: 1024,
height: 1024,
steps: 4, // Recommended default steps for Flux.1 Schnell/Lightning
cfgScale: 1.0, // Recommended default CFG for Flux.1 Schnell
sampler: "euler_a",
seed: -1,
denoisingStrength: 0.7,
useGpu: true,
useTaesd: true,
useFlashAttn: true,
useTiling: false,
threads: 4,
backendType: "auto",
});
// Load hardware specifications on mount
useEffect(() => {
async function loadSpecs() {
try {
const hardware = await getHardwareSpecs();
const backendInfo = await getBackendOptions();
setBackendOptions(backendInfo);
setSpecs(hardware);
// Default CPU threads count to match physical cores
setConstraints((prev) => ({
...prev,
threads: hardware.cpu_cores_physical || 4,
backendType: prev.backendType === "auto" ? backendInfo.defaultBackendType || "cpu" : prev.backendType,
useGpu: (prev.backendType === "auto" ? backendInfo.defaultBackendType : prev.backendType) !== "cpu",
}));
} catch (err) {
console.error("Error fetching hardware specs:", err);
}
}
loadSpecs();
}, []);
const refreshReadiness = useCallback(async () => {
setIsReadinessBusy(true);
try {
const [healthInfo, candidates] = await Promise.all([
getHealth(),
getCleanupCandidates().catch(() => []),
]);
setHealth(healthInfo);
setCleanupItems(candidates);
} finally {
setIsReadinessBusy(false);
}
}, []);
useEffect(() => {
refreshReadiness();
}, [refreshReadiness]);
const copyDiagnostics = useCallback(async () => {
try {
const diagnostics = await getDiagnostics();
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2));
setDiagnosticsCopied(true);
setTimeout(() => setDiagnosticsCopied(false), 1800);
} catch (err) {
showAlert({ title: "Diagnostics Failed", message: err.message || String(err), danger: true });
}
}, [showAlert]);
const cleanupSafeItems = useCallback(async () => {
if (cleanupItems.length === 0) return;
const totalBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
const ok = await showConfirm({
title: "Clean Safe Files?",
message: `Delete ${cleanupItems.length} temporary/cache item${cleanupItems.length === 1 ? "" : "s"} and free about ${formatBytes(totalBytes)}?\n\nModels and generated outputs are not included.`,
confirmLabel: "Clean",
danger: true,
});
if (!ok) return;
setIsReadinessBusy(true);
try {
await cleanupCandidates(cleanupItems.map((item) => item.id));
await refreshReadiness();
} catch (err) {
showAlert({ title: "Cleanup Failed", message: err.message || String(err), danger: true });
} finally {
setIsReadinessBusy(false);
}
}, [cleanupItems, refreshReadiness, showAlert, showConfirm]);
useEffect(() => {
async function loadSavedOutputs() {
const outputs = await listGeneratedOutputs();
setGeneratedImages(outputs);
}
loadSavedOutputs();
}, []);
// Save active model configuration to localStorage for persistence across reloads
useEffect(() => {
if (activeModel) {
localStorage.setItem("active-model", activeModel);
} else {
localStorage.removeItem("active-model");
}
}, [activeModel]);
// Check if server is already running on mount (handles page refresh and startup lag)
useEffect(() => {
let checkInterval = null;
async function checkServerRunning() {
const status = await getBackendStatus();
const isRunning = Boolean(status.ready);
if (isRunning) {
if (checkInterval) clearInterval(checkInterval);
const savedModel = localStorage.getItem("active-model");
if (savedModel) {
setActiveModel(savedModel);
setServerRunning(true);
} else {
// Query the active model from status if possible, otherwise use fallback
try {
if (status && status.settings && status.settings.model) {
const modelName = status.settings.model.split(/[\\/]/).pop();
setActiveModel(modelName);
}
} catch (_) {}
setServerRunning(true);
}
} else {
// If server is not running, check if they had a simulated model active in browser mode
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
if (!isTauriDesktop) {
const savedModel = localStorage.getItem("active-model");
if (savedModel) {
setActiveModel(savedModel);
setServerRunning(false);
}
}
}
}
checkServerRunning();
// Poll every 2 seconds to auto-detect when the backend becomes ready
checkInterval = setInterval(checkServerRunning, 2000);
return () => {
if (checkInterval) clearInterval(checkInterval);
};
}, []);
// Poll system telemetry usage statistics on interval
useEffect(() => {
async function updateTelemetry() {
try {
const stats = await getTelemetry();
setTelemetry((prev) => (
prev.cpu_usage === stats.cpu_usage &&
prev.ram_used_gb === stats.ram_used_gb &&
prev.ram_total_gb === stats.ram_total_gb &&
prev.gpu_name === stats.gpu_name &&
prev.vram_used_gb === stats.vram_used_gb &&
prev.vram_total_gb === stats.vram_total_gb
? prev
: stats
));
} catch (err) {
// Telemetry errors ignored
}
}
updateTelemetry();
const interval = setInterval(updateTelemetry, 1500); // Poll every 1.5 seconds
return () => clearInterval(interval);
}, []);
// Sync active model settings default parameters
useEffect(() => {
if (activeModel) {
const name = activeModel.toLowerCase();
if (name.includes("flux") || name.includes("schnell")) {
setConstraints((prev) => ({
...prev,
steps: 4,
cfgScale: 1.0,
width: 1024,
height: 1024,
}));
} else if (name.includes("lightning") || name.includes("turbo")) {
setConstraints((prev) => ({
...prev,
steps: 4,
cfgScale: 1.5,
width: 1024,
height: 1024,
}));
} else if (name.includes("sd15")) {
setConstraints((prev) => ({
...prev,
steps: 25,
cfgScale: 7.0,
width: 512,
height: 512,
}));
} else if (name.includes("sd35")) {
setConstraints((prev) => ({
...prev,
steps: 20,
cfgScale: 4.5,
width: 1024,
height: 1024,
}));
} else {
// Fallback for custom or SD 1.x models (like CyberRealistic or arbitrary safetensors)
setConstraints((prev) => ({
...prev,
steps: 20,
cfgScale: 7.0,
width: 512,
height: 512,
}));
}
}
}, [activeModel]);
const activeTabContent = useMemo(() => {
switch (activeTab) {
case "generator":
return (
<Generator
prompt={prompt}
setPrompt={setPrompt}
negativePrompt={negativePrompt}
setNegativePrompt={setNegativePrompt}
constraints={constraints}
setConstraints={setConstraints}
activeModel={activeModel}
generatedImages={generatedImages}
setGeneratedImages={setGeneratedImages}
isGenerating={isGenerating}
setIsGenerating={setIsGenerating}
generationProgress={generationProgress}
setGenerationProgress={setGenerationProgress}
setActiveTab={setActiveTab}
inputImage={inputImage}
setInputImage={setInputImage}
showAlert={showAlert}
showConfirm={showConfirm}
/>
);
case "models":
return (
<ModelManager
activeModel={activeModel}
setActiveModel={setActiveModel}
serverRunning={serverRunning}
setServerRunning={setServerRunning}
constraints={constraints}
showAlert={showAlert}
showConfirm={showConfirm}
/>
);
case "constraints":
return (
<ImageConstraints
constraints={constraints}
setConstraints={setConstraints}
activeModel={activeModel}
specs={specs}
backendOptions={backendOptions}
/>
);
default:
return null;
}
}, [
activeTab,
prompt,
negativePrompt,
constraints,
activeModel,
generatedImages,
isGenerating,
generationProgress,
inputImage,
serverRunning,
specs,
backendOptions,
showAlert,
showConfirm,
]);
const sidebarContent = useMemo(() => (
<Sidebar
activeTab={activeTab}
setActiveTab={setActiveTab}
specs={specs}
/>
), [activeTab, specs]);
const handleStopServer = useCallback(async () => {
if (!serverRunning || isStoppingServer) return;
setIsStoppingServer(true);
try {
await stopServer();
setServerRunning(false);
setActiveModel(null);
} catch (err) {
console.error("Failed to stop server:", err);
showAlert({ title: "Stop Server Failed", message: err.message || String(err), danger: true });
} finally {
setIsStoppingServer(false);
}
}, [serverRunning, isStoppingServer, showAlert]);
const readinessIssues = [
...(health?.stale ? ["Restart Local AI Image Generator so the local server loads the latest API."] : []),
...(health?.issues || []),
];
const cleanupBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
const showReadinessPanel = Boolean(health && (readinessIssues.length > 0 || cleanupItems.length > 0));
return (
<div className="app-container">
{/* Sidebar with Navigation Rail & Specs */}
{sidebarContent}
{/* Main Panel */}
<div className="main-content">
{/* Top telemetry specs chips bar */}
<TopStatusBar
telemetry={telemetry}
serverRunning={serverRunning}
activeModel={activeModel}
onStopServer={handleStopServer}
isStoppingServer={isStoppingServer}
theme={theme}
setTheme={setTheme}
/>
{showReadinessPanel && (
<div className={`m3-card readiness-card ${health?.stale || readinessIssues.length > 0 ? "readiness-card-warning" : ""}`}>
<div className="readiness-header">
<div>
<h3 className="m3-card-title" style={{ marginBottom: "4px" }}>
{health?.stale ? "Restart Required" : readinessIssues.length > 0 ? "System Readiness" : "Safe Cleanup Available"}
</h3>
<p className="m3-card-subtitle" style={{ margin: 0 }}>
{health?.stale
? `Running server build: ${health.build || "unknown"}`
: readinessIssues.length > 0
? "Local AI Image Generator found setup items that may need attention."
: `${cleanupItems.length} temporary item${cleanupItems.length === 1 ? "" : "s"} can be cleaned (${formatBytes(cleanupBytes)}).`}
</p>
</div>
<div className="readiness-actions">
<button className="m3-btn m3-btn-outlined" onClick={refreshReadiness} disabled={isReadinessBusy}>
{isReadinessBusy ? "Checking" : "Refresh"}
</button>
<button className="m3-btn m3-btn-tonal" onClick={copyDiagnostics}>
{diagnosticsCopied ? "Copied" : "Copy Diagnostics"}
</button>
{cleanupItems.length > 0 && (
<button className="m3-btn m3-btn-error" onClick={cleanupSafeItems} disabled={isReadinessBusy}>
Clean {formatBytes(cleanupBytes)}
</button>
)}
</div>
</div>
{readinessIssues.length > 0 && (
<div className="readiness-issues">
{readinessIssues.slice(0, 4).map((issue) => (
<span key={issue}>{issue}</span>
))}
</div>
)}
{cleanupItems.length > 0 && (
<div className="readiness-cleanup-list">
{cleanupItems.slice(0, 3).map((item) => (
<span key={item.id} title={item.path}>
{item.name} · {item.size} · {item.reason}
</span>
))}
</div>
)}
</div>
)}
{/* Dynamic Workspace Container */}
{activeTabContent}
</div>
{dialog && (
<div className="app-dialog-backdrop" role="presentation">
<div className="app-dialog" role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
<h3 id="app-dialog-title" className="app-dialog-title">{dialog.title}</h3>
<p className="app-dialog-message">{dialog.message}</p>
<div className="app-dialog-actions">
{dialog.type === "confirm" && (
<button className="m3-btn m3-btn-outlined" onClick={() => closeDialog(false)}>
{dialog.cancelLabel}
</button>
)}
<button
className={`m3-btn ${dialog.danger ? "m3-btn-error" : "m3-btn-filled"}`}
onClick={() => closeDialog(true)}
>
{dialog.confirmLabel}
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default App;
+878
View File
@@ -0,0 +1,878 @@
import React, { memo, useState, useRef, useCallback } from "react";
import { Sparkles, Download, Copy, RefreshCw, ImagePlus, Check, Sliders, Layers, Trash2 } from "lucide-react";
import { generateImage, isTauri, startServer, stopServer, waitForServerReady, getBackendStatus, getGenerationProgress, saveGeneratedOutput, deleteGeneratedOutputs } from "../services/api";
const GalleryItem = memo(({ img, idx, isSelected, onClick }) => {
const handleClick = (e) => {
onClick(idx, e);
};
return (
<div
className={`gallery-item ${isSelected ? "selected" : ""}`}
onClick={handleClick}
title={`Prompt: ${img.prompt}\nSeed: ${img.seed}`}
>
<img src={img.url} className="gallery-thumb" alt="Thumbnail" loading="lazy" decoding="async" />
{isSelected && (
<div className="gallery-select-badge">
<Check size={14} />
</div>
)}
</div>
);
});
function Generator({
prompt,
setPrompt,
negativePrompt,
setNegativePrompt,
constraints,
setConstraints,
activeModel,
generatedImages,
setGeneratedImages,
isGenerating,
setIsGenerating,
generationProgress,
setGenerationProgress,
setActiveTab,
inputImage,
setInputImage,
showAlert = async ({ message }) => window.alert(message),
showConfirm = async ({ message }) => window.confirm(message),
}) {
const [outputImage, setOutputImage] = useState(null);
const [outputSeed, setOutputSeed] = useState(null);
const [genDuration, setGenDuration] = useState(null);
const [copySuccess, setCopySuccess] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const [estimatedLeftTime, setEstimatedLeftTime] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [generationSpeed, setGenerationSpeed] = useState("");
const [isCpuFallback, setIsCpuFallback] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [isRestartingBackend, setIsRestartingBackend] = useState(false);
const [restartLoadProgress, setRestartLoadProgress] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const [selectedGalleryIndexes, setSelectedGalleryIndexes] = useState([]);
const timerRef = useRef(null);
const fileInputRef = useRef(null);
const abortControllerRef = useRef(null);
// Trigger file dialog for Image-to-Image
const handleDropzoneClick = () => {
fileInputRef.current.click();
};
// Convert uploaded image file to Base64
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setInputImage(reader.result);
};
reader.readAsDataURL(file);
}
};
const clearInputImage = (e) => {
e.stopPropagation();
setInputImage(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
// Trigger main image generation process
const handleGenerate = async () => {
if (!prompt.trim()) return;
setErrorMsg(null);
setIsRestartingBackend(true);
setRestartLoadProgress(null);
let needsRestart = false;
try {
const status = await getBackendStatus();
if (status && status.settings) {
const settings = status.settings;
const currentModelName = settings.model ? settings.model.split(/[\\/]/).pop() : null;
const targetModelName = activeModel ? activeModel.split(/[\\/]/).pop() : null;
if (currentModelName !== targetModelName ||
parseInt(settings.steps) !== parseInt(constraints.steps) ||
Math.abs(parseFloat(settings.cfgScale) - parseFloat(constraints.cfgScale)) > 0.05 ||
settings.sampler !== constraints.sampler ||
parseInt(settings.threads) !== parseInt(constraints.threads) ||
Boolean(settings.useGpu) !== (constraints.useGpu !== false) ||
(settings.backendType || (settings.useGpu === false ? "cpu" : "auto")) !== (constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"))) {
needsRestart = true;
}
} else {
needsRestart = true;
}
} catch (e) {
needsRestart = true;
}
if (needsRestart) {
try {
console.log("Settings out of sync, restarting backend...");
await startServer(activeModel, constraints);
let isReady = false;
let crashError = null;
for (let i = 0; i < 240; i++) { // Poll for up to 120 seconds (240 * 500ms)
const status = await getBackendStatus();
if (status.loading) {
setRestartLoadProgress({
progress: status.loading.progress || 0,
phase: status.loading.phase || "Loading model...",
speed: status.loading.speed || "",
current: status.loading.current || 0,
total: status.loading.total || 0,
backendMode: status.loading.backendMode || status.settings?.backendMode || "",
device: status.loading.device || status.settings?.backendDevice || "",
});
}
if (status.ready) {
isReady = true;
break;
}
if (status.error) {
crashError = status.error;
break;
}
if (!status.running && i > 3) {
crashError = "The backend process terminated immediately on startup.";
break;
}
await new Promise((r) => setTimeout(r, 500));
}
if (!isReady) {
throw new Error(crashError || "Backend server failed to start after applying configuration changes.");
}
} catch (err) {
console.error("Failed to restart backend:", err);
setErrorMsg(err.message || "Failed to sync settings with backend.");
setIsRestartingBackend(false);
return;
}
}
setIsRestartingBackend(false);
setRestartLoadProgress(null);
if (timerRef.current) clearInterval(timerRef.current);
setIsGenerating(true);
setGenerationProgress(0);
setElapsedTime(0);
setCurrentStep(0);
setGenerationSpeed("");
setIsCpuFallback(false);
setGenDuration(null);
setOutputImage(null);
// Calculate dynamic step estimation times
// CPU takes ~35s per step at 512px, ~140s at 1024px. GPU is ~0.04x of CPU time.
const baseGpuStepTime = (constraints.width >= 1024 ? 140 : 35) * 0.04;
const baseCpuStepTime = (constraints.width >= 1024 ? 140 : 35);
const gpuSelected = (constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto")) !== "cpu";
let activeStepTime = gpuSelected ? baseGpuStepTime : baseCpuStepTime;
let cpuFallbackDetected = false;
let fallbackTime = 0;
let fallbackProgress = 0;
let elapsedSeconds = 0;
setEstimatedLeftTime(Math.round(constraints.steps * activeStepTime));
timerRef.current = setInterval(async () => {
elapsedSeconds++;
setElapsedTime(elapsedSeconds);
try {
const progress = await getGenerationProgress();
if (progress && progress.active) {
const step = progress.step || 0;
const steps = progress.steps || constraints.steps || 20;
const speed = progress.speed || "";
setCurrentStep(step);
setGenerationSpeed(speed);
// Calculate progress percentage
let progressPercent = 0;
if (steps > 0) {
progressPercent = Math.round((step / steps) * 100);
}
progressPercent = Math.min(99, progressPercent);
setGenerationProgress((prev) => Math.max(prev, progressPercent));
// Calculate remaining time
let remaining = 0;
const numericSpeed = parseFloat(speed);
if (!isNaN(numericSpeed) && numericSpeed > 0) {
const remainingSteps = Math.max(0, steps - step);
if (speed.includes("it/s")) {
remaining = remainingSteps / numericSpeed;
} else if (speed.includes("s/it")) {
remaining = remainingSteps * numericSpeed;
}
} else {
const activeStepTime = gpuSelected ? baseGpuStepTime : baseCpuStepTime;
remaining = Math.max(0, (steps - step) * activeStepTime);
}
// Detect CPU fallback / slow generation if GPU was requested but it runs slow
if (gpuSelected && !cpuFallbackDetected) {
const isSlowItS = speed.includes("it/s") && numericSpeed < 0.2;
const isSlowSIt = speed.includes("s/it") && numericSpeed > 5.0;
if (isSlowItS || isSlowSIt) {
cpuFallbackDetected = true;
setIsCpuFallback(true);
}
}
setEstimatedLeftTime(Math.round(remaining));
} else {
// Creep up slowly while loading the model/preparing
setGenerationProgress((prev) => Math.min(15, prev + 1));
setEstimatedLeftTime((prev) => Math.max(1, prev - 1));
}
} catch (e) {
console.warn("Failed to get generation progress, running simulation fallback:", e);
// Fallback simulation logic
if (gpuSelected && !cpuFallbackDetected && elapsedSeconds > constraints.steps * baseGpuStepTime * 1.3) {
cpuFallbackDetected = true;
setIsCpuFallback(true);
activeStepTime = baseCpuStepTime;
fallbackTime = elapsedSeconds;
fallbackProgress = Math.min(95, Math.round((elapsedSeconds / (constraints.steps * baseGpuStepTime)) * 100));
}
let step = Math.min(constraints.steps - 1, Math.floor(elapsedSeconds / activeStepTime));
let expectedTotal = constraints.steps * activeStepTime;
if (elapsedSeconds >= expectedTotal) {
const computedStepTime = Math.ceil(elapsedSeconds / Math.max(0.5, step));
activeStepTime = Math.max(activeStepTime + 15, computedStepTime + 15);
expectedTotal = constraints.steps * activeStepTime;
step = Math.min(constraints.steps - 1, Math.floor(elapsedSeconds / activeStepTime));
}
setCurrentStep(step);
const remaining = Math.max(1, expectedTotal - elapsedSeconds);
setEstimatedLeftTime(Math.round(remaining));
let progressPercent = 0;
if (cpuFallbackDetected) {
const remainingProgress = 99 - fallbackProgress;
const remainingTime = expectedTotal - fallbackTime;
if (remainingTime > 0) {
const ratio = Math.min(1.0, (elapsedSeconds - fallbackTime) / remainingTime);
if (ratio < 0.85) {
progressPercent = Math.round(fallbackProgress + ratio * remainingProgress);
} else {
const x = (elapsedSeconds - (fallbackTime + remainingTime * 0.85)) / (activeStepTime * 2);
const decay = 1 - Math.exp(-x);
progressPercent = Math.min(99, Math.round(fallbackProgress + 0.85 * remainingProgress + (0.14 * remainingProgress) * decay));
}
} else {
progressPercent = 99;
}
} else {
if (elapsedSeconds < expectedTotal * 0.85) {
progressPercent = Math.round((elapsedSeconds / expectedTotal) * 100);
} else {
const x = (elapsedSeconds - expectedTotal * 0.85) / (activeStepTime * 2);
const decay = 1 - Math.exp(-x);
progressPercent = Math.min(99, Math.round(85 + 14 * decay));
}
}
setGenerationProgress((p) => Math.max(p, progressPercent));
}
}, 1000);
try {
abortControllerRef.current = new AbortController();
const result = await generateImage(
prompt,
negativePrompt,
constraints,
activeModel,
inputImage, // passes base64 if I2I
(prog) => setGenerationProgress((prev) => Math.max(prev, prog)),
abortControllerRef.current.signal
);
if (timerRef.current) clearInterval(timerRef.current);
setGenerationProgress(100);
setCurrentStep(constraints.steps);
setEstimatedLeftTime(0);
setGenerationSpeed("");
const metadata = {
prompt: prompt,
negativePrompt: negativePrompt,
seed: result.seed,
steps: constraints.steps,
cfgScale: constraints.cfgScale,
width: constraints.width,
height: constraints.height,
sampler: constraints.sampler,
model: activeModel,
duration_sec: result.duration_sec,
timestamp: new Date().toLocaleTimeString(),
inputImage: Boolean(inputImage),
};
let savedOutput = null;
let savedUrl = null;
try {
savedOutput = await saveGeneratedOutput(result.image, metadata);
savedUrl = savedOutput.url;
if (!savedUrl) {
throw new Error("The local server saved the image but did not return a file URL.");
}
} catch (saveErr) {
console.error("Generated image could not be saved:", saveErr);
throw new Error(`Image generated but could not be saved to USB: ${saveErr.message || saveErr}`);
}
setOutputImage(savedUrl);
setOutputSeed(result.seed);
setGenDuration(result.duration_sec);
const historyItem = {
...metadata,
...(savedOutput || {}),
url: savedUrl,
};
setGeneratedImages((prev) => [historyItem, ...prev]);
} catch (e) {
if (e.name === "AbortError") {
console.log("Generation request was aborted by the user");
} else {
console.error("Generation failed:", e);
setErrorMsg(e.message || "Generation failed. Please try again.");
}
if (timerRef.current) clearInterval(timerRef.current);
} finally {
setIsGenerating(false);
abortControllerRef.current = null;
}
};
// Stop/Cancel Generation Handler
const handleCancelGeneration = async () => {
if (await showConfirm({
title: "Stop Generation?",
message: "The current image generation will be cancelled and the local model server may restart.",
confirmLabel: "Stop",
danger: true,
})) {
if (timerRef.current) clearInterval(timerRef.current);
// Set cancelling state first to keep the progress overlay active in cancelling mode
setIsCancelling(true);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// In native desktop mode, restart the server process to immediately free CPU threads/memory
if (isTauri()) {
try {
console.log("Cancelling: Stopping server to kill generation threads...");
await stopServer();
if (activeModel) {
console.log("Cancelling: Restarting server for future generations...");
await startServer(activeModel, constraints);
const ready = await waitForServerReady();
if (!ready) {
console.warn("Server failed to restart properly.");
}
}
} catch (err) {
console.error("Failed to restart server on cancellation:", err);
} finally {
setIsCancelling(false);
setIsGenerating(false);
setGenerationProgress(0);
}
} else {
// Browser Mode
setIsCancelling(false);
setIsGenerating(false);
setGenerationProgress(0);
}
}
};
// Copy generated image to clipboard
const handleCopyImage = async () => {
if (!outputImage) return;
try {
if (outputImage.startsWith("data:image")) {
// base64 conversion for clipboard
const response = await fetch(outputImage);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
]);
} else {
// Remote Unsplash images fallback (draw on canvas then copy)
const img = new Image();
img.crossOrigin = "anonymous";
img.src = outputImage;
img.onload = async () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
canvas.toBlob(async (blob) => {
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
]);
}, "image/png");
};
}
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error("Failed to copy image:", err);
}
};
// Download generated image
const handleDownload = () => {
if (!outputImage) return;
const link = document.createElement("a");
link.href = outputImage;
link.download = `diffusion-${outputSeed || Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const downloadImageUrl = (url, filename = `diffusion-${Date.now()}.png`) => {
if (!url) return;
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Reuse prompt settings from history
const loadHistoryItem = useCallback((item) => {
setPrompt(item.prompt);
setNegativePrompt(item.negativePrompt);
setConstraints((prev) => ({
...prev,
steps: item.steps,
cfgScale: item.cfgScale,
width: item.width,
height: item.height,
sampler: item.sampler,
seed: item.seed,
}));
setOutputImage(item.url);
setOutputSeed(item.seed);
setGenDuration(null);
}, [setPrompt, setNegativePrompt, setConstraints]);
const toggleGallerySelection = useCallback((idx, event) => {
const multi = event.ctrlKey || event.metaKey || event.shiftKey;
setSelectedGalleryIndexes((prev) => {
if (!multi) return [idx];
return prev.includes(idx) ? prev.filter((item) => item !== idx) : [...prev, idx];
});
if (generatedImages[idx]) {
loadHistoryItem(generatedImages[idx]);
}
}, [generatedImages, loadHistoryItem]);
const selectedGalleryItems = selectedGalleryIndexes
.map((idx) => generatedImages[idx])
.filter(Boolean);
const clearGallerySelection = () => {
setSelectedGalleryIndexes([]);
};
const useSelectedGalleryItem = () => {
if (selectedGalleryItems[0]) {
loadHistoryItem(selectedGalleryItems[0]);
}
};
const downloadSelectedGalleryItems = () => {
selectedGalleryItems.forEach((item, index) => {
const seed = item.seed ?? Date.now();
downloadImageUrl(item.url, `diffusion-${seed}-${index + 1}.png`);
});
};
const deleteSelectedGalleryItems = async () => {
if (selectedGalleryItems.length === 0) return;
const count = selectedGalleryItems.length;
const confirmed = await showConfirm({
title: "Delete Selected Outputs?",
message: `Delete ${count} selected output${count === 1 ? "" : "s"} from USB? This removes the image and metadata files.`,
confirmLabel: "Delete",
danger: true,
});
if (!confirmed) return;
try {
await deleteGeneratedOutputs(selectedGalleryItems);
const selectedSet = new Set(selectedGalleryIndexes);
setGeneratedImages((prev) => prev.filter((_, idx) => !selectedSet.has(idx)));
setSelectedGalleryIndexes([]);
if (selectedGalleryItems.some((item) => item.url === outputImage)) {
setOutputImage(null);
setOutputSeed(null);
setGenDuration(null);
}
} catch (err) {
console.error("Failed to delete selected outputs:", err);
showAlert({
title: "Delete Failed",
message: err.message || String(err),
danger: true,
});
}
};
return (
<div className="workspace-area">
<div className="workspace-title-section">
<h2 className="workspace-title">Generator Dashboard</h2>
<p className="workspace-subtitle">
Create high-fidelity images completely locally on your external drive.
</p>
</div>
<div className="generator-layout">
{/* Left Side: Parameters & Input */}
<div>
<div className="m3-card">
<h3 className="m3-card-title">
<Sparkles size={18} style={{ color: "var(--md-sys-color-primary)" }} />
Generation Workspace
</h3>
<div className="m3-field-group">
{/* Prompt Textarea */}
<div className="m3-text-field">
<label className="m3-text-field-label">Positive Prompt</label>
<textarea
className="m3-textarea"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Enter a descriptive prompt (e.g. 'A futuristic city in cyberpunk aesthetic, high details, cinematic lighting')..."
disabled={isGenerating}
/>
</div>
{/* Negative Prompt Text Input */}
<div className="m3-text-field">
<label className="m3-text-field-label">Negative Prompt (Optional)</label>
<input
type="text"
className="m3-input"
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
placeholder="Items to avoid (e.g. 'blurry, low quality, deformed hands, texts')..."
disabled={isGenerating}
/>
</div>
{/* Configuration Status Chips (Material 3 style) */}
<div className="m3-text-field">
<label className="m3-text-field-label">Active Image Constraints</label>
<div className="chips-container">
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
<Sliders size={14} />
<span>Resolution: {constraints.width}x{constraints.height}</span>
</div>
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
<RefreshCw size={14} />
<span>Steps: {constraints.steps}</span>
</div>
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
<Layers size={14} />
<span>CFG: {constraints.cfgScale.toFixed(1)}</span>
</div>
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
<Sparkles size={14} />
<span>Sampler: {constraints.sampler}</span>
</div>
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
<span>Seed: {constraints.seed === -1 ? "Random" : constraints.seed}</span>
</div>
</div>
</div>
{/* Image to Image Dropzone Toggle */}
<div className="image-to-image-section">
<label className="m3-text-field-label" style={{ display: "block", marginBottom: "8px" }}>
Image-to-Image Source (Optional)
</label>
<div className="image-dropzone" onClick={handleDropzoneClick}>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
accept="image/*"
onChange={handleFileChange}
disabled={isGenerating}
/>
{inputImage ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<img src={inputImage} className="dropped-image-preview" alt="Source" />
<button
className="m3-btn m3-btn-error"
style={{
position: "absolute",
top: "4px",
right: "4px",
height: "30px",
width: "30px",
borderRadius: "50%",
padding: 0
}}
onClick={clearInputImage}
>
×
</button>
</div>
) : (
<>
<ImagePlus size={24} style={{ color: "var(--md-sys-color-primary)" }} />
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>
Click or drag image here for Image-to-Image guidance
</span>
</>
)}
</div>
</div>
{/* Generate Trigger Button */}
<button
className="m3-btn m3-btn-filled"
style={{ height: "48px", marginTop: "8px" }}
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim() || !activeModel}
>
<Sparkles size={18} />
<span>{!activeModel ? "Select Model to Generate" : "Generate Local Image"}</span>
</button>
</div>
</div>
</div>
{/* Right Side: Image Output Preview */}
<div className="m3-card output-panel">
<div className="output-image-container">
{isGenerating || isCancelling || isRestartingBackend ? (
<div className="progress-overlay">
{isRestartingBackend ? (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", padding: "20px" }}>
<div className="progress-spinner"></div>
<div className="progress-text" style={{ fontSize: "1.1rem", fontWeight: 700, textAlign: "center" }}>
Syncing Settings & Initializing...
</div>
{restartLoadProgress ? (
<div style={{ width: "min(360px, 90%)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "12px", fontSize: "0.82rem", fontWeight: 700, marginBottom: "6px" }}>
<span>
{restartLoadProgress.phase}
{restartLoadProgress.speed ? ` (${restartLoadProgress.speed})` : ""}
</span>
<span>
{restartLoadProgress.total > 0
? `Loaded ${restartLoadProgress.current} / ${restartLoadProgress.total} tensors`
: `${Math.round(restartLoadProgress.progress)}%`}
</span>
</div>
<div className="progress-bar-container" style={{ margin: 0 }}>
<div
className="progress-bar-fill"
style={{ width: `${Math.min(100, Math.max(0, restartLoadProgress.progress))}%`, transition: "width 0.25s ease" }}
></div>
</div>
{(restartLoadProgress.backendMode || restartLoadProgress.device) && (
<div style={{ fontSize: "0.78rem", opacity: 0.8, textAlign: "center", marginTop: "8px" }}>
Loading on {restartLoadProgress.backendMode || "backend"}{restartLoadProgress.device ? ` • ${restartLoadProgress.device}` : ""}
</div>
)}
</div>
) : (
<p style={{ fontSize: "0.85rem", opacity: 0.8, textAlign: "center", maxWidth: "280px", margin: 0 }}>
Adjusting inference settings and restarting the local model server.
</p>
)}
</div>
) : isCancelling ? (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", padding: "20px" }}>
<div className="progress-spinner"></div>
<div className="progress-text" style={{ fontSize: "1.1rem", fontWeight: 700, textAlign: "center" }}>
Cancelling Generation...
</div>
<p style={{ fontSize: "0.85rem", opacity: 0.8, textAlign: "center", maxWidth: "280px", margin: 0 }}>
Stopping CPU/GPU execution threads and reloading model weights. Please wait a moment.
</p>
</div>
) : (
<>
<div className="progress-spinner"></div>
<div className="progress-text" style={{ fontSize: "1.1rem", fontWeight: 700 }}>Generating Locally...</div>
{isCpuFallback && (
<div style={{ fontSize: "0.8rem", color: "var(--md-sys-color-primary)", fontWeight: 600, background: "var(--md-sys-color-primary-container)", padding: "4px 12px", borderRadius: "12px", margin: "4px 0 8px 0", animation: "pulse 2s infinite" }}>
⚠️ CPU Fallback Detected (Running Slower)
</div>
)}
{/* Real-time stats display */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", width: "80%", margin: "8px 0 0 0", fontSize: "0.85rem", opacity: 0.9 }}>
<div style={{ textAlign: "left" }}>
<strong>Step:</strong> {currentStep} / {constraints.steps} ({constraints.steps - currentStep} left)
</div>
<div style={{ textAlign: "right" }}>
<strong>Elapsed:</strong> {elapsedTime}s{generationSpeed && ` (${generationSpeed})`}
</div>
</div>
<div className="progress-bar-container" style={{ margin: "4px 0 10px 0" }}>
<div
className="progress-bar-fill"
style={{ width: `${generationProgress}%`, transition: "width 1s linear" }}
></div>
</div>
<div style={{ fontSize: "0.85rem", opacity: 0.8, display: "flex", gap: "8px", alignItems: "center" }}>
<span>Progress: {generationProgress}%</span>
<span>•</span>
<span>Est. remaining: {estimatedLeftTime}s</span>
</div>
<button
className="m3-btn m3-btn-error"
style={{ marginTop: "16px", height: "36px", padding: "0 24px" }}
onClick={handleCancelGeneration}
>
Stop Generating
</button>
</>
)}
</div>
) : outputImage ? (
<img src={outputImage} className="output-image" alt="Generated Output" />
) : errorMsg ? (
<div style={{ textAlign: "center", padding: "24px", color: "var(--md-sys-color-error)", display: "flex", flexDirection: "column", alignItems: "center", gap: "16px" }}>
<div style={{ background: "rgba(239, 68, 68, 0.1)", padding: "16px", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 style={{ margin: 0, fontSize: "1.2rem", fontWeight: "bold" }}>Generation Failed</h3>
<p style={{ margin: 0, fontSize: "0.95rem", opacity: 0.9, maxWidth: "320px", lineHeight: "1.4" }}>
{errorMsg}
</p>
<div style={{ marginTop: "8px", fontSize: "0.85rem", opacity: 0.8, color: "var(--md-sys-color-on-surface-variant)" }}>
Hint: Try lowering the resolution in the <strong>Image Constraints</strong> settings (e.g., to 512x512).
</div>
</div>
) : (
<div style={{ textAlign: "center" }}>
<Sparkles className="output-placeholder-icon" />
<p className="output-placeholder-text">
Your generated image will appear here.<br />
Enter a prompt on the left and click Generate.
</p>
</div>
)}
</div>
{outputImage && !isGenerating && (
<>
{/* Telemetry info for previous generation */}
<div style={{ width: "100%", display: "flex", justifyContent: "space-between", marginTop: "12px", fontSize: "0.8rem", color: "var(--md-sys-color-outline)" }}>
<span>Seed: {outputSeed}</span>
{genDuration && <span>Inference Time: {genDuration.toFixed(1)}s</span>}
</div>
{/* Actions for generated image */}
<div className="image-actions">
<button className="m3-btn m3-btn-tonal" onClick={handleCopyImage}>
{copySuccess ? <Check size={16} /> : <Copy size={16} />}
<span>{copySuccess ? "Copied" : "Copy"}</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={handleDownload}>
<Download size={16} />
<span>Save to USB</span>
</button>
</div>
</>
)}
</div>
</div>
{/* History / Gallery Grid */}
<div className="gallery-section">
<div className="gallery-header">
<h3 className="m3-card-title" style={{ marginBottom: 0 }}>Recent Outputs Gallery</h3>
{selectedGalleryItems.length > 0 && (
<div className="gallery-selection-actions">
<span className="gallery-selection-count">{selectedGalleryItems.length} selected</span>
<button className="m3-btn m3-btn-tonal" onClick={useSelectedGalleryItem}>
<Check size={14} />
<span>Use</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={downloadSelectedGalleryItems}>
<Download size={14} />
<span>Download</span>
</button>
<button className="m3-btn m3-btn-error" onClick={deleteSelectedGalleryItems}>
<Trash2 size={14} />
<span>Delete</span>
</button>
<button className="m3-btn m3-btn-outlined" onClick={clearGallerySelection}>
Clear
</button>
</div>
)}
</div>
{generatedImages.length === 0 ? (
<div className="m3-card gallery-empty">
No images generated in this session yet. Everything will be saved directly on your external drive.
</div>
) : (
<>
<div className="gallery-hint">Click to select one image. Ctrl-click or Shift-click to select multiple.</div>
<div className="gallery-grid">
{generatedImages.map((img, idx) => (
<GalleryItem
key={img.image || idx}
img={img}
idx={idx}
isSelected={selectedGalleryIndexes.includes(idx)}
onClick={toggleGallerySelection}
/>
))}
</div>
</>
)}
</div>
</div>
);
}
export default memo(Generator);
@@ -0,0 +1,302 @@
import React, { memo } from "react";
import { Crop, Sliders, Cpu, Info } from "lucide-react";
const ASPECT_RATIOS = [
{ id: "1:1", label: "1:1 Square", width: 512, height: 512, sdxl_width: 1024, sdxl_height: 1024, desc: "Social posts & avatars" },
{ id: "4:3", label: "4:3 Photo", width: 640, height: 480, sdxl_width: 1152, sdxl_height: 864, desc: "Classic photo look" },
{ id: "16:9", label: "16:9 Landscape", width: 768, height: 432, sdxl_width: 1216, sdxl_height: 684, desc: "Widescreen landscape" },
{ id: "9:16", label: "9:16 Portrait", width: 432, height: 768, sdxl_width: 684, sdxl_height: 1216, desc: "Tall phone screen" }
];
const isSD15OrCustomModel = (modelName) => {
if (!modelName) return true;
const name = modelName.toLowerCase();
if (name.includes("flux") || name.includes("schnell")) return false;
if (name.includes("sdxl") || name.includes("lightning") || name.includes("turbo")) return false;
if (name.includes("sd3")) return false;
return true;
};
function ImageConstraints({ constraints, setConstraints, activeModel, specs, backendOptions }) {
const isSD15OrCustom = activeModel ? isSD15OrCustomModel(activeModel) : false;
const availableBackends = backendOptions?.options?.length
? backendOptions.options
: [{ id: "cpu", label: "CPU", available: true }];
const updateConstraint = (key, value) => {
setConstraints((prev) => ({
...prev,
[key]: value
}));
};
const handleAspectRatioChange = (ratio, sizeType) => {
const isSDXL = sizeType === "sdxl" && !isSD15OrCustom;
const selected = ASPECT_RATIOS.find((r) => r.id === ratio);
if (selected) {
let w = isSDXL ? selected.sdxl_width : selected.width;
let h = isSDXL ? selected.sdxl_height : selected.height;
if (isSD15OrCustom) {
if (w > h) {
h = Math.round((h * 512) / w);
w = 512;
} else {
w = Math.round((w * 512) / h);
h = 512;
}
w = Math.round(w / 64) * 64;
h = Math.round(h / 64) * 64;
}
updateConstraint("width", w);
updateConstraint("height", h);
}
};
const handleBackendChange = (backendType) => {
setConstraints((prev) => ({
...prev,
backendType,
useGpu: backendType !== "cpu",
}));
};
return (
<div className="workspace-area">
<div className="workspace-title-section">
<h2 className="workspace-title">Image Constraints</h2>
<p className="workspace-subtitle">
Configure size, quality, and performance controls for image generation.
</p>
</div>
<div className="generator-layout">
{/* Left Column: Size & Quality */}
<div>
{/* Card 1: Picture Size & Shape */}
<div className="m3-card">
<h3 className="m3-card-title">
<Crop size={18} style={{ color: "var(--md-sys-color-primary)" }} />
1. Picture Size & Shape
</h3>
{isSD15OrCustom && (
<div style={{
background: "rgba(251, 191, 36, 0.1)",
border: "1px solid rgb(251, 191, 36)",
color: "var(--md-sys-color-on-surface)",
padding: "12px",
borderRadius: "8px",
marginBottom: "16px",
fontSize: "0.85rem",
display: "flex",
gap: "8px",
alignItems: "flex-start"
}}>
<Info size={16} style={{ color: "rgb(251, 191, 36)", flexShrink: 0, marginTop: "2px" }} />
<div>
<strong>Low-VRAM Protection Active:</strong> Capped at 512x512 for the loaded SD 1.x model (<code>{activeModel}</code>). Generating at larger sizes (like 1024x1024) is disabled to prevent out-of-memory crashes on your {specs && specs.gpu_name && !specs.gpu_name.includes("Loading") ? specs.gpu_name : "graphics processor"}.
</div>
</div>
)}
<div className="m3-field-group">
{/* Size switcher */}
<div className="m3-text-field">
<label className="m3-text-field-label">AI Generation Engine Optimization</label>
<div className="m3-segmented-button">
{isSD15OrCustom ? (
<div
className="m3-segment-item disabled"
style={{
opacity: 0.5,
cursor: "not-allowed",
textDecoration: "line-through",
backgroundColor: "var(--md-sys-color-surface-variant)"
}}
title="High Quality Mode is disabled for SD 1.x models."
>
High Quality Mode (1024px)
</div>
) : (
<div
className={`m3-segment-item ${constraints.width >= 1024 ? "active" : ""}`}
onClick={() => {
updateConstraint("width", 1024);
updateConstraint("height", 1024);
}}
>
High Quality Mode (1024px)
</div>
)}
<div
className={`m3-segment-item ${constraints.width < 1024 || isSD15OrCustom ? "active" : ""}`}
onClick={() => {
updateConstraint("width", 512);
updateConstraint("height", 512);
}}
>
Standard Speed Mode (512px)
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
{isSD15OrCustom
? "*Standard Speed Mode (512px) is forced for SD 1.5 / custom models to ensure stable local generations."
: "*Use High Quality (1024px) for Flux or SDXL models. Use Standard (512px) for SD 1.5."}
</span>
</div>
{/* Aspect Ratio Buttons */}
<div className="m3-text-field">
<label className="m3-text-field-label">Select Shape (Aspect Ratio)</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
{ASPECT_RATIOS.map((ratio) => {
const isActive = Math.abs((constraints.width / constraints.height) - (ratio.width / ratio.height)) < 0.1;
return (
<button
key={ratio.id}
className={`m3-btn m3-btn-outlined aspect-ratio-btn ${isActive ? "active" : ""}`}
style={{
height: "86px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "8px",
borderRadius: "var(--md-shape-corner-medium)",
borderColor: "var(--md-sys-color-outline-variant)"
}}
onClick={() => handleAspectRatioChange(ratio.id, isSD15OrCustom ? "sd15" : (constraints.width >= 1024 ? "sdxl" : "sd15"))}
>
<div className={`aspect-ratio-preview ratio-${ratio.id.replace(":", "-")}`}></div>
<span style={{ fontWeight: 600, fontSize: "0.85rem", marginTop: "2px" }}>{ratio.label}</span>
<span style={{ fontSize: "0.68rem", opacity: 0.7 }}>
{isSD15OrCustom
? `Max: ${ratio.id === "1:1" ? "512x512" : ratio.id === "4:3" ? "512x384" : ratio.id === "16:9" ? "512x288" : "288x512"}`
: ratio.desc}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
{/* Card 2: Quality & Logic */}
<div className="m3-card">
<h3 className="m3-card-title">
<Sliders size={18} style={{ color: "var(--md-sys-color-primary)" }} />
2. Quality, Speed & Logic
</h3>
<div className="m3-field-group">
{/* Quality Steps Slider */}
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Detail Steps (Inference speed)</span>
<span className="m3-slider-value">{constraints.steps} steps</span>
</div>
<input
type="range"
className="m3-slider"
value={constraints.steps}
onChange={(e) => updateConstraint("steps", parseInt(e.target.value))}
min="1"
max="60"
/>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
The number of times the AI cleans up the image. More steps = sharper details, but takes longer.
</span>
</div>
{/* CFG Scale Slider */}
<div className="m3-slider-group" style={{ marginTop: "8px" }}>
<div className="m3-slider-header">
<span className="m3-slider-label">Prompt Strictness (Instruction obedience)</span>
<span className="m3-slider-value">{constraints.cfgScale.toFixed(1)}</span>
</div>
<input
type="range"
className="m3-slider"
value={constraints.cfgScale}
onChange={(e) => updateConstraint("cfgScale", parseFloat(e.target.value))}
min="1"
max="15"
step="0.5"
/>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
How closely the AI follows your text prompt. Lower = more creative. Higher = more strict.
</span>
</div>
{/* Random Seed DNA */}
<div className="m3-text-field" style={{ marginTop: "8px" }}>
<label className="m3-text-field-label">Image Blueprint (DNA Seed)</label>
<div style={{ display: "flex", gap: "8px" }}>
<input
type="number"
className="m3-input"
value={constraints.seed}
onChange={(e) => updateConstraint("seed", parseInt(e.target.value) || -1)}
placeholder="-1 for a brand new image..."
style={{ flex: 1, height: "40px" }}
/>
<button
className="m3-btn m3-btn-tonal"
onClick={() => updateConstraint("seed", -1)}
style={{ height: "40px" }}
>
Random (-1)
</button>
</div>
</div>
</div>
</div>
</div>
{/* Right Column: Speed & System */}
<div>
{/* Card 3: Performance hacks */}
<div className="m3-card">
<h3 className="m3-card-title">
<Cpu size={18} style={{ color: "var(--md-sys-color-primary)" }} />
3. System Settings
</h3>
<div className="m3-field-group">
<div className="m3-text-field">
<label className="m3-text-field-label">Generation Backend</label>
<div className="m3-segmented-button">
{availableBackends.map((backend) => (
<div
key={backend.id}
className={`m3-segment-item ${constraints.backendType === backend.id || (!constraints.backendType && backend.id === "cpu") ? "active" : ""}`}
onClick={() => handleBackendChange(backend.id)}
title={backend.id === "cuda" ? "CUDA appears only when an NVIDIA CUDA backend is available." : undefined}
>
{backend.label}
</div>
))}
</div>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px", lineHeight: 1.35 }}>
CPU is slow but safest. Vulkan works on supported GPUs. CUDA is shown only when NVIDIA CUDA support is available.
</span>
{backendOptions?.unavailable?.length > 0 && (
<div style={{ marginTop: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
{backendOptions.unavailable.map((backend) => (
<span key={backend.id} style={{ fontSize: "0.75rem", color: "var(--md-sys-color-error)", lineHeight: 1.35 }}>
{backend.label} unavailable: {backend.reason}
</span>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default memo(ImageConstraints);
@@ -0,0 +1,845 @@
import React, { memo, useState, useEffect } from "react";
import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle } from "lucide-react";
import { listLocalModels, startServer, stopServer, importModelFile, deleteModel, downloadModel, cancelModelDownload, getDownloadProgress, getBackendStatus, pingServer, formatBytes, normalizeModel } from "../services/api";
const MODEL_LIBRARY = [
{
group: "SDXL - Best Quality",
items: [
{
name: "Juggernaut XL v9 Lightning",
filename: "Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
format: "Safetensors",
approxSize: "6.6 GB",
resolution: "1024x1024",
notes: "Photorealistic SDXL model tuned for 4-8 step Lightning generation.",
url: "https://huggingface.co/RunDiffusion/Juggernaut-XL-Lightning/resolve/main/Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
},
{
name: "DreamShaper XL Lightning",
filename: "DreamShaperXL_Lightning.safetensors",
format: "Safetensors",
approxSize: "6.6 GB",
resolution: "1024x1024",
notes: "All-around SDXL model for fantasy art, renders, illustration, and stylized images.",
url: "https://huggingface.co/Lykon/dreamshaper-xl-lightning/resolve/main/DreamShaperXL_Lightning.safetensors",
},
{
name: "SDXL Base 1.0 Q4_0 GGUF",
filename: "stable-diffusion-xl-base-1.0-Q4_0.gguf",
format: "GGUF",
approxSize: "3.8 GB",
resolution: "1024x1024",
notes: "Quantized SDXL base model for lower VRAM or CPU-friendly generation.",
url: "https://huggingface.co/hum-ma/SDXL-models-GGUF/resolve/7e15380138e7069ca3aaef5bf0401c3406e3a593/stable-diffusion-xl-base-1.0-Q4_0.gguf",
},
],
},
{
group: "SD 1.5 - Ultra Fast / Low VRAM",
items: [
{
name: "DreamShaper 8",
filename: "DreamShaper_8_pruned.safetensors",
format: "Safetensors",
approxSize: "2.1 GB",
resolution: "512x512",
notes: "General-purpose SD 1.5 model for illustration, anime, and semi-realistic portraits.",
url: "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors",
},
{
name: "CyberRealistic V8",
filename: "CyberRealistic_V8_FP16.safetensors",
format: "Safetensors",
approxSize: "2.1 GB",
resolution: "512x512",
notes: "High-fidelity realism and photorealism in the SD 1.5 ecosystem.",
url: "https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V8_FP16.safetensors",
},
{
name: "ReV Animated v1.2.2",
filename: "rev-animated-v1-2-2.safetensors",
format: "Safetensors",
approxSize: "2.1 GB",
resolution: "512x512",
notes: "Semi-realistic, 2.5D, anime, fantasy, and stylized digital art.",
url: "https://huggingface.co/danbrown/RevAnimated-v1-2-2/resolve/main/rev-animated-v1-2-2.safetensors",
},
],
},
];
function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRunning, constraints, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message) }) {
const [localModels, setLocalModels] = useState([]);
const [downloadingModelId, setDownloadingModelId] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadSpeed, setDownloadSpeed] = useState("");
const [loadingModelId, setLoadingModelId] = useState(null);
const [modelLoadProgress, setModelLoadProgress] = useState(null);
const [importProgress, setImportProgress] = useState(null); // null when not importing
const [importInfo, setImportInfo] = useState({ filename: "", speed: "0.0", eta: 0, status: "" });
const [downloadUrl, setDownloadUrl] = useState("");
const [importAbortController, setImportAbortController] = useState(null);
const [isUnloading, setIsUnloading] = useState(false);
const [unloadProgress, setUnloadProgress] = useState({ progress: 0, phase: "" });
const [pendingLoadModel, setPendingLoadModel] = useState(null);
const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
const modelNames = localModels.map((model) => normalizeModel(model).filename);
const isBusy = loadingModelId !== null || isUnloading;
useEffect(() => {
fetchModels();
checkActiveDownload();
}, []);
useEffect(() => {
if (!serverRunning && !loadingModelId) {
setBackendInfo({ backendMode: "", backendBinary: "", backendDevice: "" });
return;
}
let cancelled = false;
const updateBackendInfo = async () => {
try {
const status = await getBackendStatus();
if (cancelled) return;
setBackendInfo({
backendMode: status.settings?.backendMode || status.loading?.backendMode || "",
backendBinary: status.settings?.backendBinary || status.loading?.backendBinary || "",
backendDevice: status.settings?.backendDevice || status.loading?.device || "",
});
} catch (_) {}
};
updateBackendInfo();
const interval = setInterval(updateBackendInfo, 2000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [serverRunning, loadingModelId]);
const checkActiveDownload = async () => {
try {
const status = await getDownloadProgress();
if (status.active && status.filename) {
startProgressPolling(status.filename);
}
} catch (e) {
console.error("Check active download failed:", e);
}
};
const startProgressPolling = (modelId) => {
setDownloadingModelId(modelId);
const interval = setInterval(async () => {
try {
const status = await getDownloadProgress();
if (status.active) {
setDownloadProgress(status.progress === -1 ? 0 : status.progress);
setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
} else {
clearInterval(interval);
setDownloadingModelId(null);
if (status.error) {
if (!String(status.error).toLowerCase().includes("cancelled")) {
showAlert({ title: "Download Failed", message: status.error, danger: true });
}
} else {
fetchModels();
}
}
} catch (e) {
console.error("Polling error:", e);
}
}, 1000);
};
const fetchModels = async () => {
try {
const list = await listLocalModels();
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
} catch (e) {
console.error(e);
}
};
const downloadByUrl = async (url, expectedFilename) => {
if (downloadingModelId) return;
if (modelNames.includes(expectedFilename)) {
if (!(await showConfirm({
title: "Overwrite Model?",
message: `"${expectedFilename}" is already downloaded. Do you want to download and overwrite it?`,
confirmLabel: "Overwrite",
}))) {
return;
}
}
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
if (!isTauriDesktop && !isLocalServerMode) {
showAlert({ title: "Server Required", message: "Model download requires the local image generator server.", danger: true });
return;
}
try {
const res = await downloadModel(url);
if (res.ok) {
startProgressPolling(expectedFilename);
} else {
showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true });
}
} catch (err) {
showAlert({ title: "Download Error", message: err.message, danger: true });
}
};
// Download custom model from URL
const handleUrlDownloadSubmit = async (e) => {
e.preventDefault();
if (!downloadUrl.trim()) return;
let modelName = "model.gguf";
try {
const parsed = new URL(downloadUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("URL protocol must be http or https");
}
modelName = parsed.pathname.split("/").pop() || "";
if (!modelName) throw new Error("Could not extract filename from URL");
const lowerName = modelName.toLowerCase();
if (!lowerName.endsWith(".gguf") && !lowerName.endsWith(".safetensors") && !lowerName.endsWith(".ckpt")) {
throw new Error("URL must point to a .gguf, .safetensors, or .ckpt weights file");
}
} catch (err) {
showAlert({ title: "Invalid URL", message: err.message, danger: true });
return;
}
await downloadByUrl(downloadUrl.trim(), modelName);
setDownloadUrl("");
};
const handleLibraryDownload = (model) => {
downloadByUrl(model.url, model.filename);
};
const handleCancelDownload = async () => {
await cancelModelDownload();
setDownloadingModelId(null);
setDownloadProgress(0);
setDownloadSpeed("");
await fetchModels();
};
// Start the model server backend
const handleLoadModel = async (modelId) => {
if (activeModel && activeModel !== modelId && serverRunning) {
setPendingLoadModel(modelId);
return;
}
await performLoadModel(modelId);
};
const performLoadModel = async (modelId) => {
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
if (!isTauriDesktop && !isLocalServerMode) {
const isAlreadyRunning = await pingServer();
const cmd = `.\\backend\\win\\vulkan\\sd-vulkan.exe --listen-port 8080 --model .\\models\\${modelId} --threads 8`;
if (isAlreadyRunning) {
if (await showConfirm({ title: "Web Browser Mode", message: `To load "${modelId}" on the active GPU server, restart your terminal backend with:\n\n${cmd}\n\nUpdate the UI status anyway?`, confirmLabel: "Update UI" })) {
setActiveModel(modelId);
setServerRunning(true);
}
} else {
if (await showConfirm({ title: "Backend Not Running", message: `C++ backend server is not running on port 8080.\n\nTo run locally, start:\n\n${cmd}\n\nProceed in Simulation Mode instead?`, confirmLabel: "Proceed" })) {
setActiveModel(modelId);
setServerRunning(false);
}
}
return;
}
setLoadingModelId(modelId);
setModelLoadProgress({
progress: 0,
phase: "Starting backend...",
speed: "",
current: 0,
total: 0,
model: modelId,
backendMode: "",
backendBinary: "",
device: "",
});
try {
const response = await startServer(modelId, constraints);
console.log(response);
let isReady = false;
let crashError = null;
for (let i = 0; i < 240; i++) { // Poll for up to 120 seconds (240 * 500ms)
const status = await getBackendStatus();
if (status.loading) {
setModelLoadProgress({
progress: status.loading.progress || 0,
phase: status.loading.phase || "Loading model...",
speed: status.loading.speed || "",
current: status.loading.current || 0,
total: status.loading.total || 0,
model: status.loading.model || modelId,
backendMode: status.loading.backendMode || status.settings?.backendMode || "",
backendBinary: status.loading.backendBinary || status.settings?.backendBinary || "",
device: status.loading.device || status.settings?.backendDevice || "",
});
}
if (status.ready) {
isReady = true;
break;
}
if (status.error) {
crashError = status.error;
break;
}
if (!status.running && i > 3) {
crashError = "The backend process terminated immediately on startup.";
break;
}
await new Promise((r) => setTimeout(r, 500));
}
if (isReady) {
setModelLoadProgress((prev) => ({
...(prev || {}),
progress: 100,
phase: "Model ready",
speed: "",
current: prev?.current || 0,
total: prev?.total || 0,
model: modelId,
}));
setActiveModel(modelId);
setServerRunning(true);
} else {
throw new Error(crashError || "Model server failed to respond on port 8080.");
}
} catch (e) {
console.error("Failed to load model:", e);
showAlert({ title: "Model Load Failed", message: e.message || String(e), danger: true });
} finally {
setTimeout(() => setModelLoadProgress(null), 800);
setLoadingModelId(null);
}
};
const handleUnloadThenLoad = async () => {
const nextModel = pendingLoadModel;
setPendingLoadModel(null);
await handleUnloadModel();
if (nextModel) {
await performLoadModel(nextModel);
}
};
// Kill/Unload model server
const handleUnloadModel = async () => {
if (isUnloading) return;
setIsUnloading(true);
setUnloadProgress({ progress: 10, phase: "Stopping backend process..." });
const poll = setInterval(async () => {
try {
const status = await getBackendStatus();
if (status.unloading?.active) {
setUnloadProgress({
progress: status.unloading.progress || 50,
phase: status.unloading.phase || "Unloading model...",
});
} else {
setUnloadProgress((prev) => ({
progress: Math.min(95, prev.progress + 10),
phase: prev.phase || "Unloading model...",
}));
}
} catch (_) {}
}, 300);
try {
await stopServer();
setUnloadProgress({ progress: 100, phase: "Model unloaded" });
setActiveModel(null);
setPendingLoadModel(null);
setServerRunning(false);
} catch (e) {
console.error(e);
showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
} finally {
clearInterval(poll);
setTimeout(() => {
setIsUnloading(false);
setUnloadProgress({ progress: 0, phase: "" });
}, 500);
}
};
// Delete/Remove model file
const handleDeleteModel = async (filename) => {
if (await showConfirm({
title: "Delete Model?",
message: `Delete "${filename}" from your drive?`,
confirmLabel: "Delete",
danger: true,
})) {
try {
await deleteModel(filename);
// Refresh local models list
await fetchModels();
// If it was the active model, unload the server
if (activeModel === filename) {
await handleUnloadModel();
}
} catch (err) {
console.error("Failed to delete model:", err);
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
}
}
};
const executeImport = async (sourcePath, displayName) => {
const controller = new AbortController();
setImportAbortController(controller);
setImportProgress(0);
setImportInfo({
filename: displayName,
speed: "0.0",
eta: 0,
status: "Initiating file copy..."
});
try {
await importModelFile(sourcePath, (progressData) => {
setImportProgress(Math.round(progressData.progress));
setImportInfo({
filename: progressData.filename,
speed: progressData.speed_mb_s.toFixed(1),
eta: Math.round(progressData.eta_secs),
status: progressData.status
});
}, controller.signal);
// Refresh list
await fetchModels();
} catch (err) {
console.error("Import failed:", err);
if (err.name !== "AbortError") {
showAlert({ title: "Import Failed", message: err.message || String(err), danger: true });
}
} finally {
setImportProgress(null);
setImportAbortController(null);
}
};
const handleCancelImport = () => {
if (importAbortController) {
importAbortController.abort();
}
};
const handleImportFile = (e) => {
const file = e.target.files[0];
if (file) {
executeImport(file, file.name);
}
e.target.value = "";
};
const isUrlDownloading = Boolean(downloadingModelId);
return (
<div className="workspace-area">
<div className="workspace-title-section">
<h2 className="workspace-title">Model Manager</h2>
<p className="workspace-subtitle">
Manage local files and download recommended weights directly to your local models folder.
</p>
</div>
{/* Active Model Status Tonal Box */}
{activeModel && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h4 style={{ fontWeight: 700 }}>Active Model: {activeModel}</h4>
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
The local C++ stable-diffusion server is running. Real-time telemetry is displaying performance status in the top bar.
</p>
{(backendInfo.backendMode || backendInfo.backendDevice || backendInfo.backendBinary) && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginTop: "10px" }}>
{backendInfo.backendMode && (
<span className="status-chip" style={{ cursor: "default" }}>
<HardDrive size={14} />
<span>Loaded on {backendInfo.backendMode}</span>
</span>
)}
{backendInfo.backendDevice && (
<span className="status-chip" style={{ cursor: "default" }}>
<span>{backendInfo.backendDevice}</span>
</span>
)}
{backendInfo.backendBinary && (
<span className="status-chip" style={{ cursor: "default" }}>
<span>{backendInfo.backendBinary}</span>
</span>
)}
</div>
)}
</div>
<button className="m3-btn m3-btn-error" onClick={handleUnloadModel} disabled={isUnloading || loadingModelId !== null}>
{isUnloading ? <RefreshCw className="progress-spinner" size={16} /> : <Trash2 size={16} />}
<span>{isUnloading ? "Unloading" : "Unload Server"}</span>
</button>
</div>
</div>
)}
{isUnloading && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-error)", marginTop: "16px" }}>
<h4 style={{ fontWeight: 700, marginBottom: "8px" }}>Unloading Model</h4>
<div className="model-progress-section" style={{ margin: "8px 0 0 0" }}>
<div className="model-progress-label">
<span>{unloadProgress.phase || "Unloading model..."}</span>
<span>{Math.round(unloadProgress.progress)}%</span>
</div>
<div className="model-progress-bar">
<div className="model-progress-fill" style={{ width: `${Math.min(100, Math.max(0, unloadProgress.progress))}%`, transition: "width 0.25s ease" }}></div>
</div>
</div>
</div>
)}
{modelLoadProgress && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-secondary)", marginTop: "16px" }}>
<h4 style={{ fontWeight: 700, marginBottom: "8px" }}>
Loading Model: {modelLoadProgress.model || loadingModelId}
</h4>
{(modelLoadProgress.backendMode || modelLoadProgress.backendBinary || modelLoadProgress.device) && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginBottom: "10px" }}>
{modelLoadProgress.backendMode && (
<span className="status-chip" style={{ cursor: "default" }}>
<HardDrive size={14} />
<span>Loading on {modelLoadProgress.backendMode}</span>
</span>
)}
{modelLoadProgress.device && (
<span className="status-chip" style={{ cursor: "default" }}>
<span>{modelLoadProgress.device}</span>
</span>
)}
{modelLoadProgress.backendBinary && (
<span className="status-chip" style={{ cursor: "default" }}>
<span>{modelLoadProgress.backendBinary}</span>
</span>
)}
</div>
)}
<div className="model-progress-section" style={{ margin: "8px 0 0 0" }}>
<div className="model-progress-label">
<span>
{modelLoadProgress.phase}
{modelLoadProgress.speed ? ` (${modelLoadProgress.speed})` : ""}
</span>
<span>
{modelLoadProgress.total > 0
? `Loaded ${modelLoadProgress.current} / ${modelLoadProgress.total} tensors`
: `${Math.round(modelLoadProgress.progress)}%`}
</span>
</div>
<div className="model-progress-bar">
<div
className="model-progress-fill"
style={{ width: `${Math.min(100, Math.max(0, modelLoadProgress.progress))}%`, transition: "width 0.25s ease" }}
></div>
</div>
</div>
</div>
)}
{/* Detected Local Models Section */}
<div className="m3-card" style={{ marginTop: "24px" }}>
<h3 className="m3-card-title">
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
Local Models ({localModels.length})
</h3>
{localModels.length === 0 ? (
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-outline)", textAlign: "center", padding: "16px 0" }}>
No models detected in app/models/. Download from the library below or import a file.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{localModels.map((model) => {
const filename = model.filename;
const isActive = activeModel === filename;
return (
<div
key={filename}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
background: isActive ? "var(--md-sys-color-primary-container)" : "var(--md-sys-color-surface-variant)",
color: isActive ? "var(--md-sys-color-on-primary-container)" : "var(--md-sys-color-on-surface-variant)",
borderRadius: "var(--md-shape-corner-medium)",
border: "1px solid var(--md-sys-color-outline-variant)"
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>{filename}</span>
<span style={{ fontSize: "0.75rem", opacity: 0.8 }}>
Local Weights File • {model.size || formatBytes(model.sizeBytes)}
</span>
</div>
<div style={{ display: "flex", gap: "8px" }}>
{isActive ? (
<button className="m3-btn m3-btn-error" style={{ height: "36px", padding: "0 16px" }} onClick={handleUnloadModel} disabled={isUnloading}>
{isUnloading ? <RefreshCw className="progress-spinner" size={14} /> : <Trash2 size={14} />}
<span>{isUnloading ? "Unloading" : "Unload"}</span>
</button>
) : (
<>
<button
className="m3-btn m3-btn-filled"
style={{ height: "36px", padding: "0 16px" }}
onClick={() => handleLoadModel(filename)}
disabled={isBusy}
>
{loadingModelId === filename ? (
<RefreshCw className="progress-spinner" size={14} />
) : (
<Database size={14} />
)}
<span>{loadingModelId === filename ? "Loading" : "Load"}</span>
</button>
<button
className="m3-btn m3-btn-error"
style={{ height: "36px", width: "36px", padding: 0, minWidth: "36px" }}
onClick={() => handleDeleteModel(filename)}
disabled={isBusy}
title="Delete from models folder"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
<h3 className="m3-card-title">
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
Model Library
</h3>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{MODEL_LIBRARY.map((section) => (
<div key={section.group} className="m3-card" style={{ margin: 0 }}>
<h4 style={{ fontWeight: 700, marginBottom: "12px" }}>{section.group}</h4>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "12px" }}>
{section.items.map((model) => {
const installed = modelNames.includes(model.filename);
const downloading = downloadingModelId === model.filename;
return (
<div key={model.filename} style={{ display: "flex", flexDirection: "column", gap: "10px", padding: "14px", background: "var(--md-sys-color-surface-variant)", border: "1px solid var(--md-sys-color-outline-variant)", borderRadius: "var(--md-shape-corner-medium)" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span style={{ fontWeight: 700 }}>{model.name}</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
{model.format} • approx. {model.approxSize} • {model.resolution}
</span>
</div>
<p style={{ fontSize: "0.78rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.35, margin: 0 }}>
{model.notes}
</p>
{downloading && (
<div className="model-progress-section" style={{ marginTop: "2px" }}>
<div className="model-progress-label">
<span style={{ display: "inline-flex", alignItems: "center", gap: "8px" }}>
<span className="loading-dot"></span>
Downloading
</span>
<span>{downloadProgress > 0 ? `${downloadProgress}%` : "Preparing"}</span>
</div>
<div className="model-progress-bar">
<div className="model-progress-fill" style={{ width: `${Math.min(100, Math.max(0, downloadProgress))}%`, transition: "width 0.2s ease" }}></div>
</div>
{downloadSpeed && (
<div style={{ fontSize: "0.7rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
{downloadSpeed}
</div>
)}
</div>
)}
<button
className={installed ? "m3-btn m3-btn-tonal" : "m3-btn m3-btn-filled"}
style={{ height: "36px", marginTop: "auto" }}
onClick={() => handleLibraryDownload(model)}
disabled={installed || downloadingModelId !== null}
>
{installed ? <HardDrive size={14} /> : downloading ? <RefreshCw className="progress-spinner" size={14} /> : <DownloadCloud size={14} />}
<span>{installed ? "Downloaded" : downloading ? "Downloading" : "Download"}</span>
</button>
</div>
);
})}
</div>
</div>
))}
</div>
{/* Local Drag and Drop Importer */}
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
<h3 className="m3-card-title">
<FolderOpen size={20} style={{ color: "var(--md-sys-color-primary)" }} />
Local Model Import
</h3>
</div>
{/* Importing Progress Bar */}
{importProgress !== null && (
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "12px" }}>
<h4 style={{ fontWeight: 600 }}>Copying Model to local folder: {importInfo.filename}</h4>
<button className="m3-btn m3-btn-error" style={{ height: "34px", padding: "0 14px" }} onClick={handleCancelImport}>
<Square size={14} />
<span>Stop Import</span>
</button>
</div>
<div className="model-progress-section" style={{ margin: "12px 0 6px 0" }}>
<div className="model-progress-label">
<span>{importInfo.status} ({importInfo.speed} MB/s)</span>
<span>{importProgress}% ({importInfo.eta}s remaining)</span>
</div>
<div className="model-progress-bar">
<div className="model-progress-fill" style={{ width: `${importProgress}%`, transition: "width 0.15s ease" }}></div>
</div>
</div>
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "16px", marginTop: "16px" }}>
<label className="import-box" style={{ margin: 0, height: "100%", justifyContent: "center" }}>
<input
type="file"
style={{ display: "none" }}
accept=".gguf,.safetensors,.ckpt"
onChange={handleImportFile}
disabled={importProgress !== null}
/>
<FolderOpen className="import-icon" />
<span style={{ fontWeight: 600 }}>Choose weights file</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
Select `.gguf`, `.safetensors` or `.ckpt` weights.
</span>
</label>
<div className="m3-card" style={{ display: "flex", flexDirection: "column", justifyContent: "center", margin: 0, padding: "20px" }}>
<h4 style={{ fontWeight: 600, marginBottom: "8px", display: "flex", alignItems: "center", gap: "8px" }}>
<DownloadCloud size={16} />
Download Model from URL
</h4>
<p style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginBottom: "12px" }}>
Download any GGUF or Safetensors model from Hugging Face or other sites directly to your models folder.
</p>
{isUrlDownloading ? (
<div className="model-progress-section" style={{ marginTop: "0px" }}>
<div className="model-progress-label">
<span style={{ fontSize: "0.75rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: "200px" }} title={downloadingModelId}>
{downloadingModelId}
</span>
<span>{downloadProgress}%</span>
</div>
<div className="model-progress-bar" style={{ margin: "4px 0" }}>
<div className="model-progress-fill" style={{ width: `${downloadProgress}%`, transition: "width 0.15s ease" }}></div>
</div>
<div style={{ fontSize: "0.7rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
{downloadSpeed}
</div>
<button className="m3-btn m3-btn-error" style={{ height: "34px", padding: "0 14px", marginTop: "10px" }} onClick={handleCancelDownload}>
<Square size={14} />
<span>Stop Download</span>
</button>
</div>
) : (
<form onSubmit={handleUrlDownloadSubmit} style={{ display: "flex", gap: "8px" }}>
<input
type="text"
className="m3-input"
style={{ flex: 1, height: "40px" }}
placeholder="e.g. https://huggingface.co/..."
value={downloadUrl}
onChange={(e) => setDownloadUrl(e.target.value)}
disabled={downloadingModelId !== null}
/>
<button
type="submit"
className="m3-btn m3-btn-filled"
style={{ height: "40px" }}
disabled={downloadingModelId !== null}
>
Download
</button>
</form>
)}
</div>
</div>
{pendingLoadModel && (
<div style={{ position: "fixed", inset: 0, zIndex: 50, background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }}>
<div className="m3-card" style={{ maxWidth: "460px", width: "100%", margin: 0, border: "1px solid var(--md-sys-color-outline-variant)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px" }}>
<AlertTriangle size={22} style={{ color: "var(--md-sys-color-error)" }} />
<h3 style={{ fontSize: "1.05rem", fontWeight: 700 }}>Unload Current Model?</h3>
</div>
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.45, marginBottom: "16px" }}>
"{activeModel}" is already loaded. Unload it before loading "{pendingLoadModel}".
</p>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
<button className="m3-btn m3-btn-outlined" onClick={() => setPendingLoadModel(null)} disabled={isUnloading}>
Cancel
</button>
<button className="m3-btn m3-btn-error" onClick={handleUnloadModel} disabled={isUnloading}>
<Trash2 size={14} />
<span>Unload Only</span>
</button>
<button className="m3-btn m3-btn-filled" onClick={handleUnloadThenLoad} disabled={isUnloading}>
{isUnloading ? <RefreshCw className="progress-spinner" size={14} /> : <RefreshCw size={14} />}
<span>Unload and Load</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default memo(ModelManager);
+65
View File
@@ -0,0 +1,65 @@
import React, { memo } from "react";
import { Image, FolderDown, Sliders, Shield, Terminal } from "lucide-react";
function Sidebar({ activeTab, setActiveTab, specs }) {
return (
<div className="sidebar">
<div>
{/* Sidebar Header */}
<div className="sidebar-logo">
<Shield className="sidebar-logo-icon" />
<span className="sidebar-logo-text">Local AI Studio</span>
</div>
{/* Sidebar Navigation Links (Material 3 style) */}
<div className="nav-list">
<div
className={`nav-item ${activeTab === "generator" ? "active" : ""}`}
onClick={() => setActiveTab("generator")}
>
<Image size={20} />
<span>Image Generator</span>
</div>
<div
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
onClick={() => setActiveTab("models")}
>
<FolderDown size={20} />
<span>Model Manager</span>
</div>
<div
className={`nav-item ${activeTab === "constraints" ? "active" : ""}`}
onClick={() => setActiveTab("constraints")}
>
<Sliders size={20} />
<span>Image Constraints</span>
</div>
</div>
</div>
{/* Sidebar Footer with Host Telemetry System Specs */}
<div className="sidebar-footer">
<div className="sidebar-specs-header">
<Terminal size={12} />
<span>Host Specifications</span>
</div>
<div className="sidebar-specs-item" title={specs.cpu_name}>
CPU: {specs.cpu_name}
</div>
<div className="sidebar-specs-item" title={specs.gpu_name}>
GPU: {specs.gpu_name}
</div>
<div className="sidebar-specs-item">
Memory: {specs.ram_total_gb.toFixed(0)} GB RAM ({specs.cpu_cores_physical} Cores)
</div>
<div className="sidebar-specs-os">
OS: {specs.os_name}
</div>
</div>
</div>
);
}
export default memo(Sidebar);
@@ -0,0 +1,83 @@
import React, { memo } from "react";
import { Cpu, HardDrive, Database, Square, RefreshCw, Sun, Moon } from "lucide-react";
function TopStatusBar({ telemetry, serverRunning, activeModel, onStopServer, isStoppingServer = false, theme, setTheme }) {
const formatGb = (value) => {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number.toFixed(number >= 10 ? 0 : 1) : "--";
};
const getStatusText = () => {
if (serverRunning) return "Server Active";
return "Local Mode";
};
const getStatusClass = () => {
if (serverRunning) return "status-indicator busy";
return "status-indicator offline";
};
return (
<div className="top-status-bar">
<div className="current-model-info">
<div className={getStatusClass()}></div>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
{getStatusText()}
</span>
{activeModel && (
<>
<span style={{ color: "var(--md-sys-color-outline-variant)" }}>|</span>
<span style={{ color: "var(--md-sys-color-primary)", fontWeight: 700 }}>
{activeModel}
</span>
</>
)}
</div>
<div className="telemetry-group">
<button
className="theme-toggle-btn"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
{serverRunning && (
<button
className="m3-btn m3-btn-error"
style={{ height: "34px", padding: "0 14px" }}
onClick={onStopServer}
disabled={isStoppingServer}
title="Stop local model server"
>
{isStoppingServer ? <RefreshCw className="progress-spinner" size={14} /> : <Square size={14} />}
<span>{isStoppingServer ? "Stopping" : "Stop Server"}</span>
</button>
)}
{/* CPU Telemetry Chip */}
<div className="telemetry-chip" title="CPU Utilization">
<Cpu className="telemetry-chip-icon" style={{ color: "var(--md-sys-color-primary)" }} />
<span>CPU: {Number.isFinite(Number(telemetry.cpu_usage)) ? telemetry.cpu_usage : "--"}%</span>
</div>
{/* RAM Telemetry Chip */}
<div className="telemetry-chip" title="System Memory Usage">
<HardDrive className="telemetry-chip-icon" style={{ color: "var(--md-sys-color-secondary)" }} />
<span>RAM: {formatGb(telemetry.ram_used_gb)} / {formatGb(telemetry.ram_total_gb)} GB</span>
</div>
{/* GPU VRAM Telemetry Chip */}
{telemetry.vram_total_gb > 0 && (
<div className="telemetry-chip" title={`${telemetry.gpu_name} VRAM`}>
<Database className="telemetry-chip-icon" style={{ color: "var(--md-sys-color-tertiary)" }} />
<span>VRAM: {formatGb(telemetry.vram_used_gb)} / {formatGb(telemetry.vram_total_gb)} GB</span>
</div>
)}
</div>
</div>
);
}
export default memo(TopStatusBar);
+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+810
View File
@@ -0,0 +1,810 @@
// Tauri API helper for desktop mode, falling back to HTTP/Mock in browser
import { invoke } from "@tauri-apps/api/core";
// Helper to check if running inside Tauri desktop container
export const isTauri = () => {
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
};
// Cached hardware specs
let cachedSpecs = null;
export const EXPECTED_SERVER_BUILD = "polish-setup-v1";
const isLocalServerMode = () => {
return typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
};
export function formatBytes(bytes) {
const value = Number(bytes) || 0;
if (value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
return `${(value / (1024 ** idx)).toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
}
export function normalizeModel(model) {
if (typeof model === "string") {
return { filename: model, sizeBytes: 0, size: "Unknown" };
}
return {
filename: model?.filename || model?.name || "",
sizeBytes: Number(model?.sizeBytes || 0),
size: model?.size || (model?.sizeBytes ? formatBytes(model.sizeBytes) : "Unknown"),
};
}
async function readJsonResponse(res, fallbackMessage = "The local server returned an invalid response.") {
const text = await res.text();
let data = {};
try {
data = JSON.parse(text || "{}");
} catch (_) {
const looksLikeHtml = text.trim().startsWith("<!doctype") || text.trim().startsWith("<html");
throw new Error(looksLikeHtml ? "The local server is serving an older frontend/API. Restart the image generator." : fallbackMessage);
}
if (!res.ok || data.ok === false) {
if (data.error === "Unknown API endpoint") {
throw new Error("Restart the image generator so the local server loads the latest API.");
}
throw new Error(data.error || `Request failed (HTTP ${res.status})`);
}
return data;
}
export async function getHealth() {
try {
const res = await fetch("/api/health");
const data = await readJsonResponse(res, "The local server returned an invalid health response.");
return {
...data,
stale: data.build !== EXPECTED_SERVER_BUILD,
};
} catch (err) {
return {
ok: false,
stale: true,
build: "unknown",
issues: [err.message || "Could not reach the local server."],
checks: [],
ports: {},
};
}
}
export async function getDiagnostics() {
const res = await fetch("/api/diagnostics");
return await readJsonResponse(res, "The local server returned invalid diagnostics.");
}
export async function getCleanupCandidates() {
const res = await fetch("/api/cleanup-candidates");
const data = await readJsonResponse(res, "The local server returned invalid cleanup data.");
return data.candidates || [];
}
export async function cleanupCandidates(ids) {
const res = await fetch("/api/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
return await readJsonResponse(res, "The local server returned invalid cleanup data.");
}
// Get CPU and GPU specifications
export async function getHardwareSpecs() {
if (cachedSpecs) return cachedSpecs;
if (isTauri()) {
try {
cachedSpecs = await invoke("get_hardware_specs");
return cachedSpecs;
} catch (e) {
console.warn("Failed to get hardware specs via Tauri, using fallback:", e);
}
}
if (isLocalServerMode()) {
try {
const res = await fetch("/api/hardware-specs");
if (res.ok) {
cachedSpecs = await res.json();
return cachedSpecs;
}
} catch (e) {
console.warn("Failed to get hardware specs from local server:", e);
}
}
// Static preview fallback. Do not invent host hardware.
cachedSpecs = {
os_name: "Unavailable",
cpu_name: "Unavailable",
cpu_cores_physical: 4,
cpu_cores_logical: 4,
ram_total_gb: 0,
gpu_name: "Unavailable",
};
return cachedSpecs;
}
// Get CPU/RAM/VRAM real-time utilization
export async function getTelemetry() {
if (isTauri()) {
try {
const stats = await invoke("get_telemetry");
return stats;
} catch (e) {
// Ignore and use fallback
}
}
if (isLocalServerMode()) {
try {
const res = await fetch("/api/telemetry");
if (res.ok) {
return await res.json();
}
} catch (_) {}
}
return {
cpu_usage: 0,
ram_used_gb: 0,
ram_total_gb: 0,
gpu_name: "Unavailable",
vram_used_gb: 0,
vram_total_gb: 0,
};
}
export async function getBackendOptions() {
if (isLocalServerMode()) {
try {
const res = await fetch("/api/backend-options");
if (res.ok) {
return await res.json();
}
} catch (e) {
console.warn("Failed to get backend options from local server:", e);
}
}
return {
options: [{ id: "cpu", label: "CPU", available: true }],
cudaAvailable: false,
vulkanAvailable: false,
defaultBackendType: "cpu",
};
}
// Get list of model files on the USB
export async function listLocalModels() {
if (isTauri()) {
try {
return await invoke("list_local_models");
} catch (e) {
console.warn("Failed to list local models via Tauri:", e);
}
}
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
if (isLocalServerMode) {
return await listModelsFromDisk();
}
// Static preview fallback: show only models the user imported in this browser.
let saved = localStorage.getItem("imported-models");
if (saved === null) {
localStorage.setItem("imported-models", JSON.stringify([]));
saved = "[]";
}
const imported = JSON.parse(saved);
return imported.map(normalizeModel);
}
// Start (or restart) backend stable-diffusion.cpp server with correct CLI flags
// In web/portable mode this calls serve.cjs management API which restarts the
// sd-vulkan.exe process with --steps, --cfg-scale, --sampling-method flags.
export async function startServer(modelPath, constraints) {
if (isTauri()) {
const launchParams = {
model_path: modelPath,
port: 8080,
use_gpu: constraints.useGpu !== false,
backend_type: constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"),
threads: constraints.threads || 8,
};
return await invoke("start_server", { params: launchParams });
}
// Web/portable mode — call serve.cjs management API
const modelName = modelPath ? modelPath.split(/[\\/]/).pop() : null;
try {
const res = await fetch("/api/restart-backend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
steps: constraints.steps || 20,
cfgScale: constraints.cfgScale || 7.0,
sampler: constraints.sampler || "euler_a",
threads: constraints.threads || 8,
use_gpu: constraints.useGpu !== false,
backend_type: constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"),
}),
});
const text = await res.text();
const data = JSON.parse(text || "{}");
if (!res.ok || data.ok === false) {
throw new Error(data.error || `Backend restart failed (HTTP ${res.status})`);
}
console.log("Backend restart:", data.message);
return data.message;
} catch (e) {
console.warn("Could not reach management API:", e.message);
if (isLocalServerMode()) {
throw e;
}
return "Backend management unavailable";
}
}
// Stop backend server
export async function stopServer() {
if (isTauri()) {
return await invoke("stop_server");
}
try {
await fetch("/api/stop-backend", { method: "POST" });
} catch (_) {}
return "Backend stopped";
}
// Get current backend status and active settings
export async function getBackendStatus() {
try {
const r = await fetch("/api/backend-status");
return await r.json();
} catch (_) {
return { ready: false, settings: {} };
}
}
export async function listGeneratedOutputs() {
try {
const res = await fetch("/api/outputs");
const data = await res.json();
return data.outputs || [];
} catch (_) {
return [];
}
}
export async function saveGeneratedOutput(image, metadata) {
const res = await fetch("/api/save-output", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image, metadata }),
});
const data = await readJsonResponse(res, "The local server returned an invalid save response.");
return data.output;
}
export async function deleteGeneratedOutputs(outputs) {
const res = await fetch("/api/delete-outputs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outputs }),
});
return await readJsonResponse(res, "The local server returned an invalid delete response.");
}
// List model files from the models folder (via management API in web mode)
export async function listModelsFromDisk() {
try {
const r = await fetch("/api/models");
const data = await r.json();
return (data.models || []).map(normalizeModel);
} catch (_) {
return [];
}
}
// Generate image (T2I / I2I)
// Handles API calls to sd-server or mocks them if server is unreachable
export async function generateImage(prompt, negativePrompt, constraints, activeModelName, inputImageBase64, onProgress, signal) {
console.log("Initiating image generation:", { prompt, negativePrompt, constraints, activeModelName });
const port = 8080;
const baseUrl = `http://127.0.0.1:${port}`;
// Prepare payload based on standard stable-diffusion.cpp REST endpoint schemas
const payload = {
prompt: prompt,
negative_prompt: negativePrompt || "",
width: constraints.width || 512,
height: constraints.height || 512,
steps: constraints.steps || 20,
cfg_scale: constraints.cfgScale || 7.0,
seed: constraints.seed === -1 ? Math.floor(Math.random() * 1000000) : constraints.seed,
sampler: constraints.sampler || "euler_a",
image: inputImageBase64 || null, // Image to image source (base64)
denoising_strength: constraints.denoisingStrength || 0.7,
};
// Mock generation helper for offline/browser execution
const runMockGeneration = async () => {
// Simulate generation steps progress
const stepsCount = payload.steps;
for (let i = 1; i <= stepsCount; i++) {
if (signal && signal.aborted) {
throw new DOMException("The user aborted a request.", "AbortError");
}
await new Promise(r => setTimeout(r, (3000 / stepsCount))); // 3 seconds total
if (onProgress) {
onProgress(Math.round((i / stepsCount) * 100));
}
}
const pLower = prompt.toLowerCase();
const seedVal = Math.abs(payload.seed) || Math.floor(Math.random() * 1000);
// Generate a beautiful, local vector SVG representing the requested subject, completely offline-compatible
const primaryBg = "#0F172A"; // dark premium background
let gradientStart = "#3B82F6";
let gradientEnd = "#8B5CF6";
let sceneShapes = "";
let keywordLabel = "Abstract Generation";
if (pLower.includes("moon") || pLower.includes("space") || pLower.includes("astronomy") || pLower.includes("sky")) {
gradientStart = "#1E293B";
gradientEnd = "#0F172A";
keywordLabel = "Luminous Moon in Starry Sky";
sceneShapes = `
<!-- Stars -->
<circle cx="80" cy="100" r="1.5" fill="#ffffff" opacity="0.6"/>
<circle cx="160" cy="60" r="1" fill="#ffffff" opacity="0.5"/>
<circle cx="280" cy="120" r="2" fill="#ffffff" opacity="0.8"/>
<circle cx="360" cy="80" r="1.5" fill="#ffffff" opacity="0.4"/>
<circle cx="440" cy="150" r="1" fill="#ffffff" opacity="0.7"/>
<circle cx="400" cy="280" r="2" fill="#ffffff" opacity="0.9"/>
<circle cx="100" cy="300" r="1.5" fill="#ffffff" opacity="0.5"/>
<!-- Moon -->
<circle cx="256" cy="220" r="90" fill="url(#moonGrad)" />
<circle cx="226" cy="190" r="80" fill="#0F172A" />
`;
} else if (pLower.includes("cat") || pLower.includes("kitten") || pLower.includes("feline")) {
gradientStart = "#4F46E5";
gradientEnd = "#7C3AED";
keywordLabel = "Stylized Cat Portrait";
sceneShapes = `
<!-- Cat head & ears -->
<polygon points="176,280 146,160 216,210" fill="#312E81" />
<polygon points="336,280 366,160 296,210" fill="#312E81" />
<circle cx="256" cy="270" r="80" fill="#4338CA" />
<!-- Eyes -->
<ellipse cx="226" cy="260" rx="14" ry="8" fill="#10B981" />
<ellipse cx="286" cy="260" rx="14" ry="8" fill="#10B981" />
<circle cx="226" cy="260" r="4" fill="#000" />
<circle cx="286" cy="260" r="4" fill="#000" />
<!-- Nose & Whiskers -->
<polygon points="256,285 248,275 264,275" fill="#F43F5E" />
<line x1="226" y1="290" x2="166" y2="280" stroke="#E2E8F0" stroke-width="2" />
<line x1="226" y1="295" x2="156" y2="295" stroke="#E2E8F0" stroke-width="2" />
<line x1="286" y1="290" x2="346" y2="280" stroke="#E2E8F0" stroke-width="2" />
<line x1="286" y1="295" x2="356" y2="295" stroke="#E2E8F0" stroke-width="2" />
`;
} else if (pLower.includes("dog") || pLower.includes("puppy") || pLower.includes("canine")) {
gradientStart = "#D97706";
gradientEnd = "#B45309";
keywordLabel = "Friendly Dog Portrait";
sceneShapes = `
<!-- Dog head & floppy ears -->
<ellipse cx="166" cy="260" rx="20" ry="60" fill="#78350F" />
<ellipse cx="346" cy="260" rx="20" ry="60" fill="#78350F" />
<circle cx="256" cy="250" r="70" fill="#92400E" />
<ellipse cx="256" cy="280" rx="40" ry="30" fill="#D97706" />
<!-- Eyes -->
<circle cx="226" cy="230" r="10" fill="#1E293B" />
<circle cx="286" cy="230" r="10" fill="#1E293B" />
<circle cx="222" cy="226" r="3" fill="#ffffff" />
<circle cx="282" cy="226" r="3" fill="#ffffff" />
<!-- Nose -->
<ellipse cx="256" cy="275" rx="16" ry="10" fill="#1E293B" />
`;
} else if (pLower.includes("city") || pLower.includes("cyberpunk") || pLower.includes("tokyo") || pLower.includes("neon")) {
gradientStart = "#111827";
gradientEnd = "#311042";
keywordLabel = "Neon Cyberpunk Skyline";
sceneShapes = `
<!-- Skyline -->
<rect x="60" y="160" width="80" height="352" fill="#1F2937" opacity="0.9"/>
<rect x="180" y="100" width="100" height="412" fill="#111827" />
<rect x="320" y="200" width="120" height="312" fill="#1F2937" opacity="0.85"/>
<rect x="100" y="240" width="110" height="272" fill="#374151" opacity="0.6"/>
<!-- Neon details -->
<line x1="230" y1="100" x2="230" y2="40" stroke="#EC4899" stroke-width="3" />
<rect x="200" y="140" width="60" height="15" fill="#10B981" opacity="0.7"/>
<rect x="200" y="170" width="60" height="15" fill="#10B981" opacity="0.7"/>
<rect x="200" y="200" width="60" height="15" fill="#3B82F6" opacity="0.7"/>
<rect x="70" y="200" width="15" height="100" fill="#F59E0B" opacity="0.5"/>
<rect x="340" y="250" width="20" height="150" fill="#EF4444" opacity="0.6"/>
`;
} else if (pLower.includes("car") || pLower.includes("vehicle") || pLower.includes("supercar")) {
gradientStart = "#DC2626";
gradientEnd = "#991B1B";
keywordLabel = "Futuristic Concept Car";
sceneShapes = `
<!-- Road / Perspective grid -->
<line x1="256" y1="350" x2="0" y2="512" stroke="#4B5563" stroke-width="3" />
<line x1="256" y1="350" x2="512" y2="512" stroke="#4B5563" stroke-width="3" />
<!-- Car body -->
<polygon points="126,380 256,330 386,380 346,440 166,440" fill="#EF4444" />
<polygon points="176,370 256,340 336,370 326,390 186,390" fill="#1E293B" opacity="0.9" />
<!-- Headlights -->
<polygon points="136,410 176,415 166,425 136,420" fill="#FBBF24" />
<polygon points="376,410 336,415 346,425 376,420" fill="#FBBF24" />
<!-- Wheels -->
<ellipse cx="156" cy="440" rx="30" ry="15" fill="#111" />
<ellipse cx="356" cy="440" rx="30" ry="15" fill="#111" />
`;
} else {
// General gradient waves
gradientStart = "#06B6D4";
gradientEnd = "#0891B2";
keywordLabel = "Abstract Creative Concept";
sceneShapes = `
<path d="M0,256 C150,150 350,350 512,256 L512,512 L0,512 Z" fill="#0891B2" opacity="0.4" />
<path d="M0,350 C200,200 300,450 512,350 L512,512 L0,512 Z" fill="#0E7490" opacity="0.6" />
<circle cx="380" cy="180" r="50" fill="#F59E0B" opacity="0.8" />
`;
}
const svgString = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${gradientStart}" />
<stop offset="100%" stop-color="${gradientEnd}" />
</linearGradient>
<linearGradient id="moonGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FEF08A" />
<stop offset="100%" stop-color="#FDE047" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" fill="url(#bgGrad)" />
<!-- Shapes -->
${sceneShapes}
<!-- Premium UI Overlay Frame -->
<rect x="20" y="20" width="472" height="472" fill="none" stroke="#ffffff" stroke-width="1.5" opacity="0.15" rx="8"/>
<!-- Prompt & Details text -->
<rect x="40" y="410" width="432" height="62" fill="#0F172A" opacity="0.85" rx="6" />
<text x="60" y="435" fill="#F1F5F9" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="bold">${keywordLabel}</text>
<text x="60" y="455" fill="#94A3B8" font-family="system-ui, -apple-system, sans-serif" font-size="11">Seed: ${seedVal} • Steps: ${payload.steps} • Scale: ${payload.cfg_scale}</text>
</svg>
`;
const base64Svg = btoa(unescape(encodeURIComponent(svgString)));
const mockImageUrl = `data:image/svg+xml;base64,${base64Svg}`;
return {
image: mockImageUrl,
seed: payload.seed,
duration_sec: 3.0,
};
};
// stable-diffusion.cpp server only exposes /v1/images/generations (OpenAI-compat)
// Steps, cfg_scale, seed, sample_method are passed in the body and read by the server
const isImg2Img = !!payload.image;
const genBody = {
prompt: payload.prompt,
negative_prompt: payload.negative_prompt || "",
n: 1,
size: `${payload.width}x${payload.height}`,
response_format: "b64_json",
// Generation parameters — read by stable-diffusion.cpp from the request body
steps: payload.steps,
cfg_scale: payload.cfg_scale,
seed: payload.seed,
sample_method: payload.sampler || "euler_a",
};
// img2img extra fields
if (isImg2Img) {
genBody.init_images = [payload.image];
genBody.denoising_strength = payload.denoising_strength || 0.7;
}
// Attempt real HTTP call
try {
const response = await fetch(`${baseUrl}/v1/images/generations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: signal,
body: JSON.stringify(genBody),
});
if (response.ok) {
const data = await response.json();
// Response: { data: [{ b64_json: "..." }] }
const imgB64 = data?.data?.[0]?.b64_json ?? data?.images?.[0];
if (imgB64) {
return {
image: `data:image/png;base64,${imgB64}`,
seed: data.data?.[0]?.seed ?? payload.seed,
duration_sec: 4.5,
};
}
} else {
let errMsg = `Generation failed (HTTP ${response.status})`;
try {
const errJson = await response.json();
if (errJson?.detail || errJson?.error?.message) {
errMsg = errJson.detail || errJson.error.message;
}
} catch (_) {}
throw new Error(errMsg);
}
} catch (err) {
if (err.name === "AbortError" || err.message.startsWith("Generation failed")) throw err;
console.warn("Could not reach local server. Falling back to simulation mode.", err);
}
// Fallback to mock generation if server is offline/not reachable
return await runMockGeneration();
}
// Perform model file import (copy to USB in Tauri, simulated in Web mode)
export async function importModelFile(sourcePath, onProgress, signal) {
if (sourcePath instanceof File) {
const file = sourcePath;
const isLocalMode = isLocalServerMode();
if (!isLocalMode) {
throw new Error("File import requires the local image generator server.");
}
await uploadModelFile(file, onProgress, signal);
return { response: "Model imported successfully" };
}
if (isTauri()) {
const { listen } = await import("@tauri-apps/api/event");
let unlisten = null;
if (onProgress) {
unlisten = await listen("import-progress", (event) => {
onProgress(event.payload);
});
}
try {
const response = await invoke("import_model_file", { sourcePath });
return { response, unlisten };
} catch (e) {
if (unlisten) unlisten();
throw e;
}
}
// Fallback simulation in browser
const filename = sourcePath.split(/[\\/]/).pop() || "imported_model.gguf";
console.log(`Web Mode: Simulating copying ${filename} to USB models folder`);
const totalSteps = 40;
const start = Date.now();
for (let i = 1; i <= totalSteps; i++) {
if (signal?.aborted) {
throw new DOMException("The user aborted a request.", "AbortError");
}
await new Promise((r) => setTimeout(r, 150)); // ~6 seconds copy total
const progress = (i / totalSteps) * 100;
const elapsedSecs = (Date.now() - start) / 1000;
const speed = 40 + Math.sin(i) * 5 + Math.random() * 2; // ~42 MB/s
const eta = (totalSteps - i) * 0.15;
if (onProgress) {
onProgress({
filename,
progress,
speed_mb_s: speed,
eta_secs: eta,
status: "Copying to USB..."
});
}
}
// Save to localStorage
const saved = localStorage.getItem("imported-models");
const imported = saved ? JSON.parse(saved) : [];
if (!imported.includes(filename)) {
imported.push({ filename, sizeBytes: 0, size: "Unknown" });
localStorage.setItem("imported-models", JSON.stringify(imported));
}
return { response: "Model imported successfully" };
}
function uploadModelFile(file, onProgress, signal) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const startedAt = Date.now();
let abortedByUser = false;
xhr.open("POST", `/api/import-model?filename=${encodeURIComponent(file.name)}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const abortUpload = () => {
abortedByUser = true;
xhr.abort();
};
if (signal) {
if (signal.aborted) {
abortUpload();
} else {
signal.addEventListener("abort", abortUpload, { once: true });
}
}
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !onProgress) return;
const elapsedSecs = Math.max(0.1, (Date.now() - startedAt) / 1000);
const speedBytes = event.loaded / elapsedSecs;
const remainingBytes = Math.max(0, event.total - event.loaded);
onProgress({
filename: file.name,
progress: (event.loaded / event.total) * 100,
speed_mb_s: speedBytes / (1024 * 1024),
eta_secs: remainingBytes / Math.max(1, speedBytes),
status: "Copying to models folder..."
});
};
xhr.onload = () => {
let data = {};
try {
data = JSON.parse(xhr.responseText || "{}");
} catch (_) {}
if (xhr.status >= 200 && xhr.status < 300 && data.ok !== false) {
if (onProgress) {
onProgress({
filename: file.name,
progress: 100,
speed_mb_s: 0,
eta_secs: 0,
status: "Import complete"
});
}
resolve(data);
} else {
reject(new Error(data.error || `Import failed (HTTP ${xhr.status})`));
}
};
xhr.onerror = () => reject(new Error("Import failed while uploading the file."));
xhr.onabort = () => reject(new DOMException(abortedByUser ? "Import cancelled by user." : "Import aborted.", "AbortError"));
xhr.send(file);
});
}
// Delete model file (Tauri disk deletion or localStorage cleanup)
export async function deleteModel(filename) {
if (isTauri()) {
return await invoke("delete_model_file", { filename });
}
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
if (isLocalServerMode) {
try {
const res = await fetch("/api/delete-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to delete model file");
}
return data;
} catch (err) {
console.error("Failed to delete model via local API:", err);
throw err;
}
}
// Browser Mode
const saved = localStorage.getItem("imported-models");
let imported = saved ? JSON.parse(saved) : [];
imported = imported.filter((model) => normalizeModel(model).filename !== filename);
localStorage.setItem("imported-models", JSON.stringify(imported));
return `Simulated deletion of ${filename} from browser session`;
}
// Ping the server to check if it is active and responding
export async function pingServer() {
const port = 8080;
const baseUrl = `http://127.0.0.1:${port}`;
try {
const response = await fetch(`${baseUrl}/v1/models`, { method: "GET" });
return response.ok;
} catch (e) {
return false;
}
}
// Wait for the server to be ready by polling
export async function waitForServerReady(maxAttempts = 30) {
const attempts = isTauri() || isLocalServerMode() ? maxAttempts : 3;
for (let i = 0; i < attempts; i++) {
let ok = false;
if (isLocalServerMode()) {
const status = await getBackendStatus();
ok = Boolean(status.ready);
} else {
ok = await pingServer();
}
if (ok) return true;
await new Promise((r) => setTimeout(r, 500)); // check every 500ms
}
return false;
}
// Start model file download from a URL on the server
export async function downloadModel(url) {
try {
const res = await fetch("/api/download-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
return await res.json();
} catch (e) {
console.error("Failed to start model download:", e);
return { ok: false, error: e.message };
}
}
export async function cancelModelDownload() {
try {
const res = await fetch("/api/cancel-download", { method: "POST" });
return await res.json();
} catch (e) {
console.error("Failed to cancel model download:", e);
return { ok: false, error: e.message };
}
}
// Get the server-side model download progress
export async function getDownloadProgress() {
try {
const res = await fetch("/api/download-progress");
return await res.json();
} catch (e) {
console.error("Failed to get download progress:", e);
return { active: false, error: e.message };
}
}
// Get the server-side image generation progress
export async function getGenerationProgress() {
try {
const res = await fetch("/api/generation-progress");
return await res.json();
} catch (e) {
console.error("Failed to get generation progress:", e);
return { active: false, error: e.message };
}
}
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
build: {
// Output to app/dist/ so serve.cjs can find it
outDir: "../dist",
emptyOutDir: true,
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
proxy: {
"/txt2img": { target: "http://127.0.0.1:8080", changeOrigin: true },
"/img2img": { target: "http://127.0.0.1:8080", changeOrigin: true },
"/v1": { target: "http://127.0.0.1:8080", changeOrigin: true },
"/api": { target: "http://127.0.0.1:1422", changeOrigin: true },
}
},
}));