feat: add offline model manager

This commit is contained in:
Evandro committed 2026-06-11 00:51:52 -03:00
1 parent a88206db2b
commit f324566b42
9 files changed
+1094 -21

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
pub const DEFAULT_MODEL_ID: &str = "parakeet-tdt-0.6b-v3-q8_0";
pub const DEFAULT_MODEL_URL: &str = "https://huggingface.co/cstr/parakeet-tdt-0.6b-v3-GGUF/resolve/main/parakeet-tdt-0.6b-v3-q8_0.gguf";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineModel {
pub id: String,
pub display_name: String,
pub repository: String,
pub filename: String,
pub quantization: String,
pub size_bytes: u64,
pub download_url: String,
pub languages: Vec<String>,
pub recommended_for_simple_pc: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum ModelInstallStatus {
NotInstalled,
Ready,
Error(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ModelWithStatus {
pub model: OfflineModel,
pub status: ModelInstallStatus,
pub local_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ModelDownloadResult {
pub model_id: String,
pub path: PathBuf,
pub bytes_written: u64,
}
pub fn catalog() -> Vec<OfflineModel> {
vec![OfflineModel {
id: DEFAULT_MODEL_ID.to_string(),
display_name: "Parakeet TDT 0.6B v3 Q8".to_string(),
repository: "cstr/parakeet-tdt-0.6b-v3-GGUF".to_string(),
filename: "parakeet-tdt-0.6b-v3-q8_0.gguf".to_string(),
quantization: "Q8_0".to_string(),
size_bytes: 745_121_600,
download_url: DEFAULT_MODEL_URL.to_string(),
languages: vec![
"bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it", "lv",
"lt", "mt", "pl", "pt", "ro", "sk", "sl", "es", "sv", "ru", "uk",
]
.into_iter()
.map(str::to_string)
.collect(),
recommended_for_simple_pc: true,
}]
}
pub fn model_statuses(model_dir: &Path) -> Vec<ModelWithStatus> {
catalog()
.into_iter()
.map(|model| {
let path = model_dir.join(&model.filename);
let status = status_for_path(&path, model.size_bytes);
ModelWithStatus {
model,
local_path: path.exists().then_some(path),
status,
}
})
.collect()
}
pub fn status_for_path(path: &Path, expected_size: u64) -> ModelInstallStatus {
match fs::metadata(path) {
Ok(metadata) if metadata.len() >= expected_size => ModelInstallStatus::Ready,
Ok(metadata) => ModelInstallStatus::Error(format!(
"arquivo incompleto: {} de {} bytes",
metadata.len(), expected_size
)),
Err(_) => ModelInstallStatus::NotInstalled,
}
}
pub fn default_model() -> OfflineModel {
catalog()
.into_iter()
.find(|model| model.id == DEFAULT_MODEL_ID)
.expect("catálogo deve conter o modelo padrão")
}
pub fn default_model_path(model_dir: &Path) -> PathBuf {
model_dir.join(default_model().filename)
}
pub fn download_default_model(model_dir: &Path) -> AppResult<ModelDownloadResult> {
let model = default_model();
fs::create_dir_all(model_dir).map_err(|error| AppError::Io(error.to_string()))?;
let final_path = model_dir.join(&model.filename);
let temp_path = model_dir.join(format!("{}.partial", model.filename));
let mut response = reqwest::blocking::get(&model.download_url)
.map_err(|error| AppError::Network(error.to_string()))?
.error_for_status()
.map_err(|error| AppError::Network(error.to_string()))?;
let mut file = fs::File::create(&temp_path).map_err(|error| AppError::Io(error.to_string()))?;
let mut buffer = [0_u8; 1024 * 128];
let mut bytes_written = 0_u64;
loop {
let read = response
.read(&mut buffer)
.map_err(|error| AppError::Network(error.to_string()))?;
if read == 0 {
break;
}
file.write_all(&buffer[..read])
.map_err(|error| AppError::Io(error.to_string()))?;
bytes_written += read as u64;
}
file.flush().map_err(|error| AppError::Io(error.to_string()))?;
if bytes_written < model.size_bytes {
let _ = fs::remove_file(&temp_path);
return Err(AppError::InvalidModel(format!(
"download incompleto: {bytes_written} de {} bytes",
model.size_bytes
)));
}
fs::rename(&temp_path, &final_path).map_err(|error| AppError::Io(error.to_string()))?;
Ok(ModelDownloadResult {
model_id: model.id,
path: final_path,
bytes_written,
})
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
#[test]
fn catalog_contains_parakeet_q8_model() {
let model = default_model();
assert_eq!(model.repository, "cstr/parakeet-tdt-0.6b-v3-GGUF");
assert_eq!(model.filename, "parakeet-tdt-0.6b-v3-q8_0.gguf");
assert_eq!(model.quantization, "Q8_0");
assert_eq!(model.download_url, DEFAULT_MODEL_URL);
assert!(model.languages.contains(&"pt".to_string()));
}
#[test]
fn status_detects_ready_model_by_size() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("transcript-model-test-{unique}"));
fs::create_dir_all(&dir).unwrap_or_else(|error| panic!("create temp dir: {error}"));
let path = dir.join("model.gguf");
fs::write(&path, [1_u8, 2, 3, 4]).unwrap_or_else(|error| panic!("write model: {error}"));
assert_eq!(status_for_path(&path, 4), ModelInstallStatus::Ready);
assert!(matches!(status_for_path(&path, 5), ModelInstallStatus::Error(_)));
let _ = fs::remove_file(path);
let _ = fs::remove_dir(dir);
}
}