feat: scaffold frontend application with main layout, navigation, and API service integration
This commit is contained in:
1 parent
43a85f8d24
commit
c170f963a4
8 files changed
+985
-456
No files matched your search
@@ -222,8 +222,8 @@ body {
|
||||
.text-chat-layout {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0px;
|
||||
padding: 20px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 Settings from "./components/Settings";
|
||||
import TextChat from "./components/TextChat";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, stopServer } from "./services/api";
|
||||
import "./App.css";
|
||||
@@ -114,6 +114,25 @@ function App() {
|
||||
backendType: "auto",
|
||||
});
|
||||
|
||||
const [textSettings, setTextSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("textSettings");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
contextSize: 4096,
|
||||
temperature: 0.7,
|
||||
systemPrompt: "You are a helpful local AI assistant.",
|
||||
threads: 4
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("textSettings", JSON.stringify(textSettings));
|
||||
}, [textSettings]);
|
||||
|
||||
// Load hardware specifications on mount
|
||||
useEffect(() => {
|
||||
async function loadSpecs() {
|
||||
@@ -129,6 +148,10 @@ function App() {
|
||||
backendType: prev.backendType === "auto" ? backendInfo.defaultBackendType || "cpu" : prev.backendType,
|
||||
useGpu: (prev.backendType === "auto" ? backendInfo.defaultBackendType : prev.backendType) !== "cpu",
|
||||
}));
|
||||
setTextSettings((prev) => ({
|
||||
...prev,
|
||||
threads: prev.threads || hardware.cpu_cores_physical || 4
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Error fetching hardware specs:", err);
|
||||
}
|
||||
@@ -458,6 +481,8 @@ function App() {
|
||||
setActiveTab={setActiveTab}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
specs={specs}
|
||||
textSettings={textSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -474,6 +499,8 @@ function App() {
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
activeTab={activeTab}
|
||||
specs={specs}
|
||||
textSettings={textSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -482,11 +509,15 @@ function App() {
|
||||
specs={specs}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
textSettings={textSettings}
|
||||
setTextSettings={setTextSettings}
|
||||
setActiveModel={setActiveModel}
|
||||
setServerRunning={setServerRunning}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "constraints" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<ImageConstraints
|
||||
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<Settings
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
activeModel={activeModel}
|
||||
@@ -497,6 +528,8 @@ function App() {
|
||||
setActiveModel={setActiveModel}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
textSettings={textSettings}
|
||||
setTextSettings={setTextSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import React, { memo, useState, useRef, useCallback } from "react";
|
||||
import { Sparkles, Download, Copy, RefreshCw, Check, Sliders, Trash2, ImagePlus } from "lucide-react";
|
||||
import { generateImage, startServer, stopServer, waitForServerReady, getBackendStatus, getGenerationProgress, saveGeneratedOutput, deleteGeneratedOutputs } from "../services/api";
|
||||
import {
|
||||
generateImage,
|
||||
startServer,
|
||||
stopServer,
|
||||
waitForServerReady,
|
||||
getBackendStatus,
|
||||
getGenerationProgress,
|
||||
saveGeneratedOutput,
|
||||
deleteGeneratedOutputs,
|
||||
getLlmStatus,
|
||||
listLlmModels,
|
||||
startLlm,
|
||||
chatWithLlm
|
||||
} from "../services/api";
|
||||
|
||||
const GalleryItem = memo(({ img, idx, isSelected, onClick }) => {
|
||||
const handleClick = (e) => {
|
||||
@@ -39,6 +52,8 @@ function Generator({
|
||||
setActiveTab,
|
||||
showAlert = async ({ message }) => window.alert(message),
|
||||
showConfirm = async ({ message }) => window.confirm(message),
|
||||
specs,
|
||||
textSettings,
|
||||
}) {
|
||||
const [outputImage, setOutputImage] = useState(null);
|
||||
const [outputSeed, setOutputSeed] = useState(null);
|
||||
@@ -56,10 +71,81 @@ function Generator({
|
||||
const [isDecoding, setIsDecoding] = useState(false);
|
||||
const [selectedGalleryIndexes, setSelectedGalleryIndexes] = useState([]);
|
||||
const [baseImage, setBaseImage] = useState(null);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
const hasRealGenerationStepRef = useRef(false);
|
||||
|
||||
const handleEnhancePrompt = async () => {
|
||||
if (!prompt.trim() || isEnhancing || isGenerating) return;
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
const status = await getLlmStatus();
|
||||
let targetModel = status.settings?.model;
|
||||
|
||||
if (!status.ready) {
|
||||
const models = await listLlmModels();
|
||||
if (models.length === 0) {
|
||||
showAlert({
|
||||
title: "No Text Models",
|
||||
message: "You haven't downloaded any text models yet. Please go to the 'Model Manager' or 'Text Chat' tab to download a GGUF model first.",
|
||||
danger: true
|
||||
});
|
||||
setIsEnhancing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const savedLlm = localStorage.getItem("selectedLlmModel") || models[0]?.filename;
|
||||
targetModel = models.some(m => m.filename === savedLlm) ? savedLlm : models[0]?.filename;
|
||||
|
||||
const confirm = await showConfirm({
|
||||
title: "Load Text Model?",
|
||||
message: `Enhancing your prompt requires loading the local text model "${targetModel}". This will temporarily unload the image model from memory. Do you want to proceed?`,
|
||||
confirmLabel: "Load & Enhance",
|
||||
cancelLabel: "Cancel"
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
setIsEnhancing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cores = textSettings?.threads || specs?.cpu_cores_physical || 4;
|
||||
const context = textSettings?.contextSize || 2048;
|
||||
await startLlm(targetModel, {
|
||||
threads: cores,
|
||||
contextSize: context,
|
||||
gpuLayers: -1
|
||||
});
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant. Rewrite the user's image prompt to be more descriptive and detailed for image generation. Keep it under 80 words. Output ONLY the rewritten prompt, no explanation or introductory text.";
|
||||
const messages = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Please expand this prompt: "${prompt}"` }
|
||||
];
|
||||
|
||||
const temp = textSettings?.temperature || 0.7;
|
||||
const response = await chatWithLlm(messages, { temperature: temp, maxTokens: 128 });
|
||||
let cleanResponse = response.content.trim();
|
||||
if (cleanResponse.startsWith('"') && cleanResponse.endsWith('"')) {
|
||||
cleanResponse = cleanResponse.slice(1, -1);
|
||||
}
|
||||
cleanResponse = cleanResponse.replace(/^Here is the expanded prompt:?\s*/i, "");
|
||||
cleanResponse = cleanResponse.replace(/^Enhanced prompt:?\s*/i, "");
|
||||
|
||||
setPrompt(cleanResponse);
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
title: "Prompt Enhancement Failed",
|
||||
message: err.message || String(err),
|
||||
danger: true
|
||||
});
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (constraints.backendType === "apple-npu" || constraints.backendType === "openvino-npu") {
|
||||
setBaseImage(null);
|
||||
@@ -592,7 +678,28 @@ function Generator({
|
||||
<div className="m3-field-group">
|
||||
{/* Prompt Textarea */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Positive Prompt</label>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<label className="m3-text-field-label">Positive Prompt</label>
|
||||
<button
|
||||
type="button"
|
||||
className="m3-btn m3-btn-tonal"
|
||||
style={{ height: "28px", padding: "0 10px", fontSize: "0.75rem", borderRadius: "14px", display: "flex", alignItems: "center", gap: "4px" }}
|
||||
onClick={handleEnhancePrompt}
|
||||
disabled={isGenerating || isEnhancing || !prompt.trim()}
|
||||
>
|
||||
{isEnhancing ? (
|
||||
<>
|
||||
<span className="progress-spinner" style={{ width: "12px", height: "12px", borderWidth: "2px" }} />
|
||||
<span>Enhancing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={12} />
|
||||
<span>Enhance Prompt</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="m3-textarea"
|
||||
value={prompt}
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import React, { memo, useState, useEffect, useCallback } from "react";
|
||||
import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle } from "lucide-react";
|
||||
import { listLocalModels, startServer, stopServer, importModelFile, deleteModel, downloadModel, downloadOpenVinoModel, cancelModelDownload, getDownloadProgress, getBackendStatus, pingServer, formatBytes, normalizeModel } from "../services/api";
|
||||
import {
|
||||
listLocalModels,
|
||||
startServer,
|
||||
stopServer,
|
||||
importModelFile,
|
||||
deleteModel,
|
||||
downloadModel,
|
||||
downloadOpenVinoModel,
|
||||
cancelModelDownload,
|
||||
getDownloadProgress,
|
||||
getBackendStatus,
|
||||
pingServer,
|
||||
formatBytes,
|
||||
normalizeModel,
|
||||
listLlmModels,
|
||||
startLlm,
|
||||
stopLlm,
|
||||
downloadLlmModel,
|
||||
deleteLlmModel,
|
||||
importLlmModel,
|
||||
getLlmStatus
|
||||
} from "../services/api";
|
||||
|
||||
const MODEL_LIBRARY = [
|
||||
{
|
||||
@@ -60,6 +81,32 @@ const MODEL_LIBRARY = [
|
||||
},
|
||||
];
|
||||
|
||||
const TEXT_MODEL_LIBRARY = [
|
||||
{
|
||||
group: "Recommended Text Models (llama.cpp)",
|
||||
items: [
|
||||
{
|
||||
name: "Qwen2.5 Coder 0.5B Instruct Q4_K_M",
|
||||
filename: "qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
|
||||
format: "GGUF",
|
||||
approxSize: "491 MB",
|
||||
resolution: "N/A",
|
||||
notes: "Extremely fast, lightweight assistant, perfect for low RAM/VRAM machines.",
|
||||
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
|
||||
},
|
||||
{
|
||||
name: "SmolLM2 1.7B Instruct Q4_K_M",
|
||||
filename: "smollm2-1.7b-instruct-q4_k_m.gguf",
|
||||
format: "GGUF",
|
||||
approxSize: "1.1 GB",
|
||||
resolution: "N/A",
|
||||
notes: "Excellent lightweight assistant with strong logic, reasoning, and prompt expansion capabilities.",
|
||||
url: "https://huggingface.co/HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF/resolve/main/smollm2-1.7b-instruct-q4_k_m.gguf",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const OPENVINO_MODEL_LIBRARY = [
|
||||
{
|
||||
group: "Intel NPU - OpenVINO Test",
|
||||
@@ -96,7 +143,22 @@ const COREML_MODEL_LIBRARY = [
|
||||
},
|
||||
];
|
||||
|
||||
function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRunning, constraints, setConstraints, telemetry, backendOptions, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message), activeTab }) {
|
||||
function ModelManager({
|
||||
activeModel,
|
||||
setActiveModel,
|
||||
serverRunning,
|
||||
setServerRunning,
|
||||
constraints,
|
||||
setConstraints,
|
||||
telemetry,
|
||||
backendOptions,
|
||||
showAlert = async ({ message }) => window.alert(message),
|
||||
showConfirm = async ({ message }) => window.confirm(message),
|
||||
activeTab,
|
||||
specs,
|
||||
textSettings,
|
||||
}) {
|
||||
const [activeModelType, setActiveModelType] = useState("image"); // "image" or "text"
|
||||
const [localModels, setLocalModels] = useState([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||
const [downloadingModelId, setDownloadingModelId] = useState(null);
|
||||
@@ -113,8 +175,11 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
const [pendingLoadModel, setPendingLoadModel] = useState(null);
|
||||
const [vramWarning, setVramWarning] = useState(null);
|
||||
const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
|
||||
const [activeLlmModel, setActiveLlmModel] = useState(null);
|
||||
const [llmRunning, setLlmRunning] = useState(false);
|
||||
|
||||
const cancelLoadRef = React.useRef(false);
|
||||
|
||||
const handleCancelLoad = async () => {
|
||||
cancelLoadRef.current = true;
|
||||
try {
|
||||
@@ -133,44 +198,74 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
backendOptions?.unavailable?.some((opt) => opt.id === "apple-npu")
|
||||
);
|
||||
|
||||
let visibleModelLibrary = [...MODEL_LIBRARY];
|
||||
if (openvinoSupported) {
|
||||
visibleModelLibrary = [...visibleModelLibrary, ...OPENVINO_MODEL_LIBRARY];
|
||||
}
|
||||
if (appleNpuSupported) {
|
||||
visibleModelLibrary = [...visibleModelLibrary, ...COREML_MODEL_LIBRARY];
|
||||
let visibleModelLibrary = [];
|
||||
if (activeModelType === "image") {
|
||||
visibleModelLibrary = [...MODEL_LIBRARY];
|
||||
if (openvinoSupported) {
|
||||
visibleModelLibrary = [...visibleModelLibrary, ...OPENVINO_MODEL_LIBRARY];
|
||||
}
|
||||
if (appleNpuSupported) {
|
||||
visibleModelLibrary = [...visibleModelLibrary, ...COREML_MODEL_LIBRARY];
|
||||
}
|
||||
} else {
|
||||
visibleModelLibrary = [...TEXT_MODEL_LIBRARY];
|
||||
}
|
||||
|
||||
const getLocalModelInfo = (modelId) => localModels.map(normalizeModel).find((model) => model.filename === modelId);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
if (activeModelType === "image") {
|
||||
const list = await listLocalModels();
|
||||
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
|
||||
} else {
|
||||
const list = await listLlmModels();
|
||||
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLocalModels([]);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
}, [activeModelType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "models") {
|
||||
fetchModels();
|
||||
checkActiveDownload();
|
||||
}
|
||||
}, [activeTab]);
|
||||
}, [activeTab, fetchModels]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
checkActiveDownload();
|
||||
}, []);
|
||||
}, [activeModelType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverRunning && !loadingModelId) {
|
||||
setBackendInfo({ backendMode: "", backendBinary: "", backendDevice: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const updateBackendInfo = async () => {
|
||||
try {
|
||||
const status = await getBackendStatus();
|
||||
const [sdStatus, llmStatus] = await Promise.all([
|
||||
getBackendStatus(),
|
||||
getLlmStatus()
|
||||
]);
|
||||
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 || "",
|
||||
backendMode: sdStatus.settings?.backendMode || sdStatus.loading?.backendMode || "",
|
||||
backendBinary: sdStatus.settings?.backendBinary || sdStatus.loading?.backendBinary || "",
|
||||
backendDevice: sdStatus.settings?.backendDevice || sdStatus.loading?.device || "",
|
||||
});
|
||||
|
||||
if (llmStatus.ready) {
|
||||
setActiveLlmModel(llmStatus.settings?.model || null);
|
||||
setLlmRunning(true);
|
||||
} else {
|
||||
setActiveLlmModel(null);
|
||||
setLlmRunning(false);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
@@ -219,18 +314,6 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
const list = await listLocalModels();
|
||||
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadByUrl = async (url, expectedFilename) => {
|
||||
if (downloadingModelId) return;
|
||||
|
||||
@@ -248,13 +331,18 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
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 });
|
||||
showAlert({ title: "Server Required", message: "Model download requires the local server.", danger: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await downloadModel(url);
|
||||
if (res.ok) {
|
||||
let res;
|
||||
if (activeModelType === "image") {
|
||||
res = await downloadModel(url);
|
||||
} else {
|
||||
res = await downloadLlmModel(url);
|
||||
}
|
||||
if (res && res.ok) {
|
||||
startProgressPolling(expectedFilename);
|
||||
} else {
|
||||
showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true });
|
||||
@@ -264,26 +352,16 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
}
|
||||
};
|
||||
|
||||
// Download custom model from URL
|
||||
const handleUrlDownloadSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!downloadUrl.trim()) return;
|
||||
|
||||
let modelName = "model.gguf";
|
||||
let modelName = "model.safetensors";
|
||||
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 });
|
||||
modelName = parsed.pathname.split("/").pop() || modelName;
|
||||
} catch (_) {
|
||||
showAlert({ title: "Invalid URL", message: "Please provide a valid HTTP/HTTPS link.", danger: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,65 +370,130 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
};
|
||||
|
||||
const handleLibraryDownload = async (model) => {
|
||||
if (downloadingModelId) return;
|
||||
|
||||
if (model.backendType === "openvino-npu") {
|
||||
if (downloadingModelId) return;
|
||||
const confirmed = await showConfirm({
|
||||
title: "Download OpenVINO Model?",
|
||||
message: `Download "${model.name}" (~${model.approxSize})? OpenVINO models are downloaded folder-by-folder via python script.`,
|
||||
confirmLabel: "Download",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const res = await downloadOpenVinoModel(model.id);
|
||||
if (res.ok) {
|
||||
startProgressPolling(model.filename);
|
||||
} else {
|
||||
showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true });
|
||||
}
|
||||
await downloadOpenVinoModel(model.id);
|
||||
startProgressPolling(model.name);
|
||||
} catch (err) {
|
||||
showAlert({ title: "Download Error", message: err.message, danger: true });
|
||||
showAlert({ title: "Download Failed", message: err.message, danger: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (model.format === "CoreML") {
|
||||
downloadByUrl(model.url, `${model.filename}.zip`);
|
||||
return;
|
||||
|
||||
const confirmed = await showConfirm({
|
||||
title: "Download Model?",
|
||||
message: `Download "${model.name}" (~${model.approxSize})?`,
|
||||
confirmLabel: "Download",
|
||||
});
|
||||
if (confirmed) {
|
||||
await downloadByUrl(model.url, model.filename);
|
||||
}
|
||||
downloadByUrl(model.url, model.filename);
|
||||
};
|
||||
|
||||
const handleCancelDownload = async () => {
|
||||
await cancelModelDownload();
|
||||
setDownloadingModelId(null);
|
||||
setDownloadProgress(0);
|
||||
setDownloadSpeed("");
|
||||
await fetchModels();
|
||||
try {
|
||||
await cancelModelDownload();
|
||||
setDownloadingModelId(null);
|
||||
setDownloadProgress(0);
|
||||
setDownloadSpeed("");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Check VRAM and trigger warning or load directly
|
||||
const checkVramAndLoad = async (modelId) => {
|
||||
const modelInfo = getLocalModelInfo(modelId);
|
||||
if (modelInfo && telemetry && telemetry.vram_total_gb > 0) {
|
||||
const isCpuMode = constraints.backendType === "cpu" || !constraints.useGpu;
|
||||
const isOpenVino = modelInfo.backendType === "openvino-npu";
|
||||
if (!isCpuMode && !isOpenVino) {
|
||||
const freeVramGb = telemetry.vram_total_gb - telemetry.vram_used_gb;
|
||||
const modelSizeGb = (modelInfo.sizeBytes || 0) / (1024 * 1024 * 1024);
|
||||
if (modelSizeGb > freeVramGb) {
|
||||
setVramWarning({
|
||||
modelId,
|
||||
modelSizeGb,
|
||||
freeVramGb,
|
||||
totalVramGb: telemetry.vram_total_gb,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!modelInfo) {
|
||||
await performLoadModel(modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSizeBytes = Number(modelInfo.sizeBytes) || 0;
|
||||
if (totalSizeBytes <= 0) {
|
||||
await performLoadModel(modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
const freeVramGb = Number(telemetry.vram_total_gb - telemetry.vram_used_gb) || 0;
|
||||
const modelSizeGb = totalSizeBytes / (1024 ** 3);
|
||||
const hasNvidiaGpu = Boolean(telemetry.gpu_name && telemetry.gpu_name.toLowerCase().includes("nvidia"));
|
||||
|
||||
const isCpuOnlyBackend = constraints.backendType === "cpu" || constraints.useGpu === false;
|
||||
|
||||
if (hasNvidiaGpu && !isCpuOnlyBackend && modelInfo.backendType !== "openvino-npu" && modelInfo.format !== "CoreML") {
|
||||
const safetyMarginGb = 0.8;
|
||||
if (modelSizeGb + safetyMarginGb > freeVramGb) {
|
||||
setVramWarning({
|
||||
modelId,
|
||||
modelSizeGb,
|
||||
freeVramGb,
|
||||
totalVramGb: telemetry.vram_total_gb,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await performLoadModel(modelId);
|
||||
};
|
||||
|
||||
// Start the model server backend
|
||||
const handleLoadModel = async (modelId) => {
|
||||
if (activeModel && activeModel !== modelId && serverRunning) {
|
||||
setPendingLoadModel(modelId);
|
||||
return;
|
||||
if (activeModelType === "image") {
|
||||
if (activeModel && activeModel !== modelId && serverRunning) {
|
||||
setPendingLoadModel(modelId);
|
||||
return;
|
||||
}
|
||||
await checkVramAndLoad(modelId);
|
||||
} else {
|
||||
setLoadingModelId(modelId);
|
||||
setModelLoadProgress({
|
||||
progress: 10,
|
||||
phase: "Stopping image engine if running...",
|
||||
speed: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
model: modelId,
|
||||
backendMode: "",
|
||||
backendBinary: "",
|
||||
device: "",
|
||||
});
|
||||
try {
|
||||
await stopServer();
|
||||
setModelLoadProgress({
|
||||
progress: 40,
|
||||
phase: "Starting llama.cpp server...",
|
||||
speed: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
model: modelId,
|
||||
backendMode: "",
|
||||
backendBinary: "",
|
||||
device: "",
|
||||
});
|
||||
const cores = textSettings?.threads || specs?.cpu_cores_physical || 4;
|
||||
const context = textSettings?.contextSize || 4096;
|
||||
const res = await startLlm(modelId, {
|
||||
threads: cores,
|
||||
contextSize: context,
|
||||
gpuLayers: -1
|
||||
});
|
||||
setActiveLlmModel(modelId);
|
||||
setLlmRunning(true);
|
||||
setActiveModel(null);
|
||||
setServerRunning(false);
|
||||
} catch (err) {
|
||||
showAlert({ title: "Model Load Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setModelLoadProgress(null);
|
||||
setLoadingModelId(null);
|
||||
}
|
||||
}
|
||||
await checkVramAndLoad(modelId);
|
||||
};
|
||||
|
||||
const performLoadModel = async (modelId, forcedConstraints = null) => {
|
||||
@@ -395,6 +538,11 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
device: "",
|
||||
});
|
||||
try {
|
||||
// Unload active text engine
|
||||
await stopLlm();
|
||||
setActiveLlmModel(null);
|
||||
setLlmRunning(false);
|
||||
|
||||
const modelInfo = getLocalModelInfo(modelId);
|
||||
const isCoreMLModel = modelInfo?.format === "CoreML" || modelInfo?.backendType === "apple-npu";
|
||||
const activeConstraints = forcedConstraints || constraints;
|
||||
@@ -419,6 +567,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
cfgScale: activeConstraints.cfgScale || 1,
|
||||
}
|
||||
: activeConstraints;
|
||||
|
||||
const response = await startServer(modelId, loadConstraints);
|
||||
console.log(response);
|
||||
|
||||
@@ -495,48 +644,61 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
}
|
||||
};
|
||||
|
||||
// Kill/Unload model server
|
||||
const handleUnloadModel = async () => {
|
||||
if (isUnloading) return;
|
||||
setIsUnloading(true);
|
||||
setUnloadProgress({ progress: 10, phase: "Stopping backend process..." });
|
||||
if (activeModelType === "image") {
|
||||
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);
|
||||
|
||||
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(() => {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
setIsUnloading(true);
|
||||
setUnloadProgress({ progress: 50, phase: "Stopping llama.cpp backend process..." });
|
||||
try {
|
||||
await stopLlm();
|
||||
setActiveLlmModel(null);
|
||||
setLlmRunning(false);
|
||||
} catch (e) {
|
||||
showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
|
||||
} finally {
|
||||
setIsUnloading(false);
|
||||
setUnloadProgress({ progress: 0, phase: "" });
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delete/Remove model file
|
||||
const handleDeleteModel = async (filename) => {
|
||||
if (await showConfirm({
|
||||
title: "Delete Model?",
|
||||
@@ -545,13 +707,18 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
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();
|
||||
if (activeModelType === "image") {
|
||||
await deleteModel(filename);
|
||||
if (activeModel === filename) {
|
||||
await handleUnloadModel();
|
||||
}
|
||||
} else {
|
||||
await deleteLlmModel(filename);
|
||||
if (activeLlmModel === filename) {
|
||||
await handleUnloadModel();
|
||||
}
|
||||
}
|
||||
await fetchModels();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete model:", err);
|
||||
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
|
||||
@@ -571,17 +738,28 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
});
|
||||
|
||||
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);
|
||||
if (activeModelType === "image") {
|
||||
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);
|
||||
} else {
|
||||
await importLlmModel(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);
|
||||
@@ -594,12 +772,6 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelImport = () => {
|
||||
if (importAbortController) {
|
||||
importAbortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
@@ -619,8 +791,26 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Selector for Image vs Text */}
|
||||
<div style={{ display: "flex", gap: "12px", borderBottom: "1px solid var(--border-color)", paddingBottom: "16px", marginBottom: "20px" }}>
|
||||
<button
|
||||
className={`m3-btn ${activeModelType === "image" ? "m3-btn-filled" : "m3-btn-outlined"}`}
|
||||
onClick={() => setActiveModelType("image")}
|
||||
style={{ height: "40px", padding: "0 20px" }}
|
||||
>
|
||||
Image Models (SD)
|
||||
</button>
|
||||
<button
|
||||
className={`m3-btn ${activeModelType === "text" ? "m3-btn-filled" : "m3-btn-outlined"}`}
|
||||
onClick={() => setActiveModelType("text")}
|
||||
style={{ height: "40px", padding: "0 20px" }}
|
||||
>
|
||||
Text Models (GGUF)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Model Status Tonal Box */}
|
||||
{activeModel && (
|
||||
{activeModelType === "image" && 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>
|
||||
@@ -649,64 +839,34 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
</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>
|
||||
{activeModelType === "text" && activeLlmModel && (
|
||||
<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 Text Model: {activeLlmModel}</h4>
|
||||
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
|
||||
The local C++ llama.cpp server is running. Chat requests can be sent via the Text Chat interface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Progress Bar */}
|
||||
{modelLoadProgress && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-secondary)", marginTop: "16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "16px", marginBottom: "12px" }}>
|
||||
<h4 style={{ fontWeight: 700, margin: 0 }}>
|
||||
Loading Model: {modelLoadProgress.model || loadingModelId}
|
||||
</h4>
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
style={{ height: "30px", padding: "0 12px", fontSize: "0.78rem", borderRadius: "8px" }}
|
||||
onClick={handleCancelLoad}
|
||||
>
|
||||
Cancel Loading
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h4 style={{ fontWeight: 600 }}>Loading Weights: {modelLoadProgress.model}</h4>
|
||||
<button className="m3-btn m3-btn-error" style={{ height: "34px", padding: "0 14px" }} onClick={handleCancelLoad}>
|
||||
<Square size={14} />
|
||||
<span>Cancel Load</span>
|
||||
</button>
|
||||
</div>
|
||||
{(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-section" style={{ margin: "12px 0 6px 0" }}>
|
||||
<div className="model-progress-label">
|
||||
<span>
|
||||
{modelLoadProgress.phase}
|
||||
@@ -714,17 +874,30 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
</span>
|
||||
<span>
|
||||
{modelLoadProgress.total > 0
|
||||
? modelLoadProgress.total <= 10
|
||||
? `Loading components: ${modelLoadProgress.current} / ${modelLoadProgress.total} (${Math.round(modelLoadProgress.progress)}%)`
|
||||
: `Loaded ${modelLoadProgress.current} / ${modelLoadProgress.total} tensors`
|
||||
? `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 className="model-progress-fill" style={{ width: `${Math.min(100, Math.max(0, modelLoadProgress.progress))}%`, transition: "width 0.2s ease" }}></div>
|
||||
</div>
|
||||
{(modelLoadProgress.backendMode || modelLoadProgress.device) && (
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "6px" }}>
|
||||
Initializing {modelLoadProgress.backendMode || "backend"}
|
||||
{modelLoadProgress.device ? ` • ${modelLoadProgress.device}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unloading Progress Bar */}
|
||||
{isUnloading && activeModelType === "image" && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-error)", marginTop: "24px" }}>
|
||||
<h4 style={{ fontWeight: 600 }}>{unloadProgress.phase || "Unloading active model..."}</h4>
|
||||
<div className="model-progress-section" style={{ margin: "12px 0 0 0" }}>
|
||||
<div className="model-progress-bar">
|
||||
<div className="model-progress-fill" style={{ width: `${unloadProgress.progress}%`, background: "var(--md-sys-color-error)", transition: "width 0.15s ease" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -734,7 +907,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
<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})
|
||||
Local {activeModelType === "image" ? "Image" : "Text"} Models ({localModels.length})
|
||||
</h3>
|
||||
|
||||
{isLoadingModels ? (
|
||||
@@ -744,13 +917,15 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
</div>
|
||||
) : 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.
|
||||
{activeModelType === "image"
|
||||
? "No image models detected in app/models/. Download from the library below or import a file."
|
||||
: "No text models detected in app/llm-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;
|
||||
const isActive = activeModelType === "image" ? activeModel === filename : activeLlmModel === filename;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -769,7 +944,10 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
<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 }}>
|
||||
{model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File"} • {model.size || formatBytes(model.sizeBytes)}
|
||||
{activeModelType === "image"
|
||||
? (model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File")
|
||||
: "llama.cpp GGUF Model"
|
||||
} • {model.size || formatBytes(model.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -813,7 +991,6 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
)}
|
||||
</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)" }} />
|
||||
@@ -834,7 +1011,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
<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}
|
||||
{model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.78rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.35, margin: 0 }}>
|
||||
@@ -891,7 +1068,6 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Local Drag and Drop Importer */}
|
||||
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
@@ -926,14 +1102,14 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
accept=".gguf,.safetensors,.ckpt"
|
||||
accept={activeModelType === "image" ? ".safetensors,.ckpt" : ".gguf"}
|
||||
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.
|
||||
Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : "`.gguf` weights."}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -943,7 +1119,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
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.
|
||||
Download any {activeModelType === "image" ? "Safetensors" : "GGUF"} model from Hugging Face directly to your models folder.
|
||||
</p>
|
||||
{isUrlDownloading ? (
|
||||
<div className="model-progress-section" style={{ marginTop: "0px" }}>
|
||||
|
||||
+179
-80
@@ -1,5 +1,5 @@
|
||||
import React, { memo, useEffect } from "react";
|
||||
import { Crop, Sliders, Cpu, Info } from "lucide-react";
|
||||
import { Crop, Sliders, Cpu, Info, MessageSquare, SlidersHorizontal } from "lucide-react";
|
||||
import { stopServer } from "../services/api";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
@@ -18,7 +18,7 @@ const isSD15OrCustomModel = (modelName) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
function ImageConstraints({
|
||||
function Settings({
|
||||
constraints,
|
||||
setConstraints,
|
||||
activeModel,
|
||||
@@ -27,6 +27,8 @@ function ImageConstraints({
|
||||
serverRunning,
|
||||
setServerRunning,
|
||||
setActiveModel,
|
||||
textSettings,
|
||||
setTextSettings,
|
||||
showAlert = async ({ message }) => window.alert(message),
|
||||
showConfirm = async ({ message }) => window.confirm(message),
|
||||
}) {
|
||||
@@ -57,6 +59,13 @@ function ImageConstraints({
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTextSetting = (key, value) => {
|
||||
setTextSettings((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAspectRatioChange = (ratio, sizeType) => {
|
||||
if (isOpenVinoNpu && ratio !== "1:1") return;
|
||||
const isSDXL = sizeType === "sdxl" && !isSD15OrCustom;
|
||||
@@ -138,20 +147,20 @@ function ImageConstraints({
|
||||
return (
|
||||
<div className="workspace-area">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Image Constraints</h2>
|
||||
<h2 className="workspace-title">Settings & Parameters</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Configure size, quality, and performance controls for image generation.
|
||||
Configure size, quality, and performance controls for local image and text models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="generator-layout">
|
||||
{/* Left Column: Size & Quality */}
|
||||
{/* Left Column: Image Settings */}
|
||||
<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
|
||||
1. Image Size & Shape
|
||||
</h3>
|
||||
|
||||
{isOpenVinoNpu ? (
|
||||
@@ -333,20 +342,176 @@ function ImageConstraints({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Image Memory Optimizations */}
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<SlidersHorizontal size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
3. Image Optimizations
|
||||
</h3>
|
||||
|
||||
<div className="m3-field-group">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={constraints.vaeTiling !== false}
|
||||
onChange={(e) => updateConstraint("vaeTiling", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Enable VAE Tiling</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Builds the image in smaller, bite-sized sections. This heavily reduces graphics memory usage with no loss in speed. Highly recommended for computers with standard graphics cards.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={constraints.vaeOnCpu === true}
|
||||
onChange={(e) => updateConstraint("vaeOnCpu", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Run VAE on CPU</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Performs the final image rendering step using your computer's main memory (RAM) instead of graphics memory. Saves graphics memory, but makes the final stage of creating the image slightly slower.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={constraints.useFlashAttn !== false}
|
||||
onChange={(e) => updateConstraint("useFlashAttn", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Enable Flash Attention</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Accelerates generation using memory-efficient attention. On some specific Mac models or GPUs, this may cause a slight slowdown, so you can disable it if needed.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Speed & System */}
|
||||
{/* Right Column: Text Settings & System */}
|
||||
<div>
|
||||
{/* Card 3: Performance hacks */}
|
||||
{/* Card 4: Text Generation Settings (llama.cpp) */}
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<MessageSquare size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
4. Text Generation Settings (GGUF)
|
||||
</h3>
|
||||
|
||||
<div className="m3-field-group">
|
||||
{/* System Prompt instructions */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Default System Instructions</label>
|
||||
<textarea
|
||||
value={textSettings.systemPrompt}
|
||||
onChange={(e) => updateTextSetting("systemPrompt", e.target.value)}
|
||||
placeholder="Set AI instructions (e.g. 'You are a helpful local assistant')..."
|
||||
className="system-prompt"
|
||||
style={{ marginTop: "6px", width: "100%", minHeight: "85px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context size selector */}
|
||||
<div className="m3-text-field" style={{ marginTop: "12px" }}>
|
||||
<label className="m3-text-field-label">Context Window Limit</label>
|
||||
<select
|
||||
value={textSettings.contextSize}
|
||||
onChange={(e) => updateTextSetting("contextSize", Number(e.target.value))}
|
||||
className="m3-input"
|
||||
style={{ marginTop: "6px", height: "40px", cursor: "pointer" }}
|
||||
>
|
||||
<option value="2048">2,048 tokens (Fast/Low Memory)</option>
|
||||
<option value="4096">4,096 tokens (Balanced / Recommended)</option>
|
||||
<option value="8192">8,192 tokens (More history)</option>
|
||||
<option value="16384">16,384 tokens (Deep context)</option>
|
||||
</select>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
|
||||
Controls the maximum memory allocated for conversation history. Larger limits allow longer chats but use more RAM/VRAM.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Temperature slider */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "16px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Temperature (Creativity)</span>
|
||||
<span className="m3-slider-value">{textSettings.temperature}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.temperature}
|
||||
onChange={(e) => updateTextSetting("temperature", parseFloat(e.target.value))}
|
||||
min="0.1"
|
||||
max="2.0"
|
||||
step="0.05"
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
Lower temperature produces focused and coherent replies; higher temperature allows more creative and diverse responses.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CPU Threads slider */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "16px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">CPU Threads</span>
|
||||
<span className="m3-slider-value">{textSettings.threads} threads</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.threads}
|
||||
onChange={(e) => updateTextSetting("threads", parseInt(e.target.value))}
|
||||
min="1"
|
||||
max={Math.max(16, specs?.cpu_cores_logical || 16)}
|
||||
step="1"
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
Number of physical CPU threads allocated for computation. Typically should match or be slightly lower than your physical cores.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: System Settings (Generation Backend) */}
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<Cpu size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
3. System Settings
|
||||
5. Image Generation Backend
|
||||
</h3>
|
||||
|
||||
<div className="m3-field-group">
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Generation Backend</label>
|
||||
<label className="m3-text-field-label">Active Image Accelerator</label>
|
||||
<div className="m3-segmented-button">
|
||||
{availableBackends.map((backend) => (
|
||||
<div
|
||||
@@ -354,6 +519,7 @@ function ImageConstraints({
|
||||
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}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{backend.label}
|
||||
</div>
|
||||
@@ -366,6 +532,7 @@ function ImageConstraints({
|
||||
"CPU is slow but safest. Vulkan works on supported GPUs. CUDA is shown only when NVIDIA CUDA support is available."
|
||||
)}
|
||||
</span>
|
||||
|
||||
{constraints.backendType === "cuda" && specs?.gpu_name && String(specs.gpu_name).toLowerCase().includes("gtx") && (
|
||||
<div style={{
|
||||
marginTop: "12px",
|
||||
@@ -380,6 +547,7 @@ function ImageConstraints({
|
||||
<strong>Performance Alert:</strong> Your graphics card (<code>{specs.gpu_name}</code>) is a GTX-series GPU which lacks hardware <strong>Tensor Cores</strong>. Running in CUDA mode will be up to 3x slower. We strongly recommend switching to <strong>Vulkan GPU</strong> for optimal generation speed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backendOptions?.unavailable?.length > 0 && (
|
||||
<div style={{ marginTop: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{backendOptions.unavailable.map((backend) => (
|
||||
@@ -390,75 +558,6 @@ function ImageConstraints({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="m3-text-field" style={{ marginTop: "20px" }}>
|
||||
<label className="m3-text-field-label">Memory Optimization (Graphics Memory)</label>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px", marginTop: "10px" }}>
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={constraints.vaeTiling !== false}
|
||||
onChange={(e) => updateConstraint("vaeTiling", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Enable VAE Tiling</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Builds the image in smaller, bite-sized sections. This heavily reduces graphics memory usage with no loss in speed. Highly recommended for computers with standard graphics cards.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={constraints.vaeOnCpu === true}
|
||||
onChange={(e) => updateConstraint("vaeOnCpu", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Run VAE on CPU</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Performs the final image rendering step using your computer's main memory (RAM) instead of graphics memory. Saves graphics memory, but makes the final stage of creating the image slightly slower.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={constraints.useFlashAttn !== false}
|
||||
onChange={(e) => updateConstraint("useFlashAttn", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Enable Flash Attention</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Accelerates generation using memory-efficient attention. On some specific Mac models or GPUs, this may cause a slight slowdown, so you can disable it if needed.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -467,4 +566,4 @@ function ImageConstraints({
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ImageConstraints);
|
||||
export default memo(Settings);
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { memo } from "react";
|
||||
import { Image, FolderDown, MessageSquare, Sliders, Shield, Terminal } from "lucide-react";
|
||||
import { Image, FolderDown, MessageSquare, Settings, Shield, Terminal } from "lucide-react";
|
||||
|
||||
function Sidebar({ activeTab, setActiveTab, specs }) {
|
||||
return (
|
||||
@@ -38,11 +38,11 @@ function Sidebar({ activeTab, setActiveTab, specs }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "constraints" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("constraints")}
|
||||
className={`nav-item ${activeTab === "settings" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("settings")}
|
||||
>
|
||||
<Sliders size={20} />
|
||||
<span>Image Constraints</span>
|
||||
<Settings size={20} />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bot, Download, LoaderCircle, MessageSquare, Play, Send, Square, Trash2, Upload } from "lucide-react";
|
||||
import { Bot, LoaderCircle, Send, Trash2, Square } from "lucide-react";
|
||||
import {
|
||||
chatWithLlm,
|
||||
deleteLlmModel,
|
||||
downloadLlmModel,
|
||||
getDownloadProgress,
|
||||
getLlmStatus,
|
||||
importLlmModel,
|
||||
listLlmModels,
|
||||
startLlm,
|
||||
stopLlm,
|
||||
} from "../services/api";
|
||||
|
||||
const RECOMMENDED_MODELS = [
|
||||
{
|
||||
name: "Qwen2.5 Coder 0.5B Instruct Q4_K_M",
|
||||
size: "491 MB",
|
||||
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
|
||||
},
|
||||
{
|
||||
name: "SmolLM2 1.7B Instruct Q4_K_M",
|
||||
size: "1.1 GB",
|
||||
url: "https://huggingface.co/HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF/resolve/main/smollm2-1.7b-instruct-q4_k_m.gguf",
|
||||
},
|
||||
];
|
||||
|
||||
function TextChat({ specs, showAlert, showConfirm }) {
|
||||
function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings, setActiveModel, setServerRunning }) {
|
||||
const [models, setModels] = useState([]);
|
||||
const [status, setStatus] = useState({ ready: false, running: false, settings: {} });
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [systemPrompt, setSystemPrompt] = useState("You are a helpful local AI assistant.");
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [download, setDownload] = useState(null);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [importProgress, setImportProgress] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [loadingModel, setLoadingModel] = useState(null);
|
||||
const [tokenUsage, setTokenUsage] = useState({
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
});
|
||||
|
||||
const bottomRef = useRef(null);
|
||||
const completedDownloadRef = useRef("");
|
||||
const loadingModelRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadingModelRef.current = loadingModel;
|
||||
}, [loadingModel]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextModels, nextStatus] = await Promise.all([listLlmModels(), getLlmStatus()]);
|
||||
setModels(nextModels);
|
||||
setStatus(nextStatus);
|
||||
const active = nextStatus.settings?.model;
|
||||
setSelectedModel((current) => active || current || nextModels[0]?.filename || "");
|
||||
setSelectedModel((current) => {
|
||||
const saved = localStorage.getItem("selectedLlmModel");
|
||||
const savedExists = nextModels.some((m) => m.filename === saved);
|
||||
return active || current || (savedExists ? saved : "") || nextModels[0]?.filename || "";
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => {});
|
||||
const timer = setInterval(() => {
|
||||
getLlmStatus().then(setStatus).catch(() => {});
|
||||
getLlmStatus().then((nextStatus) => {
|
||||
setStatus(nextStatus);
|
||||
// If it suddenly loaded or became ready externally, update selection and reset loading states
|
||||
if (nextStatus.ready && nextStatus.settings?.model) {
|
||||
setSelectedModel(nextStatus.settings.model);
|
||||
setLoadingModel(null);
|
||||
}
|
||||
}).catch(() => {});
|
||||
getDownloadProgress().then((state) => {
|
||||
if (state.kind === "text" && (state.active || state.error || state.progress === 100)) {
|
||||
setDownload(state);
|
||||
const completionKey = `${state.filename || ""}:${state.downloadedBytes || 0}`;
|
||||
if (!state.active && !state.error && completedDownloadRef.current !== completionKey) {
|
||||
completedDownloadRef.current = completionKey;
|
||||
@@ -68,33 +69,61 @@ function TextChat({ specs, showAlert, showConfirm }) {
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isBusy]);
|
||||
}, [messages, isBusy, loadingModel]);
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!selectedModel || isBusy) return;
|
||||
const handleModelChange = async (filename) => {
|
||||
if (!filename) {
|
||||
if (status.ready) {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await stopLlm();
|
||||
setStatus((prev) => ({ ...prev, ready: false, running: false }));
|
||||
setSelectedModel("");
|
||||
setMessages([]);
|
||||
setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
} catch (err) {
|
||||
showAlert({ title: "Unload Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedModel(filename);
|
||||
localStorage.setItem("selectedLlmModel", filename);
|
||||
setIsBusy(true);
|
||||
setLoadingModel(filename);
|
||||
try {
|
||||
const result = await startLlm(selectedModel, {
|
||||
threads: specs.cpu_cores_physical || 4,
|
||||
contextSize: 4096,
|
||||
// Unload active image engine if running
|
||||
if (setActiveModel) setActiveModel(null);
|
||||
if (setServerRunning) setServerRunning(false);
|
||||
|
||||
const result = await startLlm(filename, {
|
||||
threads: textSettings?.threads || specs?.cpu_cores_physical || 4,
|
||||
contextSize: textSettings?.contextSize || 4096,
|
||||
gpuLayers: -1,
|
||||
});
|
||||
setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings });
|
||||
setMessages([]);
|
||||
setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
} catch (err) {
|
||||
showAlert({ title: "Text Model Load Failed", message: err.message, danger: true });
|
||||
if (loadingModelRef.current === filename) {
|
||||
showAlert({ title: "Text Model Load Failed", message: err.message, danger: true });
|
||||
}
|
||||
} finally {
|
||||
setLoadingModel(null);
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unloadModel = async () => {
|
||||
setIsBusy(true);
|
||||
const handleCancelLlmLoad = async () => {
|
||||
try {
|
||||
await stopLlm();
|
||||
setStatus((current) => ({ ...current, ready: false, running: false }));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoadingModel(null);
|
||||
setIsBusy(false);
|
||||
setSelectedModel("");
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
@@ -105,12 +134,19 @@ function TextChat({ specs, showAlert, showConfirm }) {
|
||||
setInput("");
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const systemPrompt = textSettings?.systemPrompt || "You are a helpful local AI assistant.";
|
||||
const requestMessages = [
|
||||
...(systemPrompt.trim() ? [{ role: "system", content: systemPrompt.trim() }] : []),
|
||||
...nextMessages,
|
||||
];
|
||||
const content = await chatWithLlm(requestMessages, { temperature: 0.7, maxTokens: 768 });
|
||||
setMessages([...nextMessages, { role: "assistant", content }]);
|
||||
const response = await chatWithLlm(requestMessages, {
|
||||
temperature: textSettings?.temperature || 0.7,
|
||||
maxTokens: 768
|
||||
});
|
||||
setMessages([...nextMessages, { role: "assistant", content: response.content }]);
|
||||
if (response.usage) {
|
||||
setTokenUsage(response.usage);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessages([...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }]);
|
||||
} finally {
|
||||
@@ -118,77 +154,206 @@ function TextChat({ specs, showAlert, showConfirm }) {
|
||||
}
|
||||
};
|
||||
|
||||
const startDownload = async (url) => {
|
||||
if (!url || download?.active) return;
|
||||
try {
|
||||
const result = await downloadLlmModel(url);
|
||||
setDownload({ active: true, filename: result.filename, progress: 0, kind: "text" });
|
||||
setCustomUrl("");
|
||||
} catch (err) {
|
||||
showAlert({ title: "Text Model Download Failed", message: err.message, danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
const importFile = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".gguf")) {
|
||||
showAlert({ title: "Unsupported File", message: "Text models must use the .gguf format.", danger: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await importLlmModel(file, (progress) => setImportProgress(progress));
|
||||
setImportProgress(null);
|
||||
await refresh();
|
||||
setSelectedModel(file.name);
|
||||
} catch (err) {
|
||||
setImportProgress(null);
|
||||
showAlert({ title: "Text Model Import Failed", message: err.message, danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
const removeModel = async (filename) => {
|
||||
const confirmed = await showConfirm({
|
||||
title: "Delete Text Model?",
|
||||
message: `Delete "${filename}" from app/llm-models?`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await deleteLlmModel(filename);
|
||||
if (selectedModel === filename) setSelectedModel("");
|
||||
await refresh();
|
||||
const handleClearChat = () => {
|
||||
setMessages([]);
|
||||
setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-chat-layout">
|
||||
<section className="text-chat-main">
|
||||
<div className="text-chat-header">
|
||||
<div>
|
||||
<h2><MessageSquare size={22} /> Text Chat</h2>
|
||||
<p>Private chat powered by llama.cpp and local GGUF models.</p>
|
||||
<div className="text-chat-header" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={isBusy}
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: "600",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "var(--md-shape-corner-medium)",
|
||||
background: "var(--md-sys-color-surface-variant)",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
padding: "8px 16px",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
minWidth: "220px"
|
||||
}}
|
||||
>
|
||||
<option value="">No model loaded (Select GGUF)</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.filename} value={m.filename}>
|
||||
{m.filename} {m.filename === status.settings?.model && status.ready ? "• Active" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isBusy && !loadingModel && <LoaderCircle className="progress-spinner" size={16} />}
|
||||
{selectedModel && (!status.ready || status.settings?.model !== selectedModel) && !loadingModel && (
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={() => handleModelChange(selectedModel)}
|
||||
disabled={isBusy}
|
||||
style={{
|
||||
height: "38px",
|
||||
padding: "0 16px",
|
||||
fontSize: "0.85rem",
|
||||
borderRadius: "var(--md-shape-corner-medium)",
|
||||
background: "var(--md-sys-color-primary)",
|
||||
color: "var(--md-sys-color-on-primary)",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px"
|
||||
}}
|
||||
>
|
||||
Load Model
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "20px" }}>
|
||||
{/* Active Backend status info */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
background: status.ready ? "var(--md-sys-color-success)" : "var(--md-sys-color-outline-variant)",
|
||||
flexShrink: 0
|
||||
}}></span>
|
||||
<div style={{ display: "flex", flexDirection: "column", lineHeight: 1.1 }}>
|
||||
<span style={{ fontSize: "0.68rem", color: "var(--md-sys-color-outline)", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Active Backend
|
||||
</span>
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: "700", color: status.ready ? "var(--md-sys-color-on-surface)" : "var(--md-sys-color-outline)" }}>
|
||||
{status.ready ? (status.settings?.backendMode || "llama.cpp") : "Offline"}
|
||||
{status.ready && ` (${status.settings?.threads || 4}T)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Small circular gauge for context */}
|
||||
{(() => {
|
||||
const maxTokens = status.settings?.contextSize || 4096;
|
||||
const used = tokenUsage.total_tokens || 0;
|
||||
const percent = Math.min(100, Math.round((used / maxTokens) * 100));
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }} title={`Context Used: ${used} / ${maxTokens} tokens`}>
|
||||
<div style={{ position: "relative", width: "40px", height: "40px", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="16" stroke="var(--border-color)" strokeWidth="3" fill="transparent" />
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
stroke="var(--md-sys-color-primary)"
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
strokeDasharray={2 * Math.PI * 16}
|
||||
strokeDashoffset={2 * Math.PI * 16 * (1 - percent / 100)}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 20 20)"
|
||||
style={{ transition: "stroke-dashoffset 0.35s" }}
|
||||
/>
|
||||
</svg>
|
||||
<div style={{ position: "absolute", textAlign: "center" }}>
|
||||
<span style={{ fontSize: "0.65rem", fontWeight: "700" }}>{percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", lineHeight: 1.2 }}>
|
||||
<span style={{ fontSize: "0.65rem", color: "var(--md-sys-color-outline)", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Context Used
|
||||
</span>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: "600", color: "var(--md-sys-color-on-surface)" }}>
|
||||
{used} / {maxTokens}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Clear Conversation button */}
|
||||
<button
|
||||
className="m3-btn m3-btn-outlined"
|
||||
style={{
|
||||
height: "36px",
|
||||
padding: "0 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
fontSize: "0.82rem",
|
||||
borderRadius: "var(--md-shape-corner-medium)"
|
||||
}}
|
||||
onClick={handleClearChat}
|
||||
disabled={messages.length === 0}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className={`text-engine-badge ${status.ready ? "ready" : ""}`}>
|
||||
{status.ready ? `${status.settings?.backendMode || status.backendMode || "llama.cpp"} ready` : status.backendInstalled ? "Backend installed" : "Run setup to install llama.cpp"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && (
|
||||
<div className="chat-empty">
|
||||
<Bot size={42} />
|
||||
<h3>Local text generation</h3>
|
||||
<p>Choose a GGUF model, load it, and start chatting. Loading text automatically unloads the image engine.</p>
|
||||
{loadingModel ? (
|
||||
<div className="chat-empty" style={{ maxWidth: "480px", margin: "auto", textAlign: "center", padding: "60px 20px" }}>
|
||||
<LoaderCircle className="progress-spinner" size={48} style={{ color: "var(--md-sys-color-primary)", marginBottom: "16px" }} />
|
||||
<h3 style={{ fontWeight: 600, fontSize: "1.25rem", marginBottom: "8px", color: "var(--md-sys-color-on-surface)" }}>Loading Text Model</h3>
|
||||
<code style={{
|
||||
display: "block",
|
||||
background: "var(--md-sys-color-surface-variant)",
|
||||
color: "var(--md-sys-color-on-surface-variant)",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.85rem",
|
||||
marginBottom: "20px",
|
||||
wordBreak: "break-all",
|
||||
fontFamily: "monospace"
|
||||
}}>
|
||||
{loadingModel}
|
||||
</code>
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-outline)", lineHeight: 1.5, marginBottom: "24px" }}>
|
||||
Initializing llama.cpp server and loading the model weights into memory. This can take up to 30 seconds depending on model size and hardware speed.
|
||||
</p>
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
onClick={handleCancelLlmLoad}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
height: "38px",
|
||||
padding: "0 16px",
|
||||
fontSize: "0.85rem",
|
||||
borderRadius: "var(--md-shape-corner-medium)"
|
||||
}}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
<span>Cancel Load</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.length === 0 && (
|
||||
<div className="chat-empty">
|
||||
<Bot size={42} />
|
||||
<h3>Local ChatGPT-style Interface</h3>
|
||||
<p>Choose a GGUF text model above to load it. Your conversation history stays completely private on this machine.</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message, index) => (
|
||||
<div key={`${message.role}-${index}`} className={`chat-message ${message.role} ${message.error ? "error" : ""}`}>
|
||||
<strong>{message.role === "user" ? "You" : "Local AI"}</strong>
|
||||
<div>{message.content}</div>
|
||||
</div>
|
||||
))}
|
||||
{isBusy && status.ready && <div className="chat-thinking"><LoaderCircle className="progress-spinner" size={16} /> Generating...</div>}
|
||||
</>
|
||||
)}
|
||||
{messages.map((message, index) => (
|
||||
<div key={`${message.role}-${index}`} className={`chat-message ${message.role} ${message.error ? "error" : ""}`}>
|
||||
<strong>{message.role === "user" ? "You" : "Local AI"}</strong>
|
||||
<div>{message.content}</div>
|
||||
</div>
|
||||
))}
|
||||
{isBusy && status.ready && <div className="chat-thinking"><LoaderCircle className="progress-spinner" size={16} /> Generating...</div>}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
@@ -202,7 +367,7 @@ function TextChat({ specs, showAlert, showConfirm }) {
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={status.ready ? "Message your local model..." : "Load a text model to begin"}
|
||||
placeholder={status.ready ? "Message your local model..." : "Select and load a GGUF model above to begin"}
|
||||
disabled={!status.ready || isBusy}
|
||||
/>
|
||||
<button className="m3-btn m3-btn-filled" onClick={sendMessage} disabled={!input.trim() || !status.ready || isBusy}>
|
||||
@@ -210,60 +375,6 @@ function TextChat({ specs, showAlert, showConfirm }) {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="text-chat-settings">
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">Text Model</h3>
|
||||
<select value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)} disabled={status.running || isBusy}>
|
||||
<option value="">Select a GGUF model</option>
|
||||
{models.map((model) => <option key={model.filename} value={model.filename}>{model.filename} ({model.size})</option>)}
|
||||
</select>
|
||||
<div className="text-model-actions">
|
||||
{!status.running ? (
|
||||
<button className="m3-btn m3-btn-filled" onClick={loadModel} disabled={!selectedModel || isBusy}>
|
||||
{isBusy ? <LoaderCircle className="progress-spinner" size={16} /> : <Play size={16} />} Load
|
||||
</button>
|
||||
) : (
|
||||
<button className="m3-btn m3-btn-error" onClick={unloadModel} disabled={isBusy}>
|
||||
<Square size={16} /> Unload
|
||||
</button>
|
||||
)}
|
||||
<button className="m3-btn m3-btn-outlined" onClick={() => fileInputRef.current?.click()} disabled={isBusy}>
|
||||
<Upload size={16} /> Import
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".gguf" hidden onChange={importFile} />
|
||||
</div>
|
||||
{importProgress && <p className="text-progress">Importing: {Math.round(importProgress.progress || 0)}%</p>}
|
||||
{models.map((model) => (
|
||||
<div className="text-local-model" key={model.filename}>
|
||||
<span title={model.filename}>{model.filename}</span>
|
||||
<button title="Delete model" onClick={() => removeModel(model.filename)} disabled={status.settings?.model === model.filename && status.running}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">System Prompt</h3>
|
||||
<textarea className="system-prompt" value={systemPrompt} onChange={(event) => setSystemPrompt(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">Download GGUF</h3>
|
||||
{RECOMMENDED_MODELS.map((model) => (
|
||||
<button className="text-download-option" key={model.url} onClick={() => startDownload(model.url)} disabled={download?.active}>
|
||||
<span>{model.name}</span><small>{model.size}</small><Download size={16} />
|
||||
</button>
|
||||
))}
|
||||
<div className="text-url-row">
|
||||
<input value={customUrl} onChange={(event) => setCustomUrl(event.target.value)} placeholder="Direct Hugging Face .gguf URL" />
|
||||
<button className="m3-btn m3-btn-tonal" onClick={() => startDownload(customUrl)} disabled={!customUrl || download?.active}>Download</button>
|
||||
</div>
|
||||
{download?.active && <p className="text-progress">Downloading {download.filename}: {download.progress < 0 ? "starting" : `${download.progress}%`} {download.speed || ""}</p>}
|
||||
{download?.error && !String(download.error).toLowerCase().includes("cancelled") && <p className="text-progress error">{download.error}</p>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -353,7 +353,10 @@ export async function chatWithLlm(messages, options = {}) {
|
||||
const data = await readJsonResponse(res, "The local text model returned an invalid response.");
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error("The text model returned an empty response.");
|
||||
return content;
|
||||
return {
|
||||
content,
|
||||
usage: data?.usage || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadLlmModel(url) {
|
||||
|
||||
Reference in new issue
Block a user