feat: initialize project with static file server, backend management, and React frontend components

This commit is contained in:
techjarves committed 2026-06-15 07:22:54 +05:30
1 parent 83bb0bff12
commit cf1b7e548a
7 files changed
+744 -79

No files matched your search

+109
View File
@@ -1268,6 +1268,115 @@ html[data-theme="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
transition: width 0.25s ease;
}
.model-discovery-controls {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--border-color-muted);
border-radius: var(--md-shape-corner-large);
background: var(--md-sys-color-surface);
}
.model-search-box {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 12px;
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-outline);
}
.model-search-box:focus-within {
border-color: var(--md-sys-color-primary);
}
.model-search-box input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--md-sys-color-on-surface);
font: inherit;
}
.model-search-box button {
display: inline-flex;
padding: 4px;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
}
.model-filter-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.model-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: var(--md-shape-corner-full);
background: transparent;
color: var(--md-sys-color-on-surface-variant);
font-weight: 600;
cursor: pointer;
}
.model-filter-chip.selected {
border-color: var(--md-sys-color-primary);
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
.model-filter-clear {
border: 0;
background: transparent;
color: var(--md-sys-color-primary);
font-weight: 600;
cursor: pointer;
}
.model-discovery-loading {
display: flex;
align-items: center;
gap: 12px;
color: var(--md-sys-color-outline);
font-size: 0.82rem;
font-weight: 600;
}
.model-search-warning {
padding: 10px 12px;
border-radius: var(--md-shape-corner-small);
background: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
font-size: 0.8rem;
}
.model-search-empty {
color: var(--md-sys-color-on-surface-variant);
text-align: center;
}
.model-load-more {
align-self: center;
min-width: 180px;
margin: 4px 0 8px;
}
/* Importer & Library */
.import-box {
border: 2px dashed var(--border-color);
+7 -55
View File
@@ -447,13 +447,6 @@ function App() {
}
}, [serverRunning, isStoppingServer, showAlert]);
const readinessIssues = [
...(health?.stale ? ["Restart Local AI Image Generator so the local server loads the latest API."] : []),
...(health?.issues || []),
];
const cleanupBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
const showReadinessPanel = Boolean(health && (readinessIssues.length > 0 || cleanupItems.length > 0));
return (
<div className="app-container">
{/* Sidebar with Navigation Rail & Specs */}
@@ -472,54 +465,6 @@ function App() {
setTheme={setTheme}
/>
{showReadinessPanel && (
<div className={`m3-card readiness-card ${health?.stale || readinessIssues.length > 0 ? "readiness-card-warning" : ""}`}>
<div className="readiness-header">
<div>
<h3 className="m3-card-title" style={{ marginBottom: "4px" }}>
{health?.stale ? "Restart Required" : readinessIssues.length > 0 ? "System Readiness" : "Safe Cleanup Available"}
</h3>
<p className="m3-card-subtitle" style={{ margin: 0 }}>
{health?.stale
? `Running server build: ${health.build || "unknown"}`
: readinessIssues.length > 0
? "Local AI Image Generator found setup items that may need attention."
: `${cleanupItems.length} temporary item${cleanupItems.length === 1 ? "" : "s"} can be cleaned (${formatBytes(cleanupBytes)}).`}
</p>
</div>
<div className="readiness-actions">
<button className="m3-btn m3-btn-outlined" onClick={refreshReadiness} disabled={isReadinessBusy}>
{isReadinessBusy ? "Checking" : "Refresh"}
</button>
<button className="m3-btn m3-btn-tonal" onClick={copyDiagnostics}>
{diagnosticsCopied ? "Copied" : "Copy Diagnostics"}
</button>
{cleanupItems.length > 0 && (
<button className="m3-btn m3-btn-error" onClick={cleanupSafeItems} disabled={isReadinessBusy}>
Clean {formatBytes(cleanupBytes)}
</button>
)}
</div>
</div>
{readinessIssues.length > 0 && (
<div className="readiness-issues">
{readinessIssues.slice(0, 4).map((issue) => (
<span key={issue}>{issue}</span>
))}
</div>
)}
{cleanupItems.length > 0 && (
<div className="readiness-cleanup-list">
{cleanupItems.slice(0, 3).map((item) => (
<span key={item.id} title={item.path}>
{item.name} · {item.size} · {item.reason}
</span>
))}
</div>
)}
</div>
)}
{/* Dynamic Workspace Container */}
<div style={{ display: activeTab === "generator" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
<Generator
@@ -595,6 +540,13 @@ function App() {
showConfirm={showConfirm}
textSettings={textSettings}
setTextSettings={setTextSettings}
health={health}
cleanupItems={cleanupItems}
isReadinessBusy={isReadinessBusy}
refreshReadiness={refreshReadiness}
copyDiagnostics={copyDiagnostics}
cleanupSafeItems={cleanupSafeItems}
diagnosticsCopied={diagnosticsCopied}
/>
</div>
</div>
+174 -11
View File
@@ -1,5 +1,5 @@
import React, { memo, useState, useEffect, useCallback } from "react";
import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle } from "lucide-react";
import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle, Search, X } from "lucide-react";
import {
listLocalModels,
startServer,
@@ -20,9 +20,16 @@ import {
downloadLlmModel,
deleteLlmModel,
importLlmModel,
getLlmStatus
getLlmStatus,
searchHuggingFaceModels
} from "../services/api";
const MODEL_FILTERS = [
{ id: "potato", label: "Potato PC" },
{ id: "vision", label: "Vision" },
{ id: "uncensored", label: "Uncensored" },
];
const MODEL_LIBRARY = [
{
group: "SDXL - Best Quality",
@@ -233,6 +240,16 @@ function ModelManager({
const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
const [activeLlmModel, setActiveLlmModel] = useState(null);
const [llmRunning, setLlmRunning] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const [selectedFilters, setSelectedFilters] = useState([]);
const [huggingFaceModels, setHuggingFaceModels] = useState([]);
const [isSearchingModels, setIsSearchingModels] = useState(false);
const [modelSearchError, setModelSearchError] = useState("");
const [hasHuggingFaceResults, setHasHuggingFaceResults] = useState(false);
const [modelSearchPage, setModelSearchPage] = useState(1);
const [hasMoreModels, setHasMoreModels] = useState(false);
const [isLoadingMoreModels, setIsLoadingMoreModels] = useState(false);
const searchRequestRef = React.useRef(0);
const cancelLoadRef = React.useRef(false);
@@ -253,6 +270,9 @@ function ModelManager({
backendOptions?.options?.some((opt) => opt.id === "apple-npu") ||
backendOptions?.unavailable?.some((opt) => opt.id === "apple-npu")
);
const displayedHuggingFaceModels = huggingFaceModels.filter((model) => (
selectedFilters.every((filterId) => model.tags?.includes(filterId))
));
let visibleModelLibrary = [];
if (activeModelType === "image") {
@@ -264,8 +284,74 @@ function ModelManager({
visibleModelLibrary = [...visibleModelLibrary, ...COREML_MODEL_LIBRARY];
}
} else {
visibleModelLibrary = [...TEXT_MODEL_LIBRARY];
visibleModelLibrary = [{
group: "Recommended Text Models from Hugging Face",
items: hasHuggingFaceResults ? displayedHuggingFaceModels : TEXT_MODEL_LIBRARY[0].items,
}];
}
useEffect(() => {
if (activeModelType !== "text") return;
const requestId = ++searchRequestRef.current;
const timer = setTimeout(async () => {
setIsSearchingModels(true);
setModelSearchError("");
try {
const result = await searchHuggingFaceModels(modelSearch, selectedFilters, 1);
if (requestId !== searchRequestRef.current) return;
const matchingModels = result.models.filter((model) => (
selectedFilters.every((filterId) => model.tags?.includes(filterId))
));
setHuggingFaceModels(matchingModels);
setHasHuggingFaceResults(true);
setModelSearchPage(1);
setHasMoreModels(result.hasMore);
} catch (err) {
if (requestId !== searchRequestRef.current) return;
setHuggingFaceModels([]);
setHasHuggingFaceResults(false);
setHasMoreModels(false);
setModelSearchError(err.message || "Could not search Hugging Face.");
} finally {
if (requestId === searchRequestRef.current) setIsSearchingModels(false);
}
}, modelSearch ? 400 : 0);
return () => clearTimeout(timer);
}, [activeModelType, modelSearch, selectedFilters]);
const toggleModelFilter = (filterId) => {
setSelectedFilters((current) => (
current.includes(filterId)
? current.filter((id) => id !== filterId)
: [...current, filterId]
));
};
const handleLoadMoreModels = async () => {
if (isLoadingMoreModels || !hasMoreModels) return;
const nextPage = modelSearchPage + 1;
setIsLoadingMoreModels(true);
setModelSearchError("");
try {
const [result] = await Promise.all([
searchHuggingFaceModels(modelSearch, selectedFilters, nextPage),
new Promise((resolve) => setTimeout(resolve, 350)),
]);
const matchingModels = result.models.filter((model) => (
selectedFilters.every((filterId) => model.tags?.includes(filterId))
));
setHuggingFaceModels((current) => {
const known = new Set(current.map((model) => model.id));
return [...current, ...matchingModels.filter((model) => !known.has(model.id))];
});
setModelSearchPage(result.page);
setHasMoreModels(result.hasMore);
} catch (err) {
setModelSearchError(err.message || "Could not load more Hugging Face models.");
} finally {
setIsLoadingMoreModels(false);
}
};
const getLocalModelInfo = (modelId) => localModels.map(normalizeModel).find((model) => model.filename === modelId);
@@ -1060,12 +1146,70 @@ function ModelManager({
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
<h3 className="m3-card-title">
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
Model Library
{activeModelType === "text" ? "Hugging Face Model Library" : "Model Library"}
</h3>
</div>
{activeModelType === "text" && (
<div className="model-discovery-controls">
<div className="model-search-box">
<Search size={18} />
<input
type="search"
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
placeholder="Search Hugging Face GGUF models..."
aria-label="Search Hugging Face models"
/>
{modelSearch && (
<button type="button" onClick={() => setModelSearch("")} title="Clear search" aria-label="Clear model search">
<X size={16} />
</button>
)}
</div>
<div className="model-filter-row" aria-label="Model filters">
{MODEL_FILTERS.map((filter) => {
const selected = selectedFilters.includes(filter.id);
return (
<button
key={filter.id}
type="button"
className={`model-filter-chip ${selected ? "selected" : ""}`}
onClick={() => toggleModelFilter(filter.id)}
aria-pressed={selected}
>
{filter.label}
{selected && <X size={14} />}
</button>
);
})}
{selectedFilters.length > 0 && (
<button type="button" className="model-filter-clear" onClick={() => setSelectedFilters([])}>
Clear filters
</button>
)}
</div>
{isSearchingModels && (
<div className="model-discovery-loading">
<RefreshCw className="progress-spinner" size={16} />
<span>Loading models from Hugging Face...</span>
</div>
)}
{modelSearchError && (
<div className="model-search-warning">
{modelSearchError} Showing the built-in fallback catalog.
</div>
)}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{visibleModelLibrary.map((section) => (
{activeModelType === "text" && hasHuggingFaceResults && !isSearchingModels && displayedHuggingFaceModels.length === 0 && (
<div className="m3-card model-search-empty">
No downloadable single-file GGUF models matched this search and filter combination.
</div>
)}
{visibleModelLibrary.filter((section) => section.items.length > 0).map((section) => (
<div key={section.group} className="m3-card" style={{ margin: 0 }}>
<h4 style={{ fontWeight: 700, marginBottom: "12px" }}>{section.group}</h4>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "12px" }}>
@@ -1073,14 +1217,17 @@ function ModelManager({
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);
const isRecommended = typeof model.recommendedFit === "boolean"
? model.recommendedFit
: model.recommendedTiers && model.recommendedTiers.includes(systemTier);
const fitLabel = model.fitLabel || "Recommended Fit";
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 key={`${model.id || section.group}:${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, display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
{model.name}
{isRecommended && (
<span style={{
<span title={model.fitReason || `Recommended for ${systemTier} tier hardware`} style={{
fontSize: "0.68rem",
color: "#2e7d32",
background: "#e8f5e9",
@@ -1089,13 +1236,18 @@ function ModelManager({
fontWeight: "bold",
border: "1px solid #c8e6c9"
}}>
[Recommended Fit]
[{fitLabel}]
</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>
{model.size && model.size !== "Unknown" && (
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-on-surface-variant)", fontWeight: 600 }}>
File size: {model.size}
</span>
)}
</div>
<p style={{ fontSize: "0.78rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.35, margin: 0 }}>
{model.notes}
@@ -1129,7 +1281,7 @@ function ModelManager({
<span>{installed ? "Downloaded" : downloading ? "Downloading" : "Download"}</span>
</button>
<a
href={model.url}
href={model.pageUrl || model.url}
target="_blank"
rel="noreferrer"
style={{
@@ -1141,7 +1293,7 @@ function ModelManager({
textDecoration: "none",
}}
>
Save to Downloads folder
{model.pageUrl ? "View on Hugging Face" : "Save to Downloads folder"}
</a>
</div>
);
@@ -1149,6 +1301,17 @@ function ModelManager({
</div>
</div>
))}
{activeModelType === "text" && hasHuggingFaceResults && hasMoreModels && !isSearchingModels && (
<button
type="button"
className="m3-btn m3-btn-tonal model-load-more"
onClick={handleLoadMoreModels}
disabled={isLoadingMoreModels}
>
{isLoadingMoreModels && <RefreshCw className="progress-spinner" size={16} />}
<span>{isLoadingMoreModels ? "Loading more models..." : "Load More"}</span>
</button>
)}
</div>
{/* Local Drag and Drop Importer */}
+63 -1
View File
@@ -1,6 +1,6 @@
import React, { memo, useEffect } from "react";
import { Crop, Sliders, Cpu, Info, MessageSquare, SlidersHorizontal } from "lucide-react";
import { stopServer } from "../services/api";
import { stopServer, formatBytes } from "../services/api";
const ASPECT_RATIOS = [
{ id: "1:1", label: "1:1 Square", width: 512, height: 512, sdxl_width: 1024, sdxl_height: 1024, desc: "Social posts & avatars" },
@@ -31,6 +31,13 @@ function Settings({
setTextSettings,
showAlert = async ({ message }) => window.alert(message),
showConfirm = async ({ message }) => window.confirm(message),
health,
cleanupItems,
isReadinessBusy,
refreshReadiness,
copyDiagnostics,
cleanupSafeItems,
diagnosticsCopied,
}) {
const isSD15OrCustom = activeModel ? isSD15OrCustomModel(activeModel) : false;
const isOpenVinoNpu = constraints.backendType === "openvino-npu";
@@ -41,6 +48,13 @@ function Settings({
const isMac = availableBackends.some(b => b.id === "metal" || b.id === "apple-npu") ||
(specs?.os_name && (specs.os_name.toLowerCase().includes("darwin") || specs.os_name.toLowerCase().includes("mac")));
const readinessIssues = [
...(health?.stale ? ["Restart Local AI Image Generator so the local server loads the latest API."] : []),
...(health?.issues || []),
];
const cleanupBytes = (cleanupItems || []).reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
const showReadinessPanel = Boolean(health && (readinessIssues.length > 0 || (cleanupItems && cleanupItems.length > 0)));
useEffect(() => {
if (isOpenVinoNpu && constraints.steps > 8) {
setConstraints((prev) => ({ ...prev, steps: 8, npuSteps: 8 }));
@@ -153,6 +167,54 @@ function Settings({
</p>
</div>
{showReadinessPanel && (
<div className={`m3-card readiness-card ${health?.stale || readinessIssues.length > 0 ? "readiness-card-warning" : ""}`} style={{ marginBottom: "20px" }}>
<div className="readiness-header">
<div>
<h3 className="m3-card-title" style={{ marginBottom: "4px" }}>
{health?.stale ? "Restart Required" : readinessIssues.length > 0 ? "System Readiness" : "Safe Cleanup Available"}
</h3>
<p className="m3-card-subtitle" style={{ margin: 0 }}>
{health?.stale
? `Running server build: ${health.build || "unknown"}`
: readinessIssues.length > 0
? "Local AI Image Generator found setup items that may need attention."
: `${(cleanupItems || []).length} temporary item${(cleanupItems || []).length === 1 ? "" : "s"} can be cleaned (${formatBytes(cleanupBytes)}).`}
</p>
</div>
<div className="readiness-actions">
<button className="m3-btn m3-btn-outlined" onClick={refreshReadiness} disabled={isReadinessBusy}>
{isReadinessBusy ? "Checking" : "Refresh"}
</button>
<button className="m3-btn m3-btn-tonal" onClick={copyDiagnostics}>
{diagnosticsCopied ? "Copied" : "Copy Diagnostics"}
</button>
{(cleanupItems || []).length > 0 && (
<button className="m3-btn m3-btn-error" onClick={cleanupSafeItems} disabled={isReadinessBusy}>
Clean {formatBytes(cleanupBytes)}
</button>
)}
</div>
</div>
{readinessIssues.length > 0 && (
<div className="readiness-issues">
{readinessIssues.slice(0, 4).map((issue) => (
<span key={issue}>{issue}</span>
))}
</div>
)}
{(cleanupItems || []).length > 0 && (
<div className="readiness-cleanup-list">
{cleanupItems.slice(0, 3).map((item) => (
<span key={item.id} title={item.path}>
{item.name} · {item.size} · {item.reason}
</span>
))}
</div>
)}
</div>
)}
<div className="generator-layout">
{/* Left Column: Image Settings */}
<div>
+24 -8
View File
@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X } from "lucide-react";
import {
chatWithLlm,
getDownloadProgress,
getLlmStatus,
listLlmModels,
streamChatWithLlm,
startLlm,
stopLlm,
} from "../services/api";
@@ -279,22 +279,38 @@ function TextChat({
const firstTitle = isNew ? (displayTitleText.slice(0, 26) + (displayTitleText.length > 26 ? "..." : "")) : null;
saveConversationState(convId, nextMessages, selectedModel, firstTitle);
setMessages([...nextMessages, { role: "assistant", content: "" }]);
try {
const systemPrompt = textSettings?.systemPrompt || "You are a helpful local AI assistant.";
const requestMessages = [
...(systemPrompt.trim() ? [{ role: "system", content: systemPrompt.trim() }] : []),
...nextMessages,
];
const response = await chatWithLlm(requestMessages, {
temperature: textSettings?.temperature || 0.7,
maxTokens: 768
let assistantText = "";
const response = await streamChatWithLlm(requestMessages, {
temperature: textSettings?.temperature || 0.7,
maxTokens: 768,
}, (_token, fullText) => {
assistantText = fullText;
setMessages((prev) => {
const updated = [...prev];
if (updated.length > 0 && updated[updated.length - 1].role === "assistant") {
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: fullText,
};
}
return updated;
});
});
const finalMessages = [...nextMessages, { role: "assistant", content: response.content }];
const finalMessages = [...nextMessages, { role: "assistant", content: assistantText }];
setMessages(finalMessages);
if (response.usage) {
setTokenUsage(response.usage);
}
saveConversationState(convId, finalMessages, selectedModel);
if (response.usage) setTokenUsage(response.usage);
// Clean up attached files on success
setAttachments([]);
+80
View File
@@ -321,6 +321,22 @@ export async function listLlmModels() {
return (data.models || []).map(normalizeModel);
}
export async function searchHuggingFaceModels(query = "", filters = [], page = 1) {
const params = new URLSearchParams();
if (query.trim()) params.set("query", query.trim());
if (filters.length > 0) params.set("filters", filters.join(","));
params.set("page", String(page));
const res = await fetch(`/api/huggingface/models?${params.toString()}`);
const data = await readJsonResponse(res, "Hugging Face model search returned invalid data.");
return {
models: Array.isArray(data.models) ? data.models : [],
source: data.source || "huggingface",
warning: data.warning || "",
page: Number(data.page || page),
hasMore: Boolean(data.hasMore),
};
}
export async function startLlm(model, options = {}) {
const res = await fetch("/api/llm/start", {
method: "POST",
@@ -359,6 +375,70 @@ export async function chatWithLlm(messages, options = {}) {
};
}
export async function streamChatWithLlm(messages, options = {}, onToken = () => {}) {
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,
stream: true,
}),
});
if (!res.ok) {
const data = await readJsonResponse(res, `Chat request failed (HTTP ${res.status}).`);
throw new Error(data.error || `Chat request failed (HTTP ${res.status}).`);
}
if (!res.body) throw new Error("Streaming is not supported by this browser.");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let content = "";
let usage = null;
const consumeEvent = (eventText) => {
const data = eventText
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!data || data === "[DONE]") return data === "[DONE]";
const parsed = JSON.parse(data);
const token = parsed.choices?.[0]?.delta?.content || "";
if (token) {
content += token;
onToken(token, content);
}
if (parsed.usage) usage = parsed.usage;
return false;
};
let finished = false;
while (!finished) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() || "";
for (const eventText of events) {
if (consumeEvent(eventText)) {
finished = true;
break;
}
}
if (done) break;
}
if (!finished && buffer.trim()) consumeEvent(buffer);
if (!content) throw new Error("The text model returned an empty streamed response.");
return { content, usage };
}
export async function downloadLlmModel(url) {
const res = await fetch("/api/llm/download-model", {
method: "POST",