implement modular frontend components, backend services, and multi-platform configuration scripts for the local AI image generator

This commit is contained in:
techjarves committed 2026-06-23 05:48:26 +05:30
1 parent c280683a9d
commit 4a840679d6
14 files changed
+1114 -2089

No files matched your search

+1
View File
@@ -769,6 +769,7 @@ function App() {
activeModel={activeModel}
specs={specs}
backendOptions={backendOptions}
setBackendOptions={setBackendOptions}
serverRunning={serverRunning}
setServerRunning={setServerRunning}
setActiveModel={setActiveModel}
+5 -2
View File
@@ -183,8 +183,11 @@ function Generator({
const settings = status.settings;
const currentModelName = settings.model ? settings.model.split(/[\\/]/).pop() : null;
const targetModelName = activeModel ? activeModel.split(/[\\/]/).pop() : null;
const requestedBackend = constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto");
const currentBackend = settings.backendType || (settings.useGpu === false ? "cpu" : "auto");
const shouldCompareModel = requestedBackend !== "openvino-npu";
if (currentModelName !== targetModelName ||
if ((shouldCompareModel && currentModelName !== targetModelName) ||
parseInt(settings.steps) !== parseInt(constraints.steps) ||
Math.abs(parseFloat(settings.cfgScale) - parseFloat(constraints.cfgScale)) > 0.05 ||
settings.sampler !== constraints.sampler ||
@@ -192,7 +195,7 @@ function Generator({
Boolean(settings.useGpu) !== (constraints.useGpu !== false) ||
parseInt(settings.width || 512) !== parseInt(constraints.width || 512) ||
parseInt(settings.height || 512) !== parseInt(constraints.height || 512) ||
(settings.backendType || (settings.useGpu === false ? "cpu" : "auto")) !== (constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"))) {
currentBackend !== requestedBackend) {
needsRestart = true;
}
} else {
+45 -2
View File
@@ -726,6 +726,44 @@ function ModelManager({
return;
}
const isOpenVinoSelected = constraints.backendType === "openvino-npu";
const isOpenVinoModel = modelInfo.backendType === "openvino-npu";
const isCoreMLModel = modelInfo.format === "CoreML" || modelInfo.backendType === "apple-npu";
const isStandardWeights = /\.(safetensors|ckpt|gguf)$/i.test(modelId) || (!isOpenVinoModel && !isCoreMLModel);
if (isOpenVinoSelected && isStandardWeights) {
const gpuBackend = backendOptions?.options?.find((backend) =>
["vulkan", "cuda", "rocm", "metal"].includes(backend.id)
);
const cpuBackend = backendOptions?.options?.find((backend) => backend.id === "cpu");
const targetBackend = gpuBackend || cpuBackend;
if (!targetBackend) {
await showAlert({
title: "Switch Backend Required",
message: `"${modelId}" is a standard weights file. OpenVINO NPU can only load downloaded OpenVINO model folders, and no GPU or CPU backend is currently installed.`,
danger: true,
});
return;
}
const confirmed = await showConfirm({
title: "Switch Accelerator?",
message: `"${modelId}" cannot load on OpenVINO NPU. Switch to ${targetBackend.label} and load it there?`,
confirmLabel: `Switch to ${targetBackend.label}`,
cancelLabel: "Cancel",
});
if (!confirmed) return;
const nextConstraints = {
...constraints,
backendType: targetBackend.id,
useGpu: targetBackend.id !== "cpu",
};
setConstraints(nextConstraints);
await performLoadModel(modelId, nextConstraints);
return;
}
const totalSizeBytes = Number(modelInfo.sizeBytes) || 0;
if (totalSizeBytes <= 0) {
await performLoadModel(modelId);
@@ -944,6 +982,7 @@ function ModelManager({
let isReady = false;
let crashError = null;
let readyStatus = null;
const isOpenVinoModel = modelInfo?.backendType === "openvino-npu";
const maxStartupPolls = (isOpenVinoModel || isCoreMLModel) ? 1200 : 240;
for (let i = 0; i < maxStartupPolls; i++) {
@@ -967,6 +1006,7 @@ function ModelManager({
}
if (status.ready) {
isReady = true;
readyStatus = status;
break;
}
if (status.error) {
@@ -981,6 +1021,9 @@ function ModelManager({
}
if (isReady) {
const loadedModelId = readyStatus?.settings?.backendType === "openvino-npu"
? (readyStatus.settings.model || readyStatus.loading?.model || modelId)
: modelId;
setModelLoadProgress((prev) => ({
...(prev || {}),
progress: 100,
@@ -988,9 +1031,9 @@ function ModelManager({
speed: "",
current: prev?.current || 0,
total: prev?.total || 0,
model: modelId,
model: loadedModelId,
}));
setActiveModel(modelId);
setActiveModel(loadedModelId);
setServerRunning(true);
} else {
throw new Error(crashError || `Model server failed to respond on port ${backendPort}.`);
+179 -7
View File
@@ -3,9 +3,22 @@ import {
Crop, Sliders, Cpu, Info, MessageSquare, SlidersHorizontal, Zap,
ChevronDown, Image, Type, Settings2, Gauge, Brain, Sparkles,
Monitor, HardDrive, MemoryStick, Thermometer, Hash, Layers,
ChevronRight, Box, Wand2, Lightbulb, RotateCcw, Check, Palette, Volume2
ChevronRight, Box, Wand2, Lightbulb, RotateCcw, Check, Palette, Volume2,
DownloadCloud, RefreshCw
} from "lucide-react";
import { stopServer, formatBytes, getLlmBackends, getLlmStats, getLlmStatus, benchmarkLlm, startLlm, stopLlm } from "../services/api";
import {
stopServer,
formatBytes,
getLlmBackends,
getLlmStats,
getLlmStatus,
benchmarkLlm,
startLlm,
stopLlm,
downloadBackend,
getDownloadProgress,
getBackendOptions,
} from "../services/api";
import { THEMES } from "../themes";
const ASPECT_RATIOS = [
@@ -200,6 +213,7 @@ function Settings({
activeModel,
specs,
backendOptions,
setBackendOptions,
serverRunning,
setServerRunning,
setActiveModel,
@@ -225,6 +239,13 @@ function Settings({
const [llmBackends, setLlmBackends] = useState({ available: [], candidates: [] });
const [llmStats, setLlmStats] = useState({ benchmarks: [] });
const [benchmarkBusy, setBenchmarkBusy] = useState(false);
const [backendDownload, setBackendDownload] = useState({
active: false,
backendId: "",
progress: 0,
speed: "",
error: null,
});
const [expandedSections, setExpandedSections] = useState(() => {
return {
@@ -273,6 +294,68 @@ function Settings({
const availableBackends = backendOptions?.options?.length
? backendOptions.options
: [{ id: "cpu", label: "CPU", available: true }];
const unavailableBackends = Array.isArray(backendOptions?.unavailable)
? backendOptions.unavailable.filter((backend) => !availableBackends.some((available) => available.id === backend.id))
: [];
const visibleBackends = [...availableBackends, ...unavailableBackends.map((backend) => ({ ...backend, available: false }))];
const refreshBackendOptions = useCallback(async () => {
if (typeof setBackendOptions !== "function") return;
const nextOptions = await getBackendOptions();
setBackendOptions(nextOptions);
}, [setBackendOptions]);
useEffect(() => {
refreshBackendOptions().catch(() => {});
const handleFocus = () => refreshBackendOptions().catch(() => {});
const handleVisibility = () => {
if (document.visibilityState === "visible") handleFocus();
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [refreshBackendOptions]);
useEffect(() => {
if (!backendDownload.active) return undefined;
let cancelled = false;
const poll = async () => {
try {
const progress = await getDownloadProgress();
if (cancelled) return;
setBackendDownload((prev) => ({
...prev,
active: Boolean(progress.active),
progress: Number(progress.progress ?? prev.progress ?? 0),
speed: progress.speed || prev.speed || "",
error: progress.error || null,
}));
if (!progress.active && !progress.error) {
await refreshBackendOptions();
}
} catch (err) {
if (!cancelled) {
setBackendDownload((prev) => ({
...prev,
active: false,
error: err.message || "Download failed",
}));
}
}
};
poll();
const interval = setInterval(poll, 700);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [backendDownload.active, refreshBackendOptions]);
const updateConstraint = (key, value) => {
setConstraints((prev) => ({
@@ -466,6 +549,33 @@ function Settings({
}));
};
const handleBackendDownload = async (backend) => {
if (!backend?.id || backendDownload.active) return;
try {
setBackendDownload({
active: true,
backendId: backend.id,
progress: 0,
speed: "Starting",
error: null,
});
await downloadBackend(backend.id);
} catch (err) {
setBackendDownload({
active: false,
backendId: backend.id,
progress: 0,
speed: "",
error: err.message || "Download failed",
});
await showAlert({
title: "Backend Download Failed",
message: err.message || "Could not start backend download.",
danger: true,
});
}
};
// ─── Image Settings ───
const renderImageSettings = () => (
<>
@@ -667,17 +777,79 @@ function Settings({
<span className="m3-slider-label">Accelerator</span>
</div>
<div className="m3-segmented-button" style={{ flexWrap: "wrap" }}>
{availableBackends.map((b) => (
{visibleBackends.map((b) => {
const isAvailable = b.available !== false;
const isDownloading = backendDownload.active && backendDownload.backendId === b.id;
return (
<button
key={b.id}
className={`m3-segment-item ${constraints.backendType === b.id ? "active" : ""}`}
onClick={() => handleBackendChange(b.id)}
className={`m3-segment-item ${constraints.backendType === b.id ? "active" : ""} ${!isAvailable ? "disabled" : ""}`}
onClick={() => isAvailable && handleBackendChange(b.id)}
disabled={!isAvailable}
title={!isAvailable ? b.reason : undefined}
style={{ flex: "1 1 auto", minWidth: "80px" }}
>
{b.label}
{isDownloading ? `${Math.max(0, Math.min(100, Math.round(backendDownload.progress)))}%` : b.label}
</button>
))}
);
})}
</div>
{unavailableBackends.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "10px" }}>
{unavailableBackends.map((backend) => {
const isDownloading = backendDownload.active && backendDownload.backendId === backend.id;
const progress = Math.max(0, Math.min(100, Math.round(backendDownload.progress || 0)));
return (
<div
key={`download-${backend.id}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "10px",
padding: "10px",
border: "1px solid var(--md-sys-color-outline-variant)",
borderRadius: "8px",
background: "var(--md-sys-color-surface-container-low)",
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 700, color: "var(--md-sys-color-on-surface)" }}>
{backend.label}
</div>
<div className="settings-option-desc" title={backendDownload.error || backend.reason}>
{isDownloading
? `${progress}% ${backendDownload.speed ? `- ${backendDownload.speed}` : ""}`
: backendDownload.error && backendDownload.backendId === backend.id
? backendDownload.error
: backend.reason}
</div>
{isDownloading && (
<div className="model-progress-bar" style={{ marginTop: "6px" }}>
<div
className="model-progress-fill"
style={{
width: `${progress}%`,
transition: "width 0.2s ease",
}}
/>
</div>
)}
</div>
<button
className="m3-btn m3-btn-tonal"
onClick={() => handleBackendDownload(backend)}
disabled={backendDownload.active}
style={{ flexShrink: 0 }}
>
{isDownloading ? <RefreshCw className="progress-spinner" size={14} /> : <DownloadCloud size={14} />}
<span>{isDownloading ? "Downloading" : "Download"}</span>
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
+16 -2
View File
@@ -205,6 +205,15 @@ export async function getBackendOptions() {
};
}
export async function downloadBackend(backendId) {
const res = await fetch("/api/download-backend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ backend_id: backendId }),
});
return await readJsonResponse(res, "The local server returned an invalid backend download response.");
}
// Get list of model files on the USB
export async function listLocalModels() {
if (isTauri()) {
@@ -247,19 +256,24 @@ export async function startServer(modelPath, constraints) {
}
// Web/portable mode — call serve.cjs management API
const backendType = constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto");
const modelName = modelPath ? modelPath.split(/[\\/]/).pop() : null;
const isOpenVinoBackend = backendType === "openvino-npu";
const requestModel = isOpenVinoBackend && /\.(safetensors|ckpt|gguf)$/i.test(modelName || "")
? null
: modelName;
try {
const res = await fetch("/api/restart-backend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
model: requestModel,
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"),
backend_type: backendType,
width: constraints.width || 512,
height: constraints.height || 512,
vae_tiling: constraints.vaeTiling !== false,