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:
1 parent
01b629cb4a
commit
9a093dca15
23 files changed
+1798
-344
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { createDefaultRuntimeSnapshot, createRuntimeLifecycleResult } from "./runtime-utils";
|
||||
import type {
|
||||
HuggingFaceDownloadProgress,
|
||||
ModelLibraryState,
|
||||
HuggingFaceModelResult,
|
||||
RuntimeConfig,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
|
||||
export const HUGGING_FACE_DOWNLOAD_EVENT = "hugging-face-download-progress";
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
@@ -146,6 +148,62 @@ export async function searchHuggingFaceModels(query: string, token = ""): Promis
|
||||
});
|
||||
}
|
||||
|
||||
export async function startHuggingFaceDownload(repo: string, fileName: string, token = ""): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>("start_hugging_face_download_command", {
|
||||
repo,
|
||||
fileName,
|
||||
token: token.trim() || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[api] startHuggingFaceDownload failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelHuggingFaceDownload(downloadId: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>("cancel_hugging_face_download_command", {
|
||||
downloadId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[api] cancelHuggingFaceDownload failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelLibraryState(): Promise<ModelLibraryState> {
|
||||
return invoke<ModelLibraryState>("get_model_library_state_command");
|
||||
}
|
||||
|
||||
/// Hot-swap: stop runtime → update model_path → restart.
|
||||
export async function swapModel(newModelPath: string): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("swap_model_command", { newModelPath });
|
||||
} catch (err) {
|
||||
console.error("[api] swapModel failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a local .gguf model file from disk.
|
||||
export async function deleteModelFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>("delete_model_file", { filePath });
|
||||
} catch (err) {
|
||||
console.error("[api] deleteModelFile failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the folder containing a model in the native file explorer.
|
||||
export async function openModelFolder(filePath: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>("open_model_folder_command", { filePath });
|
||||
} catch (err) {
|
||||
console.error("[api] openModelFolder failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type { HuggingFaceDownloadProgress };
|
||||
@@ -117,6 +117,7 @@ export interface RuntimeConnection {
|
||||
export interface RuntimeModelFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
export interface RuntimePickDirResult {
|
||||
@@ -142,6 +143,22 @@ export interface HuggingFaceModelResult {
|
||||
verified_files: HuggingFaceVerifiedFile[];
|
||||
}
|
||||
|
||||
export type HuggingFaceDownloadPhase = "started" | "progress" | "completed" | "cancelled" | "error";
|
||||
|
||||
export interface HuggingFaceDownloadProgress {
|
||||
downloadId: string;
|
||||
repo: string;
|
||||
fileName: string;
|
||||
phase: HuggingFaceDownloadPhase;
|
||||
progressPercentage: number;
|
||||
downloadedMB: number;
|
||||
totalMB: number | null;
|
||||
downloadSpeed: number;
|
||||
destinationPath: string | null;
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ModelLibraryEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HUGGING_FACE_DOWNLOAD_EVENT, type HuggingFaceDownloadProgress } from "./api";
|
||||
import { useModelDownload } from "./useModelDownload";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
const listenMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: (...args: unknown[]) => listenMock(...args),
|
||||
}));
|
||||
|
||||
function Harness({ token = "hf_token" }: { token?: string }) {
|
||||
const state = useModelDownload(token);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<output id="is-downloading">{String(state.isDownloading)}</output>
|
||||
<output id="progress">{state.progress.toFixed(1)}</output>
|
||||
<output id="speed">{state.speed.toFixed(1)}</output>
|
||||
<output id="downloaded">{state.downloaded.toFixed(1)}</output>
|
||||
<output id="total">{state.total.toFixed(1)}</output>
|
||||
<output id="active-file">{state.activeFile ?? ""}</output>
|
||||
<output id="error">{state.error ?? ""}</output>
|
||||
<button id="start" onClick={() => void state.startDownload("microsoft/phi-4", "phi-4-q4.gguf")}>
|
||||
Start
|
||||
</button>
|
||||
<button id="cancel" onClick={() => void state.cancelDownload()}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useModelDownload", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let progressListener: ((event: { payload: HuggingFaceDownloadProgress }) => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
invokeMock.mockReset();
|
||||
listenMock.mockReset();
|
||||
progressListener = null;
|
||||
listenMock.mockImplementation(async (eventName, callback) => {
|
||||
if (eventName === HUGGING_FACE_DOWNLOAD_EVENT) {
|
||||
progressListener = callback as (event: { payload: HuggingFaceDownloadProgress }) => void;
|
||||
}
|
||||
return () => undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("starts a Hugging Face download with the active token and tracks progress events", async () => {
|
||||
invokeMock.mockResolvedValue("download-1");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness token="hf_secret" />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector("#start") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("start_hugging_face_download_command", {
|
||||
repo: "microsoft/phi-4",
|
||||
fileName: "phi-4-q4.gguf",
|
||||
token: "hf_secret",
|
||||
});
|
||||
expect(container.querySelector("#is-downloading")?.textContent).toBe("true");
|
||||
expect(container.querySelector("#active-file")?.textContent).toBe("microsoft/phi-4:phi-4-q4.gguf");
|
||||
|
||||
await act(async () => {
|
||||
progressListener?.({
|
||||
payload: {
|
||||
downloadId: "download-1",
|
||||
repo: "microsoft/phi-4",
|
||||
fileName: "phi-4-q4.gguf",
|
||||
phase: "progress",
|
||||
progressPercentage: 37.5,
|
||||
downloadedMB: 768,
|
||||
totalMB: 2048,
|
||||
downloadSpeed: 12.4,
|
||||
destinationPath: null,
|
||||
message: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector("#progress")?.textContent).toBe("37.5");
|
||||
expect(container.querySelector("#speed")?.textContent).toBe("12.4");
|
||||
expect(container.querySelector("#downloaded")?.textContent).toBe("768.0");
|
||||
expect(container.querySelector("#total")?.textContent).toBe("2048.0");
|
||||
});
|
||||
|
||||
it("ignores progress for stale downloads and cancels the active one", async () => {
|
||||
invokeMock.mockResolvedValueOnce("download-1").mockResolvedValueOnce(undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector("#start") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
progressListener?.({
|
||||
payload: {
|
||||
downloadId: "download-stale",
|
||||
repo: "other/repo",
|
||||
fileName: "other.gguf",
|
||||
phase: "progress",
|
||||
progressPercentage: 90,
|
||||
downloadedMB: 90,
|
||||
totalMB: 100,
|
||||
downloadSpeed: 4,
|
||||
destinationPath: null,
|
||||
message: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector("#progress")?.textContent).toBe("0.0");
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector("#cancel") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenLastCalledWith("cancel_hugging_face_download_command", {
|
||||
downloadId: "download-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
HUGGING_FACE_DOWNLOAD_EVENT,
|
||||
cancelHuggingFaceDownload,
|
||||
startHuggingFaceDownload,
|
||||
type HuggingFaceDownloadProgress,
|
||||
} from "./api";
|
||||
|
||||
interface UseModelDownloadState {
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
speed: number;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
activeFile: string | null;
|
||||
error: string | null;
|
||||
didComplete: boolean;
|
||||
downloadedFilePath: string | null;
|
||||
resetDidComplete: () => void;
|
||||
startDownload: (repo: string, fileName: string) => Promise<void>;
|
||||
cancelDownload: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ModelDownloadSnapshot {
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
speed: number;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
activeFile: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: ModelDownloadSnapshot = {
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function toActiveFile(repo: string, fileName: string): string {
|
||||
return `${repo}:${fileName}`;
|
||||
}
|
||||
|
||||
export function useModelDownload(token: string): UseModelDownloadState {
|
||||
const [state, setState] = useState<ModelDownloadSnapshot>(INITIAL_STATE);
|
||||
const [didComplete, setDidComplete] = useState(false);
|
||||
const [downloadedFilePath, setDownloadedFilePath] = useState<string | null>(null);
|
||||
const activeDownloadIdRef = useRef<string | null>(null);
|
||||
const tokenRef = useRef(token);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = token;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const unsubscribePromise = listen<HuggingFaceDownloadProgress>(HUGGING_FACE_DOWNLOAD_EVENT, (event) => {
|
||||
const payload = event.payload;
|
||||
if (payload.downloadId !== activeDownloadIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeProgress = Number.isFinite(payload.progressPercentage) ? payload.progressPercentage : 0;
|
||||
const safeSpeed = Number.isFinite(payload.downloadSpeed) ? payload.downloadSpeed : 0;
|
||||
const safeDownloaded = Number.isFinite(payload.downloadedMB) ? payload.downloadedMB : 0;
|
||||
const safeTotal = payload.totalMB != null && Number.isFinite(payload.totalMB) ? payload.totalMB : 0;
|
||||
|
||||
const isActive = payload.phase === "started" || payload.phase === "progress";
|
||||
const isComplete = payload.phase === "completed";
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isDownloading: isActive,
|
||||
progress: safeProgress,
|
||||
speed: safeSpeed,
|
||||
downloaded: safeDownloaded,
|
||||
total: safeTotal,
|
||||
activeFile: toActiveFile(payload.repo, payload.fileName),
|
||||
error: payload.error ?? null,
|
||||
}));
|
||||
|
||||
if (isComplete) {
|
||||
const finalPath = payload.destinationPath ?? null;
|
||||
console.log(
|
||||
"[HuggingFace] Download concluído! Arquivo em:",
|
||||
finalPath
|
||||
);
|
||||
setDownloadedFilePath(finalPath);
|
||||
setDidComplete(true);
|
||||
activeDownloadIdRef.current = null;
|
||||
} else if (payload.phase === "cancelled" || payload.phase === "error") {
|
||||
activeDownloadIdRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
void unsubscribePromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(async (repo: string, fileName: string) => {
|
||||
try {
|
||||
const downloadId = await startHuggingFaceDownload(repo, fileName, tokenRef.current);
|
||||
activeDownloadIdRef.current = downloadId;
|
||||
setState({
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: toActiveFile(repo, fileName),
|
||||
error: null,
|
||||
});
|
||||
console.log("[HuggingFace] Download iniciado:", repo, fileName);
|
||||
} catch (err) {
|
||||
console.error("[useModelDownload] startDownload failed:", err);
|
||||
setState({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
activeDownloadIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelDownload = useCallback(async () => {
|
||||
if (!activeDownloadIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelHuggingFaceDownload(activeDownloadIdRef.current);
|
||||
} catch (err) {
|
||||
console.error("[useModelDownload] cancelDownload failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
didComplete,
|
||||
downloadedFilePath,
|
||||
resetDidComplete: () => {
|
||||
setDidComplete(false);
|
||||
setDownloadedFilePath(null);
|
||||
},
|
||||
startDownload,
|
||||
cancelDownload,
|
||||
};
|
||||
}
|
||||
@@ -421,234 +421,6 @@
|
||||
animation: sp-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── HuggingFace search ── */
|
||||
.sp-hf-search-header {
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Tooltip container — isolated positioning context */
|
||||
.tooltip-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-hf-info-icon {
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tooltip-container:hover .sp-hf-info-icon {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Tooltip glass — absolute float, maximum z-index */
|
||||
.sp-tooltip-content {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 340px;
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tooltip-container:hover .sp-tooltip-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sp-hf-tooltip-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sp-hf-tooltip-body {
|
||||
font-size: 11px;
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.sp-hf-tooltip-levels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sp-hf-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sp-hf-dot--green { background: #34d399; box-shadow: 0 0 4px rgba(52, 211, 153, 0.5); }
|
||||
.sp-hf-dot--blue { background: #60a5fa; box-shadow: 0 0 4px rgba(96, 165, 250, 0.5); }
|
||||
.sp-hf-dot--yellow { background: #fbbf24; box-shadow: 0 0 4px rgba(251, 191, 36, 0.5); }
|
||||
|
||||
.sp-hf-search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sp-hf-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sp-hf-results {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sp-hf-card {
|
||||
background: var(--bg3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sp-hf-card:hover { border-color: var(--border2); }
|
||||
|
||||
.sp-hf-card-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.sp-hf-repo {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-hf-author {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.sp-hf-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sp-hf-file {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sp-hf-file:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sp-hf-file:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sp-hf-name {
|
||||
flex: 1;
|
||||
min-width: 0; /* CRÍTICO: permite truncate dentro de flex-item */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.sp-hf-size {
|
||||
font-size: 11px;
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
color: var(--text3);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap; /* protege de ser esmagado pelo nome longo */
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sp-hf-quant-badge {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
color: var(--text2);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap; /* protege de ser esmagado pelo nome longo */
|
||||
}
|
||||
|
||||
.sp-hf-download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-hf-file:hover .sp-hf-download-btn {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sp-hf-download-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ── Radio controls ── */
|
||||
.sp-radio {
|
||||
display: inline-block;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SettingsPage from "./SettingsPage";
|
||||
import type { RuntimeSnapshot } from "../runtime/types";
|
||||
|
||||
const startDownloadMock = vi.fn();
|
||||
const cancelDownloadMock = vi.fn();
|
||||
|
||||
const modelDownloadState = {
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null as string | null,
|
||||
error: null as string | null,
|
||||
startDownload: startDownloadMock,
|
||||
cancelDownload: cancelDownloadMock,
|
||||
};
|
||||
|
||||
vi.mock("../runtime/api", () => ({
|
||||
pickRuntimeModelFiles: vi.fn(async () => []),
|
||||
pickRuntimeModelsDir: vi.fn(async () => null),
|
||||
queryRuntimeGpu: vi.fn(async () => null),
|
||||
searchHuggingFaceModels: vi.fn(async () => [
|
||||
{
|
||||
repo_id: "microsoft/phi-4",
|
||||
author: "microsoft",
|
||||
verified_files: [
|
||||
{
|
||||
path: "quantized/phi-4-q4.gguf",
|
||||
size: 4_294_967_296,
|
||||
quantization: "q4",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime/useModelDownload", () => ({
|
||||
useModelDownload: () => modelDownloadState,
|
||||
}));
|
||||
|
||||
function createSnapshot(): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: "",
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
active_command: "idle",
|
||||
last_command_result: {
|
||||
command: "save",
|
||||
outcome: "succeeded",
|
||||
message: "",
|
||||
},
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
hugging_face_token: "hf_token",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("SettingsPage Hugging Face downloads", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
startDownloadMock.mockReset();
|
||||
cancelDownloadMock.mockReset();
|
||||
Object.assign(modelDownloadState, {
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
activeFile: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("starts a real download from the Hugging Face row", async () => {
|
||||
const runtime = {
|
||||
snapshot: createSnapshot(),
|
||||
savedBaseline: createSnapshot().config,
|
||||
draftConfig: createSnapshot().config,
|
||||
draftDirty: false,
|
||||
loading: false,
|
||||
busy: false,
|
||||
lifecycleBusy: false,
|
||||
scannedModels: [],
|
||||
isScanningModels: false,
|
||||
updateDraft: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
startLocal: vi.fn(),
|
||||
stopLocal: vi.fn(),
|
||||
scanModels: vi.fn(async () => undefined),
|
||||
resetDraft: vi.fn(),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(<SettingsPage runtime={runtime} onClose={() => undefined} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const input = container.querySelector('input[placeholder="Search for GGUF models…"]') as HTMLInputElement;
|
||||
setInputValue(input, "phi-4");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Search")) as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const fileRow = container.querySelector(".sp-hf-file");
|
||||
expect(fileRow).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
(fileRow?.querySelector('button[aria-label^="Download "]') as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(startDownloadMock).toHaveBeenCalledWith("microsoft/phi-4", "quantized/phi-4-q4.gguf");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user