implement modular frontend components, backend services, and multi-platform configuration scripts for the local AI image generator
This commit is contained in:
1 parent
c280683a9d
commit
4a840679d6
14 files changed
+1114
-2089
No files matched your search
Generated
-2028
File diff suppressed because it is too large.
Load diff
@@ -769,6 +769,7 @@ function App() {
|
||||
activeModel={activeModel}
|
||||
specs={specs}
|
||||
backendOptions={backendOptions}
|
||||
setBackendOptions={setBackendOptions}
|
||||
serverRunning={serverRunning}
|
||||
setServerRunning={setServerRunning}
|
||||
setActiveModel={setActiveModel}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}.`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user