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

+1
View File
@@ -878,6 +878,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell",
"tokio",
]
[[package]]
+1
View File
@@ -25,6 +25,7 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["fs", "io-util", "sync", "time"] }
runtime-deps = { path = "../../crates/runtime-deps" }
[profile.release]
+113 -2
View File
@@ -1,3 +1,5 @@
use std::sync::Arc;
use tauri::{AppHandle, Manager, State};
mod gpu;
@@ -9,9 +11,13 @@ use runtime::process::find_localllm_binary;
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
use gpu::query_gpu;
use model_library::huggingface::search_hugging_face_models;
use model_library::huggingface::{
cancel_hugging_face_download, search_hugging_face_models, start_hugging_face_download,
HuggingFaceDownloadState,
};
use model_library::local_index::list_entries;
use model_library::paths::{managed_models_dir, model_library_index_path};
use model_library::paths::{managed_models_dir, model_library_index_path, resolve_models_dir};
use runtime::config::{load_runtime_config, save_runtime_config};
use model_library::types::{ModelLibraryEntry, ModelLibraryState};
use model_files::{import_model, pick_model_files, pick_models_dir, scan_models};
@@ -80,6 +86,105 @@ async fn search_hugging_face_models_command(
search_hugging_face_models(&query, token.as_deref()).await
}
#[tauri::command]
async fn start_hugging_face_download_command(
app: AppHandle,
state: State<'_, Arc<HuggingFaceDownloadState>>,
repo: String,
file_name: String,
token: Option<String>,
) -> Result<String, String> {
// Load the user-configured models_dir (defaults to ./models if None/empty)
let config = load_runtime_config(&app)?;
let models_root = resolve_models_dir(config.models_dir.as_deref());
start_hugging_face_download(
app,
state.inner().clone(),
repo,
file_name,
token,
models_root,
)
.await
}
#[tauri::command]
fn cancel_hugging_face_download_command(
state: State<'_, Arc<HuggingFaceDownloadState>>,
download_id: String,
) -> Result<(), String> {
cancel_hugging_face_download(state.inner(), &download_id)
}
/// Swap the active model: stops the runtime, updates model_path, restarts.
/// This is the hot-swap primitive — used when user picks a different local model.
#[tauri::command]
async fn swap_model_command(
app: AppHandle,
state: State<'_, RuntimeState>,
new_model_path: String,
) -> Result<RuntimeSnapshot, String> {
// Stop first — this is a no-op if already stopped
stop_runtime(state.clone())?;
// Load, update model_path, save
let mut config = load_runtime_config(&app)?;
config.model_path = Some(new_model_path);
save_runtime_config(&app, &config)
.map_err(|e| format!("swap save failed: {e}"))?;
// Restart with the updated config
start_runtime(app, state)
}
/// Delete a local .gguf model file from disk.
#[tauri::command]
fn delete_model_file(file_path: String) -> Result<(), String> {
let path = std::path::Path::new(&file_path);
if !path.exists() {
return Err(format!("File not found: {file_path}"));
}
std::fs::remove_file(path).map_err(|e| format!("Delete failed: {e}"))
}
/// Open the folder containing a model file in the native file explorer
/// and optionally select/highlight the file.
#[tauri::command]
fn open_model_folder_command(file_path: String) -> Result<(), String> {
let path = std::path::Path::new(&file_path);
let _folder = path.parent().unwrap_or(path);
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.args(["/select,", &file_path])
.spawn()
.map_err(|e| format!("Failed to open Explorer: {e}"))?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.args(["-R", &file_path])
.spawn()
.map_err(|e| format!("Failed to open Finder: {e}"))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(folder)
.spawn()
.map_err(|e| format!("Failed to open file manager: {e}"))?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let _ = folder; // folder is only used on some platforms
return Err("Unsupported platform".into());
}
Ok(())
}
#[tauri::command]
fn list_model_library_entries_command(app: AppHandle) -> Result<Vec<ModelLibraryEntry>, String> {
let app_data_dir = app
@@ -112,6 +217,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(RuntimeState::new())
.manage(Arc::new(HuggingFaceDownloadState::default()))
.invoke_handler(tauri::generate_handler![
query_gpu,
scan_models,
@@ -119,6 +225,11 @@ pub fn run() {
pick_model_files,
import_model,
search_hugging_face_models_command,
start_hugging_face_download_command,
cancel_hugging_face_download_command,
swap_model_command,
delete_model_file,
open_model_folder_command,
list_model_library_entries_command,
get_model_library_state_command,
bootstrap_runtime_command,
@@ -1,7 +1,69 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
use reqwest::header::{AUTHORIZATION, USER_AGENT};
use serde::Deserialize;
use tauri::{AppHandle, Emitter};
use tokio::{fs, io::AsyncWriteExt};
use super::types::{HuggingFaceModelResult, HuggingFaceVerifiedFile};
use super::types::{
HuggingFaceDownloadPhase, HuggingFaceDownloadProgress, HuggingFaceModelResult,
HuggingFaceVerifiedFile,
};
pub const HUGGING_FACE_DOWNLOAD_EVENT: &str = "hugging-face-download-progress";
pub const HUGGING_FACE_USER_AGENT: &str = "ForgeLM/0.1";
#[derive(Default)]
pub struct HuggingFaceDownloadState {
next_id: std::sync::atomic::AtomicU64,
cancellations: Mutex<HashMap<String, Arc<AtomicBool>>>,
}
impl HuggingFaceDownloadState {
pub fn start_download(&self) -> (String, Arc<AtomicBool>) {
let sequence = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
let download_id = format!("hf-download-{sequence}");
let cancellation = Arc::new(AtomicBool::new(false));
let mut guard = self
.cancellations
.lock()
.expect("hugging face download state poisoned");
guard.insert(download_id.clone(), cancellation.clone());
(download_id, cancellation)
}
pub fn cancel(&self, download_id: &str) -> bool {
let mut guard = self
.cancellations
.lock()
.expect("hugging face download state poisoned");
if let Some(flag) = guard.remove(download_id) {
flag.store(true, Ordering::Relaxed);
true
} else {
false
}
}
pub fn finish(&self, download_id: &str) {
let mut guard = self
.cancellations
.lock()
.expect("hugging face download state poisoned");
guard.remove(download_id);
}
}
#[derive(Deserialize, Debug)]
struct HubSibling {
@@ -24,8 +86,359 @@ fn quantization_label(path: &str) -> Option<String> {
stem.rsplit_once('-').map(|(_, suffix)| suffix.to_string())
}
fn validate_relative_segments(value: &str, label: &str) -> Result<Vec<String>, String> {
if value.trim().is_empty() {
return Err(format!("{label} cannot be empty"));
}
if value.contains('\\') {
return Err(format!("{label} contains invalid separators"));
}
let segments: Vec<String> = value.split('/').map(str::to_string).collect();
if segments
.iter()
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return Err(format!("{label} contains invalid path segments"));
}
Ok(segments)
}
fn managed_download_destination(
managed_root: &Path,
repo: &str,
file_name: &str,
) -> Result<PathBuf, String> {
let repo_segments = validate_relative_segments(repo, "repo")?;
let file_segments = validate_relative_segments(file_name, "file name")?;
let mut destination = managed_root.to_path_buf();
for segment in repo_segments.into_iter().chain(file_segments.into_iter()) {
destination.push(segment);
}
Ok(destination)
}
pub fn hugging_face_resolve_url(repo: &str, file_name: &str) -> String {
format!("https://huggingface.co/{repo}/resolve/main/{file_name}")
}
pub fn download_progress_mib(bytes: u64) -> f64 {
bytes as f64 / 1_048_576.0
}
pub fn download_progress_percentage(downloaded_bytes: u64, total_bytes: Option<u64>) -> f64 {
match total_bytes {
Some(total) if total > 0 => {
((downloaded_bytes as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
}
_ => 0.0,
}
}
fn download_speed_mib_per_second(bytes_delta: u64, elapsed: Duration) -> f64 {
let seconds = elapsed.as_secs_f64();
if seconds <= 0.0 {
return 0.0;
}
download_progress_mib(bytes_delta) / seconds
}
fn emit_download_progress(app: &AppHandle, payload: &HuggingFaceDownloadProgress) -> Result<(), String> {
app.emit(HUGGING_FACE_DOWNLOAD_EVENT, payload)
.map_err(|error| format!("failed to emit download progress: {error}"))
}
fn build_progress_payload(
download_id: &str,
repo: &str,
file_name: &str,
phase: HuggingFaceDownloadPhase,
downloaded_bytes: u64,
total_bytes: Option<u64>,
download_speed: f64,
destination_path: Option<&Path>,
message: Option<String>,
error: Option<String>,
) -> HuggingFaceDownloadProgress {
HuggingFaceDownloadProgress {
download_id: download_id.to_string(),
repo: repo.to_string(),
file_name: file_name.to_string(),
phase,
progress_percentage: download_progress_percentage(downloaded_bytes, total_bytes),
downloaded_mb: download_progress_mib(downloaded_bytes),
total_mb: total_bytes.map(download_progress_mib),
download_speed,
destination_path: destination_path.map(|path| path.to_string_lossy().to_string()),
message,
error,
}
}
async fn remove_if_exists(path: &Path) {
if fs::metadata(path).await.is_ok() {
let _ = fs::remove_file(path).await;
}
}
async fn run_hugging_face_download(
app: AppHandle,
state: Arc<HuggingFaceDownloadState>,
download_id: String,
repo: String,
file_name: String,
token: Option<String>,
cancellation: Arc<AtomicBool>,
managed_root: PathBuf,
) -> Result<(), String> {
let destination = managed_download_destination(&managed_root, &repo, &file_name)?;
println!(
"[HF Download] download_id={} repo={} file={} destination={}",
download_id,
repo,
file_name,
destination.display()
);
let temp_path = destination.with_extension("part");
let parent = destination
.parent()
.ok_or_else(|| "failed to resolve download destination".to_string())?;
fs::create_dir_all(parent)
.await
.map_err(|error| format!("failed to prepare download directory: {error}"))?;
println!(
"[HF Download] download_id={} directory ready, initiating HTTP request to {}",
download_id,
hugging_face_resolve_url(&repo, &file_name)
);
let client = reqwest::Client::new();
let mut request = client
.get(hugging_face_resolve_url(&repo, &file_name))
.header(USER_AGENT, HUGGING_FACE_USER_AGENT);
if let Some(value) = token.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
request = request.header(AUTHORIZATION, format!("Bearer {value}"));
}
let response = request
.send()
.await
.map_err(|error| format!("Hugging Face download request failed: {error}"))?
.error_for_status()
.map_err(|error| format!("Hugging Face download request failed: {error}"))?;
let total_bytes = response.content_length();
println!(
"[HF Download] download_id={} response received, content_length={:?}",
download_id,
total_bytes
);
emit_download_progress(
&app,
&build_progress_payload(
&download_id,
&repo,
&file_name,
HuggingFaceDownloadPhase::Started,
0,
total_bytes,
0.0,
Some(&destination),
Some("Download started".to_string()),
None,
),
)?;
let mut stream = response;
let mut file = fs::File::create(&temp_path)
.await
.map_err(|error| format!("failed to create temp download file: {error}"))?;
let mut downloaded_bytes = 0_u64;
let mut last_emit = Instant::now();
let mut last_emit_bytes = 0_u64;
loop {
if cancellation.load(Ordering::Relaxed) {
println!(
"[HF Download] download_id={} CANCELLED at bytes={}/{:?}",
download_id, downloaded_bytes, total_bytes
);
let _ = file.flush().await;
drop(file);
remove_if_exists(&temp_path).await;
emit_download_progress(
&app,
&build_progress_payload(
&download_id,
&repo,
&file_name,
HuggingFaceDownloadPhase::Cancelled,
downloaded_bytes,
total_bytes,
0.0,
Some(&destination),
Some("Download cancelled".to_string()),
None,
),
)?;
state.finish(&download_id);
return Ok(());
}
let next_chunk = stream
.chunk()
.await
.map_err(|error| format!("failed to read download stream: {error}"))?;
let Some(chunk) = next_chunk else {
break;
};
file.write_all(&chunk)
.await
.map_err(|error| format!("failed to write download chunk: {error}"))?;
downloaded_bytes += chunk.len() as u64;
let elapsed = last_emit.elapsed();
if elapsed >= Duration::from_millis(500) {
let speed = download_speed_mib_per_second(downloaded_bytes - last_emit_bytes, elapsed);
emit_download_progress(
&app,
&build_progress_payload(
&download_id,
&repo,
&file_name,
HuggingFaceDownloadPhase::Progress,
downloaded_bytes,
total_bytes,
speed,
Some(&destination),
None,
None,
),
)?;
last_emit = Instant::now();
last_emit_bytes = downloaded_bytes;
}
}
file.flush()
.await
.map_err(|error| format!("failed to flush download file: {error}"))?;
drop(file);
remove_if_exists(&destination).await;
fs::rename(&temp_path, &destination)
.await
.map_err(|error| format!("failed to finalize download file: {error}"))?;
let elapsed = last_emit.elapsed();
let speed = download_speed_mib_per_second(downloaded_bytes - last_emit_bytes, elapsed);
println!(
"[HF Download] download_id={} COMPLETED downloaded_bytes={} destination={}",
download_id,
downloaded_bytes,
destination.display()
);
emit_download_progress(
&app,
&build_progress_payload(
&download_id,
&repo,
&file_name,
HuggingFaceDownloadPhase::Completed,
downloaded_bytes,
total_bytes,
speed,
Some(&destination),
Some("Download completed".to_string()),
None,
),
)?;
state.finish(&download_id);
Ok(())
}
pub async fn start_hugging_face_download(
app: AppHandle,
state: Arc<HuggingFaceDownloadState>,
repo: String,
file_name: String,
token: Option<String>,
managed_root: PathBuf,
) -> Result<String, String> {
let (download_id, cancellation) = state.start_download();
let task_app = app.clone();
let task_state = state.clone();
let task_download_id = download_id.clone();
let task_repo = repo.clone();
let task_file_name = file_name.clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = run_hugging_face_download(
task_app.clone(),
task_state.clone(),
task_download_id.clone(),
task_repo.clone(),
task_file_name.clone(),
token,
cancellation,
managed_root,
)
.await
{
println!(
"[HF Download] download_id={} ERROR: {}",
task_download_id, error
);
task_state.finish(&task_download_id);
let _ = emit_download_progress(
&task_app,
&build_progress_payload(
&task_download_id,
&task_repo,
&task_file_name,
HuggingFaceDownloadPhase::Error,
0,
None,
0.0,
None,
None,
Some(error),
),
);
}
});
Ok(download_id)
}
pub fn cancel_hugging_face_download(
state: &HuggingFaceDownloadState,
download_id: &str,
) -> Result<(), String> {
if state.cancel(download_id) {
Ok(())
} else {
Err(format!("download {download_id} was not found"))
}
}
#[cfg(test)]
pub fn filter_verified_gguf_files(payload: &str) -> Result<Vec<HuggingFaceVerifiedFile>, serde_json::Error> {
pub fn filter_verified_gguf_files(
payload: &str,
) -> Result<Vec<HuggingFaceVerifiedFile>, serde_json::Error> {
let model: HubModel = serde_json::from_str(payload)?;
Ok(verified_files_from_model(model))
}
@@ -66,7 +479,7 @@ pub async fn search_hugging_face_models(
("limit", "12"),
("full", "true"),
])
.header(USER_AGENT, "ForgeLM/0.1");
.header(USER_AGENT, HUGGING_FACE_USER_AGENT);
if let Some(value) = token.map(str::trim).filter(|value| !value.is_empty()) {
request = request.header(AUTHORIZATION, format!("Bearer {value}"));
@@ -109,7 +522,12 @@ pub async fn search_hugging_face_models(
#[cfg(test)]
mod tests {
use super::filter_verified_gguf_files;
use std::path::Path;
use super::{
download_progress_mib, download_progress_percentage, filter_verified_gguf_files,
hugging_face_resolve_url, managed_download_destination,
};
#[test]
fn search_hf_gguf_filters_verified_files_and_excludes_mmproj() {
@@ -132,4 +550,52 @@ mod tests {
assert_eq!(files[1].path, "subdir/model-q8_0.gguf");
assert_eq!(files[1].quantization.as_deref(), Some("q8_0"));
}
#[test]
fn builds_direct_hugging_face_resolve_urls() {
assert_eq!(
hugging_face_resolve_url("microsoft/phi-4-mini", "phi-4-q4.gguf"),
"https://huggingface.co/microsoft/phi-4-mini/resolve/main/phi-4-q4.gguf"
);
}
#[test]
fn converts_bytes_to_mebibytes_for_progress_reporting() {
assert_eq!(download_progress_mib(1_048_576), 1.0);
assert_eq!(download_progress_mib(2_621_440), 2.5);
}
#[test]
fn calculates_progress_percentage_from_content_length() {
assert_eq!(download_progress_percentage(524_288, Some(1_048_576)), 50.0);
assert_eq!(download_progress_percentage(1_048_576, Some(1_048_576)), 100.0);
assert_eq!(download_progress_percentage(1_048_576, None), 0.0);
}
#[test]
fn builds_managed_destination_from_repo_and_file_path() {
let destination = managed_download_destination(
Path::new("/models"),
"microsoft/phi-4",
"quantized/phi-4-q4.gguf",
)
.expect("destination path");
assert_eq!(
destination,
Path::new("/models")
.join("microsoft")
.join("phi-4")
.join("quantized")
.join("phi-4-q4.gguf")
);
}
#[test]
fn rejects_parent_directory_segments_in_download_paths() {
let error = managed_download_destination(Path::new("/models"), "microsoft/../phi-4", "model.gguf")
.expect_err("invalid path should fail");
assert!(error.contains("invalid path segments"));
}
}
@@ -22,6 +22,7 @@ pub fn load_index(path: &Path) -> Result<ModelLibraryIndex, String> {
}
}
#[allow(dead_code)]
pub fn save_index(path: &Path, index: &ModelLibraryIndex) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
@@ -3,6 +3,18 @@ use std::path::{Path, PathBuf};
const MODEL_LIBRARY_DIR: &str = "model-library";
const INDEX_FILE: &str = "index.json";
const MANAGED_MODELS_DIR: &str = "managed-models";
const DEFAULT_MODELS_DIR: &str = "models";
/// Resolves the models directory from the user-configured `models_dir`.
/// - `None` → `./models` (relative to the app's working directory)
/// - `Some("")` → `./models`
/// - `Some(path)` → that exact path
pub fn resolve_models_dir(models_dir: Option<&str>) -> PathBuf {
match models_dir {
None | Some("") => PathBuf::from(DEFAULT_MODELS_DIR),
Some(path) => PathBuf::from(path),
}
}
pub fn model_library_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join(MODEL_LIBRARY_DIR)
@@ -12,6 +24,9 @@ pub fn model_library_index_path(app_data_dir: &Path) -> PathBuf {
model_library_dir(app_data_dir).join(INDEX_FILE)
}
/// Legacy path for the managed-models location inside AppData.
/// Used only for index persistence — downloads now go to the user-configured
/// `models_dir` (defaults to `./models` in the project root).
pub fn managed_models_dir(app_data_dir: &Path) -> PathBuf {
model_library_dir(app_data_dir).join(MANAGED_MODELS_DIR)
}
@@ -46,6 +46,32 @@ pub struct HuggingFaceModelResult {
pub verified_files: Vec<HuggingFaceVerifiedFile>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HuggingFaceDownloadPhase {
Started,
Progress,
Completed,
Cancelled,
Error,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HuggingFaceDownloadProgress {
pub download_id: String,
pub repo: String,
pub file_name: String,
pub phase: HuggingFaceDownloadPhase,
pub progress_percentage: f64,
pub downloaded_mb: f64,
pub total_mb: Option<f64>,
pub download_speed: f64,
pub destination_path: Option<String>,
pub message: Option<String>,
pub error: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct ModelLibraryState {
+1 -1
View File
@@ -667,7 +667,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
type="button"
aria-label="Send message"
>
<ArrowUp size={18} weight="duotone" />
Send <ArrowUp size={18} weight="duotone" />
</button>
)}
</div>
+1 -1
View File
@@ -126,7 +126,7 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
>
{hasReasoning && (
<details className="msg-thinking">
<summary className="thinking-summary">
<summary className="reasoning-toggle" aria-label="Toggle thinking details">
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
+58
View File
@@ -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 };
+17
View File
@@ -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");
});
});
+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>