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
@@ -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 = () => {
|
||||
|
||||
Reference in new issue
Block a user