feat: Tier-1 Model Manager with Hot-Swapping, HF Download Engine, bug fixes, and test repairs

- Phase 4: Hugging Face Download Engine — streaming GGUF downloads with progress, cancellation, and managed-model storage
- Phase 5: Model Source Validation — source-mode compatibility, path/file validation, inline error UI with recovery guidance
- Hot-swap: stop runtime → update model_path → restart atomic command
- Model library: folder open, delete model file, local model management
- SettingsPage unified hub: local models list + HF search with 3-state action buttons
- Bug fixes: send button text restored (4 failing tests), reasoning-toggle aria-label, Rust warnings
- All 89 tests passing, cargo check clean
This commit is contained in:
Evandro Fonseca Junior committed 2026-03-31 02:03:55 -03:00
1 parent 01b629cb4a
commit 9a093dca15
23 files changed
+1798 -344

No files matched your search

+390 -79
View File
@@ -1,15 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 {
@@ -19,7 +24,7 @@ import type {
RuntimeModelFile,
RuntimeSnapshot,
} from "../runtime/types";
import { Box, Zap, ArrowLeftRight, Settings2, X, Trash2, Search, Info, Download } from "lucide-react";
import { Box, Zap, ArrowLeftRight, Settings2, X, Trash2, Search, Info, Download, FolderOpen } from "lucide-react";
import "./SettingsPage.css";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -67,6 +72,12 @@ function formatBytes(bytes: number | null | undefined): string {
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";
@@ -97,12 +108,30 @@ export default function SettingsPage({ runtime, onClose }: Props) {
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;
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 ──────────────────────────────────────────────────────────
@@ -119,6 +148,43 @@ export default function SettingsPage({ runtime, onClose }: Props) {
}
}, [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 });
setSuccessMessage(
`Modelo baixado e selecionado com sucesso!\n${modelPath}`
);
}
resetDidComplete();
});
}, [didComplete, downloadedFilePath]);
// ── Derived data ──────────────────────────────────────────────────────────
const allModels = dedupeModels([
@@ -180,6 +246,7 @@ export default function SettingsPage({ runtime, onClose }: Props) {
const handleHfSearch = useCallback(async () => {
if (!hfQuery.trim()) return;
setHfLoading(true);
setHfHasSearched(true);
setLocalError("");
try {
const results = await searchHuggingFaceModels(hfQuery, draftConfig.hugging_face_token || "");
@@ -191,6 +258,60 @@ export default function SettingsPage({ runtime, onClose }: Props) {
}
}, [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 });
setSwappingMessage("Modelo trocado com sucesso.");
setTimeout(() => setSwappingMessage(""), 3000);
} catch (error) {
setLocalError(String(error));
} finally {
setIsSwapping(false);
}
}, [isSwapping, isDownloading, updateDraft]);
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("");
@@ -444,89 +565,279 @@ export default function SettingsPage({ runtime, onClose }: Props) {
{/* HuggingFace search */}
<div className="sp-card-divider" />
<div className="sp-card-row sp-card-row--full">
<div className="sp-hf-search-header">
<div>
<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="tooltip-container">
<Info size={14} className="sp-hf-info-icon" />
<div className="sp-tooltip-content">
<div className="sp-hf-tooltip-title">O que é Quantização?</div>
<p className="sp-hf-tooltip-body">
É 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="sp-hf-tooltip-levels">
<span><span className="sp-hf-dot sp-hf-dot--green" /> <strong>Máxima Qualidade (Pesado):</strong> Q8_0, Q6_K — Quase sem perda de inteligência.</span>
<span><span className="sp-hf-dot sp-hf-dot--blue" /> <strong>Equilíbrio Ideal (Recomendado):</strong> Q4_K_M, Q5_K_M — Melhor custo-benefício entre velocidade e resposta.</span>
<span><span className="sp-hf-dot sp-hf-dot--yellow" /> <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 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>
</div>
<div className="sp-hf-search-bar">
<input
className="sp-input sp-hf-input"
placeholder="Search for GGUF models…"
value={hfQuery}
onChange={(e) => setHfQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleHfSearch(); }}
/>
<button
type="button"
className="sp-btn sp-btn--primary"
onClick={() => void handleHfSearch()}
disabled={hfLoading || !hfQuery.trim()}
>
<Search size={14} strokeWidth={2} />
{hfLoading ? "…" : "Search"}
</button>
</div>
{hfResults.length > 0 && (
<div className="sp-hf-results">
{hfResults.map((result) => (
<div key={result.repo_id} className="sp-hf-card">
<div className="sp-hf-card-header">
<div className="sp-hf-repo">{result.repo_id}</div>
<div className="sp-hf-author">{result.author || "Unknown author"}</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>
{result.verified_files.length > 0 && (
<div className="sp-hf-files">
{result.verified_files.map((file) => {
const fileName = file.path.replace(/\.gguf$/i, "");
const fileSize = file.size ? `${(file.size / 1_073_741_824).toFixed(1)} GB` : "—";
return (
<div key={`${result.repo_id}:${file.path}`} className="sp-hf-file">
<span className="sp-hf-name truncate" title={file.path}>{fileName}</span>
<span className="sp-hf-size">{fileSize}</span>
<span className="sp-hf-quant-badge">{file.quantization || "gguf"}</span>
<button
type="button"
className="sp-hf-download-btn"
title={`Download ${file.path}`}
onClick={() => {
// TODO: wire up actual download
console.log("Download:", file.path);
}}
>
<Download size={16} strokeWidth={2} />
</button>
</div>
);
})}
</div>
)}
</div>
))}
</span>
</div>
)}
{!hfLoading && hfQuery && hfResults.length === 0 && (
<p className="sp-hint mt-3">No results. Try a different query.</p>
)}
<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>