Initial commit
This commit is contained in:
commit
a9460556e4
24 files changed
+9226
No files matched your search
@@ -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?
|
||||
@@ -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>
|
||||
Generated
+2028
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user