- Apply Manus design tokens to SettingsPage.css - Update .sp-nav, .sp-card, .sp-section, .sp-input with manus-* vars - Settings page now matches chat UI Manus dark theme
1237 lines
57 KiB
TypeScript
1237 lines
57 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
pickRuntimeModelFiles,
|
|
pickRuntimeModelsDir,
|
|
queryRuntimeGpu,
|
|
searchHuggingFaceModels,
|
|
getModelLibraryState,
|
|
swapModel,
|
|
deleteModelFile,
|
|
openModelFolder,
|
|
} from "../runtime/api";
|
|
import {
|
|
dedupeModels,
|
|
pathName,
|
|
toModelFile,
|
|
} from "../runtime/runtime-settings-form";
|
|
import { useModelDownload } from "../runtime/useModelDownload";
|
|
import { CTX_PRESETS, DEFAULT_CTX_SIZE, MIN_CTX_SIZE } from "../runtime/runtime-defaults";
|
|
import { getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime/runtime-utils";
|
|
import type {
|
|
HuggingFaceModelResult,
|
|
RuntimeConfig,
|
|
RuntimeGpuInfo,
|
|
RuntimeModelFile,
|
|
RuntimeSnapshot,
|
|
} from "../runtime/types";
|
|
import { Box, Zap, ArrowLeftRight, Settings2, X, Trash2, Search, Info, Download, FolderOpen } from "lucide-react";
|
|
import "./SettingsPage.css";
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
type Section = "model" | "runtime" | "connection" | "advanced";
|
|
|
|
interface SettingsRuntime {
|
|
snapshot: RuntimeSnapshot;
|
|
savedBaseline: RuntimeConfig;
|
|
draftConfig: RuntimeConfig;
|
|
draftDirty: boolean;
|
|
loading: boolean;
|
|
busy: boolean;
|
|
lifecycleBusy: boolean;
|
|
scannedModels: RuntimeModelFile[];
|
|
isScanningModels: boolean;
|
|
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
|
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
|
startLocal: () => Promise<unknown>;
|
|
stopLocal: () => Promise<unknown>;
|
|
scanModels: (dir: string | null) => Promise<void>;
|
|
resetDraft: () => void;
|
|
}
|
|
|
|
interface Props {
|
|
runtime: SettingsRuntime;
|
|
onClose: () => void;
|
|
onModelSwapped?: (modelId: string) => void;
|
|
}
|
|
|
|
// ─── Nav definition ──────────────────────────────────────────────────────────
|
|
|
|
const NAV: { id: Section; label: string; icon: typeof Box }[] = [
|
|
{ id: "model", label: "Model", icon: Box },
|
|
{ id: "runtime", label: "Runtime", icon: Zap },
|
|
{ id: "connection", label: "Connection", icon: ArrowLeftRight },
|
|
{ id: "advanced", label: "Advanced", icon: Settings2 },
|
|
];
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function formatBytes(bytes: number | null | undefined): string {
|
|
if (!bytes) return "";
|
|
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GiB`;
|
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MiB`;
|
|
return `${(bytes / 1024).toFixed(0)} KiB`;
|
|
}
|
|
|
|
function formatMiBAsGbMb(mib: number): string {
|
|
if (!Number.isFinite(mib) || mib <= 0) return "0 MB";
|
|
if (mib >= 1024) return `${(mib / 1024).toFixed(2)} GB`;
|
|
return `${mib.toFixed(1)} MB`;
|
|
}
|
|
|
|
function statusColor(snapshot: RuntimeSnapshot): string {
|
|
const tone = getRuntimeStatusTone(snapshot);
|
|
if (tone === "success") return "sp-dot--success";
|
|
if (tone === "warning") return "sp-dot--warn";
|
|
if (tone === "danger") return "sp-dot--danger";
|
|
return "sp-dot--idle";
|
|
}
|
|
|
|
function statusPillClass(snapshot: RuntimeSnapshot): string {
|
|
const tone = getRuntimeStatusTone(snapshot);
|
|
if (tone === "success") return "sp-status-pill--success";
|
|
if (tone === "warning") return "sp-status-pill--warn";
|
|
if (tone === "danger") return "sp-status-pill--danger";
|
|
return "";
|
|
}
|
|
|
|
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
|
|
|
export default function SettingsPage({ runtime, onClose, onModelSwapped }: Props) {
|
|
const {
|
|
snapshot, savedBaseline, draftConfig, draftDirty,
|
|
loading, busy, lifecycleBusy,
|
|
scannedModels, isScanningModels,
|
|
updateDraft, saveConfig, startLocal, stopLocal, scanModels, resetDraft,
|
|
} = runtime;
|
|
|
|
const [section, setSection] = useState<Section>("model");
|
|
const [hfQuery, setHfQuery] = useState("");
|
|
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
|
|
const [hfLoading, setHfLoading] = useState(false);
|
|
const [hfHasSearched, setHfHasSearched] = useState(false);
|
|
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
|
|
const [localError, setLocalError] = useState("");
|
|
const [successMessage, setSuccessMessage] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [isSwapping, setIsSwapping] = useState(false);
|
|
const [swappingMessage, setSwappingMessage] = useState("");
|
|
|
|
const didAutoScan = useRef(false);
|
|
const busyAll = busy || lifecycleBusy || saving || isSwapping;
|
|
const {
|
|
activeFile: activeDownloadKey,
|
|
cancelDownload,
|
|
didComplete,
|
|
downloaded,
|
|
downloadedFilePath,
|
|
error: downloadError,
|
|
isDownloading,
|
|
progress,
|
|
resetDidComplete,
|
|
speed,
|
|
startDownload,
|
|
total,
|
|
} = useModelDownload(draftConfig.hugging_face_token || "");
|
|
|
|
// ── Side-effects ──────────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
queryRuntimeGpu().then(setGpuInfo).catch(() => undefined);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (section !== "model" || didAutoScan.current) return;
|
|
const dir = draftConfig.models_dir?.trim();
|
|
if (dir && scannedModels.length === 0 && !isScanningModels) {
|
|
didAutoScan.current = true;
|
|
void scanModels(dir);
|
|
}
|
|
}, [section, draftConfig.models_dir, scannedModels.length, isScanningModels, scanModels]);
|
|
|
|
useEffect(() => {
|
|
if (downloadError) {
|
|
setLocalError(downloadError);
|
|
}
|
|
}, [downloadError]);
|
|
|
|
useEffect(() => {
|
|
if (isDownloading) {
|
|
setSuccessMessage("");
|
|
}
|
|
}, [isDownloading]);
|
|
|
|
useEffect(() => {
|
|
if (!didComplete) return;
|
|
const downloadedPath = downloadedFilePath;
|
|
const modelPath = downloadedPath;
|
|
|
|
getModelLibraryState()
|
|
.then(({ managed_dir }) => {
|
|
if (managed_dir) {
|
|
void scanModels(managed_dir);
|
|
}
|
|
return null;
|
|
})
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
if (modelPath) {
|
|
console.log("[SettingsPage] Auto-selecionando modelo baixado:", modelPath);
|
|
updateDraft({ model_path: modelPath });
|
|
onModelSwapped?.(modelPath);
|
|
setSuccessMessage(
|
|
`Modelo baixado e selecionado com sucesso!\n${modelPath}`
|
|
);
|
|
}
|
|
resetDidComplete();
|
|
});
|
|
}, [didComplete, downloadedFilePath]);
|
|
|
|
// ── Derived data ──────────────────────────────────────────────────────────
|
|
|
|
const allModels = dedupeModels([
|
|
...scannedModels,
|
|
...draftConfig.extra_model_paths.map(toModelFile),
|
|
]);
|
|
|
|
const isLocal = draftConfig.connection_mode === "local";
|
|
const isRunning = snapshot.process_running;
|
|
const isStopping = snapshot.status === "stopping";
|
|
const runtimeStatusLabel = loading ? "Loading..." : getRuntimeStateLabel(snapshot);
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
|
|
|
const handlePickDir = useCallback(async () => {
|
|
setLocalError("");
|
|
try {
|
|
const result = await pickRuntimeModelsDir();
|
|
if (!result) return;
|
|
updateDraft({ models_dir: result.dir });
|
|
void scanModels(result.dir);
|
|
} catch (e) {
|
|
setLocalError(String(e));
|
|
}
|
|
}, [updateDraft, scanModels]);
|
|
|
|
const handlePickFiles = useCallback(async () => {
|
|
setLocalError("");
|
|
try {
|
|
const picked = await pickRuntimeModelFiles();
|
|
if (picked.length === 0) return;
|
|
const next = dedupeModels([
|
|
...draftConfig.extra_model_paths.map(toModelFile),
|
|
...picked,
|
|
]);
|
|
updateDraft({
|
|
extra_model_paths: next.map((m) => m.path),
|
|
model_path: draftConfig.model_path || next[0]?.path || null,
|
|
});
|
|
} catch (e) {
|
|
setLocalError(String(e));
|
|
}
|
|
}, [draftConfig, updateDraft]);
|
|
|
|
const handleSelectModel = useCallback((path: string) => {
|
|
updateDraft({ model_path: path, selected_model_id: null });
|
|
}, [updateDraft]);
|
|
|
|
const handleRemoveExtra = useCallback((path: string) => {
|
|
const next = draftConfig.extra_model_paths.filter((p) => p !== path);
|
|
updateDraft({
|
|
extra_model_paths: next,
|
|
model_path: draftConfig.model_path === path
|
|
? (next[0] ?? scannedModels[0]?.path ?? null)
|
|
: draftConfig.model_path,
|
|
});
|
|
}, [draftConfig, scannedModels, updateDraft]);
|
|
|
|
const handleHfSearch = useCallback(async () => {
|
|
if (!hfQuery.trim()) return;
|
|
setHfLoading(true);
|
|
setHfHasSearched(true);
|
|
setLocalError("");
|
|
try {
|
|
const results = await searchHuggingFaceModels(hfQuery, draftConfig.hugging_face_token || "");
|
|
setHfResults(results);
|
|
} catch (e) {
|
|
setLocalError(String(e));
|
|
} finally {
|
|
setHfLoading(false);
|
|
}
|
|
}, [hfQuery, draftConfig.hugging_face_token]);
|
|
|
|
const handleDownload = useCallback(async (repo: string, filePath: string) => {
|
|
setLocalError("");
|
|
try {
|
|
await startDownload(repo, filePath);
|
|
} catch (error) {
|
|
setLocalError(String(error));
|
|
}
|
|
}, [startDownload]);
|
|
|
|
const handleCancelDownload = useCallback(async () => {
|
|
setLocalError("");
|
|
try {
|
|
await cancelDownload();
|
|
} catch (error) {
|
|
setLocalError(String(error));
|
|
}
|
|
}, [cancelDownload]);
|
|
|
|
const handleModelSwap = useCallback(async (newModelPath: string) => {
|
|
if (isSwapping || isDownloading) return;
|
|
setLocalError("");
|
|
setSuccessMessage("");
|
|
setIsSwapping(true);
|
|
setSwappingMessage(`Trocando para ${pathName(newModelPath)}…`);
|
|
try {
|
|
await swapModel(newModelPath);
|
|
updateDraft({ model_path: newModelPath });
|
|
onModelSwapped?.(newModelPath);
|
|
setSwappingMessage("Modelo trocado com sucesso.");
|
|
setTimeout(() => setSwappingMessage(""), 3000);
|
|
} catch (error) {
|
|
setLocalError(String(error));
|
|
} finally {
|
|
setIsSwapping(false);
|
|
}
|
|
}, [isSwapping, isDownloading, updateDraft, onModelSwapped]);
|
|
|
|
const handleOpenFolder = useCallback(async (filePath: string) => {
|
|
try {
|
|
await openModelFolder(filePath);
|
|
} catch (error) {
|
|
setLocalError(String(error));
|
|
}
|
|
}, []);
|
|
|
|
const handleDeleteModel = useCallback(async (filePath: string) => {
|
|
setLocalError("");
|
|
try {
|
|
await deleteModelFile(filePath);
|
|
runtime.scanModels(draftConfig.models_dir ?? null);
|
|
} catch (error) {
|
|
setLocalError(String(error));
|
|
}
|
|
}, [runtime, draftConfig.models_dir]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true);
|
|
setLocalError("");
|
|
try {
|
|
await saveConfig(draftConfig);
|
|
} catch (e) {
|
|
setLocalError(String(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [saveConfig, draftConfig]);
|
|
|
|
const handleSaveAndStart = useCallback(async () => {
|
|
setSaving(true);
|
|
setLocalError("");
|
|
try {
|
|
await saveConfig(draftConfig);
|
|
await startLocal();
|
|
} catch (e) {
|
|
setLocalError(String(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [saveConfig, draftConfig, startLocal]);
|
|
|
|
const handleStop = useCallback(async () => {
|
|
setLocalError("");
|
|
try {
|
|
await stopLocal();
|
|
} catch (e) {
|
|
setLocalError(String(e));
|
|
}
|
|
}, [stopLocal]);
|
|
|
|
// ── Render ───────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="sp-shell flex h-full overflow-hidden">
|
|
|
|
{/* ── Left navigation ──────────────────────────────────────────── */}
|
|
<nav className="sp-nav">
|
|
<div className="sp-nav-header">
|
|
<div className="sp-nav-brand">ForgeLM</div>
|
|
<h2 className="sp-nav-title">Settings</h2>
|
|
</div>
|
|
|
|
<div className="sp-nav-items">
|
|
{NAV.map((item) => {
|
|
const Icon = item.icon;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setSection(item.id)}
|
|
className={`sp-nav-item${section === item.id ? " sp-nav-item--active" : ""}`}
|
|
>
|
|
<Icon size={16} strokeWidth={1.5} />
|
|
<span className="flex-1">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Runtime status pill */}
|
|
<div className="sp-nav-status">
|
|
<span className={`sp-dot ${statusColor(snapshot)}`} />
|
|
<span className="text-xs sp-nav-status-label">
|
|
{runtimeStatusLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="sp-nav-footer">
|
|
<button type="button" onClick={onClose} className="sp-back-btn">
|
|
← Back to Chat
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* ── Main area (right side) ─────────────────────────────────── */}
|
|
<div className="settings-main-area">
|
|
<div className="settings-scroll-container">
|
|
<div className="settings-content">
|
|
|
|
{/* ═══════════════════════════════════ MODEL SECTION ═══════════════════════════ */}
|
|
{section === "model" && (
|
|
<>
|
|
<header className="page-header sp-section-header">
|
|
<div>
|
|
<h2 className="sp-section-title">Model</h2>
|
|
<p className="sp-section-desc">
|
|
Select a local GGUF model file for inference.
|
|
</p>
|
|
</div>
|
|
{isRunning && (
|
|
<span className={`sp-status-pill ${statusPillClass(snapshot)}`}>
|
|
<span className="sp-dot sp-dot--inline" />
|
|
{isStopping ? "Stopping local API" : getRuntimeStateLabel(snapshot)}
|
|
</span>
|
|
)}
|
|
</header>
|
|
|
|
<div className="settings-cards">
|
|
{localError && <div className="sp-error-inline">{localError}</div>}
|
|
|
|
{/* Active model */}
|
|
<div className="sp-card">
|
|
{snapshot.config.model_path ? (
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Active model</div>
|
|
<div className="sp-card-row-desc">Currently loaded GGUF file</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<div className="sp-model-name">{pathName(snapshot.config.model_path)}</div>
|
|
<div className="sp-model-path">{snapshot.config.model_path}</div>
|
|
{draftDirty && draftConfig.model_path !== snapshot.config.model_path && (
|
|
<div className="sp-pending mt-2">
|
|
Draft → {pathName(draftConfig.model_path) || "None selected"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Active model</div>
|
|
<div className="sp-card-row-desc">No model selected yet</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<p className="sp-empty">Use the sections below to load one.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scan folder */}
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Scan folder</div>
|
|
<div className="sp-card-row-desc">Browse and scan a directory for .gguf files</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<div className="sp-folder-actions">
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--secondary"
|
|
onClick={() => void handlePickDir()}
|
|
disabled={busyAll}
|
|
>
|
|
Browse…
|
|
</button>
|
|
{draftConfig.models_dir && (
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--ghost"
|
|
onClick={() => { void scanModels(draftConfig.models_dir!); }}
|
|
disabled={busyAll || isScanningModels}
|
|
>
|
|
{isScanningModels ? "Scanning…" : "Rescan"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{draftConfig.models_dir && (
|
|
<div className="sp-folder-path">{draftConfig.models_dir}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model list from scan */}
|
|
{(isScanningModels || scannedModels.length > 0) && (
|
|
<>
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row sp-card-row--full">
|
|
{isScanningModels && scannedModels.length === 0 && (
|
|
<div className="sp-scanning-row">Scanning for models…</div>
|
|
)}
|
|
<div className="sp-model-list">
|
|
{scannedModels.map((model) => {
|
|
const selected = draftConfig.model_path === model.path;
|
|
return (
|
|
<button
|
|
key={model.path}
|
|
type="button"
|
|
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}`}
|
|
onClick={() => handleModelSwap(model.path)}
|
|
disabled={busyAll}
|
|
>
|
|
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
|
|
<span className="flex-1 truncate sp-model-row-name">{model.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Browse individual files */}
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Individual files</div>
|
|
<div className="sp-card-row-desc">Add specific .gguf files without scanning a folder</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--secondary"
|
|
onClick={() => void handlePickFiles()}
|
|
disabled={busyAll}
|
|
>
|
|
Pick files…
|
|
</button>
|
|
{draftConfig.extra_model_paths.length > 0 && (
|
|
<div className="sp-model-list mt-3">
|
|
{draftConfig.extra_model_paths.map((p) => {
|
|
const selected = draftConfig.model_path === p;
|
|
return (
|
|
<div
|
|
key={p}
|
|
className={`sp-model-row${selected ? " sp-model-row--selected" : ""}${busyAll ? " sp-model-row--disabled" : ""}`}
|
|
role="row"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="sp-model-row-select"
|
|
onClick={() => handleModelSwap(p)}
|
|
disabled={busyAll}
|
|
aria-pressed={selected}
|
|
>
|
|
<span className={`sp-radio${selected ? " sp-radio--on" : ""}`} />
|
|
<span className="flex-1 truncate sp-model-row-name">{pathName(p)}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="sp-remove-btn"
|
|
onClick={() => handleRemoveExtra(p)}
|
|
disabled={busyAll}
|
|
aria-label="Remove file"
|
|
>
|
|
<Trash2 size={14} strokeWidth={1.5} />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* HuggingFace search */}
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row sp-card-row--full">
|
|
<div className="flex flex-col gap-4 w-full">
|
|
<div className="flex items-start justify-between gap-4 w-full">
|
|
<div className="min-w-0">
|
|
<div className="sp-card-row-title">Search HuggingFace</div>
|
|
<div className="sp-card-row-desc">
|
|
{!draftConfig.hugging_face_token && "Add HF token in Advanced for private models"}
|
|
</div>
|
|
</div>
|
|
|
|
<span className="group relative inline-flex shrink-0 items-center">
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/10 bg-white/5 text-neutral-400 transition-colors hover:text-neutral-200 focus:outline-none focus:ring-2 focus:ring-blue-400/60"
|
|
aria-label="Explain quantization levels"
|
|
>
|
|
<Info size={14} />
|
|
</button>
|
|
|
|
<div className="pointer-events-none absolute right-0 top-full z-[9999] mt-2 hidden w-[340px] rounded-xl border border-white/10 bg-slate-950/95 p-4 text-left shadow-2xl backdrop-blur-xl group-hover:block group-focus-within:block">
|
|
<div className="mb-2 text-xs font-semibold text-white">O que é Quantização?</div>
|
|
<p className="mb-3 text-[11px] leading-5 text-neutral-300">
|
|
É a compressão do modelo para que ele rode mais rápido e caiba na memória (RAM/VRAM) do seu PC.
|
|
</p>
|
|
<div className="flex flex-col gap-1.5 text-[11px] leading-5 text-neutral-300">
|
|
<span>
|
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.7)]" />
|
|
<strong>Máxima Qualidade (Pesado):</strong> Q8_0, Q6_K — Quase sem perda de inteligência.
|
|
</span>
|
|
<span>
|
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-blue-400 shadow-[0_0_6px_rgba(96,165,250,0.7)]" />
|
|
<strong>Equilíbrio Ideal (Recomendado):</strong> Q4_K_M, Q5_K_M — Melhor custo-benefício.
|
|
</span>
|
|
<span>
|
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-amber-300 shadow-[0_0_6px_rgba(251,191,36,0.7)]" />
|
|
<strong>Alta Compressão (Leve):</strong> Q2_K, Q3_K_M — Roda em PCs fracos, mas o modelo fica mais "esquecido" ou impreciso.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center w-full">
|
|
<input
|
|
className="h-11 w-full min-w-0 rounded-xl border border-white/10 bg-white/5 px-4 text-sm text-neutral-100 outline-none transition-colors placeholder:text-neutral-500 focus:border-blue-400/60 focus:bg-white/10"
|
|
placeholder="Search for GGUF models…"
|
|
value={hfQuery}
|
|
onChange={(e) => {
|
|
setHfQuery(e.target.value);
|
|
setHfHasSearched(false);
|
|
}}
|
|
onKeyDown={(e) => { if (e.key === "Enter") void handleHfSearch(); }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl border border-blue-400/30 bg-blue-500/15 px-4 text-sm font-medium text-blue-300 transition-colors hover:bg-blue-500/25 disabled:cursor-not-allowed disabled:opacity-50"
|
|
onClick={() => void handleHfSearch()}
|
|
disabled={hfLoading || !hfQuery.trim()}
|
|
>
|
|
<Search size={14} strokeWidth={2} />
|
|
{hfLoading ? "…" : "Search"}
|
|
</button>
|
|
</div>
|
|
|
|
{successMessage && (
|
|
<div className="mt-3 px-3 py-3 bg-emerald-500/10 border border-emerald-500/30 rounded-xl">
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-emerald-400 mt-0.5 shrink-0">✓</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-emerald-300 font-medium leading-snug">
|
|
{successMessage.split("\n")[0]}
|
|
</p>
|
|
<p className="text-xs text-emerald-400/70 font-mono mt-1 break-all leading-relaxed">
|
|
{successMessage.split("\n")[1]}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hot-swap loading indicator */}
|
|
{isSwapping && (
|
|
<div className="mt-3 px-3 py-3 bg-blue-500/10 border border-blue-500/30 rounded-xl">
|
|
<div className="flex items-center gap-2 text-sm text-blue-300">
|
|
<span className="animate-spin h-4 w-4 border-2 border-blue-400 border-t-transparent rounded-full" />
|
|
{swappingMessage}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Hub: Local library (empty query) ── */}
|
|
{!hfQuery.trim() && scannedModels.length > 0 && (
|
|
<div className="flex flex-col gap-1 w-full">
|
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide px-1 mb-1">
|
|
Modelos Locais ({scannedModels.length})
|
|
</p>
|
|
{scannedModels.map((model) => {
|
|
const isSelected = draftConfig.model_path === model.path;
|
|
const modelSize = model.size
|
|
? `${(model.size / 1_073_741_824).toFixed(1)} GB`
|
|
: "—";
|
|
return (
|
|
<div
|
|
key={model.path}
|
|
className={`sp-hf-file flex items-center justify-between gap-3 p-3 rounded-xl border transition-colors shrink-0 min-h-[48px] ${
|
|
isSelected
|
|
? "bg-blue-500/10 border-blue-500/30"
|
|
: "bg-white/5 border-white/10 hover:bg-white/5"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`flex-1 min-w-0 text-sm truncate cursor-pointer ${
|
|
isSelected ? "text-blue-300 font-medium" : "text-neutral-300"
|
|
}`}
|
|
title={model.path}
|
|
onClick={() => !isSwapping && !isDownloading && handleModelSwap(model.path)}
|
|
>
|
|
{pathName(model.path)}
|
|
</span>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-xs font-mono text-neutral-500">{modelSize}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleOpenFolder(model.path)}
|
|
className="flex items-center justify-center p-1.5 text-neutral-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-md transition-colors cursor-pointer"
|
|
title="Abrir pasta"
|
|
>
|
|
<FolderOpen size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleDeleteModel(model.path)}
|
|
className="flex items-center justify-center p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 rounded-md transition-colors cursor-pointer"
|
|
title="Excluir modelo"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{!hfQuery.trim() && scannedModels.length === 0 && !isScanningModels && (
|
|
<p className="sp-hint">Nenhum modelo local. Pesquise no HuggingFace ou configure a pasta de modelos.</p>
|
|
)}
|
|
|
|
{/* ── Hub: HuggingFace search results ── */}
|
|
{hfQuery.trim() && hfResults.length > 0 && (
|
|
<div className="flex flex-col gap-4 w-full max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
|
{hfResults.map((result) => {
|
|
const localNames = new Set(scannedModels.map((m) => pathName(m.path)));
|
|
|
|
return (
|
|
<div
|
|
key={result.repo_id}
|
|
className="flex flex-col w-full bg-white/5 border border-white/10 rounded-xl overflow-hidden shrink-0"
|
|
>
|
|
<div className="flex flex-col p-4 bg-black/20 border-b border-white/10 w-full">
|
|
<div className="text-sm font-semibold text-blue-400 truncate">{result.repo_id}</div>
|
|
<div className="text-xs text-neutral-500 truncate">{result.author || "Unknown author"}</div>
|
|
</div>
|
|
|
|
{result.verified_files.length > 0 && (
|
|
<div className="flex flex-col w-full">
|
|
{result.verified_files.map((file) => {
|
|
const downloadKey = `${result.repo_id}:${file.path}`;
|
|
const isActiveDownload = activeDownloadKey === downloadKey;
|
|
const fileName = file.path.replace(/\.gguf$/i, "");
|
|
const fileSize = file.size
|
|
? `${(file.size / 1_073_741_824).toFixed(1)} GB`
|
|
: "—";
|
|
const quantMatch = file.path.replace(/\.gguf$/i, "").split(/[-.]/).pop() ?? "gguf";
|
|
const isLocalDuplicate = localNames.has(fileName);
|
|
|
|
const localModel = scannedModels.find(
|
|
(m) => pathName(m.path) === fileName,
|
|
);
|
|
|
|
let actionButtons: React.ReactNode;
|
|
if (isActiveDownload && isDownloading) {
|
|
actionButtons = (
|
|
<>
|
|
<div className="flex items-center justify-center gap-3 shrink-0 w-[200px]">
|
|
<div className="flex flex-col items-end justify-center gap-1.5 shrink-0">
|
|
<div className="flex items-center justify-between w-full text-[10px] font-mono text-neutral-400">
|
|
<span>{Number.isFinite(speed) ? speed.toFixed(1) : "0.0"} MB/s</span>
|
|
<span>{formatMiBAsGbMb(downloaded)} / {formatMiBAsGbMb(total)}</span>
|
|
</div>
|
|
<div className="w-full h-1.5 bg-black/40 rounded-full overflow-hidden border border-white/5">
|
|
<div
|
|
className="h-full bg-blue-500 rounded-full transition-all duration-300 ease-out"
|
|
style={{ width: `${Math.max(0, Math.min(100, Number.isFinite(progress) ? progress : 0))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCancelDownload()}
|
|
className="flex items-center justify-center p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 rounded-md transition-colors cursor-pointer"
|
|
title="Cancelar Download"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
} else if (isLocalDuplicate) {
|
|
actionButtons = (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => localModel && void handleOpenFolder(localModel.path)}
|
|
className="flex items-center justify-center p-1.5 text-neutral-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-md transition-colors cursor-pointer"
|
|
title="Abrir pasta"
|
|
>
|
|
<FolderOpen size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => localModel && void handleDeleteModel(localModel.path)}
|
|
className="flex items-center justify-center p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 rounded-md transition-colors cursor-pointer"
|
|
title="Excluir"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
);
|
|
} else {
|
|
actionButtons = (
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-400 transition-colors hover:bg-white/10 hover:text-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-400/60 disabled:cursor-not-allowed disabled:opacity-50"
|
|
title={`Download ${file.path}`}
|
|
aria-label={`Download ${file.path}`}
|
|
onClick={() => void handleDownload(result.repo_id, file.path)}
|
|
disabled={isDownloading || isSwapping}
|
|
>
|
|
<Download size={16} strokeWidth={2} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={`${result.repo_id}:${file.path}`}
|
|
className="sp-hf-file flex items-center justify-between gap-4 p-3 border-b border-white/5 last:border-0 hover:bg-white/5 transition-colors shrink-0 min-h-[48px] w-full"
|
|
>
|
|
<span className="flex-1 min-w-0 text-sm text-neutral-300 truncate" title={file.path}>
|
|
{fileName}
|
|
</span>
|
|
<div className="flex items-center gap-3 shrink-0 whitespace-nowrap">
|
|
<span className="text-xs font-mono text-neutral-500">{fileSize}</span>
|
|
<span className="px-2 py-0.5 bg-white/10 text-neutral-400 text-[10px] font-mono rounded">
|
|
{quantMatch}
|
|
</span>
|
|
{actionButtons}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{hfHasSearched && !hfLoading && hfQuery && hfResults.length === 0 && (
|
|
<p className="sp-hint">Nenhum resultado. Tente outra busca.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ═══════════════════════════════════ RUNTIME SECTION ════════════════════════ */}
|
|
{section === "runtime" && (
|
|
<>
|
|
<header className="page-header sp-section-header">
|
|
<div>
|
|
<h2 className="sp-section-title">Runtime</h2>
|
|
<p className="sp-section-desc">GPU acceleration, context window, and startup policy.</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="settings-cards">
|
|
{localError && <div className="sp-error-inline">{localError}</div>}
|
|
|
|
<div className="sp-card">
|
|
{/* GPU acceleration */}
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">GPU acceleration</div>
|
|
<div className="sp-card-row-desc">
|
|
{gpuInfo ? `${gpuInfo.name} · ${gpuInfo.vram_mib} MiB VRAM` : "No GPU detected — CPU only"}
|
|
</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
{gpuInfo && (
|
|
<span className="sp-status-pill sp-status-pill--success">GPU detected</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">GPU layers</div>
|
|
<div className="sp-card-row-desc">Layers to offload to the GPU</div>
|
|
</div>
|
|
<div className="sp-card-row-control sp-card-row-control--wide">
|
|
<div className="sp-field-value">{draftConfig.gpu_layers}</div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={gpuInfo ? Math.max(gpuInfo.recommended_layers, draftConfig.gpu_layers, 64) : 64}
|
|
value={draftConfig.gpu_layers}
|
|
onChange={(e) => updateDraft({ gpu_layers: Number(e.target.value) })}
|
|
className="sp-slider w-full mt-2"
|
|
disabled={busyAll}
|
|
/>
|
|
<div className="sp-slider-presets">
|
|
<button
|
|
type="button"
|
|
className="sp-btn-xs"
|
|
onClick={() => updateDraft({ gpu_layers: 0 })}
|
|
>
|
|
0 — CPU only
|
|
</button>
|
|
{gpuInfo && (
|
|
<button
|
|
type="button"
|
|
className="sp-btn-xs"
|
|
onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })}
|
|
>
|
|
{gpuInfo.recommended_layers} — Recommended
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Context window */}
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Context window</div>
|
|
<div className="sp-card-row-desc">Maximum tokens the model can process</div>
|
|
</div>
|
|
<div className="sp-card-row-control sp-card-row-control--wide">
|
|
<div className="sp-field-value">{draftConfig.ctx_size.toLocaleString()} tokens</div>
|
|
<div className="sp-segmented">
|
|
{CTX_PRESETS.map((preset) => (
|
|
<button
|
|
key={preset}
|
|
type="button"
|
|
onClick={() => updateDraft({ ctx_size: preset })}
|
|
className={`sp-segmented-btn${draftConfig.ctx_size === preset ? " sp-segmented-btn--active" : ""}`}
|
|
disabled={busyAll}
|
|
>
|
|
{preset >= 1024 ? `${preset / 1024}K` : preset}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="sp-ctx-custom">
|
|
<input
|
|
type="number"
|
|
className="sp-input sp-input--sm"
|
|
value={draftConfig.ctx_size}
|
|
min={MIN_CTX_SIZE}
|
|
onChange={(e) => {
|
|
const v = Number(e.target.value);
|
|
if (v >= MIN_CTX_SIZE) updateDraft({ ctx_size: v });
|
|
}}
|
|
disabled={busyAll}
|
|
/>
|
|
<span className="sp-hint">Min {MIN_CTX_SIZE.toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Startup policy */}
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Startup policy</div>
|
|
<div className="sp-card-row-desc">When to start the local API</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<div className="sp-toggles">
|
|
{(["manual", "on_app_launch"] as const).map((policy) => {
|
|
const active = draftConfig.start_policy === policy;
|
|
return (
|
|
<label key={policy} className={`sp-toggle${active ? " sp-toggle--active" : ""}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={active}
|
|
onChange={() => updateDraft({ start_policy: policy })}
|
|
disabled={busyAll}
|
|
/>
|
|
<span className="sp-toggle-track" />
|
|
<span className="sp-toggle-label">
|
|
{policy === "manual" ? "Manual" : "Auto-start"}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="sp-toggle-desc">
|
|
{draftConfig.start_policy === "manual"
|
|
? "Start the local API from the runtime panel when needed."
|
|
: "Automatically start the local API when ForgeLM opens."}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ═══════════════════════════════════ CONNECTION SECTION ═════════════════════ */}
|
|
{section === "connection" && (
|
|
<>
|
|
<header className="page-header sp-section-header">
|
|
<div>
|
|
<h2 className="sp-section-title">Connection</h2>
|
|
<p className="sp-section-desc">
|
|
Use the built-in local inference engine or connect to a remote OpenAI-compatible server.
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="settings-cards">
|
|
{localError && <div className="sp-error-inline">{localError}</div>}
|
|
|
|
<div className="sp-card">
|
|
{/* Mode */}
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Mode</div>
|
|
<div className="sp-card-row-desc">
|
|
{draftConfig.connection_mode === "local"
|
|
? "Runs the inference server locally on your machine."
|
|
: "Forwards all requests to an external OpenAI-compatible endpoint."}
|
|
</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<div className="sp-segmented">
|
|
{(["local", "remote"] as const).map((mode) => {
|
|
const active = draftConfig.connection_mode === mode;
|
|
return (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
className={`sp-segmented-btn${active ? " sp-segmented-btn--active" : ""}`}
|
|
onClick={() => updateDraft({ connection_mode: mode })}
|
|
disabled={busyAll}
|
|
>
|
|
{mode === "local" ? "Local API" : "Remote API"}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Local settings */}
|
|
{draftConfig.connection_mode === "local" && (
|
|
<>
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Bind address</div>
|
|
<div className="sp-card-row-desc">Host and port for the local API</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<input
|
|
className="sp-input w-full"
|
|
value={draftConfig.addr}
|
|
onChange={(e) => updateDraft({ addr: e.target.value })}
|
|
placeholder="127.0.0.1:8080"
|
|
disabled={busyAll}
|
|
/>
|
|
<p className="sp-hint mt-2">
|
|
Available at{" "}
|
|
<code>http://{draftConfig.addr || "127.0.0.1:8080"}</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Remote settings */}
|
|
{draftConfig.connection_mode === "remote" && (
|
|
<>
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Server URL</div>
|
|
<div className="sp-card-row-desc">Base URL of the remote API</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<input
|
|
className="sp-input w-full"
|
|
value={draftConfig.server_url}
|
|
onChange={(e) => updateDraft({ server_url: e.target.value })}
|
|
placeholder="https://api.openai.com/v1"
|
|
disabled={busyAll}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">API key</div>
|
|
<div className="sp-card-row-desc">Authentication key for the remote server</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<input
|
|
className="sp-input w-full"
|
|
type="password"
|
|
value={draftConfig.api_key}
|
|
onChange={(e) => updateDraft({ api_key: e.target.value })}
|
|
placeholder="sk-…"
|
|
disabled={busyAll}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ═══════════════════════════════════ ADVANCED SECTION ═══════════════════════ */}
|
|
{section === "advanced" && (
|
|
<>
|
|
<header className="page-header sp-section-header">
|
|
<div>
|
|
<h2 className="sp-section-title">Advanced</h2>
|
|
<p className="sp-section-desc">HuggingFace access and diagnostic information.</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="settings-cards">
|
|
{localError && <div className="sp-error-inline">{localError}</div>}
|
|
|
|
<div className="sp-card">
|
|
{/* HF Token */}
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">HuggingFace token</div>
|
|
<div className="sp-card-row-desc">Required for gated or private models</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
{draftConfig.hugging_face_token && (
|
|
<span className="sp-status-pill sp-status-pill--success">Configured</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row">
|
|
<div className="sp-card-row-label">
|
|
<div className="sp-card-row-title">Token value</div>
|
|
<div className="sp-card-row-desc">Generate at huggingface.co/settings/tokens</div>
|
|
</div>
|
|
<div className="sp-card-row-control">
|
|
<input
|
|
className="sp-input w-full"
|
|
type="password"
|
|
value={draftConfig.hugging_face_token || ""}
|
|
onChange={(e) => updateDraft({ hugging_face_token: e.target.value })}
|
|
placeholder="hf_…"
|
|
disabled={busyAll}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Diagnostics */}
|
|
<div className="sp-card-divider" />
|
|
<div className="sp-card-row sp-card-row--full">
|
|
<div className="sp-card-row-title">Diagnostics</div>
|
|
<dl className="sp-diag-list">
|
|
<div className="sp-diag-row">
|
|
<dt className="sp-diag-key">Runtime status</dt>
|
|
<dd className="sp-diag-val">{snapshot.status}</dd>
|
|
</div>
|
|
<div className="sp-diag-row">
|
|
<dt className="sp-diag-key">Binary path</dt>
|
|
<dd className="sp-diag-val sp-mono">
|
|
{snapshot.binary_path || "Not found"}
|
|
</dd>
|
|
</div>
|
|
{snapshot.resolved_url && (
|
|
<div className="sp-diag-row">
|
|
<dt className="sp-diag-key">Resolved endpoint</dt>
|
|
<dd className="sp-diag-val sp-accent">{snapshot.resolved_url}</dd>
|
|
</div>
|
|
)}
|
|
{snapshot.last_error && (
|
|
<div className="sp-diag-row">
|
|
<dt className="sp-diag-key">Last error</dt>
|
|
<dd className="sp-diag-val sp-danger">{snapshot.last_error.message}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-action-bar">
|
|
<div className="action-bar-inner">
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--ghost btn-discard"
|
|
onClick={resetDraft}
|
|
disabled={busyAll || !draftDirty}
|
|
>
|
|
Discard
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--secondary btn-save"
|
|
onClick={() => void handleSave()}
|
|
disabled={busyAll || !draftDirty}
|
|
>
|
|
{saving ? "Saving…" : "Save"}
|
|
</button>
|
|
{isLocal && !isRunning && (
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--primary btn-save"
|
|
onClick={() => void handleSaveAndStart()}
|
|
disabled={busyAll || !Boolean(draftConfig.model_path)}
|
|
>
|
|
{saving ? "Starting…" : "Save & Start"}
|
|
</button>
|
|
)}
|
|
{isLocal && isRunning && (
|
|
<button
|
|
type="button"
|
|
className="sp-btn sp-btn--danger btn-stop"
|
|
onClick={() => void handleStop()}
|
|
disabled={busyAll || isStopping}
|
|
>
|
|
{isStopping ? "Stopping…" : "Stop runtime"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|