From 9036c050887bea482f21e95debf2a3c25fbf379b Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 30 Jun 2025 14:27:21 -0700 Subject: [PATCH] initial working version of multiple models + cleanup and onboarding --- .github/workflows/release.yml | 1 - README.md | 19 +- src-tauri/Cargo.lock | 331 +++++++++- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/models.rs | 115 ++++ src-tauri/src/lib.rs | 20 +- src-tauri/src/managers/mod.rs | 1 + src-tauri/src/managers/model.rs | 295 +++++++++ src-tauri/src/managers/transcription.rs | 167 ++++- src-tauri/src/settings.rs | 9 + src/App.tsx | 36 ++ src/components/footer/Footer.tsx | 167 +---- .../model-selector/ModelSelector.tsx | 604 ++++++++++++++++++ src/components/model-selector/index.ts | 1 + src/components/onboarding/Onboarding.tsx | 94 +++ src/components/onboarding/index.ts | 1 + .../update-checker/UpdateChecker.tsx | 176 +++++ src/components/update-checker/index.ts | 1 + src/hooks/useModels.ts | 186 ++++++ src/lib/types.ts | 10 + 21 files changed, 2026 insertions(+), 211 deletions(-) create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/models.rs create mode 100644 src-tauri/src/managers/model.rs create mode 100644 src/components/model-selector/ModelSelector.tsx create mode 100644 src/components/model-selector/index.ts create mode 100644 src/components/onboarding/Onboarding.tsx create mode 100644 src/components/onboarding/index.ts create mode 100644 src/components/update-checker/UpdateChecker.tsx create mode 100644 src/components/update-checker/index.ts create mode 100644 src/hooks/useModels.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fae455..0f607ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,6 @@ jobs: - name: Download model files run: | mkdir -p src-tauri/resources/models - curl -o src-tauri/resources/models/ggml-small.bin https://blob.handy.computer/ggml-small.bin curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx - name: install frontend dependencies diff --git a/README.md b/README.md index 60baf62..088f5c0 100644 --- a/README.md +++ b/README.md @@ -73,20 +73,23 @@ For development, you need to download the required model files: mkdir -p src-tauri/resources/models ``` -2. Download the required model files: +2. Download the required VAD model for development: ```bash - # Download Whisper model (Small model) - curl -o src-tauri/resources/models/ggml-small.bin https://blob.handy.computer/ggml-small.bin - - # Download Silero VAD model + # Download Silero VAD model (required for voice activity detection) curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx ``` -**Alternative Whisper Models:** + **Note:** Whisper models are no longer bundled with the app. Users will download their preferred model (Small, Medium, Turbo, or Large) from within the app on first run. -The `ggml-small.bin` file can be replaced with any GGML model for whisper.cpp. Available models can be found at [huggingface.co/ggerganov/whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main). +**Whisper Models:** -**Important:** If you use a different Whisper model, you'll need to update the filename reference in the manager's transcription code to match your chosen model file. +The app now supports dynamic model downloading and switching: +- **Small**: Fast, good for most use cases +- **Medium**: Better accuracy, balanced performance +- **Turbo**: Optimized large model with improved speed +- **Large**: Highest accuracy, slower processing + +Users can download and switch between models directly from the app's settings interface. No models are bundled with the app, reducing the initial download size. ## Architecture diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f4da146..fc4c599 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1995,6 +1995,25 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.7.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.4.1" @@ -2013,9 +2032,11 @@ dependencies = [ "cpal", "enigo", "env_logger", + "futures-util", "log", "once_cell", "rdev", + "reqwest 0.11.27", "rodio", "rubato", "serde", @@ -2100,6 +2121,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.2.0" @@ -2111,6 +2143,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2118,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -2129,8 +2172,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2140,12 +2183,42 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -2155,8 +2228,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -2172,8 +2245,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.2.0", + "hyper 1.6.0", "hyper-util", "rustls", "rustls-pki-types", @@ -2183,6 +2256,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.10" @@ -2192,9 +2278,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.6.0", "pin-project-lite", "socket2", "tokio", @@ -2918,6 +3004,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -3429,6 +3532,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.8.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4143,6 +4290,48 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.12" @@ -4153,10 +4342,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-rustls", "hyper-util", "ipnet", @@ -4168,12 +4357,12 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-rustls", "tokio-util", @@ -4317,6 +4506,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -4376,6 +4574,15 @@ dependencies = [ "libsamplerate-sys", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "schemars" version = "0.8.21" @@ -4409,6 +4616,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.8.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4875,6 +5105,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4904,6 +5140,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4999,7 +5256,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.2.0", "image", "jni", "libc", @@ -5013,7 +5270,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.12.12", "serde", "serde_json", "serde_repr", @@ -5263,13 +5520,13 @@ dependencies = [ "dirs 6.0.0", "flate2", "futures-util", - "http", + "http 1.2.0", "infer", "log", "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.12.12", "semver", "serde", "serde_json", @@ -5294,7 +5551,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.2.0", "jni", "objc2 0.6.1", "objc2-ui-kit", @@ -5314,7 +5571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe52ed0ef40fd7ad51a620ecb3018e32eba3040bb95025216a962a37f6f050c5" dependencies = [ "gtk", - "http", + "http 1.2.0", "jni", "log", "objc2 0.6.1", @@ -5347,7 +5604,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http", + "http 1.2.0", "infer", "json-patch", "kuchikiki", @@ -5542,6 +5799,16 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.1" @@ -5630,7 +5897,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -5887,6 +6154,12 @@ dependencies = [ "samplerate", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -6791,6 +7064,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6837,7 +7120,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.2.0", "javascriptcore-rs", "jni", "kuchikiki", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 067cfb1..bc683f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,8 @@ tauri-plugin-os = "2" enigo = "0.5.0" tauri-plugin-process = "2" rodio = "0.20.1" +reqwest = { version = "0.11", features = ["json", "stream"] } +futures-util = "0.3" [target.'cfg(target_os = "macos")'.dependencies] whisper-rs = { version = "0.13.2", features = ["metal"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..c446ac8 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs new file mode 100644 index 0000000..61eff63 --- /dev/null +++ b/src-tauri/src/commands/models.rs @@ -0,0 +1,115 @@ +use crate::managers::model::{ModelInfo, ModelManager}; +use crate::managers::transcription::TranscriptionManager; +use crate::settings::{get_settings, write_settings}; +use std::sync::Arc; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn get_available_models( + model_manager: State<'_, Arc>, +) -> Result, String> { + Ok(model_manager.get_available_models()) +} + +#[tauri::command] +pub async fn get_model_info( + model_manager: State<'_, Arc>, + model_id: String, +) -> Result, String> { + Ok(model_manager.get_model_info(&model_id)) +} + +#[tauri::command] +pub async fn download_model( + model_manager: State<'_, Arc>, + model_id: String, +) -> Result<(), String> { + model_manager + .download_model(&model_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_model( + model_manager: State<'_, Arc>, + model_id: String, +) -> Result<(), String> { + model_manager + .delete_model(&model_id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_active_model( + app_handle: AppHandle, + model_manager: State<'_, Arc>, + transcription_manager: State<'_, Arc>, + model_id: String, +) -> Result<(), String> { + // Check if model exists and is available + let model_info = model_manager + .get_model_info(&model_id) + .ok_or_else(|| format!("Model not found: {}", model_id))?; + + if !model_info.is_downloaded { + return Err(format!("Model not downloaded: {}", model_id)); + } + + // Load the model in the transcription manager + transcription_manager + .load_model(&model_id) + .map_err(|e| e.to_string())?; + + // Update settings + let mut settings = get_settings(&app_handle); + settings.selected_model = model_id.clone(); + write_settings(&app_handle, settings); + + Ok(()) +} + +#[tauri::command] +pub async fn get_current_model(app_handle: AppHandle) -> Result { + let settings = get_settings(&app_handle); + Ok(settings.selected_model) +} + +#[tauri::command] +pub async fn get_transcription_model_status( + transcription_manager: State<'_, Arc>, +) -> Result, String> { + Ok(transcription_manager.get_current_model()) +} + +#[tauri::command] +pub async fn is_model_loading( + transcription_manager: State<'_, Arc>, +) -> Result { + // Check if transcription manager has a loaded model + let current_model = transcription_manager.get_current_model(); + Ok(current_model.is_none()) +} + +#[tauri::command] +pub async fn has_any_models_available( + model_manager: State<'_, Arc>, +) -> Result { + let models = model_manager.get_available_models(); + Ok(models.iter().any(|m| m.is_downloaded)) +} + +#[tauri::command] +pub async fn has_any_models_or_downloads( + model_manager: State<'_, Arc>, +) -> Result { + let models = model_manager.get_available_models(); + // Return true if any models are downloaded OR if any downloads are in progress + Ok(models.iter().any(|m| m.is_downloaded)) +} + +#[tauri::command] +pub async fn get_recommended_first_model() -> Result { + // Recommend small model for first-time users + Ok("small".to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 41b5c76..4d350cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,12 @@ mod actions; +mod commands; mod managers; mod settings; mod shortcut; mod utils; use managers::audio::AudioRecordingManager; +use managers::model::ModelManager; use managers::transcription::TranscriptionManager; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -135,13 +137,16 @@ pub fn run() { let recording_manager = Arc::new( AudioRecordingManager::new(app).expect("Failed to initialize recording manager"), ); + let model_manager = + Arc::new(ModelManager::new(&app).expect("Failed to initialize model manager")); let transcription_manager = Arc::new( - TranscriptionManager::new(&app) + TranscriptionManager::new(&app, model_manager.clone()) .expect("Failed to initialize transcription manager"), ); // Add managers to Tauri's managed state app.manage(recording_manager.clone()); + app.manage(model_manager.clone()); app.manage(transcription_manager.clone()); shortcut::init_shortcuts(app); @@ -169,7 +174,18 @@ pub fn run() { shortcut::reset_binding, shortcut::change_ptt_setting, shortcut::change_audio_feedback_setting, - trigger_update_check + trigger_update_check, + commands::models::get_available_models, + commands::models::get_model_info, + commands::models::download_model, + commands::models::delete_model, + commands::models::set_active_model, + commands::models::get_current_model, + commands::models::get_transcription_model_status, + commands::models::is_model_loading, + commands::models::has_any_models_available, + commands::models::has_any_models_or_downloads, + commands::models::get_recommended_first_model ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/managers/mod.rs b/src-tauri/src/managers/mod.rs index a61d9ac..73a52cf 100644 --- a/src-tauri/src/managers/mod.rs +++ b/src-tauri/src/managers/mod.rs @@ -1,2 +1,3 @@ pub mod audio; +pub mod model; pub mod transcription; diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs new file mode 100644 index 0000000..86867f7 --- /dev/null +++ b/src-tauri/src/managers/model.rs @@ -0,0 +1,295 @@ +use anyhow::Result; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{App, AppHandle, Emitter, Manager}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub description: String, + pub filename: String, + pub url: Option, + pub size_mb: u64, + pub is_downloaded: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadProgress { + pub model_id: String, + pub downloaded: u64, + pub total: u64, + pub percentage: f64, +} + +pub struct ModelManager { + app_handle: AppHandle, + models_dir: PathBuf, + available_models: Mutex>, +} + +impl ModelManager { + pub fn new(app: &App) -> Result { + let app_handle = app.app_handle().clone(); + + // Create models directory in app data + let models_dir = app + .path() + .app_data_dir() + .map_err(|e| anyhow::anyhow!("Failed to get app data dir: {}", e))? + .join("models"); + + if !models_dir.exists() { + fs::create_dir_all(&models_dir)?; + } + + let mut available_models = HashMap::new(); + + available_models.insert( + "small".to_string(), + ModelInfo { + id: "small".to_string(), + name: "Whisper Small".to_string(), + description: "Fast and efficient, great for most use cases".to_string(), + filename: "ggml-small.bin".to_string(), + url: Some("https://blob.handy.computer/ggml-small.bin".to_string()), + size_mb: 244, + is_downloaded: false, + }, + ); + + // Add downloadable models + available_models.insert( + "medium".to_string(), + ModelInfo { + id: "medium".to_string(), + name: "Whisper Medium".to_string(), + description: "Good accuracy, medium speed".to_string(), + filename: "ggml-medium-q5_0.bin".to_string(), + url: Some("https://blob.handy.computer/ggml-medium-q5_0.bin".to_string()), + size_mb: 539, // Approximate size + is_downloaded: false, + }, + ); + + available_models.insert( + "turbo".to_string(), + ModelInfo { + id: "turbo".to_string(), + name: "Whisper Turbo".to_string(), + description: "Good accuracy, medium speed".to_string(), + filename: "ggml-large-v3-turbo-q5_0.bin".to_string(), + url: Some("https://blob.handy.computer/ggml-large-v3-turbo-q5_0.bin".to_string()), + size_mb: 574, // Approximate size + is_downloaded: false, + }, + ); + + available_models.insert( + "large".to_string(), + ModelInfo { + id: "large".to_string(), + name: "Whisper Large".to_string(), + description: "Highest 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()), + size_mb: 1080, // Approximate size + is_downloaded: false, + }, + ); + + let manager = Self { + app_handle, + models_dir, + available_models: Mutex::new(available_models), + }; + + // Migrate any bundled models to user directory + manager.migrate_bundled_models()?; + + // Check which models are already downloaded + manager.update_download_status()?; + + Ok(manager) + } + + pub fn get_available_models(&self) -> Vec { + let models = self.available_models.lock().unwrap(); + models.values().cloned().collect() + } + + pub fn get_model_info(&self, model_id: &str) -> Option { + let models = self.available_models.lock().unwrap(); + models.get(model_id).cloned() + } + + fn migrate_bundled_models(&self) -> Result<()> { + // Check for bundled models and copy them to user directory + let bundled_models = ["ggml-small.bin"]; // Add other bundled models here if any + + for filename in &bundled_models { + let bundled_path = self.app_handle.path().resolve( + &format!("resources/models/{}", filename), + tauri::path::BaseDirectory::Resource, + ); + + if let Ok(bundled_path) = bundled_path { + if bundled_path.exists() { + let user_path = self.models_dir.join(filename); + + // Only copy if user doesn't already have the model + if !user_path.exists() { + println!("Migrating bundled model {} to user directory", filename); + fs::copy(&bundled_path, &user_path)?; + println!("Successfully migrated {}", filename); + } + } + } + } + + Ok(()) + } + + fn update_download_status(&self) -> Result<()> { + let mut models = self.available_models.lock().unwrap(); + + for model in models.values_mut() { + let model_path = self.models_dir.join(&model.filename); + model.is_downloaded = model_path.exists(); + } + + Ok(()) + } + + pub async fn download_model(&self, model_id: &str) -> Result<()> { + let model_info = { + let models = self.available_models.lock().unwrap(); + models.get(model_id).cloned() + }; + + let model_info = + model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + + let url = model_info + .url + .ok_or_else(|| anyhow::anyhow!("No download URL for model"))?; + let model_path = self.models_dir.join(&model_info.filename); + + // Don't download if downloaded version already exists + if model_path.exists() { + // Update status and return + self.update_download_status()?; + return Ok(()); + } + + println!("Downloading model {} from {}", model_id, url); + + // Create HTTP client + let client = reqwest::Client::new(); + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download model: HTTP {}", + response.status() + )); + } + + let total_size = response.content_length().unwrap_or(0); + let mut downloaded = 0u64; + let mut stream = response.bytes_stream(); + + // Create the file + let mut file = std::fs::File::create(&model_path)?; + + // Download with progress + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + file.write_all(&chunk)?; + downloaded += chunk.len() as u64; + + let percentage = if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }; + + // Emit progress event + let progress = DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage, + }; + + let _ = self.app_handle.emit("model-download-progress", &progress); + } + + file.flush()?; + + // Update download status + self.update_download_status()?; + + // Emit completion event + let _ = self.app_handle.emit("model-download-complete", model_id); + + println!( + "Successfully downloaded model {} to {:?}", + model_id, model_path + ); + Ok(()) + } + + pub fn delete_model(&self, model_id: &str) -> Result<()> { + println!("ModelManager: delete_model called for: {}", model_id); + + let model_info = { + let models = self.available_models.lock().unwrap(); + models.get(model_id).cloned() + }; + + let model_info = + model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + + println!("ModelManager: Found model info: {:?}", model_info); + + let model_path = self.models_dir.join(&model_info.filename); + println!("ModelManager: Model path: {:?}", model_path); + + if model_path.exists() { + println!("ModelManager: Deleting model file at: {:?}", model_path); + fs::remove_file(&model_path)?; + println!("ModelManager: Model file deleted successfully"); + } else { + return Err(anyhow::anyhow!("Model file not found")); + } + + // Update download status + self.update_download_status()?; + println!("ModelManager: Download status updated"); + + Ok(()) + } + + pub fn get_model_path(&self, model_id: &str) -> Result { + let model_info = self + .get_model_info(model_id) + .ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + + if !model_info.is_downloaded { + return Err(anyhow::anyhow!("Model not available: {}", model_id)); + } + + let model_path = self.models_dir.join(&model_info.filename); + if model_path.exists() { + Ok(model_path) + } else { + Err(anyhow::anyhow!("Model file not found: {}", model_id)) + } + } +} diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index a194660..7d64087 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,36 +1,158 @@ +use crate::managers::model::ModelManager; +use crate::settings::get_settings; use anyhow::Result; -use std::sync::Mutex; -use tauri::{App, Manager}; +use serde::Serialize; +use std::sync::{Arc, Mutex}; +use tauri::{App, AppHandle, Emitter, Manager}; use whisper_rs::install_whisper_log_trampoline; use whisper_rs::{ FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters, WhisperState, }; +#[derive(Clone, Debug, Serialize)] +pub struct ModelStateEvent { + pub event_type: String, + pub model_id: Option, + pub model_name: Option, + pub error: Option, +} + pub struct TranscriptionManager { - state: Mutex, + state: Mutex>, + context: Mutex>, + model_manager: Arc, + app_handle: AppHandle, + current_model_id: Mutex>, } impl TranscriptionManager { - pub fn new(app: &App) -> Result { - let whisper_path = app.path().resolve( - "resources/models/ggml-small.bin", - tauri::path::BaseDirectory::Resource, - )?; - let path = whisper_path + pub fn new(app: &App, model_manager: Arc) -> Result { + let app_handle = app.app_handle().clone(); + + let manager = Self { + state: Mutex::new(None), + context: Mutex::new(None), + model_manager, + app_handle: app_handle.clone(), + current_model_id: Mutex::new(None), + }; + + // Try to load the default model from settings, but don't fail if no models are available + let settings = get_settings(&app_handle); + let _ = manager.load_model(&settings.selected_model); + + Ok(manager) + } + + pub fn load_model(&self, model_id: &str) -> Result<()> { + // Emit loading started event + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "loading_started".to_string(), + model_id: Some(model_id.to_string()), + model_name: None, + error: None, + }, + ); + + let model_info = self + .model_manager + .get_model_info(model_id) + .ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + + if !model_info.is_downloaded { + let error_msg = "Model not downloaded"; + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "loading_failed".to_string(), + model_id: Some(model_id.to_string()), + model_name: Some(model_info.name.clone()), + error: Some(error_msg.to_string()), + }, + ); + return Err(anyhow::anyhow!(error_msg)); + } + + let model_path = self.model_manager.get_model_path(model_id)?; + + let path_str = model_path .to_str() - .expect("Path contains invalid UTF-8 Chars"); + .ok_or_else(|| anyhow::anyhow!("Invalid path for model: {}", model_id))?; + println!( + "Loading transcription model {} from: {}", + model_id, path_str + ); + + // Install log trampoline once per model load (safe to call multiple times) install_whisper_log_trampoline(); - // Load the model - let context = WhisperContext::new_with_params(path, WhisperContextParameters::default()) - .map_err(|e| anyhow::anyhow!("Failed to load whisper model: {}", e))?; - // Create state - let state = context.create_state().expect("failed to create state"); + // Create new context + let context = + WhisperContext::new_with_params(path_str, WhisperContextParameters::default()) + .map_err(|e| { + let error_msg = format!("Failed to load whisper model {}: {}", model_id, e); + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "loading_failed".to_string(), + model_id: Some(model_id.to_string()), + model_name: Some(model_info.name.clone()), + error: Some(error_msg.clone()), + }, + ); + anyhow::anyhow!(error_msg) + })?; - Ok(Self { - state: Mutex::new(state), - }) + // Create new state + let state = context.create_state().map_err(|e| { + let error_msg = format!("Failed to create state for model {}: {}", model_id, e); + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "loading_failed".to_string(), + model_id: Some(model_id.to_string()), + model_name: Some(model_info.name.clone()), + error: Some(error_msg.clone()), + }, + ); + anyhow::anyhow!(error_msg) + })?; + + // Update the current context and state + { + let mut current_context = self.context.lock().unwrap(); + *current_context = Some(context); + } + { + let mut current_state = self.state.lock().unwrap(); + *current_state = Some(state); + } + { + let mut current_model = self.current_model_id.lock().unwrap(); + *current_model = Some(model_id.to_string()); + } + + // Emit loading completed event + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "loading_completed".to_string(), + model_id: Some(model_id.to_string()), + model_name: Some(model_info.name.clone()), + error: None, + }, + ); + + println!("Successfully loaded transcription model: {}", model_id); + Ok(()) + } + + pub fn get_current_model(&self) -> Option { + let current_model = self.current_model_id.lock().unwrap(); + current_model.clone() } pub fn transcribe(&self, audio: Vec) -> Result { @@ -41,11 +163,16 @@ impl TranscriptionManager { if audio.len() == 0 { println!("Empty audio vector"); - // TODO error return Ok(result); } - let mut state = self.state.lock().unwrap(); + let mut state_guard = self.state.lock().unwrap(); + let state = state_guard.as_mut().ok_or_else(|| { + anyhow::anyhow!( + "No model loaded. Please download and select a model from settings first." + ) + })?; + // Initialize parameters let mut params = FullParams::new(SamplingStrategy::default()); params.set_language(Some("auto")); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index cf5edc6..5a92cd9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -18,6 +18,14 @@ pub struct AppSettings { pub bindings: HashMap, pub push_to_talk: bool, pub audio_feedback: bool, + #[serde(default = "default_model")] + pub selected_model: String, +} + +fn default_model() -> String { + // Default to empty string if no models are available yet + // The UI will handle prompting for model download + "".to_string() } pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; @@ -49,6 +57,7 @@ pub fn get_default_settings() -> AppSettings { bindings, push_to_talk: true, audio_feedback: false, + selected_model: "".to_string(), } } diff --git a/src/App.tsx b/src/App.tsx index 1ecb7a0..99a670d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,44 @@ import { Settings } from "./components/settings/Settings"; import HandyTextLogo from "./components/icons/HandyTextLogo"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; +import Onboarding from "./components/onboarding"; +import { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; function App() { + const [showOnboarding, setShowOnboarding] = useState(null); + + useEffect(() => { + checkOnboardingStatus(); + }, []); + + const checkOnboardingStatus = async () => { + try { + // Always check if they have any models available + const modelsAvailable: boolean = await invoke("has_any_models_available"); + setShowOnboarding(!modelsAvailable); + } catch (error) { + console.error("Failed to check onboarding status:", error); + setShowOnboarding(true); + } + }; + + const handleModelSelected = () => { + // Transition to main app - user has started a download + setShowOnboarding(false); + }; + + if (showOnboarding) { + return ( +
+
+ + +
+
+ ); + } + return (
diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index 031ce7c..3022dfd 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,21 +1,11 @@ -import React, { useState, useEffect, useRef } from "react"; -import { check } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; +import React, { useState, useEffect } from "react"; import { getVersion } from "@tauri-apps/api/app"; -import { listen } from "@tauri-apps/api/event"; + +import ModelSelector from "../model-selector"; +import UpdateChecker from "../update-checker"; const Footer: React.FC = () => { - const [isChecking, setIsChecking] = useState(false); - const [updateAvailable, setUpdateAvailable] = useState(false); - const [isInstalling, setIsInstalling] = useState(false); - const [downloadProgress, setDownloadProgress] = useState(0); const [version, setVersion] = useState(""); - const [showUpToDate, setShowUpToDate] = useState(false); - - const upToDateTimeoutRef = useRef>(); - const isManualCheckRef = useRef(false); - const downloadedBytesRef = useRef(0); - const contentLengthRef = useRef(0); useEffect(() => { const fetchVersion = async () => { @@ -29,155 +19,20 @@ const Footer: React.FC = () => { }; fetchVersion(); - checkForUpdates(); - - const unlisten = listen("check-for-updates", () => { - handleManualUpdateCheck(); - }); - - return () => { - if (upToDateTimeoutRef.current) { - clearTimeout(upToDateTimeoutRef.current); - } - unlisten.then((fn) => fn()); - }; }, []); - const checkForUpdates = async () => { - if (isChecking) return; - - try { - setIsChecking(true); - const update = await check(); - - if (update) { - setUpdateAvailable(true); - setShowUpToDate(false); - } else { - setUpdateAvailable(false); - - if (isManualCheckRef.current) { - setShowUpToDate(true); - if (upToDateTimeoutRef.current) { - clearTimeout(upToDateTimeoutRef.current); - } - upToDateTimeoutRef.current = setTimeout(() => { - setShowUpToDate(false); - }, 3000); - } - } - } catch (error) { - console.error("Failed to check for updates:", error); - } finally { - setIsChecking(false); - isManualCheckRef.current = false; - } - }; - - const handleManualUpdateCheck = () => { - isManualCheckRef.current = true; - checkForUpdates(); - }; - - const installUpdate = async () => { - try { - setIsInstalling(true); - setDownloadProgress(0); - downloadedBytesRef.current = 0; - contentLengthRef.current = 0; - const update = await check(); - - if (!update) { - console.log("No update available during install attempt"); - return; - } - - await update.downloadAndInstall((event) => { - switch (event.event) { - case "Started": - downloadedBytesRef.current = 0; - contentLengthRef.current = event.data.contentLength ?? 0; - break; - case "Progress": - downloadedBytesRef.current += event.data.chunkLength; - const progress = - contentLengthRef.current > 0 - ? Math.round( - (downloadedBytesRef.current / contentLengthRef.current) * - 100, - ) - : 0; - setDownloadProgress(Math.min(progress, 100)); - break; - } - }); - await relaunch(); - } catch (error) { - console.error("Failed to install update:", error); - } finally { - setIsInstalling(false); - setDownloadProgress(0); - downloadedBytesRef.current = 0; - contentLengthRef.current = 0; - } - }; - - const getStatusText = () => { - if (isInstalling) { - return downloadProgress > 0 && downloadProgress < 100 - ? `Downloading... ${downloadProgress.toString().padStart(3)}%` - : downloadProgress === 100 - ? "Installing..." - : "Preparing..."; - } - if (isChecking) return "Checking..."; - if (showUpToDate) return "Up to date"; - if (updateAvailable) return "Update available"; - return "Check for updates"; - }; - - const getStatusAction = () => { - if (updateAvailable && !isInstalling) return installUpdate; - if (!isChecking && !isInstalling && !updateAvailable) - return handleManualUpdateCheck; - return undefined; - }; - - const isDisabled = isChecking || isInstalling; - const isClickable = - !isDisabled && (updateAvailable || (!isChecking && !showUpToDate)); - return (
-
- v{version} +
+
-
- {isClickable ? ( - - ) : ( - {getStatusText()} - )} - - {isInstalling && downloadProgress > 0 && downloadProgress < 100 && ( - - )} + {/* Update Status */} +
+ + + v{version}
diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx new file mode 100644 index 0000000..f10ffbd --- /dev/null +++ b/src/components/model-selector/ModelSelector.tsx @@ -0,0 +1,604 @@ +import React, { useState, useRef, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { ModelInfo } from "../../lib/types"; + +interface ModelStateEvent { + event_type: string; + model_id?: string; + model_name?: string; + error?: string; +} + +interface DownloadProgress { + model_id: string; + downloaded: number; + total: number; + percentage: number; +} + +type ModelStatus = "ready" | "loading" | "downloading" | "error" | "none"; + +interface DownloadStats { + startTime: number; + lastUpdate: number; + totalDownloaded: number; + speed: number; +} + +interface ModelSelectorProps { + onError?: (error: string) => void; +} + +const ModelSelector: React.FC = ({ onError }) => { + const [models, setModels] = useState([]); + const [currentModelId, setCurrentModelId] = useState(""); + const [modelStatus, setModelStatus] = useState("loading"); + const [modelError, setModelError] = useState(null); + const [modelDownloadProgress, setModelDownloadProgress] = useState< + Map + >(new Map()); + const [showModelDropdown, setShowModelDropdown] = useState(false); + const [downloadStats, setDownloadStats] = useState< + Map + >(new Map()); + + const dropdownRef = useRef(null); + + useEffect(() => { + loadModels(); + loadCurrentModel(); + + // Listen for model state changes + const modelStateUnlisten = listen( + "model-state-changed", + (event) => { + const { event_type, model_id, model_name, error } = event.payload; + + switch (event_type) { + case "loading_started": + setModelStatus("loading"); + setModelError(null); + break; + case "loading_completed": + setModelStatus("ready"); + setModelError(null); + if (model_id) setCurrentModelId(model_id); + break; + case "loading_failed": + setModelStatus("error"); + setModelError(error || "Failed to load model"); + break; + } + }, + ); + + // Listen for model download progress + const downloadProgressUnlisten = listen( + "model-download-progress", + (event) => { + const progress = event.payload; + setModelDownloadProgress((prev) => { + const newMap = new Map(prev); + newMap.set(progress.model_id, progress); + return newMap; + }); + setModelStatus("downloading"); + + // Update download stats for speed calculation + const now = Date.now(); + setDownloadStats((prev) => { + const current = prev.get(progress.model_id); + const newStats = new Map(prev); + + if (!current) { + // First progress update - initialize + newStats.set(progress.model_id, { + startTime: now, + lastUpdate: now, + totalDownloaded: progress.downloaded, + speed: 0, + }); + } else { + // Calculate speed over last few seconds + const timeDiff = (now - current.lastUpdate) / 1000; // seconds + const bytesDiff = progress.downloaded - current.totalDownloaded; + + if (timeDiff > 0.5) { + // Update speed every 500ms + const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s + // Smooth the speed with exponential moving average, but ensure positive values + const validCurrentSpeed = Math.max(0, currentSpeed); + const smoothedSpeed = + current.speed > 0 + ? current.speed * 0.8 + validCurrentSpeed * 0.2 + : validCurrentSpeed; + + newStats.set(progress.model_id, { + startTime: current.startTime, + lastUpdate: now, + totalDownloaded: progress.downloaded, + speed: Math.max(0, smoothedSpeed), + }); + } + } + + return newStats; + }); + }, + ); + + // Listen for model download completion + const downloadCompleteUnlisten = listen( + "model-download-complete", + (event) => { + const modelId = event.payload; + setModelDownloadProgress((prev) => { + const newMap = new Map(prev); + newMap.delete(modelId); + return newMap; + }); + setDownloadStats((prev) => { + const newStats = new Map(prev); + newStats.delete(modelId); + return newStats; + }); + loadModels(); // Refresh models list + + // Auto-select the newly downloaded model + setTimeout(() => { + loadCurrentModel(); + handleModelSelect(modelId); + }, 500); + }, + ); + + // Click outside to close dropdown + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setShowModelDropdown(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + modelStateUnlisten.then((fn) => fn()); + downloadProgressUnlisten.then((fn) => fn()); + downloadCompleteUnlisten.then((fn) => fn()); + }; + }, []); + + const loadModels = async () => { + try { + const modelList = await invoke("get_available_models"); + setModels(modelList); + } catch (err) { + console.error("Failed to load models:", err); + } + }; + + const loadCurrentModel = async () => { + try { + const current = await invoke("get_current_model"); + setCurrentModelId(current); + + if (current) { + // Check if model is actually loaded + const transcriptionStatus = await invoke( + "get_transcription_model_status", + ); + if (transcriptionStatus === current) { + setModelStatus("ready"); + } else { + setModelStatus("loading"); + } + } else { + setModelStatus("none"); + } + } catch (err) { + console.error("Failed to load current model:", err); + setModelStatus("error"); + setModelError("Failed to check model status"); + } + }; + + const handleModelSelect = async (modelId: string) => { + try { + setModelError(null); + setShowModelDropdown(false); + await invoke("set_active_model", { modelId }); + setCurrentModelId(modelId); + } catch (err) { + const errorMsg = `${err}`; + setModelError(errorMsg); + setModelStatus("error"); + onError?.(errorMsg); + } + }; + + const handleModelDownload = async (modelId: string) => { + try { + setModelError(null); + await invoke("download_model", { modelId }); + } catch (err) { + const errorMsg = `${err}`; + setModelError(errorMsg); + setModelStatus("error"); + onError?.(errorMsg); + } + }; + + const getModelStatusColor = (status: ModelStatus): string => { + switch (status) { + case "ready": + return "bg-green-400"; + case "loading": + return "bg-yellow-400 animate-pulse"; + case "downloading": + return "bg-logo-primary animate-pulse"; + case "error": + return "bg-red-400"; + case "none": + return "bg-red-400"; + default: + return "bg-mid-gray/60"; + } + }; + + const getCurrentModel = () => { + return models.find((m) => m.id === currentModelId); + }; + + const getModelDisplayText = (): string => { + if (modelDownloadProgress.size > 0) { + if (modelDownloadProgress.size === 1) { + const [progress] = Array.from(modelDownloadProgress.values()); + const percentage = Math.max( + 0, + Math.min(100, Math.round(progress.percentage)), + ); + return `Downloading ${percentage}%`; + } else { + return `Downloading ${modelDownloadProgress.size} models...`; + } + } + + const currentModel = getCurrentModel(); + + switch (modelStatus) { + case "ready": + return currentModel?.name || "Model Ready"; + case "loading": + return currentModel ? `Loading ${currentModel.name}...` : "Loading..."; + case "error": + return modelError || "Model Error"; + case "none": + return "No Model - Download Required"; + default: + return currentModel?.name || "Select Model"; + } + }; + + const formatFileSize = (sizeMb: number): string => { + if (sizeMb < 1024) return `${sizeMb}MB`; + return `${(sizeMb / 1024).toFixed(1)}GB`; + }; + + const getAvailableModels = () => { + return models.filter((m) => m.is_downloaded); + }; + + const getDownloadableModels = () => { + return models.filter((m) => !m.is_downloaded); + }; + + const availableModels = getAvailableModels(); + const downloadableModels = getDownloadableModels(); + const isFirstRun = availableModels.length === 0 && models.length > 0; + + return ( + <> + {/* Model Status and Switcher */} +
+ + + {/* Model Dropdown */} + {showModelDropdown && ( +
+ {/* First Run Welcome */} + {isFirstRun && ( +
+
+ Welcome to Handy! +
+
+ Download a model below to get started with transcription. +
+
+ )} + + {/* Available Models */} + {availableModels.length > 0 && ( +
+
+ Available Models +
+ {availableModels.map((model) => ( +
handleModelSelect(model.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleModelSelect(model.id); + } + }} + tabIndex={0} + role="button" + className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ + currentModelId === model.id + ? "bg-logo-primary/10 text-logo-primary" + : "" + }`} + > +
+
+
{model.name}
+
+ {model.description} +
+
+
+ {currentModelId === model.id && ( +
+ Active +
+ )} + {(() => { + const shouldShowDelete = currentModelId !== model.id; + return shouldShowDelete; + })() && ( + + )} +
+
+
+ ))} +
+ )} + + {/* Downloadable Models */} + {downloadableModels.length > 0 && ( +
+ {(availableModels.length > 0 || isFirstRun) && ( +
+ )} +
+ {isFirstRun ? "Choose a Model" : "Download Models"} +
+ {downloadableModels.map((model) => ( +
{ + if (modelDownloadProgress.has(model.id)) { + e.preventDefault(); + return; + } + handleModelDownload(model.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (modelDownloadProgress.has(model.id)) { + return; + } + handleModelDownload(model.id); + } + }} + tabIndex={0} + role="button" + aria-disabled={modelDownloadProgress.has(model.id)} + className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ + modelDownloadProgress.has(model.id) + ? "opacity-50 cursor-not-allowed hover:bg-transparent" + : "" + }`} + > +
+
+
+ {model.name} + {model.id === "small" && isFirstRun && ( + + Recommended + + )} +
+
+ {model.description} +
+
+ {modelDownloadProgress.has(model.id) ? ( +
+ {Math.max( + 0, + Math.min( + 100, + Math.round( + modelDownloadProgress.get(model.id)!.percentage, + ), + ), + )} + % +
+ ) : ( +
+ Download +
+ )} +
+ + {modelDownloadProgress.has(model.id) && ( +
+
+
+
+
+ )} +
+ ))} +
+ )} + + {/* No Models Available */} + {availableModels.length === 0 && + downloadableModels.length === 0 && ( +
+ No models available +
+ )} +
+ )} +
+ + {/* Download Progress Bar for Models */} + {modelDownloadProgress.size > 0 && ( +
+ {modelDownloadProgress.size === 1 ? ( + // Single download - show detailed progress + (() => { + const [progress] = Array.from(modelDownloadProgress.values()); + return ( + <> +
+
+
+
+ {downloadStats.get(progress.model_id)?.speed && + downloadStats.get(progress.model_id)!.speed > 0 ? ( + + {downloadStats.get(progress.model_id)!.speed.toFixed(1)} + MB/s + + ) : ( + Downloading... + )} +
+ + ); + })() + ) : ( + // Multiple downloads - show summary +
+
+ {Array.from(modelDownloadProgress.values()).map( + (progress, index) => ( +
+
+
+ ), + )} +
+
+ {modelDownloadProgress.size} downloading... +
+
+ )} +
+ )} + + ); +}; + +export default ModelSelector; diff --git a/src/components/model-selector/index.ts b/src/components/model-selector/index.ts new file mode 100644 index 0000000..57f043e --- /dev/null +++ b/src/components/model-selector/index.ts @@ -0,0 +1 @@ +export { default } from './ModelSelector'; diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx new file mode 100644 index 0000000..29a06fd --- /dev/null +++ b/src/components/onboarding/Onboarding.tsx @@ -0,0 +1,94 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ModelInfo } from "../../lib/types"; + +interface OnboardingProps { + onModelSelected: () => void; +} + +const Onboarding: React.FC = ({ onModelSelected }) => { + const [availableModels, setAvailableModels] = useState([]); + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadModels(); + }, []); + + const loadModels = async () => { + try { + const models: ModelInfo[] = await invoke("get_available_models"); + // Only show downloadable models for onboarding + setAvailableModels(models.filter((m) => !m.is_downloaded)); + } catch (err) { + console.error("Failed to load models:", err); + setError("Failed to load available models"); + } + }; + + const handleDownloadModel = async (modelId: string) => { + setDownloading(true); + setError(null); + + // Immediately transition to main app - download will continue in footer + onModelSelected(); + + try { + await invoke("download_model", { modelId }); + } catch (err) { + console.error("Download failed:", err); + setError(`Failed to download model: ${err}`); + setDownloading(false); + } + }; + + const getRecommendedBadge = (modelId: string): boolean => { + return modelId === "small"; + }; + + return ( +
+

+ To get started, choose a transcription model to download +

+ + {error && ( +
+

{error}

+
+ )} + +
+ {availableModels + .sort((a, b) => a.size_mb - b.size_mb) + .map((model) => ( + + ))} +
+
+ ); +}; + +export default Onboarding; diff --git a/src/components/onboarding/index.ts b/src/components/onboarding/index.ts new file mode 100644 index 0000000..c0ad2b1 --- /dev/null +++ b/src/components/onboarding/index.ts @@ -0,0 +1 @@ +export { default } from './Onboarding'; diff --git a/src/components/update-checker/UpdateChecker.tsx b/src/components/update-checker/UpdateChecker.tsx new file mode 100644 index 0000000..507d993 --- /dev/null +++ b/src/components/update-checker/UpdateChecker.tsx @@ -0,0 +1,176 @@ +import React, { useState, useEffect, useRef } from "react"; +import { check } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { listen } from "@tauri-apps/api/event"; + +interface UpdateCheckerProps { + className?: string; +} + +const UpdateChecker: React.FC = ({ className = "" }) => { + // Update checking state + const [isChecking, setIsChecking] = useState(false); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(0); + const [showUpToDate, setShowUpToDate] = useState(false); + + const upToDateTimeoutRef = useRef>(); + const isManualCheckRef = useRef(false); + const downloadedBytesRef = useRef(0); + const contentLengthRef = useRef(0); + + useEffect(() => { + checkForUpdates(); + + // Listen for update check events + const updateUnlisten = listen("check-for-updates", () => { + handleManualUpdateCheck(); + }); + + return () => { + if (upToDateTimeoutRef.current) { + clearTimeout(upToDateTimeoutRef.current); + } + updateUnlisten.then((fn) => fn()); + }; + }, []); + + // Update checking functions + const checkForUpdates = async () => { + if (isChecking) return; + + try { + setIsChecking(true); + const update = await check(); + + if (update) { + setUpdateAvailable(true); + setShowUpToDate(false); + } else { + setUpdateAvailable(false); + + if (isManualCheckRef.current) { + setShowUpToDate(true); + if (upToDateTimeoutRef.current) { + clearTimeout(upToDateTimeoutRef.current); + } + upToDateTimeoutRef.current = setTimeout(() => { + setShowUpToDate(false); + }, 3000); + } + } + } catch (error) { + console.error("Failed to check for updates:", error); + } finally { + setIsChecking(false); + isManualCheckRef.current = false; + } + }; + + const handleManualUpdateCheck = () => { + isManualCheckRef.current = true; + checkForUpdates(); + }; + + const installUpdate = async () => { + try { + setIsInstalling(true); + setDownloadProgress(0); + downloadedBytesRef.current = 0; + contentLengthRef.current = 0; + const update = await check(); + + if (!update) { + console.log("No update available during install attempt"); + return; + } + + await update.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + downloadedBytesRef.current = 0; + contentLengthRef.current = event.data.contentLength ?? 0; + break; + case "Progress": + downloadedBytesRef.current += event.data.chunkLength; + const progress = + contentLengthRef.current > 0 + ? Math.round( + (downloadedBytesRef.current / contentLengthRef.current) * + 100, + ) + : 0; + setDownloadProgress(Math.min(progress, 100)); + break; + } + }); + await relaunch(); + } catch (error) { + console.error("Failed to install update:", error); + } finally { + setIsInstalling(false); + setDownloadProgress(0); + downloadedBytesRef.current = 0; + contentLengthRef.current = 0; + } + }; + + // Update status functions + const getUpdateStatusText = () => { + if (isInstalling) { + return downloadProgress > 0 && downloadProgress < 100 + ? `Downloading... ${downloadProgress.toString().padStart(3)}%` + : downloadProgress === 100 + ? "Installing..." + : "Preparing..."; + } + if (isChecking) return "Checking..."; + if (showUpToDate) return "Up to date"; + if (updateAvailable) return "Update available"; + return "Check for updates"; + }; + + const getUpdateStatusAction = () => { + if (updateAvailable && !isInstalling) return installUpdate; + if (!isChecking && !isInstalling && !updateAvailable) + return handleManualUpdateCheck; + return undefined; + }; + + const isUpdateDisabled = isChecking || isInstalling; + const isUpdateClickable = + !isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate)); + + return ( +
+ {isUpdateClickable ? ( + + ) : ( + + {getUpdateStatusText()} + + )} + + {isInstalling && downloadProgress > 0 && downloadProgress < 100 && ( + + )} +
+ ); +}; + +export default UpdateChecker; diff --git a/src/components/update-checker/index.ts b/src/components/update-checker/index.ts new file mode 100644 index 0000000..df6c503 --- /dev/null +++ b/src/components/update-checker/index.ts @@ -0,0 +1 @@ +export { default } from './UpdateChecker'; diff --git a/src/hooks/useModels.ts b/src/hooks/useModels.ts new file mode 100644 index 0000000..a799c2a --- /dev/null +++ b/src/hooks/useModels.ts @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +interface ModelInfo { + id: string; + name: string; + filename: string; + url?: string; + size_mb: number; + is_downloaded: boolean; + is_bundled: boolean; +} + +interface DownloadProgress { + model_id: string; + downloaded: number; + total: number; + percentage: number; +} + +export const useModels = () => { + const [models, setModels] = useState([]); + const [currentModel, setCurrentModel] = useState(""); + const [downloadingModels, setDownloadingModels] = useState>( + new Set(), + ); + const [downloadProgress, setDownloadProgress] = useState< + Map + >(new Map()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasAnyModels, setHasAnyModels] = useState(false); + const [isFirstRun, setIsFirstRun] = useState(false); + + const loadModels = async () => { + try { + const modelList = await invoke("get_available_models"); + setModels(modelList); + setError(null); + } catch (err) { + setError(`Failed to load models: ${err}`); + } finally { + setLoading(false); + } + }; + + const loadCurrentModel = async () => { + try { + const current = await invoke("get_current_model"); + setCurrentModel(current); + } catch (err) { + console.error("Failed to load current model:", err); + } + }; + + const checkFirstRun = async () => { + try { + const hasModels = await invoke("has_any_models_available"); + setHasAnyModels(hasModels); + setIsFirstRun(!hasModels); + return !hasModels; + } catch (err) { + console.error("Failed to check model availability:", err); + return false; + } + }; + + const selectModel = async (modelId: string) => { + try { + setError(null); + await invoke("set_active_model", { modelId }); + setCurrentModel(modelId); + setIsFirstRun(false); + setHasAnyModels(true); + return true; + } catch (err) { + setError(`Failed to switch to model: ${err}`); + return false; + } + }; + + const downloadModel = async (modelId: string) => { + try { + setError(null); + setDownloadingModels((prev) => new Set(prev.add(modelId))); + await invoke("download_model", { modelId }); + return true; + } catch (err) { + setError(`Failed to download model: ${err}`); + setDownloadingModels((prev) => { + const next = new Set(prev); + next.delete(modelId); + return next; + }); + return false; + } + }; + + const deleteModel = async (modelId: string) => { + try { + setError(null); + await invoke("delete_model", { modelId }); + await loadModels(); // Refresh the list + return true; + } catch (err) { + setError(`Failed to delete model: ${err}`); + return false; + } + }; + + const getModelInfo = (modelId: string): ModelInfo | undefined => { + return models.find((model) => model.id === modelId); + }; + + const isModelDownloading = (modelId: string): boolean => { + return downloadingModels.has(modelId); + }; + + const getDownloadProgress = ( + modelId: string, + ): DownloadProgress | undefined => { + return downloadProgress.get(modelId); + }; + + useEffect(() => { + loadModels(); + loadCurrentModel(); + checkFirstRun(); + + // Listen for download progress + const progressUnlisten = listen( + "model-download-progress", + (event) => { + setDownloadProgress( + (prev) => new Map(prev.set(event.payload.model_id, event.payload)), + ); + }, + ); + + // Listen for download completion + const completeUnlisten = listen( + "model-download-complete", + (event) => { + const modelId = event.payload; + setDownloadingModels((prev) => { + const next = new Set(prev); + next.delete(modelId); + return next; + }); + setDownloadProgress((prev) => { + const next = new Map(prev); + next.delete(modelId); + return next; + }); + // Refresh models list to update download status + loadModels(); + }, + ); + + return () => { + progressUnlisten.then((fn) => fn()); + completeUnlisten.then((fn) => fn()); + }; + }, []); + + return { + models, + currentModel, + loading, + error, + downloadingModels, + downloadProgress, + hasAnyModels, + isFirstRun, + loadModels, + loadCurrentModel, + checkFirstRun, + selectModel, + downloadModel, + deleteModel, + getModelInfo, + isModelDownloading, + getDownloadProgress, + }; +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 59878a5..892f719 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -29,3 +29,13 @@ export type BindingResponse = z.infer; export type ShortcutBinding = z.infer; export type ShortcutBindingsMap = z.infer; export type Settings = z.infer; + +export const ModelInfoSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + size_mb: z.number(), + is_downloaded: z.boolean(), +}); + +export type ModelInfo = z.infer;