use futures_util::StreamExt; use serde::Serialize; use std::fs; use std::path::PathBuf; use tauri::{AppHandle, Emitter, Manager}; pub struct CatalogEntry { pub id: &'static str, pub label: &'static str, pub filename: &'static str, pub size_mb: u32, pub multilingual: bool, pub description: &'static str, } const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"; pub const CATALOG: &[CatalogEntry] = &[ CatalogEntry { id: "tiny.en", label: "Tiny (English)", filename: "ggml-tiny.en.bin", size_mb: 75, multilingual: false, description: "Fastest, lowest accuracy. Good for quick notes on weak hardware.", }, CatalogEntry { id: "base.en", label: "Base (English)", filename: "ggml-base.en.bin", size_mb: 142, multilingual: false, description: "Fast with decent accuracy. A sensible default for English dictation.", }, CatalogEntry { id: "small.en", label: "Small (English)", filename: "ggml-small.en.bin", size_mb: 466, multilingual: false, description: "Noticeably better accuracy, still responsive on modern CPUs.", }, CatalogEntry { id: "tiny", label: "Tiny (Multilingual)", filename: "ggml-tiny.bin", size_mb: 75, multilingual: true, description: "Fastest multilingual model.", }, CatalogEntry { id: "base", label: "Base (Multilingual)", filename: "ggml-base.bin", size_mb: 142, multilingual: true, description: "Fast multilingual dictation.", }, CatalogEntry { id: "small", label: "Small (Multilingual)", filename: "ggml-small.bin", size_mb: 466, multilingual: true, description: "Good multilingual accuracy for daily use.", }, CatalogEntry { id: "medium", label: "Medium (Multilingual)", filename: "ggml-medium.bin", size_mb: 1530, multilingual: true, description: "High accuracy, slower. Best with a strong CPU.", }, CatalogEntry { id: "large-v3-turbo-q5_0", label: "Large v3 Turbo Q5", filename: "ggml-large-v3-turbo-q5_0.bin", size_mb: 574, multilingual: true, description: "Quantized large-v3-turbo. Excellent accuracy for the size, the sweet spot for CPU inference.", }, CatalogEntry { id: "large-v3-turbo", label: "Large v3 Turbo", filename: "ggml-large-v3-turbo.bin", size_mb: 1620, multilingual: true, description: "Near large-v3 accuracy at much higher speed.", }, CatalogEntry { id: "large-v3", label: "Large v3", filename: "ggml-large-v3.bin", size_mb: 3100, multilingual: true, description: "Best accuracy whisper.cpp offers. Heavy; expect slower turnaround on CPU.", }, ]; #[derive(Serialize, Clone)] pub struct ModelInfo { pub id: String, pub label: String, pub filename: String, pub size_mb: u32, pub multilingual: bool, pub description: String, pub installed: bool, } #[derive(Serialize, Clone)] pub struct DownloadProgress { pub id: String, pub downloaded: u64, pub total: u64, pub done: bool, } pub fn models_dir(app: &AppHandle) -> Result { let dir = app .path() .app_data_dir() .map_err(|e| format!("no data dir: {e}"))? .join("models"); fs::create_dir_all(&dir).map_err(|e| format!("create models dir: {e}"))?; Ok(dir) } pub fn model_path(app: &AppHandle, id: &str) -> Result { let entry = CATALOG .iter() .find(|m| m.id == id) .ok_or_else(|| format!("unknown model id: {id}"))?; Ok(models_dir(app)?.join(entry.filename)) } pub fn list(app: &AppHandle) -> Result, String> { let dir = models_dir(app)?; Ok(CATALOG .iter() .map(|m| ModelInfo { id: m.id.into(), label: m.label.into(), filename: m.filename.into(), size_mb: m.size_mb, multilingual: m.multilingual, description: m.description.into(), installed: dir.join(m.filename).exists(), }) .collect()) } pub async fn download(app: AppHandle, id: String) -> Result<(), String> { let entry = CATALOG .iter() .find(|m| m.id == id) .ok_or_else(|| format!("unknown model id: {id}"))?; let dest = models_dir(&app)?.join(entry.filename); let part = dest.with_extension("bin.part"); let url = format!("{HF_BASE}/{}", entry.filename); let resp = reqwest::get(&url) .await .map_err(|e| format!("download failed: {e}"))? .error_for_status() .map_err(|e| format!("download failed: {e}"))?; let total = resp.content_length().unwrap_or(0); let mut file = tokio::fs::File::create(&part) .await .map_err(|e| format!("create file: {e}"))?; let mut stream = resp.bytes_stream(); let mut downloaded: u64 = 0; let mut last_emit: u64 = 0; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| format!("download interrupted: {e}"))?; tokio::io::AsyncWriteExt::write_all(&mut file, &chunk) .await .map_err(|e| format!("write failed: {e}"))?; downloaded += chunk.len() as u64; if downloaded - last_emit > 2_000_000 { last_emit = downloaded; let _ = app.emit( "model-progress", DownloadProgress { id: id.clone(), downloaded, total, done: false, }, ); } } drop(file); fs::rename(&part, &dest).map_err(|e| format!("finalize download: {e}"))?; let _ = app.emit( "model-progress", DownloadProgress { id: id.clone(), downloaded, total, done: true, }, ); Ok(()) } pub fn delete(app: &AppHandle, id: &str) -> Result<(), String> { let path = model_path(app, id)?; if path.exists() { fs::remove_file(path).map_err(|e| format!("delete model: {e}"))?; } Ok(()) }