fix: sha256 verification to prevent corrupt partial download loop (#1095)
* fix: add SHA256 verification to prevent corrupt partial download loop Fixes issue #858 — corrupt .partial files caused an infinite loop where a failed/cancelled download would resume from EOF, pass extraction, and loop forever with no way to recover without manual file deletion. Three-part fix: - Add sha2 crate and compute_sha256() helper; verify hash post-download before extraction/rename for all 15 built-in models. Hash mismatch deletes the .partial so the next attempt starts fresh. - Delete .partial on extraction failure (independent of checksums) so corrupt archives can't re-enter the loop via the same path. - Emit model-download-failed event from the command handler so all callers (ModelsSettings, Onboarding) get consistent error feedback via a central store listener rather than per-caller return-value checks. SHA256 hashes hardcoded alongside each model's URL in ModelInfo. Custom models (sha256: None) skip verification silently. * chore: remove doc comment from sha256 field Doc comments on exported specta types propagate into bindings.ts as inline JSDoc — not useful in generated TS. Removing at the source. * test: add SHA256 verification unit tests Extracts inline verify logic into ModelManager::verify_sha256() and covers all four branches: - None expected hash → skipped (custom models) - Matching hash → ok, file kept - Mismatched hash → error returned, partial file deleted - Unreadable file → error returned Ensures corrupt partial downloads are always cleaned up so the next attempt starts fresh. * chore: cargo fmt * fix: clear cancel flag on sha256 failure, spawn_blocking for hash, fallback cleanup - remove cancel_flags entry on sha256 failure to prevent a stale flag from immediately cancelling the next retry attempt (HIGH) - add fallback state cleanup in modelStore when result.status != ok, so spinner clears even if model-download-failed event is missed (HIGH) - run compute_sha256 in spawn_blocking to avoid stalling the async executor while hashing large model files (up to 1.6 GB) (MEDIUM) * emit some more events for the ui to pick up * add translations + format --------- Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
parent
e35f0a714e
commit
58cda3f3b3
28 changed files with 386 additions and 62 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -2441,6 +2441,7 @@ dependencies = [
|
|||
"rustfft",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"signal-hook",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ chrono = "0.4"
|
|||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
tar = "0.4.44"
|
||||
flate2 = "1.0"
|
||||
sha2 = "0.10"
|
||||
transcribe-rs = { version = "0.3.2", features = ["whisper-cpp", "onnx"] }
|
||||
handy-keys = "0.2.4"
|
||||
ferrous-opencc = "0.2.3"
|
||||
|
|
|
|||
|
|
@ -24,13 +24,23 @@ pub async fn get_model_info(
|
|||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn download_model(
|
||||
app_handle: AppHandle,
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
model_manager
|
||||
let result = model_manager
|
||||
.download_model(&model_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
if let Err(ref error) = result {
|
||||
let _ = app_handle.emit(
|
||||
"model-download-failed",
|
||||
serde_json::json!({ "model_id": &model_id, "error": error }),
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ use flate2::read::GzDecoder;
|
|||
use futures_util::StreamExt;
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -34,6 +35,7 @@ pub struct ModelInfo {
|
|||
pub description: String,
|
||||
pub filename: String,
|
||||
pub url: Option<String>,
|
||||
pub sha256: Option<String>,
|
||||
pub size_mb: u64,
|
||||
pub is_downloaded: bool,
|
||||
pub is_downloading: bool,
|
||||
|
|
@ -57,6 +59,31 @@ pub struct DownloadProgress {
|
|||
pub percentage: f64,
|
||||
}
|
||||
|
||||
/// RAII guard that cleans up download state (`is_downloading` flag and cancel flag)
|
||||
/// when dropped, unless explicitly disarmed. This ensures consistent cleanup on
|
||||
/// every error path without requiring manual cleanup at each `?` or `return Err`.
|
||||
struct DownloadCleanup<'a> {
|
||||
available_models: &'a Mutex<HashMap<String, ModelInfo>>,
|
||||
cancel_flags: &'a Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
|
||||
model_id: String,
|
||||
disarmed: bool,
|
||||
}
|
||||
|
||||
impl<'a> Drop for DownloadCleanup<'a> {
|
||||
fn drop(&mut self) {
|
||||
if self.disarmed {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
if let Some(model) = models.get_mut(self.model_id.as_str()) {
|
||||
model.is_downloading = false;
|
||||
}
|
||||
}
|
||||
self.cancel_flags.lock().unwrap().remove(&self.model_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ModelManager {
|
||||
app_handle: AppHandle,
|
||||
models_dir: PathBuf,
|
||||
|
|
@ -103,6 +130,9 @@ impl ModelManager {
|
|||
description: "Fast and fairly accurate.".to_string(),
|
||||
filename: "ggml-small.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-small.bin".to_string()),
|
||||
sha256: Some(
|
||||
"1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b".to_string(),
|
||||
),
|
||||
size_mb: 487,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -128,6 +158,9 @@ impl ModelManager {
|
|||
description: "Good accuracy, medium speed".to_string(),
|
||||
filename: "whisper-medium-q4_1.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/whisper-medium-q4_1.bin".to_string()),
|
||||
sha256: Some(
|
||||
"79283fc1f9fe12ca3248543fbd54b73292164d8df5a16e095e2bceeaaabddf57".to_string(),
|
||||
),
|
||||
size_mb: 492, // Approximate size
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -152,6 +185,9 @@ impl ModelManager {
|
|||
description: "Balanced accuracy and speed.".to_string(),
|
||||
filename: "ggml-large-v3-turbo.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-large-v3-turbo.bin".to_string()),
|
||||
sha256: Some(
|
||||
"1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69".to_string(),
|
||||
),
|
||||
size_mb: 1600, // Approximate size
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -176,6 +212,9 @@ impl ModelManager {
|
|||
description: "Good accuracy, but slow.".to_string(),
|
||||
filename: "ggml-large-v3-q5_0.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-large-v3-q5_0.bin".to_string()),
|
||||
sha256: Some(
|
||||
"d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1".to_string(),
|
||||
),
|
||||
size_mb: 1100, // Approximate size
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -201,6 +240,9 @@ impl ModelManager {
|
|||
.to_string(),
|
||||
filename: "breeze-asr-q5_k.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/breeze-asr-q5_k.bin".to_string()),
|
||||
sha256: Some(
|
||||
"8efbf0ce8a3f50fe332b7617da787fb81354b358c288b008d3bdef8359df64c6".to_string(),
|
||||
),
|
||||
size_mb: 1080,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -226,6 +268,9 @@ impl ModelManager {
|
|||
description: "English only. The best model for English speakers.".to_string(),
|
||||
filename: "parakeet-tdt-0.6b-v2-int8".to_string(), // Directory name
|
||||
url: Some("https://blob.handy.computer/parakeet-v2-int8.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"ac9b9429984dd565b25097337a887bb7f0f8ac393573661c651f0e7d31563991".to_string(),
|
||||
),
|
||||
size_mb: 473, // Approximate size for int8 quantized model
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -260,6 +305,9 @@ impl ModelManager {
|
|||
description: "Fast and accurate. Supports 25 European languages.".to_string(),
|
||||
filename: "parakeet-tdt-0.6b-v3-int8".to_string(), // Directory name
|
||||
url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"43d37191602727524a7d8c6da0eef11c4ba24320f5b4730f1a2497befc2efa77".to_string(),
|
||||
),
|
||||
size_mb: 478, // Approximate size for int8 quantized model
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -284,6 +332,9 @@ impl ModelManager {
|
|||
description: "Very fast, English only. Handles accents well.".to_string(),
|
||||
filename: "moonshine-base".to_string(),
|
||||
url: Some("https://blob.handy.computer/moonshine-base.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"04bf6ab012cfceebd4ac7cf88c1b31d027bbdd3cd704649b692e2e935236b7e8".to_string(),
|
||||
),
|
||||
size_mb: 58,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -310,6 +361,9 @@ impl ModelManager {
|
|||
url: Some(
|
||||
"https://blob.handy.computer/moonshine-tiny-streaming-en.tar.gz".to_string(),
|
||||
),
|
||||
sha256: Some(
|
||||
"465addcfca9e86117415677dfdc98b21edc53537210333a3ecdb58509a80abaf".to_string(),
|
||||
),
|
||||
size_mb: 31,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -336,6 +390,9 @@ impl ModelManager {
|
|||
url: Some(
|
||||
"https://blob.handy.computer/moonshine-small-streaming-en.tar.gz".to_string(),
|
||||
),
|
||||
sha256: Some(
|
||||
"dbb3e1c1832bd88a4ac712f7449a136cc2c9a18c5fe33a12ed1b7cb1cfe9cdd5".to_string(),
|
||||
),
|
||||
size_mb: 100,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -362,6 +419,9 @@ impl ModelManager {
|
|||
url: Some(
|
||||
"https://blob.handy.computer/moonshine-medium-streaming-en.tar.gz".to_string(),
|
||||
),
|
||||
sha256: Some(
|
||||
"07a66f3bff1c77e75a2f637e5a263928a08baae3c29c4c053fc968a9a9373d13".to_string(),
|
||||
),
|
||||
size_mb: 192,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -394,6 +454,9 @@ impl ModelManager {
|
|||
.to_string(),
|
||||
filename: "sense-voice-int8".to_string(),
|
||||
url: Some("https://blob.handy.computer/sense-voice-int8.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"171d611fe5d353a50bbb741b6f3ef42559b1565685684e9aa888ef563ba3e8a4".to_string(),
|
||||
),
|
||||
size_mb: 160,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -421,6 +484,9 @@ impl ModelManager {
|
|||
description: "Russian speech recognition. Fast and accurate.".to_string(),
|
||||
filename: "giga-am-v3-int8".to_string(),
|
||||
url: Some("https://blob.handy.computer/giga-am-v3-int8.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"d872462268430db140b69b72e0fc4b787b194c1dbe51b58de39444d55b6da45b".to_string(),
|
||||
),
|
||||
size_mb: 152,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -452,6 +518,9 @@ impl ModelManager {
|
|||
.to_string(),
|
||||
filename: "canary-180m-flash".to_string(),
|
||||
url: Some("https://blob.handy.computer/canary-180m-flash.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"6d9cfca6118b296e196eaedc1c8fa9788305a7b0f1feafdb6dc91932ab6e53f7".to_string(),
|
||||
),
|
||||
size_mb: 146,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -486,6 +555,9 @@ impl ModelManager {
|
|||
.to_string(),
|
||||
filename: "canary-1b-v2".to_string(),
|
||||
url: Some("https://blob.handy.computer/canary-1b-v2.tar.gz".to_string()),
|
||||
sha256: Some(
|
||||
"02305b2a25f9cf3e7deaffa7f94df00efa44f442cd55c101c2cb9c000f904666".to_string(),
|
||||
),
|
||||
size_mb: 692,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -804,7 +876,8 @@ impl ModelManager {
|
|||
name: display_name,
|
||||
description: "Not officially supported".to_string(),
|
||||
filename,
|
||||
url: None, // Custom models have no download URL
|
||||
url: None, // Custom models have no download URL
|
||||
sha256: None, // Custom models skip verification
|
||||
size_mb,
|
||||
is_downloaded: true, // Already present on disk
|
||||
is_downloading: false,
|
||||
|
|
@ -825,6 +898,56 @@ impl ModelManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies the SHA256 of `path` against `expected_sha256` (if provided).
|
||||
/// On mismatch or read error the partial file is deleted and an error is returned,
|
||||
/// so the next download attempt always starts from a clean state.
|
||||
/// When `expected_sha256` is `None` (custom user models) verification is skipped.
|
||||
fn verify_sha256(path: &Path, expected_sha256: Option<&str>, model_id: &str) -> Result<()> {
|
||||
let Some(expected) = expected_sha256 else {
|
||||
return Ok(());
|
||||
};
|
||||
match Self::compute_sha256(path) {
|
||||
Ok(actual) if actual == expected => {
|
||||
info!("SHA256 verified for model {}", model_id);
|
||||
Ok(())
|
||||
}
|
||||
Ok(actual) => {
|
||||
warn!(
|
||||
"SHA256 mismatch for model {}: expected {}, got {}",
|
||||
model_id, expected, actual
|
||||
);
|
||||
let _ = fs::remove_file(path);
|
||||
Err(anyhow::anyhow!(
|
||||
"Download verification failed for model {}: file is corrupt. Please retry.",
|
||||
model_id
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(path);
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to verify download for model {}: {}. Please retry.",
|
||||
model_id,
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the SHA256 hex digest of a file, reading in 64KB chunks to handle large models.
|
||||
fn compute_sha256(path: &Path) -> Result<String> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 65536];
|
||||
loop {
|
||||
let n = file.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
pub async fn download_model(&self, model_id: &str) -> Result<()> {
|
||||
let model_info = {
|
||||
let models = self.available_models.lock().unwrap();
|
||||
|
|
@ -877,6 +1000,15 @@ impl ModelManager {
|
|||
flags.insert(model_id.to_string(), cancel_flag.clone());
|
||||
}
|
||||
|
||||
// Guard ensures is_downloading and cancel_flags are cleaned up on every
|
||||
// error path. Disarmed only on success (which sets is_downloaded = true).
|
||||
let mut cleanup = DownloadCleanup {
|
||||
available_models: &self.available_models,
|
||||
cancel_flags: &self.cancel_flags,
|
||||
model_id: model_id.to_string(),
|
||||
disarmed: false,
|
||||
};
|
||||
|
||||
// Create HTTP client with range request for resuming
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.get(&url);
|
||||
|
|
@ -909,13 +1041,6 @@ impl ModelManager {
|
|||
if !response.status().is_success()
|
||||
&& response.status() != reqwest::StatusCode::PARTIAL_CONTENT
|
||||
{
|
||||
// Mark as not downloading on error
|
||||
{
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
if let Some(model) = models.get_mut(model_id) {
|
||||
model.is_downloading = false;
|
||||
}
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to download model: HTTP {}",
|
||||
response.status()
|
||||
|
|
@ -965,38 +1090,14 @@ impl ModelManager {
|
|||
while let Some(chunk) = stream.next().await {
|
||||
// Check if download was cancelled
|
||||
if cancel_flag.load(Ordering::Relaxed) {
|
||||
// Close the file before returning
|
||||
drop(file);
|
||||
info!("Download cancelled for: {}", model_id);
|
||||
|
||||
// Update state to mark as not downloading
|
||||
{
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
if let Some(model) = models.get_mut(model_id) {
|
||||
model.is_downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove cancel flag
|
||||
{
|
||||
let mut flags = self.cancel_flags.lock().unwrap();
|
||||
flags.remove(model_id);
|
||||
}
|
||||
|
||||
// Keep partial file for resume functionality
|
||||
// Keep partial file for resume functionality.
|
||||
// Guard handles is_downloading + cancel_flags cleanup on drop.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chunk = chunk.map_err(|e| {
|
||||
// Mark as not downloading on error
|
||||
{
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
if let Some(model) = models.get_mut(model_id) {
|
||||
model.is_downloading = false;
|
||||
}
|
||||
}
|
||||
e
|
||||
})?;
|
||||
let chunk = chunk?;
|
||||
|
||||
file.write_all(&chunk)?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
|
@ -1044,12 +1145,6 @@ impl ModelManager {
|
|||
if actual_size != total_size {
|
||||
// Download is incomplete/corrupted - delete partial and return error
|
||||
let _ = fs::remove_file(&partial_path);
|
||||
{
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
if let Some(model) = models.get_mut(model_id) {
|
||||
model.is_downloading = false;
|
||||
}
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"Download incomplete: expected {} bytes, got {} bytes",
|
||||
total_size,
|
||||
|
|
@ -1058,6 +1153,24 @@ impl ModelManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Verify SHA256 checksum. Runs in a blocking thread so the async executor is not
|
||||
// stalled while hashing large model files (up to 1.6 GB). On failure the partial
|
||||
// is deleted inside verify_sha256 so the next attempt always starts fresh.
|
||||
let _ = self.app_handle.emit("model-verification-started", model_id);
|
||||
info!("Verifying SHA256 for model {}...", model_id);
|
||||
let verify_path = partial_path.clone();
|
||||
let verify_expected = model_info.sha256.clone();
|
||||
let verify_model_id = model_id.to_string();
|
||||
let verify_result = tokio::task::spawn_blocking(move || {
|
||||
Self::verify_sha256(&verify_path, verify_expected.as_deref(), &verify_model_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("SHA256 task panicked: {}", e))?;
|
||||
verify_result?;
|
||||
let _ = self
|
||||
.app_handle
|
||||
.emit("model-verification-completed", model_id);
|
||||
|
||||
// Handle directory-based models (extract tar.gz) vs file-based models
|
||||
if model_info.is_directory {
|
||||
// Track that this model is being extracted
|
||||
|
|
@ -1094,6 +1207,9 @@ impl ModelManager {
|
|||
let error_msg = format!("Failed to extract archive: {}", e);
|
||||
// Clean up failed extraction
|
||||
let _ = fs::remove_dir_all(&temp_extract_dir);
|
||||
// Delete the corrupt partial file so the next download attempt starts fresh
|
||||
// instead of resuming from a broken archive (issue #858).
|
||||
let _ = fs::remove_file(&partial_path);
|
||||
// Remove from extracting set
|
||||
{
|
||||
let mut extracting = self.extracting_models.lock().unwrap();
|
||||
|
|
@ -1148,7 +1264,9 @@ impl ModelManager {
|
|||
fs::rename(&partial_path, &model_path)?;
|
||||
}
|
||||
|
||||
// Update download status
|
||||
// Disarm the guard — success path does its own cleanup because it
|
||||
// additionally sets is_downloaded = true.
|
||||
cleanup.disarmed = true;
|
||||
{
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
if let Some(model) = models.get_mut(model_id) {
|
||||
|
|
@ -1157,12 +1275,7 @@ impl ModelManager {
|
|||
model.partial_size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove cancel flag on successful completion
|
||||
{
|
||||
let mut flags = self.cancel_flags.lock().unwrap();
|
||||
flags.remove(model_id);
|
||||
}
|
||||
self.cancel_flags.lock().unwrap().remove(model_id);
|
||||
|
||||
// Emit completion event
|
||||
let _ = self.app_handle.emit("model-download-complete", model_id);
|
||||
|
|
@ -1357,6 +1470,7 @@ mod tests {
|
|||
description: "Test".to_string(),
|
||||
filename: "ggml-small.bin".to_string(),
|
||||
url: Some("https://example.com".to_string()),
|
||||
sha256: None,
|
||||
size_mb: 100,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
|
|
@ -1427,4 +1541,73 @@ mod tests {
|
|||
assert!(result.is_ok());
|
||||
assert_eq!(models.len(), count_before);
|
||||
}
|
||||
|
||||
// ── SHA256 verification tests ─────────────────────────────────────────────
|
||||
|
||||
/// Helper: write `data` to a temp file and return (TempDir, path).
|
||||
/// TempDir must be kept alive for the duration of the test.
|
||||
fn write_temp_file(data: &[u8]) -> (TempDir, std::path::PathBuf) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("model.partial");
|
||||
let mut f = File::create(&path).unwrap();
|
||||
f.write_all(data).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_sha256_skipped_when_none() {
|
||||
// Custom models have no expected hash — verification must be a no-op.
|
||||
let (_dir, path) = write_temp_file(b"anything");
|
||||
assert!(ModelManager::verify_sha256(&path, None, "custom").is_ok());
|
||||
assert!(
|
||||
path.exists(),
|
||||
"file must be untouched when verification is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_sha256_passes_on_correct_hash() {
|
||||
// Compute the real hash so the test is self-consistent.
|
||||
let (_dir, path) = write_temp_file(b"hello world");
|
||||
let actual = ModelManager::compute_sha256(&path).unwrap();
|
||||
assert!(
|
||||
ModelManager::verify_sha256(&path, Some(&actual), "test_model").is_ok(),
|
||||
"should pass when hash matches"
|
||||
);
|
||||
assert!(
|
||||
path.exists(),
|
||||
"file must be kept on successful verification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_sha256_fails_and_deletes_partial_on_mismatch() {
|
||||
let (_dir, path) = write_temp_file(b"this is not the real model");
|
||||
let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
let result = ModelManager::verify_sha256(&path, Some(wrong_hash), "bad_model");
|
||||
|
||||
assert!(result.is_err(), "mismatch must return an error");
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("corrupt"),
|
||||
"error message should mention corruption"
|
||||
);
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"partial file must be deleted after hash mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_sha256_fails_and_deletes_partial_when_file_missing() {
|
||||
// Simulate a partial file that was already removed (e.g. disk full mid-download).
|
||||
let dir = TempDir::new().unwrap();
|
||||
let missing_path = dir.path().join("gone.partial");
|
||||
// Don't create the file — it should not exist.
|
||||
|
||||
let result =
|
||||
ModelManager::verify_sha256(&missing_path, Some("anyexpectedhash"), "missing_model");
|
||||
|
||||
assert!(result.is_err(), "missing file must return an error");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -821,7 +821,7 @@ reset_bindings: string[] }
|
|||
export type KeyboardImplementation = "tauri" | "handy_keys"
|
||||
export type LLMPrompt = { id: string; name: string; prompt: string }
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
|
||||
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean }
|
||||
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; sha256: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean }
|
||||
export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null }
|
||||
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15"
|
||||
export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type ModelStatus =
|
|||
| "ready"
|
||||
| "loading"
|
||||
| "downloading"
|
||||
| "verifying"
|
||||
| "extracting"
|
||||
| "error"
|
||||
| "unloaded"
|
||||
|
|
@ -30,6 +31,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
currentModel,
|
||||
downloadProgress,
|
||||
downloadStats,
|
||||
verifyingModels,
|
||||
extractingModels,
|
||||
selectModel,
|
||||
} = useModelStore();
|
||||
|
|
@ -152,6 +154,20 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
};
|
||||
|
||||
const getModelDisplayText = (): string => {
|
||||
const verifyingKeys = Object.keys(verifyingModels);
|
||||
if (verifyingKeys.length > 0) {
|
||||
if (verifyingKeys.length === 1) {
|
||||
const modelId = verifyingKeys[0];
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
const modelName = model
|
||||
? getTranslatedModelName(model, t)
|
||||
: t("modelSelector.verifyingGeneric").replace("...", "");
|
||||
return t("modelSelector.verifying", { modelName });
|
||||
} else {
|
||||
return t("modelSelector.verifyingGeneric");
|
||||
}
|
||||
}
|
||||
|
||||
const extractingKeys = Object.keys(extractingModels);
|
||||
if (extractingKeys.length > 0) {
|
||||
if (extractingKeys.length === 1) {
|
||||
|
|
@ -220,6 +236,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
|||
|
||||
// Derive display status from model status + store state
|
||||
const getDisplayStatus = (): ModelStatus => {
|
||||
if (Object.keys(verifyingModels).length > 0) return "verifying";
|
||||
if (Object.keys(extractingModels).length > 0) return "extracting";
|
||||
if (Object.keys(downloadProgress).length > 0) return "downloading";
|
||||
return modelStatus;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ type ModelStatus =
|
|||
| "ready"
|
||||
| "loading"
|
||||
| "downloading"
|
||||
| "verifying"
|
||||
| "extracting"
|
||||
| "error"
|
||||
| "unloaded"
|
||||
|
|
@ -32,6 +33,8 @@ const ModelStatusButton: React.FC<ModelStatusButtonProps> = ({
|
|||
return "bg-yellow-400 animate-pulse";
|
||||
case "downloading":
|
||||
return "bg-logo-primary animate-pulse";
|
||||
case "verifying":
|
||||
return "bg-orange-400 animate-pulse";
|
||||
case "extracting":
|
||||
return "bg-orange-400 animate-pulse";
|
||||
case "error":
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const getLanguageDisplayText = (
|
|||
export type ModelCardStatus =
|
||||
| "downloadable"
|
||||
| "downloading"
|
||||
| "verifying"
|
||||
| "extracting"
|
||||
| "switching"
|
||||
| "active"
|
||||
|
|
@ -277,6 +278,16 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === "verifying" && (
|
||||
<div className="w-full mt-3">
|
||||
<div className="w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-logo-primary rounded-full animate-pulse w-full" />
|
||||
</div>
|
||||
<p className="text-xs text-text/50 mt-1">
|
||||
{t("modelSelector.verifyingGeneric")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === "extracting" && (
|
||||
<div className="w-full mt-3">
|
||||
<div className="w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
|||
downloadModel,
|
||||
selectModel,
|
||||
downloadingModels,
|
||||
verifyingModels,
|
||||
extractingModels,
|
||||
downloadProgress,
|
||||
downloadStats,
|
||||
|
|
@ -26,15 +27,21 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
|||
|
||||
const isDownloading = selectedModelId !== null;
|
||||
|
||||
// Watch for the selected model to finish downloading + extracting
|
||||
// Watch for the selected model to finish downloading + verifying + extracting
|
||||
useEffect(() => {
|
||||
if (!selectedModelId) return;
|
||||
|
||||
const model = models.find((m) => m.id === selectedModelId);
|
||||
const stillDownloading = selectedModelId in downloadingModels;
|
||||
const stillVerifying = selectedModelId in verifyingModels;
|
||||
const stillExtracting = selectedModelId in extractingModels;
|
||||
|
||||
if (model?.is_downloaded && !stillDownloading && !stillExtracting) {
|
||||
if (
|
||||
model?.is_downloaded &&
|
||||
!stillDownloading &&
|
||||
!stillVerifying &&
|
||||
!stillExtracting
|
||||
) {
|
||||
// Model is ready — select it and transition
|
||||
selectModel(selectedModelId).then((success) => {
|
||||
if (success) {
|
||||
|
|
@ -49,6 +56,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
|||
selectedModelId,
|
||||
models,
|
||||
downloadingModels,
|
||||
verifyingModels,
|
||||
extractingModels,
|
||||
selectModel,
|
||||
onModelSelected,
|
||||
|
|
@ -57,15 +65,17 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
|||
const handleDownloadModel = async (modelId: string) => {
|
||||
setSelectedModelId(modelId);
|
||||
|
||||
// Error toast is handled centrally by the model-download-failed event listener
|
||||
// in modelStore — no toast here to avoid duplicates.
|
||||
const success = await downloadModel(modelId);
|
||||
if (!success) {
|
||||
toast.error(t("onboarding.downloadFailed"));
|
||||
setSelectedModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getModelStatus = (modelId: string): ModelCardStatus => {
|
||||
if (modelId in extractingModels) return "extracting";
|
||||
if (modelId in verifyingModels) return "verifying";
|
||||
if (modelId in downloadingModels) return "downloading";
|
||||
return "downloadable";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const ModelsSettings: React.FC = () => {
|
|||
downloadingModels,
|
||||
downloadProgress,
|
||||
downloadStats,
|
||||
verifyingModels,
|
||||
extractingModels,
|
||||
loading,
|
||||
downloadModel,
|
||||
|
|
@ -78,6 +79,9 @@ export const ModelsSettings: React.FC = () => {
|
|||
if (modelId in extractingModels) {
|
||||
return "extracting";
|
||||
}
|
||||
if (modelId in verifyingModels) {
|
||||
return "verifying";
|
||||
}
|
||||
if (modelId in downloadingModels) {
|
||||
return "downloading";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
"custom": "مخصص",
|
||||
"active": "نشط",
|
||||
"noModelsAvailable": "لا توجد نماذج متاحة",
|
||||
"verifying": "جارٍ التحقق من {{modelName}}...",
|
||||
"verifyingGeneric": "جارٍ التحقق...",
|
||||
"extracting": "...جاري استخراج {{modelName}}",
|
||||
"extractingMultiple": "...جاري استخراج {{count}} من النماذج",
|
||||
"extractingGeneric": "...جاري الاستخراج",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
"custom": "Vlastní",
|
||||
"active": "Aktivní",
|
||||
"noModelsAvailable": "Nejsou dostupné žádné modely",
|
||||
"verifying": "Ověřování {{modelName}}...",
|
||||
"verifyingGeneric": "Ověřování...",
|
||||
"extracting": "Rozbaluji {{modelName}}...",
|
||||
"extractingMultiple": "Rozbaluji {{count}} modelů...",
|
||||
"extractingGeneric": "Rozbalování...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Aktiv",
|
||||
"switching": "Wechseln...",
|
||||
"noModelsAvailable": "Keine Modelle verfügbar",
|
||||
"verifying": "Überprüfung von {{modelName}}...",
|
||||
"verifyingGeneric": "Überprüfung...",
|
||||
"extracting": "Entpacke {{modelName}}...",
|
||||
"extractingMultiple": "Entpacke {{count}} Modelle...",
|
||||
"extractingGeneric": "Wird entpackt...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Active",
|
||||
"switching": "Switching...",
|
||||
"noModelsAvailable": "No models available",
|
||||
"verifying": "Verifying {{modelName}}...",
|
||||
"verifyingGeneric": "Verifying...",
|
||||
"extracting": "Extracting {{modelName}}...",
|
||||
"extractingMultiple": "Extracting {{count}} models...",
|
||||
"extractingGeneric": "Extracting...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Activo",
|
||||
"switching": "Cambiando...",
|
||||
"noModelsAvailable": "No hay modelos disponibles",
|
||||
"verifying": "Verificando {{modelName}}...",
|
||||
"verifyingGeneric": "Verificando...",
|
||||
"extracting": "Extrayendo {{modelName}}...",
|
||||
"extractingMultiple": "Extrayendo {{count}} modelos...",
|
||||
"extractingGeneric": "Extrayendo...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Actif",
|
||||
"switching": "Changement...",
|
||||
"noModelsAvailable": "Aucun modèle disponible",
|
||||
"verifying": "Vérification de {{modelName}}...",
|
||||
"verifyingGeneric": "Vérification...",
|
||||
"extracting": "Extraction de {{modelName}}...",
|
||||
"extractingMultiple": "Extraction de {{count}} modèles...",
|
||||
"extractingGeneric": "Extraction...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Attivo",
|
||||
"switching": "Cambio...",
|
||||
"noModelsAvailable": "Nessun modello disponibile",
|
||||
"verifying": "Verifica di {{modelName}}...",
|
||||
"verifyingGeneric": "Verifica...",
|
||||
"extracting": "Estrazione di {{modelName}}...",
|
||||
"extractingMultiple": "Estrazione di {{count}} modelli...",
|
||||
"extractingGeneric": "Estrazione...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "アクティブ",
|
||||
"switching": "切替中...",
|
||||
"noModelsAvailable": "利用可能なモデルがありません",
|
||||
"verifying": "{{modelName}}を検証中...",
|
||||
"verifyingGeneric": "検証中...",
|
||||
"extracting": "{{modelName}}を展開中...",
|
||||
"extractingMultiple": "{{count}}個のモデルを展開中...",
|
||||
"extractingGeneric": "展開中...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "활성",
|
||||
"switching": "전환 중...",
|
||||
"noModelsAvailable": "사용 가능한 모델이 없습니다",
|
||||
"verifying": "{{modelName}} 검증 중...",
|
||||
"verifyingGeneric": "검증 중...",
|
||||
"extracting": "{{modelName}} 압축 해제 중...",
|
||||
"extractingMultiple": "{{count}}개 모델 압축 해제 중...",
|
||||
"extractingGeneric": "압축 해제 중...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Aktywny",
|
||||
"switching": "Przełączanie...",
|
||||
"noModelsAvailable": "Brak dostępnych modeli",
|
||||
"verifying": "Weryfikacja {{modelName}}...",
|
||||
"verifyingGeneric": "Weryfikacja...",
|
||||
"extracting": "Rozpakowywanie {{modelName}}...",
|
||||
"extractingMultiple": "Rozpakowywanie {{count}} modeli...",
|
||||
"extractingGeneric": "Rozpakowywanie...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
"custom": "Personalizado",
|
||||
"active": "Ativo",
|
||||
"noModelsAvailable": "Nenhum modelo disponível",
|
||||
"verifying": "Verificando {{modelName}}...",
|
||||
"verifyingGeneric": "Verificando...",
|
||||
"extracting": "Extraindo {{modelName}}...",
|
||||
"extractingMultiple": "Extraindo {{count}} modelos...",
|
||||
"extractingGeneric": "Extraindo...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Активный",
|
||||
"switching": "Переключение...",
|
||||
"noModelsAvailable": "Нет доступных моделей",
|
||||
"verifying": "Проверка {{modelName}}...",
|
||||
"verifyingGeneric": "Проверка...",
|
||||
"extracting": "Извлечение {{modelName}}...",
|
||||
"extractingMultiple": "Извлечение моделей {{count}}...",
|
||||
"extractingGeneric": "Извлечение...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
"custom": "Özel",
|
||||
"active": "Aktif",
|
||||
"noModelsAvailable": "Kullanılabilir model yok",
|
||||
"verifying": "{{modelName}} doğrulanıyor...",
|
||||
"verifyingGeneric": "Doğrulanıyor...",
|
||||
"extracting": "{{modelName}} çıkarılıyor...",
|
||||
"extractingMultiple": "{{count}} model çıkarılıyor...",
|
||||
"extractingGeneric": "Çıkarılıyor...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
"custom": "Користувацька",
|
||||
"active": "Активна",
|
||||
"noModelsAvailable": "Немає доступних моделей",
|
||||
"verifying": "Перевірка {{modelName}}...",
|
||||
"verifyingGeneric": "Перевірка...",
|
||||
"extracting": "Розпакування {{modelName}}...",
|
||||
"extractingMultiple": "Розпакування {{count}} моделей...",
|
||||
"extractingGeneric": "Розпакування...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "Đang hoạt động",
|
||||
"switching": "Đang chuyển...",
|
||||
"noModelsAvailable": "Không có mô hình nào",
|
||||
"verifying": "Đang xác minh {{modelName}}...",
|
||||
"verifyingGeneric": "Đang xác minh...",
|
||||
"extracting": "Đang giải nén {{modelName}}...",
|
||||
"extractingMultiple": "Đang giải nén {{count}} mô hình...",
|
||||
"extractingGeneric": "Đang giải nén...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "使用中",
|
||||
"switching": "切換中...",
|
||||
"noModelsAvailable": "沒有可用的模型",
|
||||
"verifying": "正在驗證 {{modelName}}...",
|
||||
"verifyingGeneric": "正在驗證...",
|
||||
"extracting": "正在解壓 {{modelName}}...",
|
||||
"extractingMultiple": "正在解壓 {{count}} 個模型...",
|
||||
"extractingGeneric": "解壓中...",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
"active": "活跃",
|
||||
"switching": "切换中...",
|
||||
"noModelsAvailable": "没有可用的模型",
|
||||
"verifying": "正在验证 {{modelName}}...",
|
||||
"verifyingGeneric": "正在验证...",
|
||||
"extracting": "正在解压 {{modelName}}...",
|
||||
"extractingMultiple": "正在解压 {{count}} 个模型...",
|
||||
"extractingGeneric": "解压中...",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { subscribeWithSelector } from "zustand/middleware";
|
|||
import { produce } from "immer";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands, type ModelInfo } from "@/bindings";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DownloadProgress {
|
||||
model_id: string;
|
||||
|
|
@ -23,6 +24,7 @@ interface ModelsStore {
|
|||
models: ModelInfo[];
|
||||
currentModel: string;
|
||||
downloadingModels: Record<string, true>;
|
||||
verifyingModels: Record<string, true>;
|
||||
extractingModels: Record<string, true>;
|
||||
downloadProgress: Record<string, DownloadProgress>;
|
||||
downloadStats: Record<string, DownloadStats>;
|
||||
|
|
@ -43,6 +45,7 @@ interface ModelsStore {
|
|||
deleteModel: (modelId: string) => Promise<boolean>;
|
||||
getModelInfo: (modelId: string) => ModelInfo | undefined;
|
||||
isModelDownloading: (modelId: string) => boolean;
|
||||
isModelVerifying: (modelId: string) => boolean;
|
||||
isModelExtracting: (modelId: string) => boolean;
|
||||
getDownloadProgress: (modelId: string) => DownloadProgress | undefined;
|
||||
|
||||
|
|
@ -58,6 +61,7 @@ export const useModelStore = create<ModelsStore>()(
|
|||
models: [],
|
||||
currentModel: "",
|
||||
downloadingModels: {},
|
||||
verifyingModels: {},
|
||||
extractingModels: {},
|
||||
downloadProgress: {},
|
||||
downloadStats: {},
|
||||
|
|
@ -175,22 +179,27 @@ export const useModelStore = create<ModelsStore>()(
|
|||
}),
|
||||
);
|
||||
const result = await commands.downloadModel(modelId);
|
||||
if (result.status === "ok") {
|
||||
return true;
|
||||
} else {
|
||||
set({ error: `Failed to download model: ${result.error}` });
|
||||
if (result.status !== "ok") {
|
||||
// Fallback cleanup in case the model-download-failed event was not received
|
||||
// (e.g. listener not yet registered). The event handler is a no-op if it
|
||||
// arrives after this cleanup since deleting missing keys is safe.
|
||||
set(
|
||||
produce((state) => {
|
||||
delete state.downloadingModels[modelId];
|
||||
delete state.downloadProgress[modelId];
|
||||
delete state.downloadStats[modelId];
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
set({ error: `Failed to download model: ${err}` });
|
||||
return result.status === "ok";
|
||||
} catch {
|
||||
// model-download-failed event won't fire for JS exceptions (e.g. IPC error),
|
||||
// so clean up state here to avoid a stuck progress spinner.
|
||||
set(
|
||||
produce((state) => {
|
||||
delete state.downloadingModels[modelId];
|
||||
delete state.downloadProgress[modelId];
|
||||
delete state.downloadStats[modelId];
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
|
|
@ -249,6 +258,10 @@ export const useModelStore = create<ModelsStore>()(
|
|||
return modelId in get().downloadingModels;
|
||||
},
|
||||
|
||||
isModelVerifying: (modelId: string) => {
|
||||
return modelId in get().verifyingModels;
|
||||
},
|
||||
|
||||
isModelExtracting: (modelId: string) => {
|
||||
return modelId in get().extractingModels;
|
||||
},
|
||||
|
|
@ -316,6 +329,7 @@ export const useModelStore = create<ModelsStore>()(
|
|||
set(
|
||||
produce((state) => {
|
||||
delete state.downloadingModels[modelId];
|
||||
delete state.verifyingModels[modelId];
|
||||
delete state.downloadProgress[modelId];
|
||||
delete state.downloadStats[modelId];
|
||||
}),
|
||||
|
|
@ -323,6 +337,41 @@ export const useModelStore = create<ModelsStore>()(
|
|||
get().loadModels();
|
||||
});
|
||||
|
||||
listen<{ model_id: string; error: string }>(
|
||||
"model-download-failed",
|
||||
(event) => {
|
||||
const { model_id: modelId, error } = event.payload;
|
||||
set(
|
||||
produce((state) => {
|
||||
delete state.downloadingModels[modelId];
|
||||
delete state.verifyingModels[modelId];
|
||||
delete state.downloadProgress[modelId];
|
||||
delete state.downloadStats[modelId];
|
||||
state.error = error;
|
||||
}),
|
||||
);
|
||||
toast.error(error);
|
||||
},
|
||||
);
|
||||
|
||||
listen<string>("model-verification-started", (event) => {
|
||||
const modelId = event.payload;
|
||||
set(
|
||||
produce((state) => {
|
||||
state.verifyingModels[modelId] = true;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
listen<string>("model-verification-completed", (event) => {
|
||||
const modelId = event.payload;
|
||||
set(
|
||||
produce((state) => {
|
||||
delete state.verifyingModels[modelId];
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
listen<string>("model-extraction-started", (event) => {
|
||||
const modelId = event.payload;
|
||||
set(
|
||||
|
|
@ -360,6 +409,7 @@ export const useModelStore = create<ModelsStore>()(
|
|||
set(
|
||||
produce((state) => {
|
||||
delete state.downloadingModels[modelId];
|
||||
delete state.verifyingModels[modelId];
|
||||
delete state.downloadProgress[modelId];
|
||||
delete state.downloadStats[modelId];
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue