feat: add static file server, backend process manager, and base frontend components for local AI image generation

This commit is contained in:
techjarves committed 2026-06-15 06:41:15 +05:30
1 parent f1e7c29a5d
commit 2e24396b35
3 files changed
+657 -72

No files matched your search

+110 -27
View File
@@ -35,6 +35,7 @@ const MODEL_LIBRARY = [
resolution: "1024x1024",
notes: "Photorealistic SDXL model tuned for 4-8 step Lightning generation.",
url: "https://huggingface.co/RunDiffusion/Juggernaut-XL-Lightning/resolve/main/Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
recommendedTiers: ["Mid", "High"],
},
{
name: "DreamShaper XL Lightning",
@@ -44,6 +45,7 @@ const MODEL_LIBRARY = [
resolution: "1024x1024",
notes: "All-around SDXL model for fantasy art, renders, illustration, and stylized images.",
url: "https://huggingface.co/Lykon/dreamshaper-xl-lightning/resolve/main/DreamShaperXL_Lightning.safetensors",
recommendedTiers: ["Mid", "High"],
},
],
},
@@ -58,6 +60,7 @@ const MODEL_LIBRARY = [
resolution: "512x512",
notes: "General-purpose SD 1.5 model for illustration, anime, and semi-realistic portraits.",
url: "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors",
recommendedTiers: ["Low"],
},
{
name: "CyberRealistic V8",
@@ -67,6 +70,7 @@ const MODEL_LIBRARY = [
resolution: "512x512",
notes: "High-fidelity realism and photorealism in the SD 1.5 ecosystem.",
url: "https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V8_FP16.safetensors",
recommendedTiers: ["Low"],
},
{
name: "ReV Animated v1.2.2",
@@ -76,6 +80,7 @@ const MODEL_LIBRARY = [
resolution: "512x512",
notes: "Semi-realistic, 2.5D, anime, fantasy, and stylized digital art.",
url: "https://huggingface.co/danbrown/RevAnimated-v1-2-2/resolve/main/rev-animated-v1-2-2.safetensors",
recommendedTiers: ["Low"],
},
],
},
@@ -93,6 +98,7 @@ const TEXT_MODEL_LIBRARY = [
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",
recommendedTiers: ["Low"],
},
{
name: "SmolLM2 1.7B Instruct Q4_K_M",
@@ -102,6 +108,42 @@ const TEXT_MODEL_LIBRARY = [
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",
recommendedTiers: ["Low"],
},
{
name: "Qwen2.5-Coder-7B-Instruct",
filename: "qwen2.5-coder-7b-instruct-q4_k_m.gguf",
format: "GGUF",
size: "4.7 GB",
approxSize: "4.7 GB",
resolution: "N/A",
notes: "Highly intelligent coding and text assistant. Recommended for mid/high tier systems.",
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf",
recommendedTiers: ["Mid", "High"],
},
{
name: "Llama-3.1-8B-Instruct",
filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
format: "GGUF",
size: "4.9 GB",
approxSize: "4.9 GB",
resolution: "N/A",
notes: "Excellent general-purpose text model. Recommended for high-tier systems.",
url: "https://huggingface.co/lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
recommendedTiers: ["High"],
},
{
name: "LLaVA 1.5 7B",
filename: "ggml-model-q4_k.gguf",
format: "GGUF",
size: "4.5 GB",
approxSize: "4.5 GB",
resolution: "N/A",
notes: "Multimodal model capable of understanding images. Downloads companion vision projector automatically.",
url: "https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/ggml-model-q4_k.gguf",
projectorUrl: "https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/mmproj-model-f16.gguf",
projectorFilename: "mmproj-model-f16.gguf",
recommendedTiers: ["Mid", "High"],
},
],
},
@@ -143,6 +185,20 @@ const COREML_MODEL_LIBRARY = [
},
];
const getHardwareTier = (specs) => {
if (!specs) return "Low";
const ram = Number(specs.ram_total_gb) || 0;
const vram = Number(specs.gpu_vram_gb) || 0;
if (vram >= 12 || (vram >= 8 && ram >= 32)) {
return "High";
} else if (vram >= 6 || ram >= 16) {
return "Mid";
} else {
return "Low";
}
};
function ModelManager({
activeModel,
setActiveModel,
@@ -288,32 +344,6 @@ function ModelManager({
}
};
const startProgressPolling = (modelId) => {
setDownloadingModelId(modelId);
const interval = setInterval(async () => {
try {
const status = await getDownloadProgress();
if (status.active) {
setDownloadProgress(status.progress === -1 ? 0 : status.progress);
setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
} else {
clearInterval(interval);
setDownloadingModelId(null);
if (status.error) {
if (!String(status.error).toLowerCase().includes("cancelled")) {
showAlert({ title: "Download Failed", message: status.error, danger: true });
}
} else {
fetchModels();
}
}
} catch (e) {
console.error("Polling error:", e);
}
}, 1000);
};
const downloadByUrl = async (url, expectedFilename) => {
if (downloadingModelId) return;
@@ -352,6 +382,40 @@ function ModelManager({
}
};
const startProgressPolling = (modelId) => {
setDownloadingModelId(modelId);
const interval = setInterval(async () => {
try {
const status = await getDownloadProgress();
if (status.active) {
setDownloadProgress(status.progress === -1 ? 0 : status.progress);
setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
} else {
clearInterval(interval);
setDownloadingModelId(null);
if (status.error) {
if (!String(status.error).toLowerCase().includes("cancelled")) {
showAlert({ title: "Download Failed", message: status.error, danger: true });
}
} else {
fetchModels();
if (modelId === "ggml-model-q4_k.gguf") {
setTimeout(() => {
downloadByUrl(
"https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/mmproj-model-f16.gguf",
"mmproj-model-f16.gguf"
);
}, 500);
}
}
}
} catch (e) {
console.error("Polling error:", e);
}
}, 1000);
};
const handleUrlDownloadSubmit = async (e) => {
e.preventDefault();
if (!downloadUrl.trim()) return;
@@ -809,6 +873,8 @@ function ModelManager({
</button>
</div>
{/* Active Model Status Tonal Box */}
{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)" }}>
@@ -1006,10 +1072,27 @@ function ModelManager({
{section.items.map((model) => {
const installed = modelNames.includes(model.filename);
const downloading = downloadingModelId === model.filename || downloadingModelId === `${model.filename}.zip`;
const systemTier = getHardwareTier(specs);
const isRecommended = model.recommendedTiers && model.recommendedTiers.includes(systemTier);
return (
<div key={model.filename} style={{ display: "flex", flexDirection: "column", gap: "10px", padding: "14px", background: "var(--md-sys-color-surface-variant)", border: "1px solid var(--md-sys-color-outline-variant)", borderRadius: "var(--md-shape-corner-medium)" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span style={{ fontWeight: 700 }}>{model.name}</span>
<span style={{ fontWeight: 700, display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
{model.name}
{isRecommended && (
<span style={{
fontSize: "0.68rem",
color: "#2e7d32",
background: "#e8f5e9",
padding: "2px 6px",
borderRadius: "4px",
fontWeight: "bold",
border: "1px solid #c8e6c9"
}}>
[Recommended Fit]
</span>
)}
</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
{model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`}
</span>
+217 -22
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus } from "lucide-react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus, Paperclip, X } from "lucide-react";
import {
chatWithLlm,
getDownloadProgress,
@@ -30,6 +30,64 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
const completedDownloadRef = useRef("");
const loadingModelRef = useRef(null);
const [attachments, setAttachments] = useState([]);
const fileInputRef = useRef(null);
const isImage = (file) => {
return /\.(jpe?g|png|webp)$/i.test(file.name) || file.type.startsWith("image/");
};
const isTextFile = (file) => {
return /\.(txt|md|csv|js|jsx|ts|tsx|py|json|css|html|java|cpp|c|h|rs|go|sh|bat|xml|yaml|yml)$/i.test(file.name) || file.type.startsWith("text/");
};
const handleFileChange = (e) => {
const files = Array.from(e.target.files);
if (!files || files.length === 0) return;
files.forEach((file) => {
if (isImage(file)) {
const reader = new FileReader();
reader.onload = (event) => {
setAttachments((prev) => [
...prev,
{
id: Math.random().toString(36).substring(7),
file,
type: "image",
name: file.name,
dataUrl: event.target.result,
},
]);
};
reader.readAsDataURL(file);
} else if (isTextFile(file)) {
const reader = new FileReader();
reader.onload = (event) => {
setAttachments((prev) => [
...prev,
{
id: Math.random().toString(36).substring(7),
file,
type: "document",
name: file.name,
content: event.target.result,
},
]);
};
reader.readAsText(file);
} else {
showAlert({
title: "Unsupported File",
message: `File "${file.name}" is not supported. Please select an image (JPEG, PNG, WEBP) or a text/code file.`,
danger: true,
});
}
});
e.target.value = "";
};
useEffect(() => {
loadingModelRef.current = loadingModel;
}, [loadingModel]);
@@ -179,7 +237,12 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
setSelectedModel(conv.model);
}
// Try to compute approximate token usage from messages
const total = conv.messages.reduce((sum, m) => sum + m.content.split(/\s+/).length, 0);
const total = conv.messages.reduce((sum, m) => {
const text = Array.isArray(m.content)
? m.content.map(c => c.text || "").join(" ")
: (m.content || "");
return sum + text.split(/\s+/).length;
}, 0);
setTokenUsage({
prompt_tokens: Math.round(total * 0.7),
completion_tokens: Math.round(total * 0.3),
@@ -204,7 +267,8 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
const sendMessage = async () => {
const text = input.trim();
if (!text || isBusy || !status.ready) return;
const hasAttachments = attachments.length > 0;
if ((!text && !hasAttachments) || isBusy || !status.ready) return;
let convId = activeConversationId;
let isNew = false;
@@ -214,12 +278,40 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
isNew = true;
}
const nextMessages = [...messages, { role: "user", content: text }];
// Delimited context blocks for document attachments
let documentContext = "";
attachments.filter(att => att.type === "document").forEach((att) => {
documentContext += `\n[Attached File: ${att.name}]\n${att.content}\n`;
});
const combinedText = documentContext ? `${text}\n\n${documentContext}`.trim() : text;
const imageAttachments = attachments.filter(att => att.type === "image");
let userMessageContent;
if (imageAttachments.length > 0) {
userMessageContent = [
{
type: "text",
text: combinedText
},
...imageAttachments.map((img) => ({
type: "image_url",
image_url: {
url: img.dataUrl
}
}))
];
} else {
userMessageContent = combinedText;
}
const nextMessages = [...messages, { role: "user", content: userMessageContent }];
setMessages(nextMessages);
setInput("");
setIsBusy(true);
const firstTitle = isNew ? (text.slice(0, 26) + (text.length > 26 ? "..." : "")) : null;
const displayTitleText = text || (imageAttachments.length > 0 ? "Sent Image" : "Sent File");
const firstTitle = isNew ? (displayTitleText.slice(0, 26) + (displayTitleText.length > 26 ? "..." : "")) : null;
saveConversationState(convId, nextMessages, selectedModel, firstTitle);
try {
@@ -238,6 +330,9 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
setTokenUsage(response.usage);
}
saveConversationState(convId, finalMessages, selectedModel);
// Clean up attached files on success
setAttachments([]);
} catch (err) {
const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }];
setMessages(finalMessages);
@@ -510,7 +605,30 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
{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>
{Array.isArray(message.content) ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{message.content.map((item, idx) => {
if (item.type === "text") {
return <span key={idx} style={{ whiteSpace: "pre-wrap" }}>{item.text}</span>;
}
if (item.type === "image_url") {
return (
<img
key={idx}
src={item.image_url.url}
alt="Attached image"
style={{ maxWidth: "240px", maxHeight: "180px", objectFit: "contain", borderRadius: "6px", marginTop: "4px" }}
/>
);
}
return null;
})}
</div>
) : (
<span style={{ whiteSpace: "pre-wrap" }}>{message.content}</span>
)}
</div>
</div>
))}
{isBusy && status.ready && <div className="chat-thinking"><LoaderCircle className="progress-spinner" size={16} /> Generating...</div>}
@@ -519,22 +637,99 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
<div ref={bottomRef} />
</div>
<div className="chat-composer">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}}
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}>
<Send size={17} /> Send
</button>
<div className="chat-composer" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{attachments.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", paddingBottom: "8px" }}>
{attachments.map((att) => (
<div key={att.id} style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 28px 6px 8px",
background: "var(--md-sys-color-surface-variant)",
border: "1px solid var(--border-color)",
borderRadius: "6px",
fontSize: "0.8rem",
maxWidth: "200px"
}}>
{att.type === "image" ? (
<img src={att.dataUrl} alt={att.name} style={{ width: "24px", height: "24px", objectFit: "cover", borderRadius: "3px" }} />
) : (
<span style={{ fontWeight: 600 }}>📄</span>
)}
<span style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
color: "var(--md-sys-color-on-surface-variant)"
}} title={att.name}>
{att.name}
</span>
<button
onClick={() => setAttachments(prev => prev.filter(item => item.id !== att.id))}
style={{
position: "absolute",
right: "4px",
top: "50%",
transform: "translateY(-50%)",
background: "none",
border: "none",
color: "var(--md-sys-color-error)",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<X size={14} />
</button>
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: "10px", width: "100%" }}>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
multiple
onChange={handleFileChange}
/>
<button
className="m3-btn m3-btn-tonal"
onClick={() => fileInputRef.current?.click()}
style={{ padding: "0 12px", height: "48px", display: "flex", alignItems: "center", justifyContent: "center" }}
disabled={!status.ready || isBusy}
title="Attach files or images"
>
<Paperclip size={17} />
</button>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}}
placeholder={status.ready ? "Message your local model..." : "Select and load a GGUF model above to begin"}
disabled={!status.ready || isBusy}
style={{ flex: 1 }}
/>
<button
className="m3-btn m3-btn-filled"
onClick={sendMessage}
disabled={(!input.trim() && attachments.length === 0) || !status.ready || isBusy}
style={{ height: "48px" }}
>
<Send size={17} /> Send
</button>
</div>
</div>
</section>
</div>