+
+
Loading Weights: {modelLoadProgress.model}
+
- {(modelLoadProgress.backendMode || modelLoadProgress.backendBinary || modelLoadProgress.device) && (
-
- {modelLoadProgress.backendMode && (
-
-
- Loading on {modelLoadProgress.backendMode}
-
- )}
- {modelLoadProgress.device && (
-
- {modelLoadProgress.device}
-
- )}
- {modelLoadProgress.backendBinary && (
-
- {modelLoadProgress.backendBinary}
-
- )}
-
- )}
-
+
{modelLoadProgress.phase}
@@ -714,17 +874,30 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
{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)}%`}
+ {(modelLoadProgress.backendMode || modelLoadProgress.device) && (
+
+ Initializing {modelLoadProgress.backendMode || "backend"}
+ {modelLoadProgress.device ? ` • ${modelLoadProgress.device}` : ""}
+
+ )}
+
+
+ )}
+
+ {/* Unloading Progress Bar */}
+ {isUnloading && activeModelType === "image" && (
+
+
{unloadProgress.phase || "Unloading active model..."}
+
@@ -734,7 +907,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
- Local Models ({localModels.length})
+ Local {activeModelType === "image" ? "Image" : "Text"} Models ({localModels.length})
{isLoadingModels ? (
@@ -744,13 +917,15 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
) : localModels.length === 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."}
) : (
{localModels.map((model) => {
const filename = model.filename;
- const isActive = activeModel === filename;
+ const isActive = activeModelType === "image" ? activeModel === filename : activeLlmModel === filename;
return (
{filename}
- {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)}
@@ -813,7 +991,6 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
)}
-
@@ -834,7 +1011,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
{model.name}
- {model.format} • approx. {model.approxSize} • {model.resolution}
+ {model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`}
@@ -891,7 +1068,6 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
))}
-
{/* Local Drag and Drop Importer */}
@@ -926,14 +1102,14 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
Choose weights file
- Select `.gguf`, `.safetensors` or `.ckpt` weights.
+ Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : "`.gguf` weights."}
@@ -943,7 +1119,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
Download Model from URL
- 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.
{isUrlDownloading ? (
diff --git a/app/frontend/src/components/ImageConstraints.jsx b/app/frontend/src/components/Settings.jsx
similarity index 66%
rename from app/frontend/src/components/ImageConstraints.jsx
rename to app/frontend/src/components/Settings.jsx
index d0eb106..e65cf03 100644
--- a/app/frontend/src/components/ImageConstraints.jsx
+++ b/app/frontend/src/components/Settings.jsx
@@ -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 (
-
Image Constraints
+
Settings & Parameters
- Configure size, quality, and performance controls for image generation.
+ Configure size, quality, and performance controls for local image and text models.
- {/* Left Column: Size & Quality */}
+ {/* Left Column: Image Settings */}
{/* Card 1: Picture Size & Shape */}
- 1. Picture Size & Shape
+ 1. Image Size & Shape
{isOpenVinoNpu ? (
@@ -333,20 +342,176 @@ function ImageConstraints({
+
+ {/* Card 3: Image Memory Optimizations */}
+
+
+
+ 3. Image Optimizations
+
+
+
+
+
+
+
+
+
+
+
+
- {/* Right Column: Speed & System */}
+ {/* Right Column: Text Settings & System */}
- {/* Card 3: Performance hacks */}
+ {/* Card 4: Text Generation Settings (llama.cpp) */}
+
+
+
+ 4. Text Generation Settings (GGUF)
+
+
+
+ {/* System Prompt instructions */}
+
+
+
+
+ {/* Context size selector */}
+
+
+
+
+ Controls the maximum memory allocated for conversation history. Larger limits allow longer chats but use more RAM/VRAM.
+
+
+
+ {/* Temperature slider */}
+
+
+ Temperature (Creativity)
+ {textSettings.temperature}
+
+
updateTextSetting("temperature", parseFloat(e.target.value))}
+ min="0.1"
+ max="2.0"
+ step="0.05"
+ />
+
+ Lower temperature produces focused and coherent replies; higher temperature allows more creative and diverse responses.
+
+
+
+ {/* CPU Threads slider */}
+
+
+ CPU Threads
+ {textSettings.threads} threads
+
+
updateTextSetting("threads", parseInt(e.target.value))}
+ min="1"
+ max={Math.max(16, specs?.cpu_cores_logical || 16)}
+ step="1"
+ />
+
+ Number of physical CPU threads allocated for computation. Typically should match or be slightly lower than your physical cores.
+
+
+
+
+
+ {/* Card 5: System Settings (Generation Backend) */}
- 3. System Settings
+ 5. Image Generation Backend
-
+
{availableBackends.map((backend) => (
handleBackendChange(backend.id)}
title={backend.id === "cuda" ? "CUDA appears only when an NVIDIA CUDA backend is available." : undefined}
+ style={{ cursor: "pointer" }}
>
{backend.label}
@@ -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."
)}
+
{constraints.backendType === "cuda" && specs?.gpu_name && String(specs.gpu_name).toLowerCase().includes("gtx") && (
Performance Alert: Your graphics card ({specs.gpu_name}) is a GTX-series GPU which lacks hardware Tensor Cores. Running in CUDA mode will be up to 3x slower. We strongly recommend switching to Vulkan GPU for optimal generation speed.
)}
+
{backendOptions?.unavailable?.length > 0 && (
{backendOptions.unavailable.map((backend) => (
@@ -390,75 +558,6 @@ function ImageConstraints({
)}
-
-
-
-
-
-
-
-
-
-
-
-
@@ -467,4 +566,4 @@ function ImageConstraints({
);
}
-export default memo(ImageConstraints);
+export default memo(Settings);
diff --git a/app/frontend/src/components/Sidebar.jsx b/app/frontend/src/components/Sidebar.jsx
index bfc6f37..b9249ff 100644
--- a/app/frontend/src/components/Sidebar.jsx
+++ b/app/frontend/src/components/Sidebar.jsx
@@ -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 }) {
setActiveTab("constraints")}
+ className={`nav-item ${activeTab === "settings" ? "active" : ""}`}
+ onClick={() => setActiveTab("settings")}
>
-
- Image Constraints
+
+ Settings
diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx
index ac5f4e1..8f2c7f9 100644
--- a/app/frontend/src/components/TextChat.jsx
+++ b/app/frontend/src/components/TextChat.jsx
@@ -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 (
-
-
-
Text Chat
-
Private chat powered by llama.cpp and local GGUF models.
+
+
+
+ {isBusy && !loadingModel && }
+ {selectedModel && (!status.ready || status.settings?.model !== selectedModel) && !loadingModel && (
+
+ )}
+
+
+
+ {/* Active Backend status info */}
+
+
+
+
+ Active Backend
+
+
+ {status.ready ? (status.settings?.backendMode || "llama.cpp") : "Offline"}
+ {status.ready && ` (${status.settings?.threads || 4}T)`}
+
+
+
+
+ {/* 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 (
+
+
+
+
+ {percent}%
+
+
+
+
+ Context Used
+
+
+ {used} / {maxTokens}
+
+
+
+ );
+ })()}
+
+ {/* Clear Conversation button */}
+
-
- {status.ready ? `${status.settings?.backendMode || status.backendMode || "llama.cpp"} ready` : status.backendInstalled ? "Backend installed" : "Run setup to install llama.cpp"}
-
- {messages.length === 0 && (
-
-
-
Local text generation
-
Choose a GGUF model, load it, and start chatting. Loading text automatically unloads the image engine.
+ {loadingModel ? (
+
+
+
Loading Text Model
+
+ {loadingModel}
+
+
+ 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.
+
+
+ ) : (
+ <>
+ {messages.length === 0 && (
+
+
+
Local ChatGPT-style Interface
+
Choose a GGUF text model above to load it. Your conversation history stays completely private on this machine.
+
+ )}
+ {messages.map((message, index) => (
+
+
{message.role === "user" ? "You" : "Local AI"}
+
{message.content}
+
+ ))}
+ {isBusy && status.ready &&
Generating...
}
+ >
)}
- {messages.map((message, index) => (
-
-
{message.role === "user" ? "You" : "Local AI"}
-
{message.content}
-
- ))}
- {isBusy && status.ready &&
Generating...
}
@@ -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}
/>
-
-
);
}
diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js
index d18ae2c..240f995 100644
--- a/app/frontend/src/services/api.js
+++ b/app/frontend/src/services/api.js
@@ -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) {