feat: add offline model manager
This commit is contained in:
1 parent
a88206db2b
commit
f324566b42
9 files changed
+1094
-21
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user