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 {