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
@@ -1,3 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -11,6 +12,7 @@ pub struct ManagedAppState {
|
||||
pub settings: Mutex<AppSettings>,
|
||||
pub recording: Mutex<RecordingSession>,
|
||||
pub transcript: Mutex<TranscriptDraft>,
|
||||
pub model_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -35,6 +37,7 @@ impl Default for ManagedAppState {
|
||||
settings: Mutex::new(AppSettings::default()),
|
||||
recording: Mutex::new(RecordingSession::default()),
|
||||
transcript: Mutex::new(TranscriptDraft::default()),
|
||||
model_dir: default_model_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,11 +65,17 @@ impl ManagedAppState {
|
||||
settings,
|
||||
recording_status,
|
||||
transcript,
|
||||
models: model_statuses(),
|
||||
models: model_statuses(&self.model_dir),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn default_model_dir() -> PathBuf {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir())
|
||||
.join("models")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -18,6 +18,16 @@ pub enum AppError {
|
||||
Unsupported(String),
|
||||
#[error("erro de configuração: {0}")]
|
||||
Configuration(String),
|
||||
#[error("erro de E/S: {0}")]
|
||||
Io(String),
|
||||
#[error("erro de rede: {0}")]
|
||||
Network(String),
|
||||
#[error("modelo offline inválido: {0}")]
|
||||
InvalidModel(String),
|
||||
#[error("erro de áudio: {0}")]
|
||||
Audio(String),
|
||||
#[error("transcrição falhou: {0}")]
|
||||
Transcription(String),
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn run() {
|
||||
ui_commands::copy_ready_text,
|
||||
ui_commands::request_insert_text,
|
||||
ui_commands::list_models,
|
||||
ui_commands::download_default_model_command,
|
||||
ui_commands::get_settings,
|
||||
ui_commands::save_settings
|
||||
])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use tauri::State;
|
||||
|
||||
use crate::app_state::{AppSnapshot, ManagedAppState};
|
||||
use crate::injection::{FallbackInjector, InsertMethod, InsertRequest, InsertResult, TextInjector};
|
||||
use crate::models::{model_statuses, ModelWithStatus};
|
||||
use crate::models::{download_default_model, model_statuses, ModelDownloadResult, ModelWithStatus};
|
||||
use crate::settings::AppSettings;
|
||||
use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber};
|
||||
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
||||
@@ -109,8 +109,15 @@ pub(crate) fn request_insert_text(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_models() -> Vec<ModelWithStatus> {
|
||||
model_statuses()
|
||||
pub(crate) fn list_models(state: State<'_, ManagedAppState>) -> Vec<ModelWithStatus> {
|
||||
model_statuses(&state.model_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn download_default_model_command(
|
||||
state: State<'_, ManagedAppState>,
|
||||
) -> Result<ModelDownloadResult, String> {
|
||||
download_default_model(&state.model_dir).map_err(String::from)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
Reference in new issue
Block a user