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
Generated
+1
@@ -878,6 +878,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user