feat: initialize project with static file server, backend management, and React frontend components
This commit is contained in:
1 parent
83bb0bff12
commit
cf1b7e548a
7 files changed
+744
-79
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user