add cross-platform setup scripts and frontend boilerplate for local AI image generation
This commit is contained in:
1 parent
9dfc0f38c4
commit
43a85f8d24
17 files changed
+1163
-76
No files matched your search
+27
-17
@@ -1,29 +1,39 @@
|
||||
# Ignore large Stable Diffusion model weights (exceeds GitHub file limits)
|
||||
app/models/*.safetensors
|
||||
app/models/*.gguf
|
||||
app/models/*.ckpt
|
||||
app/models
|
||||
# Downloaded AI models
|
||||
app/models/
|
||||
app/openvino-models/
|
||||
app/llm-models/
|
||||
|
||||
# Downloaded portable runtimes/backends
|
||||
app/backend/
|
||||
app/llm-backend/
|
||||
app/tools/
|
||||
|
||||
# Generated files
|
||||
app/dist/
|
||||
app/outputs/
|
||||
|
||||
# Ignore generated frontend build output
|
||||
app/dist/
|
||||
|
||||
# Ignore portable Node.js binaries and packages
|
||||
app/tools/
|
||||
# Dependencies and platform-specific copies
|
||||
**/node_modules/
|
||||
**/node_modules_mac/
|
||||
**/node_modules_*/
|
||||
**/node_modules_windows/
|
||||
**/node_modules_linux/
|
||||
node_modules/
|
||||
**/node_modules_mac/
|
||||
**/.active_modules_os
|
||||
|
||||
# Ignore backend folder completely (contains massive DLLs, EXEs, and ZIPs >1GB)
|
||||
app/backend/
|
||||
# Partial downloads and archives
|
||||
*.part
|
||||
*.tmp
|
||||
app/tools/*.zip
|
||||
app/tools/*.tar.gz
|
||||
app/tools/*.tar.xz
|
||||
|
||||
# Ignore local IDE and OS temp files
|
||||
# Logs and caches
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# OS/editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
*.tmp
|
||||
@@ -1,6 +1,16 @@
|
||||
# 🖼️ Local AI Image Generator
|
||||
|
||||
### An easy, zero-setup Stable Diffusion GUI for Windows, Linux, and macOS. Run GGUF & Safetensors models offline without Python configuration.
|
||||
### An easy, zero-setup local image and text AI studio for Windows, Linux, and macOS.
|
||||
|
||||
## Text Chat
|
||||
|
||||
The Text Chat workspace runs official `llama.cpp` binaries and keeps language models separate from image models:
|
||||
|
||||
- Image models: `app/models`
|
||||
- Text GGUF models: `app/llm-models`
|
||||
- Portable llama.cpp binaries: `app/llm-backend/<platform>`
|
||||
|
||||
The normal platform setup installs both image and text backends. Text and image engines are mutually exclusive by default to avoid exhausting shared RAM or VRAM. A small Qwen2.5 Coder starter model is available from the Text Chat download panel.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -219,6 +219,239 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-chat-layout {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 18px;
|
||||
padding: 20px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.text-chat-main,
|
||||
.text-chat-settings {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.text-chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--md-shape-corner-large);
|
||||
background: var(--md-sys-color-surface);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.text-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.text-chat-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.text-chat-header p,
|
||||
.chat-empty p {
|
||||
margin-top: 5px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.text-engine-badge {
|
||||
max-width: 240px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--md-shape-corner-full);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-engine-badge.ready {
|
||||
color: var(--md-sys-color-success);
|
||||
border: 1px solid color-mix(in srgb, var(--md-sys-color-success) 45%, transparent);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
margin: auto;
|
||||
max-width: 440px;
|
||||
text-align: center;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.chat-empty h3 {
|
||||
margin-top: 10px;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
align-self: flex-start;
|
||||
max-width: min(780px, 88%);
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px var(--md-shape-corner-large) var(--md-shape-corner-large) var(--md-shape-corner-large);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
align-self: flex-end;
|
||||
border-radius: var(--md-shape-corner-large) 4px var(--md-shape-corner-large) var(--md-shape-corner-large);
|
||||
background: var(--md-sys-color-primary-container);
|
||||
}
|
||||
|
||||
.chat-message.error {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.chat-message strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.chat-thinking,
|
||||
.text-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.text-progress.error {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-composer textarea,
|
||||
.system-prompt,
|
||||
.text-chat-settings input,
|
||||
.text-chat-settings select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--md-shape-corner-medium);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-composer textarea {
|
||||
min-height: 48px;
|
||||
max-height: 160px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-chat-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.text-chat-settings .m3-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.text-model-actions,
|
||||
.text-url-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.text-local-model,
|
||||
.text-download-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--md-shape-corner-small);
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-local-model span,
|
||||
.text-download-option span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-local-model button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-error);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-download-option {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-download-option:hover {
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
|
||||
.text-download-option small {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.system-prompt {
|
||||
min-height: 85px;
|
||||
resize: vertical;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.text-chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.text-chat-main {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.text-chat-settings {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Persistent Top Status Bar */
|
||||
.top-status-bar {
|
||||
height: 64px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import TopStatusBar from "./components/TopStatusBar";
|
||||
import Generator from "./components/Generator";
|
||||
import ModelManager from "./components/ModelManager";
|
||||
import ImageConstraints from "./components/ImageConstraints";
|
||||
import TextChat from "./components/TextChat";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, stopServer } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
@@ -476,6 +477,14 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "chat" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<TextChat
|
||||
specs={specs}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "constraints" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<ImageConstraints
|
||||
constraints={constraints}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { memo } from "react";
|
||||
import { Image, FolderDown, Sliders, Shield, Terminal } from "lucide-react";
|
||||
import { Image, FolderDown, MessageSquare, Sliders, Shield, Terminal } from "lucide-react";
|
||||
|
||||
function Sidebar({ activeTab, setActiveTab, specs }) {
|
||||
return (
|
||||
@@ -21,6 +21,14 @@ function Sidebar({ activeTab, setActiveTab, specs }) {
|
||||
<span>Image Generator</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "chat" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("chat")}
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
<span>Text Chat</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bot, Download, LoaderCircle, MessageSquare, Play, Send, Square, Trash2, Upload } 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 }) {
|
||||
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 bottomRef = useRef(null);
|
||||
const completedDownloadRef = useRef("");
|
||||
|
||||
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 || "");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => {});
|
||||
const timer = setInterval(() => {
|
||||
getLlmStatus().then(setStatus).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;
|
||||
refresh().catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 1500);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isBusy]);
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!selectedModel || isBusy) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const result = await startLlm(selectedModel, {
|
||||
threads: specs.cpu_cores_physical || 4,
|
||||
contextSize: 4096,
|
||||
gpuLayers: -1,
|
||||
});
|
||||
setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings });
|
||||
} catch (err) {
|
||||
showAlert({ title: "Text Model Load Failed", message: err.message, danger: true });
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unloadModel = async () => {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await stopLlm();
|
||||
setStatus((current) => ({ ...current, ready: false, running: false }));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isBusy || !status.ready) return;
|
||||
const nextMessages = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setIsBusy(true);
|
||||
try {
|
||||
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 }]);
|
||||
} catch (err) {
|
||||
setMessages([...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }]);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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..." : "Load a text model 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextChat;
|
||||
@@ -9,7 +9,7 @@ export const isTauri = () => {
|
||||
// Cached hardware specs
|
||||
let cachedSpecs = null;
|
||||
let cachedBackendPort = null;
|
||||
export const EXPECTED_SERVER_BUILD = "polish-setup-v1";
|
||||
export const EXPECTED_SERVER_BUILD = "text-image-v1";
|
||||
|
||||
const isLocalServerMode = () => {
|
||||
return typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||
@@ -79,7 +79,8 @@ async function readJsonResponse(res, fallbackMessage = "The local server returne
|
||||
export async function getHealth() {
|
||||
try {
|
||||
const res = await fetch("/api/health");
|
||||
const data = await readJsonResponse(res, "The local server returned an invalid health response.");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `Health request failed (HTTP ${res.status})`);
|
||||
return {
|
||||
...data,
|
||||
stale: data.build !== EXPECTED_SERVER_BUILD,
|
||||
@@ -305,6 +306,78 @@ export async function getBackendStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLlmStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/llm/status");
|
||||
return await readJsonResponse(res, "The local server returned invalid text backend status.");
|
||||
} catch (err) {
|
||||
return { ready: false, running: false, backendInstalled: false, error: err.message, settings: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLlmModels() {
|
||||
const res = await fetch("/api/llm/models");
|
||||
const data = await readJsonResponse(res, "The local server returned invalid text model data.");
|
||||
return (data.models || []).map(normalizeModel);
|
||||
}
|
||||
|
||||
export async function startLlm(model, options = {}) {
|
||||
const res = await fetch("/api/llm/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
threads: options.threads,
|
||||
contextSize: options.contextSize,
|
||||
gpuLayers: options.gpuLayers,
|
||||
}),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid text backend response.");
|
||||
}
|
||||
|
||||
export async function stopLlm() {
|
||||
const res = await fetch("/api/llm/stop", { method: "POST" });
|
||||
return await readJsonResponse(res, "The local server returned an invalid text backend response.");
|
||||
}
|
||||
|
||||
export async function chatWithLlm(messages, options = {}) {
|
||||
const res = await fetch("/api/llm/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
temperature: options.temperature,
|
||||
max_tokens: options.maxTokens,
|
||||
}),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
export async function downloadLlmModel(url) {
|
||||
const res = await fetch("/api/llm/download-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid text download response.");
|
||||
}
|
||||
|
||||
export async function importLlmModel(file, onProgress, signal) {
|
||||
return await uploadModelFileToEndpoint(file, "/api/llm/import-model", onProgress, signal);
|
||||
}
|
||||
|
||||
export async function deleteLlmModel(filename) {
|
||||
const res = await fetch("/api/llm/delete-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid text model delete response.");
|
||||
}
|
||||
|
||||
export async function listGeneratedOutputs() {
|
||||
try {
|
||||
const res = await fetch("/api/outputs");
|
||||
@@ -587,12 +660,16 @@ export async function importModelFile(sourcePath, onProgress, signal) {
|
||||
}
|
||||
|
||||
function uploadModelFile(file, onProgress, signal) {
|
||||
return uploadModelFileToEndpoint(file, "/api/import-model", onProgress, signal);
|
||||
}
|
||||
|
||||
function uploadModelFileToEndpoint(file, endpoint, onProgress, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const startedAt = Date.now();
|
||||
let abortedByUser = false;
|
||||
|
||||
xhr.open("POST", `/api/import-model?filename=${encodeURIComponent(file.name)}`);
|
||||
xhr.open("POST", `${endpoint}?filename=${encodeURIComponent(file.name)}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
|
||||
const abortUpload = () => {
|
||||
|
||||
@@ -127,9 +127,14 @@ fi
|
||||
# At minimum we need CPU or Vulkan backend on Linux, and both CLI and server binaries must be executable
|
||||
CPU_SERVER_PATH="$APP_DIR/backend/linux/cpu/sd-server-cpu"
|
||||
VULKAN_SERVER_PATH="$APP_DIR/backend/linux/vulkan/sd-server-vulkan"
|
||||
LLM_VULKAN_PATH="$APP_DIR/llm-backend/linux/vulkan/llama-server"
|
||||
LLM_CPU_PATH="$APP_DIR/llm-backend/linux/cpu/llama-server"
|
||||
if [[ ! -x "$CPU_BACKEND_PATH" || ! -x "$CPU_SERVER_PATH" ]] && [[ ! -x "$BACKEND_PATH" || ! -x "$VULKAN_SERVER_PATH" ]]; then
|
||||
SETUP_REASON="Linux backend binaries are missing or not executable."
|
||||
fi
|
||||
if [[ ! -x "$LLM_VULKAN_PATH" && ! -x "$LLM_CPU_PATH" ]]; then
|
||||
SETUP_REASON="Linux llama.cpp text backend is missing or not executable."
|
||||
fi
|
||||
|
||||
if [[ -n "$SETUP_REASON" ]]; then
|
||||
echo ""
|
||||
@@ -204,6 +209,7 @@ echo " ============================================================"
|
||||
echo " Running!"
|
||||
echo " Web UI: http://localhost:${FRONTEND_PORT}"
|
||||
echo " GPU API: Auto-selected by the app (starts at 8080)"
|
||||
echo " Text API: Starts when a GGUF model is loaded (port 10086)"
|
||||
echo ""
|
||||
echo " Press Ctrl+C in this window to stop all services."
|
||||
echo " ============================================================"
|
||||
|
||||
@@ -18,6 +18,11 @@ fi
|
||||
NODE_DIR="$APP_DIR/tools/node-mac"
|
||||
NODE_BIN="$NODE_DIR/bin/node"
|
||||
BACKEND_PATH="$APP_DIR/backend/mac/sd"
|
||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
LLM_BACKEND_PATH="$APP_DIR/llm-backend/mac/arm64/llama-server"
|
||||
else
|
||||
LLM_BACKEND_PATH="$APP_DIR/llm-backend/mac/x64/llama-server"
|
||||
fi
|
||||
PLATFORM_LABEL="macOS"
|
||||
|
||||
DIST_INDEX="$APP_DIR/dist/index.html"
|
||||
@@ -102,6 +107,9 @@ fi
|
||||
if [[ ! -x "$BACKEND_PATH" ]]; then
|
||||
SETUP_REASON="No macOS Metal backend binary is installed."
|
||||
fi
|
||||
if [[ ! -x "$LLM_BACKEND_PATH" ]]; then
|
||||
SETUP_REASON="No macOS llama.cpp text backend is installed."
|
||||
fi
|
||||
|
||||
if [[ -n "$SETUP_REASON" ]]; then
|
||||
echo ""
|
||||
@@ -176,6 +184,7 @@ echo " ============================================================"
|
||||
echo " Running!"
|
||||
echo " Web UI: http://localhost:${FRONTEND_PORT}"
|
||||
echo " GPU API: Auto-selected by the app (starts at 8080)"
|
||||
echo " Text API: Starts when a GGUF model is loaded (port 10086)"
|
||||
echo ""
|
||||
echo " Press Ctrl+C in this window to stop all services."
|
||||
echo " ============================================================"
|
||||
|
||||
+33
-18
@@ -7,7 +7,7 @@ $appDir = Join-Path $rootDir "app"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Yellow
|
||||
Write-Host " Resetting Local-AI-Image-Generator..." -ForegroundColor Yellow
|
||||
Write-Host " Resetting Local AI Studio..." -ForegroundColor Yellow
|
||||
Write-Host " ============================================================" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
@@ -21,10 +21,17 @@ if (Test-Path $toolsDir) {
|
||||
# Delete backend
|
||||
$backendDir = Join-Path $appDir "backend"
|
||||
if (Test-Path $backendDir) {
|
||||
Write-Host " >> Removing backend binaries..." -ForegroundColor Cyan
|
||||
Write-Host " >> Removing image backend binaries..." -ForegroundColor Cyan
|
||||
Remove-Item $backendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete llama.cpp backend
|
||||
$llmBackendDir = Join-Path $appDir "llm-backend"
|
||||
if (Test-Path $llmBackendDir) {
|
||||
Write-Host " >> Removing llama.cpp text backend binaries..." -ForegroundColor Cyan
|
||||
Remove-Item $llmBackendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete dist
|
||||
$distDir = Join-Path $appDir "dist"
|
||||
if (Test-Path $distDir) {
|
||||
@@ -32,29 +39,37 @@ if (Test-Path $distDir) {
|
||||
Remove-Item $distDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Preserve models
|
||||
# Preserve image models
|
||||
$modelsDir = Join-Path $appDir "models"
|
||||
if (Test-Path $modelsDir) {
|
||||
Write-Host " >> Preserving downloaded models in app/models." -ForegroundColor Cyan
|
||||
Write-Host " >> Preserving image models in app/models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Delete node_modules in frontend
|
||||
$nodeModulesDir = Join-Path $appDir "frontend\node_modules"
|
||||
if (Test-Path $nodeModulesDir) {
|
||||
Write-Host " >> Removing frontend node_modules..." -ForegroundColor Cyan
|
||||
Remove-Item $nodeModulesDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
# Preserve text models
|
||||
$llmModelsDir = Join-Path $appDir "llm-models"
|
||||
if (Test-Path $llmModelsDir) {
|
||||
Write-Host " >> Preserving text models in app/llm-models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
$nodeModulesMac = Join-Path $appDir "frontend\node_modules_mac"
|
||||
if (Test-Path $nodeModulesMac) {
|
||||
Write-Host " >> Removing frontend node_modules_mac..." -ForegroundColor Cyan
|
||||
Remove-Item $nodeModulesMac -Recurse -Force -ErrorAction SilentlyContinue
|
||||
# Preserve OpenVINO models
|
||||
$openVinoModelsDir = Join-Path $appDir "openvino-models"
|
||||
if (Test-Path $openVinoModelsDir) {
|
||||
Write-Host " >> Preserving OpenVINO models in app/openvino-models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
$nodeModulesLinux = Join-Path $appDir "frontend\node_modules_linux"
|
||||
if (Test-Path $nodeModulesLinux) {
|
||||
Write-Host " >> Removing frontend node_modules_linux..." -ForegroundColor Cyan
|
||||
Remove-Item $nodeModulesLinux -Recurse -Force -ErrorAction SilentlyContinue
|
||||
# Delete all frontend dependency folders, including platform-specific copies
|
||||
$frontendDir = Join-Path $appDir "frontend"
|
||||
Get-ChildItem $frontendDir -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -eq "node_modules" -or $_.Name -like "node_modules_*" } |
|
||||
ForEach-Object {
|
||||
Write-Host " >> Removing frontend $($_.Name)..." -ForegroundColor Cyan
|
||||
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$activeOsFile = Join-Path $frontendDir ".active_modules_os"
|
||||
if (Test-Path $activeOsFile) {
|
||||
Write-Host " >> Removing frontend platform marker..." -ForegroundColor Cyan
|
||||
Remove-Item $activeOsFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +82,7 @@ if (Test-Path $lockFile) {
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host " Reset complete. Models and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " Reset complete. Image models, text models, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host " Press Enter to close..."
|
||||
+29
-16
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local AI Image Generator - Linux/macOS Reset Script
|
||||
# Local AI Studio - Linux/macOS Reset Script
|
||||
# Resets portable app dependencies/builds while preserving user models and outputs.
|
||||
#
|
||||
|
||||
@@ -12,7 +12,7 @@ APP_DIR="$ROOT_DIR/app"
|
||||
|
||||
echo ""
|
||||
echo " ============================================================"
|
||||
echo " Resetting Local-AI-Image-Generator..."
|
||||
echo " Resetting Local AI Studio..."
|
||||
echo " ============================================================"
|
||||
echo ""
|
||||
|
||||
@@ -24,35 +24,48 @@ fi
|
||||
|
||||
# Delete backend
|
||||
if [[ -d "$APP_DIR/backend" ]]; then
|
||||
echo " >> Removing backend binaries..."
|
||||
echo " >> Removing image backend binaries..."
|
||||
rm -rf "$APP_DIR/backend"
|
||||
fi
|
||||
|
||||
# Delete llama.cpp backend
|
||||
if [[ -d "$APP_DIR/llm-backend" ]]; then
|
||||
echo " >> Removing llama.cpp text backend binaries..."
|
||||
rm -rf "$APP_DIR/llm-backend"
|
||||
fi
|
||||
|
||||
# Delete dist
|
||||
if [[ -d "$APP_DIR/dist" ]]; then
|
||||
echo " >> Removing dist/ build folder..."
|
||||
rm -rf "$APP_DIR/dist"
|
||||
fi
|
||||
|
||||
# Preserve models
|
||||
# Preserve image models
|
||||
if [[ -d "$APP_DIR/models" ]]; then
|
||||
echo " >> Preserving downloaded models in app/models."
|
||||
echo " >> Preserving image models in app/models."
|
||||
fi
|
||||
|
||||
# Delete node_modules in frontend
|
||||
if [[ -L "$APP_DIR/frontend/node_modules" || -d "$APP_DIR/frontend/node_modules" ]]; then
|
||||
echo " >> Removing frontend node_modules..."
|
||||
rm -rf "$APP_DIR/frontend/node_modules"
|
||||
# Preserve text models
|
||||
if [[ -d "$APP_DIR/llm-models" ]]; then
|
||||
echo " >> Preserving text models in app/llm-models."
|
||||
fi
|
||||
|
||||
if [[ -d "$APP_DIR/frontend/node_modules_mac" ]]; then
|
||||
echo " >> Removing frontend node_modules_mac..."
|
||||
rm -rf "$APP_DIR/frontend/node_modules_mac"
|
||||
# Preserve OpenVINO models
|
||||
if [[ -d "$APP_DIR/openvino-models" ]]; then
|
||||
echo " >> Preserving OpenVINO models in app/openvino-models."
|
||||
fi
|
||||
|
||||
if [[ -d "$APP_DIR/frontend/node_modules_linux" ]]; then
|
||||
echo " >> Removing frontend node_modules_linux..."
|
||||
rm -rf "$APP_DIR/frontend/node_modules_linux"
|
||||
# Delete all frontend dependency folders, including platform-specific copies
|
||||
for modules_dir in "$APP_DIR/frontend/node_modules" "$APP_DIR/frontend"/node_modules_*; do
|
||||
if [[ -L "$modules_dir" || -d "$modules_dir" ]]; then
|
||||
echo " >> Removing frontend $(basename "$modules_dir")..."
|
||||
rm -rf "$modules_dir"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f "$APP_DIR/frontend/.active_modules_os" ]]; then
|
||||
echo " >> Removing frontend platform marker..."
|
||||
rm -f "$APP_DIR/frontend/.active_modules_os"
|
||||
fi
|
||||
|
||||
# Delete package-lock.json in frontend
|
||||
@@ -63,7 +76,7 @@ fi
|
||||
|
||||
echo ""
|
||||
echo " ============================================================"
|
||||
echo " Reset complete. Models and generated outputs were preserved."
|
||||
echo " Reset complete. Image models, text models, OpenVINO models, and generated outputs were preserved."
|
||||
echo " ============================================================"
|
||||
echo ""
|
||||
read -rp " Press Enter to close..."
|
||||
+326
-14
@@ -18,9 +18,11 @@ function readPort(value, fallback) {
|
||||
|
||||
const PORT_FRONTEND = readPort(process.env.PORT || process.env.FRONTEND_PORT, 1420);
|
||||
const PREFERRED_BACKEND_PORT = readPort(process.env.BACKEND_PORT || process.env.SD_BACKEND_PORT, 8080);
|
||||
const PREFERRED_LLM_PORT = readPort(process.env.LLM_PORT, 10086);
|
||||
let PORT_BACKEND = PREFERRED_BACKEND_PORT;
|
||||
let PORT_LLM = PREFERRED_LLM_PORT;
|
||||
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
||||
const SERVER_BUILD = "polish-setup-v1";
|
||||
const SERVER_BUILD = "text-image-v1";
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const DIST = path.join(ROOT, "app", "dist");
|
||||
const osPlatform = process.platform;
|
||||
@@ -68,6 +70,18 @@ const MODELS = path.join(ROOT, "app", "models");
|
||||
if (!fs.existsSync(MODELS)) {
|
||||
fs.mkdirSync(MODELS, { recursive: true });
|
||||
}
|
||||
const LLM_MODELS = path.join(ROOT, "app", "llm-models");
|
||||
if (!fs.existsSync(LLM_MODELS)) {
|
||||
fs.mkdirSync(LLM_MODELS, { recursive: true });
|
||||
}
|
||||
const LLM_BACKEND_PATHS = {
|
||||
winVulkan: path.join(ROOT, "app", "llm-backend", "win", "vulkan", "llama-server.exe"),
|
||||
winCpu: path.join(ROOT, "app", "llm-backend", "win", "cpu", "llama-server.exe"),
|
||||
linuxVulkan: path.join(ROOT, "app", "llm-backend", "linux", "vulkan", "llama-server"),
|
||||
linuxCpu: path.join(ROOT, "app", "llm-backend", "linux", "cpu", "llama-server"),
|
||||
macArm64: path.join(ROOT, "app", "llm-backend", "mac", "arm64", "llama-server"),
|
||||
macX64: path.join(ROOT, "app", "llm-backend", "mac", "x64", "llama-server"),
|
||||
};
|
||||
const OPENVINO_MODELS = path.join(ROOT, "app", "openvino-models");
|
||||
if (!fs.existsSync(OPENVINO_MODELS)) {
|
||||
fs.mkdirSync(OPENVINO_MODELS, { recursive: true });
|
||||
@@ -100,6 +114,17 @@ let openvinoPort = null;
|
||||
let openvinoModel = null;
|
||||
let openvinoWidth = null;
|
||||
let openvinoHeight = null;
|
||||
let llmProc = null;
|
||||
let llmReady = false;
|
||||
let llmError = null;
|
||||
let llmSettings = {
|
||||
model: null,
|
||||
threads: Math.max(1, Math.min(16, os.cpus().length || 4)),
|
||||
contextSize: 4096,
|
||||
gpuLayers: -1,
|
||||
backendMode: "",
|
||||
backendBinary: "",
|
||||
};
|
||||
let backendLoadState = {
|
||||
active: false,
|
||||
phase: "",
|
||||
@@ -557,6 +582,102 @@ async function findAvailableBackendPort() {
|
||||
throw new Error(`No free backend port found. Tried ${PREFERRED_BACKEND_PORT} and 28088-28120.`);
|
||||
}
|
||||
|
||||
async function findAvailableLlmPort() {
|
||||
const preferred = await checkPort(PREFERRED_LLM_PORT);
|
||||
if (preferred.available) return PREFERRED_LLM_PORT;
|
||||
|
||||
for (let port = 28121; port <= 28160; port += 1) {
|
||||
const candidate = await checkPort(port);
|
||||
if (candidate.available) {
|
||||
console.log(` [llm] Preferred port ${PREFERRED_LLM_PORT} is busy; using ${port} instead.`);
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No free LLM port found. Tried ${PREFERRED_LLM_PORT} and 28121-28160.`);
|
||||
}
|
||||
|
||||
function getLlmBackend() {
|
||||
const candidates = osPlatform === "win32"
|
||||
? [
|
||||
{ path: LLM_BACKEND_PATHS.winVulkan, mode: "Auto (Vulkan/CPU)" },
|
||||
{ path: LLM_BACKEND_PATHS.winCpu, mode: "CPU" },
|
||||
]
|
||||
: osPlatform === "darwin"
|
||||
? [{
|
||||
path: process.arch === "arm64" ? LLM_BACKEND_PATHS.macArm64 : LLM_BACKEND_PATHS.macX64,
|
||||
mode: process.arch === "arm64" ? "Metal GPU" : "CPU",
|
||||
}]
|
||||
: [
|
||||
{ path: LLM_BACKEND_PATHS.linuxVulkan, mode: "Auto (Vulkan/CPU)" },
|
||||
{ path: LLM_BACKEND_PATHS.linuxCpu, mode: "CPU" },
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate.path)) || null;
|
||||
}
|
||||
|
||||
function getLlmModels() {
|
||||
try {
|
||||
return fs.readdirSync(LLM_MODELS)
|
||||
.filter((filename) => filename.toLowerCase().endsWith(".gguf"))
|
||||
.map((filename) => {
|
||||
const stats = fs.statSync(path.join(LLM_MODELS, filename));
|
||||
return {
|
||||
filename,
|
||||
name: filename,
|
||||
sizeBytes: stats.size,
|
||||
size: formatBytes(stats.size),
|
||||
format: "GGUF",
|
||||
};
|
||||
});
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pingLlmReady() {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${PORT_LLM}/v1/models`, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode >= 200 && res.statusCode < 500);
|
||||
});
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForLlmReady(maxAttempts = 240) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (!llmProc) throw new Error(llmError || "llama.cpp exited during startup.");
|
||||
if (await pingLlmReady()) {
|
||||
llmReady = true;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error("llama.cpp did not become ready within 2 minutes.");
|
||||
}
|
||||
|
||||
function killLlm() {
|
||||
return new Promise((resolve) => {
|
||||
llmReady = false;
|
||||
if (!llmProc) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const proc = llmProc;
|
||||
llmProc = null;
|
||||
try { proc.kill("SIGTERM"); } catch (_) {}
|
||||
setTimeout(() => {
|
||||
try { proc.kill("SIGKILL"); } catch (_) {}
|
||||
resolve();
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
function getSetupPaths() {
|
||||
const appDir = path.join(ROOT, "app");
|
||||
if (osPlatform === "win32") {
|
||||
@@ -1367,7 +1488,77 @@ function killBackend() {
|
||||
});
|
||||
}
|
||||
|
||||
async function startLlm(settings = {}) {
|
||||
const filename = path.basename(String(settings.model || ""));
|
||||
const modelPath = path.join(LLM_MODELS, filename);
|
||||
if (!filename || !pathInside(modelPath, LLM_MODELS) || !fs.existsSync(modelPath)) {
|
||||
throw new Error("Select a downloaded GGUF text model first.");
|
||||
}
|
||||
if (!filename.toLowerCase().endsWith(".gguf")) {
|
||||
throw new Error("Text generation requires a .gguf model.");
|
||||
}
|
||||
|
||||
const backend = getLlmBackend();
|
||||
if (!backend) {
|
||||
throw new Error("llama.cpp is not installed. Run the platform setup script to install the text backend.");
|
||||
}
|
||||
|
||||
await killBackend();
|
||||
await killOpenVinoWorker();
|
||||
await killLlm();
|
||||
PORT_LLM = await findAvailableLlmPort();
|
||||
llmError = null;
|
||||
llmSettings = {
|
||||
...llmSettings,
|
||||
model: filename,
|
||||
threads: Math.max(1, Math.min(64, Number(settings.threads) || llmSettings.threads)),
|
||||
contextSize: Math.max(512, Math.min(32768, Number(settings.contextSize) || 4096)),
|
||||
gpuLayers: Number.isFinite(Number(settings.gpuLayers)) ? Number(settings.gpuLayers) : -1,
|
||||
backendMode: backend.mode,
|
||||
backendBinary: path.basename(backend.path),
|
||||
};
|
||||
|
||||
const args = [
|
||||
"--model", modelPath,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", String(PORT_LLM),
|
||||
"--ctx-size", String(llmSettings.contextSize),
|
||||
"--threads", String(llmSettings.threads),
|
||||
"--n-gpu-layers", String(llmSettings.gpuLayers),
|
||||
];
|
||||
const spawnEnv = { ...process.env };
|
||||
const backendDir = path.dirname(backend.path);
|
||||
if (osPlatform === "linux") {
|
||||
spawnEnv.LD_LIBRARY_PATH = backendDir + (spawnEnv.LD_LIBRARY_PATH ? `:${spawnEnv.LD_LIBRARY_PATH}` : "");
|
||||
} else if (osPlatform === "darwin") {
|
||||
spawnEnv.DYLD_LIBRARY_PATH = backendDir + (spawnEnv.DYLD_LIBRARY_PATH ? `:${spawnEnv.DYLD_LIBRARY_PATH}` : "");
|
||||
}
|
||||
|
||||
console.log(" [llm] Starting:", backend.path, args.join(" "));
|
||||
llmProc = spawn(backend.path, args, { stdio: "pipe", env: spawnEnv });
|
||||
llmProc.stdout.on("data", (data) => process.stdout.write(" [llm] " + data.toString()));
|
||||
llmProc.stderr.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
process.stderr.write(" [llm-err] " + output);
|
||||
if (/Vulkan\d+\s*:/i.test(output)) llmSettings.backendMode = "Vulkan GPU";
|
||||
else if (/Metal/i.test(output) && /GPU|device/i.test(output)) llmSettings.backendMode = "Metal GPU";
|
||||
else if (/\-\s+CPU\s+:/i.test(output) && llmSettings.backendMode.startsWith("Auto")) llmSettings.backendMode = "CPU";
|
||||
if (/error|failed/i.test(output) && !/no error/i.test(output)) {
|
||||
llmError = output.trim().slice(-1200);
|
||||
}
|
||||
});
|
||||
llmProc.on("exit", (code) => {
|
||||
llmReady = false;
|
||||
llmProc = null;
|
||||
if (code !== 0 && code !== null && !llmError) llmError = `llama.cpp exited with code ${code}`;
|
||||
console.log(" [llm] exited with code", code);
|
||||
});
|
||||
|
||||
await waitForLlmReady();
|
||||
}
|
||||
|
||||
async function startBackend(settings = {}) {
|
||||
await killLlm();
|
||||
if (settings.backendType === "openvino-npu") {
|
||||
await startOpenVinoWorker(settings);
|
||||
return;
|
||||
@@ -1768,7 +1959,7 @@ function describeDownloadHttpError(statusCode, url, headers = {}) {
|
||||
return `HTTP ${statusCode}`;
|
||||
}
|
||||
|
||||
function startModelDownload(url, overrideFilename = null) {
|
||||
function startModelDownload(url, overrideFilename = null, targetDir = MODELS, kind = "image") {
|
||||
if (downloadState.active && !overrideFilename) {
|
||||
console.log(" [download] Already downloading a model");
|
||||
return;
|
||||
@@ -1790,7 +1981,7 @@ function startModelDownload(url, overrideFilename = null) {
|
||||
}
|
||||
}
|
||||
|
||||
const destPath = path.join(MODELS, filename);
|
||||
const destPath = path.join(targetDir, filename);
|
||||
const tempPath = `${destPath}.part`;
|
||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
||||
downloadState = {
|
||||
@@ -1801,7 +1992,8 @@ function startModelDownload(url, overrideFilename = null) {
|
||||
eta: 0,
|
||||
totalBytes: 0,
|
||||
downloadedBytes: 0,
|
||||
error: null
|
||||
error: null,
|
||||
kind,
|
||||
};
|
||||
|
||||
console.log(` [download] Starting download of ${filename} from ${url}`);
|
||||
@@ -1853,7 +2045,7 @@ function startModelDownload(url, overrideFilename = null) {
|
||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
||||
activeDownload = null;
|
||||
downloadState.active = false;
|
||||
startModelDownload(redirectUrl, filename);
|
||||
startModelDownload(redirectUrl, filename, targetDir, kind);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1967,7 +2159,8 @@ function cancelModelDownload() {
|
||||
try { fs.unlinkSync(activeDownload.tempPath || activeDownload.destPath); } catch (_) {}
|
||||
}
|
||||
} else if (filename) {
|
||||
try { fs.unlinkSync(`${path.join(MODELS, filename)}.part`); } catch (_) {}
|
||||
const targetDir = downloadState.kind === "text" ? LLM_MODELS : MODELS;
|
||||
try { fs.unlinkSync(`${path.join(targetDir, filename)}.part`); } catch (_) {}
|
||||
}
|
||||
activeDownload = null;
|
||||
downloadState = {
|
||||
@@ -1979,6 +2172,7 @@ function cancelModelDownload() {
|
||||
totalBytes: 0,
|
||||
downloadedBytes: 0,
|
||||
error: "Download cancelled",
|
||||
kind: downloadState.kind || "image",
|
||||
};
|
||||
console.log(` [download] Cancelled download of ${filename}`);
|
||||
return true;
|
||||
@@ -2282,16 +2476,16 @@ function deleteGeneratedOutputs(outputs = []) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function streamModelUpload(req, filename) {
|
||||
function streamModelUpload(req, filename, targetDir = MODELS, textOnly = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const safeFilename = path.basename(filename || "");
|
||||
const lowerName = safeFilename.toLowerCase();
|
||||
if (!safeFilename || !isModelFile(lowerName)) {
|
||||
reject(new Error("Filename must end with .gguf, .safetensors, or .ckpt"));
|
||||
if (!safeFilename || (textOnly ? !lowerName.endsWith(".gguf") : !isModelFile(lowerName))) {
|
||||
reject(new Error(textOnly ? "Filename must end with .gguf" : "Filename must end with .gguf, .safetensors, or .ckpt"));
|
||||
return;
|
||||
}
|
||||
|
||||
const destPath = path.join(MODELS, safeFilename);
|
||||
const destPath = path.join(targetDir, safeFilename);
|
||||
const tempPath = `${destPath}.part`;
|
||||
const out = fs.createWriteStream(tempPath);
|
||||
let finished = false;
|
||||
@@ -2325,7 +2519,12 @@ function streamModelUpload(req, filename) {
|
||||
finished = true;
|
||||
fs.renameSync(tempPath, destPath);
|
||||
console.log(` [api] Imported model file: ${safeFilename}`);
|
||||
resolve(getModelInfo(safeFilename));
|
||||
if (textOnly) {
|
||||
const stats = fs.statSync(destPath);
|
||||
resolve({ filename: safeFilename, name: safeFilename, sizeBytes: stats.size, size: formatBytes(stats.size), format: "GGUF" });
|
||||
} else {
|
||||
resolve(getModelInfo(safeFilename));
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
||||
reject(err);
|
||||
@@ -2362,6 +2561,13 @@ const server = http.createServer(async (req, res) => {
|
||||
cleanupCandidates: getCleanupCandidates(),
|
||||
download: downloadState,
|
||||
generation: generationState,
|
||||
llm: {
|
||||
ready: llmReady,
|
||||
running: llmProc !== null,
|
||||
error: llmError,
|
||||
settings: llmSettings,
|
||||
port: PORT_LLM,
|
||||
},
|
||||
hardware: getHardwareSpecs(),
|
||||
telemetry: getTelemetry(),
|
||||
});
|
||||
@@ -2400,6 +2606,61 @@ const server = http.createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/status" && req.method === "GET") {
|
||||
const backend = getLlmBackend();
|
||||
return json(res, 200, {
|
||||
ready: llmReady,
|
||||
running: llmProc !== null,
|
||||
port: PORT_LLM,
|
||||
preferredPort: PREFERRED_LLM_PORT,
|
||||
error: llmError,
|
||||
settings: llmSettings,
|
||||
backendInstalled: Boolean(backend),
|
||||
backendMode: backend?.mode || "",
|
||||
backendPath: backend?.path || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/models" && req.method === "GET") {
|
||||
return json(res, 200, { models: getLlmModels() });
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/start" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
try {
|
||||
await startLlm(body);
|
||||
return json(res, 200, { ok: true, ready: llmReady, port: PORT_LLM, settings: llmSettings });
|
||||
} catch (err) {
|
||||
llmError = err.message || String(err);
|
||||
await killLlm();
|
||||
return json(res, 500, { ok: false, error: llmError });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/stop" && req.method === "POST") {
|
||||
await killLlm();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/chat" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
if (!llmReady) return json(res, 409, { ok: false, error: "Load a text model before sending a message." });
|
||||
try {
|
||||
const result = await requestJson(`http://127.0.0.1:${PORT_LLM}/v1/chat/completions`, {
|
||||
model: llmSettings.model || "local-model",
|
||||
messages: Array.isArray(body.messages) ? body.messages : [],
|
||||
temperature: Number.isFinite(Number(body.temperature)) ? Number(body.temperature) : 0.7,
|
||||
max_tokens: Math.max(1, Math.min(4096, Number(body.max_tokens) || 512)),
|
||||
stream: false,
|
||||
}, 300000);
|
||||
return json(res, 200, result);
|
||||
} catch (err) {
|
||||
return json(res, 500, { ok: false, error: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/hardware-specs
|
||||
if (req.url === "/api/hardware-specs" && req.method === "GET") {
|
||||
return json(res, 200, getHardwareSpecs());
|
||||
@@ -2499,6 +2760,25 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { ok: true, message: "Download started" });
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/download-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
const url = String(body.url || "");
|
||||
if (!url) return json(res, 400, { ok: false, error: "URL is required" });
|
||||
if (downloadState.active) return json(res, 409, { ok: false, error: "Another model download is already active." });
|
||||
try {
|
||||
const directUrl = url.includes("huggingface.co") ? url.replace("/blob/", "/resolve/") : url;
|
||||
const filename = path.basename(new URL(directUrl).pathname);
|
||||
if (!filename.toLowerCase().endsWith(".gguf")) {
|
||||
return json(res, 400, { ok: false, error: "Text model URL must point to a .gguf file." });
|
||||
}
|
||||
startModelDownload(directUrl, filename, LLM_MODELS, "text");
|
||||
return json(res, 200, { ok: true, message: "Text model download started", filename });
|
||||
} catch (err) {
|
||||
return json(res, 400, { ok: false, error: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/download-openvino-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
@@ -2598,6 +2878,37 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/api/llm/import-model") && req.method === "POST") {
|
||||
try {
|
||||
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
const result = await streamModelUpload(req, parsed.searchParams.get("filename"), LLM_MODELS, true);
|
||||
return json(res, 200, { ok: true, message: `Imported ${result.filename}`, model: result });
|
||||
} catch (err) {
|
||||
console.error(" [api] Failed to import text model:", err);
|
||||
return json(res, 500, { ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === "/api/llm/delete-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
const filename = path.basename(String(body.filename || ""));
|
||||
const modelPath = path.join(LLM_MODELS, filename);
|
||||
if (!filename || !pathInside(modelPath, LLM_MODELS)) {
|
||||
return json(res, 400, { ok: false, error: "Invalid filename" });
|
||||
}
|
||||
if (llmSettings.model === filename && llmProc) await killLlm();
|
||||
try {
|
||||
fs.unlinkSync(modelPath);
|
||||
return json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
return json(res, err.code === "ENOENT" ? 404 : 500, {
|
||||
ok: false,
|
||||
error: err.code === "ENOENT" ? "Text model not found" : err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/delete-model
|
||||
if (req.url === "/api/delete-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
@@ -2655,7 +2966,8 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
|
||||
console.log(" LOCAL AI IMAGE GENERATOR | Running");
|
||||
console.log(" Server Build: " + SERVER_BUILD);
|
||||
console.log(" Frontend : http://localhost:" + PORT_FRONTEND);
|
||||
console.log(" Backend : http://127.0.0.1:" + PORT_BACKEND);
|
||||
console.log(" Image API: http://127.0.0.1:" + PORT_BACKEND);
|
||||
console.log(" Text API : starts on http://127.0.0.1:" + PREFERRED_LLM_PORT);
|
||||
console.log(" ============================================================");
|
||||
console.log("");
|
||||
|
||||
@@ -2664,5 +2976,5 @@ server.listen(PORT_FRONTEND, "0.0.0.0", () => {
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", async () => { await killBackend(); process.exit(0); });
|
||||
process.on("SIGTERM", async () => { await killBackend(); process.exit(0); });
|
||||
process.on("SIGINT", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); process.exit(0); });
|
||||
process.on("SIGTERM", async () => { await killBackend(); await killOpenVinoWorker(); await killLlm(); process.exit(0); });
|
||||
@@ -0,0 +1,42 @@
|
||||
param([string]$Release = "b9631")
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = Split-Path -Parent $scriptDir
|
||||
$appDir = Join-Path $rootDir "app"
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
$llmRoot = Join-Path $appDir "llm-backend\win"
|
||||
|
||||
function Install-LlamaArchive {
|
||||
param([string]$Variant, [string]$AssetName)
|
||||
|
||||
$dest = Join-Path $llmRoot $Variant
|
||||
$server = Join-Path $dest "llama-server.exe"
|
||||
if (Test-Path $server) {
|
||||
Write-Host " OK llama.cpp $Variant backend already ready."
|
||||
return
|
||||
}
|
||||
|
||||
$archive = Join-Path $toolsDir $AssetName
|
||||
$extract = Join-Path $toolsDir "llama-$Variant-extract"
|
||||
$url = "https://github.com/ggml-org/llama.cpp/releases/download/$Release/$AssetName"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir, $dest | Out-Null
|
||||
Remove-Item $archive, $extract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " >> Downloading llama.cpp $Variant backend ($Release)..."
|
||||
Invoke-WebRequest -UseBasicParsing -Headers @{ "User-Agent" = "Local-AI-Studio" } -Uri $url -OutFile $archive
|
||||
Expand-Archive -Path $archive -DestinationPath $extract -Force
|
||||
|
||||
Get-ChildItem $extract -Recurse -File | ForEach-Object {
|
||||
Copy-Item $_.FullName (Join-Path $dest $_.Name) -Force
|
||||
}
|
||||
Remove-Item $archive, $extract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not (Test-Path $server)) {
|
||||
throw "llama-server.exe was not found after extracting $AssetName"
|
||||
}
|
||||
Write-Host " OK llama.cpp $Variant backend installed."
|
||||
}
|
||||
|
||||
Install-LlamaArchive -Variant "vulkan" -AssetName "llama-$Release-bin-win-vulkan-x64.zip"
|
||||
Install-LlamaArchive -Variant "cpu" -AssetName "llama-$Release-bin-win-cpu-x64.zip"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
APP_DIR="$ROOT_DIR/app"
|
||||
TOOLS_DIR="$APP_DIR/tools"
|
||||
RELEASE="${LLAMA_RELEASE:-b9631}"
|
||||
PLATFORM="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
download_and_extract() {
|
||||
local asset="$1"
|
||||
local dest="$2"
|
||||
local archive="$TOOLS_DIR/$asset"
|
||||
local url="https://github.com/ggml-org/llama.cpp/releases/download/$RELEASE/$asset"
|
||||
|
||||
if [[ -x "$dest/llama-server" ]]; then
|
||||
echo " OK llama.cpp backend already ready: $dest"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$TOOLS_DIR" "$dest"
|
||||
rm -f "$archive" "$archive.part"
|
||||
echo " >> Downloading $asset"
|
||||
curl -fSL --progress-bar "$url" -o "$archive.part"
|
||||
mv "$archive.part" "$archive"
|
||||
tar -xzf "$archive" -C "$dest" --strip-components=1
|
||||
rm -f "$archive"
|
||||
chmod +x "$dest"/llama-* 2>/dev/null || true
|
||||
|
||||
if [[ ! -x "$dest/llama-server" ]]; then
|
||||
echo " XX llama-server was not found after extracting $asset" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "Darwin" ]]; then
|
||||
if [[ "$ARCH" == "arm64" ]]; then
|
||||
download_and_extract "llama-$RELEASE-bin-macos-arm64.tar.gz" "$APP_DIR/llm-backend/mac/arm64"
|
||||
else
|
||||
download_and_extract "llama-$RELEASE-bin-macos-x64.tar.gz" "$APP_DIR/llm-backend/mac/x64"
|
||||
fi
|
||||
elif [[ "$PLATFORM" == "Linux" ]]; then
|
||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
||||
download_and_extract "llama-$RELEASE-bin-ubuntu-vulkan-arm64.tar.gz" "$APP_DIR/llm-backend/linux/vulkan"
|
||||
download_and_extract "llama-$RELEASE-bin-ubuntu-arm64.tar.gz" "$APP_DIR/llm-backend/linux/cpu"
|
||||
else
|
||||
download_and_extract "llama-$RELEASE-bin-ubuntu-vulkan-x64.tar.gz" "$APP_DIR/llm-backend/linux/vulkan"
|
||||
download_and_extract "llama-$RELEASE-bin-ubuntu-x64.tar.gz" "$APP_DIR/llm-backend/linux/cpu"
|
||||
fi
|
||||
else
|
||||
echo "Unsupported platform: $PLATFORM" >&2
|
||||
exit 1
|
||||
fi
|
||||
+10
-3
@@ -158,7 +158,7 @@ function Expand-WithProgress {
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
Print-Header
|
||||
|
||||
$steps = 4
|
||||
$steps = 5
|
||||
|
||||
# ── Step 1: Portable Node.js ──────────────────────────────────────────────────
|
||||
Print-Step 1 $steps "Setting up portable Node.js (app/tools/node-win/)"
|
||||
@@ -420,7 +420,14 @@ if ($hasNvidia) {
|
||||
}
|
||||
|
||||
# ── Step 3: npm install ───────────────────────────────────────────────────────
|
||||
Print-Step 3 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Print-Step 3 $steps "Setting up llama.cpp text backends (Vulkan + CPU)"
|
||||
& (Join-Path $scriptDir "setup-llama.ps1")
|
||||
if (-not $?) {
|
||||
Print-Fail "llama.cpp setup failed."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Print-Step 4 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $npmCmd)) {
|
||||
@@ -474,7 +481,7 @@ try {
|
||||
Print-OK "Dependencies installed!"
|
||||
|
||||
# ── Step 4: Build frontend ────────────────────────────────────────────────
|
||||
Print-Step 4 $steps "Building frontend -> app/dist/"
|
||||
Print-Step 5 $steps "Building frontend -> app/dist/"
|
||||
Write-Host ""
|
||||
|
||||
& $npmCmd run build 2>&1
|
||||
|
||||
+6
-3
@@ -258,7 +258,7 @@ if [[ "$PLATFORM" == "Linux" ]]; then
|
||||
check_glibc
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=4
|
||||
TOTAL_STEPS=5
|
||||
|
||||
# ── Step 1: Portable Node.js ────────────────────────────────────────────────
|
||||
print_step 1 $TOTAL_STEPS "Setting up portable Node.js ($NODE_DIR/)"
|
||||
@@ -541,7 +541,10 @@ fi
|
||||
fi
|
||||
|
||||
# ── Step 3: npm install ─────────────────────────────────────────────────────
|
||||
print_step 3 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
|
||||
print_step 3 $TOTAL_STEPS "Setting up llama.cpp text backend"
|
||||
bash "$SCRIPT_DIR/setup-llama.sh"
|
||||
|
||||
print_step 4 $TOTAL_STEPS "Installing frontend dependencies (app/frontend/)"
|
||||
|
||||
if [[ ! -x "$NPM_BIN" ]]; then
|
||||
print_fail "Portable npm was not found at $NPM_BIN"
|
||||
@@ -639,7 +642,7 @@ else
|
||||
fi
|
||||
|
||||
# ── Step 4: Build frontend ──────────────────────────────────────────────────
|
||||
print_step 4 $TOTAL_STEPS "Building frontend -> app/dist/"
|
||||
print_step 5 $TOTAL_STEPS "Building frontend -> app/dist/"
|
||||
|
||||
if [ "$USE_SYMLINKS" = false ]; then
|
||||
# If symlinks are disabled, run vite directly using the local node executable.
|
||||
|
||||
@@ -9,6 +9,8 @@ set DIST=%APP%\dist\index.html
|
||||
set SETUP=%~dp0scripts\setup.ps1
|
||||
set CUDA_BACKEND=%APP%\backend\win\cuda\sd-cuda.exe
|
||||
set VULKAN_BACKEND=%APP%\backend\win\vulkan\sd-vulkan.exe
|
||||
set LLM_VULKAN_BACKEND=%APP%\llm-backend\win\vulkan\llama-server.exe
|
||||
set LLM_CPU_BACKEND=%APP%\llm-backend\win\cpu\llama-server.exe
|
||||
set SERVE=%~dp0scripts\serve.cjs
|
||||
if "%FRONTEND_PORT%"=="" set FRONTEND_PORT=1420
|
||||
set SETUP_REASON=
|
||||
@@ -28,6 +30,10 @@ if not exist "%DIST%" (
|
||||
set SETUP_REASON=Frontend build is missing.
|
||||
goto :run_setup
|
||||
)
|
||||
if not exist "%LLM_VULKAN_BACKEND%" if not exist "%LLM_CPU_BACKEND%" (
|
||||
set SETUP_REASON=llama.cpp text backend is missing.
|
||||
goto :run_setup
|
||||
)
|
||||
if exist "%CUDA_BACKEND%" goto :launch
|
||||
if exist "%VULKAN_BACKEND%" goto :launch
|
||||
set SETUP_REASON=No backend binary is installed.
|
||||
@@ -88,6 +94,7 @@ echo ============================================================
|
||||
echo Running!
|
||||
echo Web UI: http://localhost:%FRONTEND_PORT%
|
||||
echo GPU API: Auto-selected by the app (starts at 8080)
|
||||
echo Text API: Starts when a GGUF model is loaded (port 10086)
|
||||
echo.
|
||||
echo Press Ctrl+C in this window to stop all services.
|
||||
echo ============================================================
|
||||
|
||||
Reference in new issue
Block a user