initial working version of multiple models + cleanup and onboarding

This commit is contained in:
CJ Pais 2025-06-30 14:27:21 -07:00
parent 5b1872a12e
commit 9036c05088
21 changed files with 2026 additions and 211 deletions

View file

@ -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

View file

@ -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

331
src-tauri/Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -0,0 +1 @@
pub mod models;

View file

@ -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<ModelManager>>,
) -> Result<Vec<ModelInfo>, String> {
Ok(model_manager.get_available_models())
}
#[tauri::command]
pub async fn get_model_info(
model_manager: State<'_, Arc<ModelManager>>,
model_id: String,
) -> Result<Option<ModelInfo>, String> {
Ok(model_manager.get_model_info(&model_id))
}
#[tauri::command]
pub async fn download_model(
model_manager: State<'_, Arc<ModelManager>>,
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<ModelManager>>,
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<ModelManager>>,
transcription_manager: State<'_, Arc<TranscriptionManager>>,
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<String, String> {
let settings = get_settings(&app_handle);
Ok(settings.selected_model)
}
#[tauri::command]
pub async fn get_transcription_model_status(
transcription_manager: State<'_, Arc<TranscriptionManager>>,
) -> Result<Option<String>, String> {
Ok(transcription_manager.get_current_model())
}
#[tauri::command]
pub async fn is_model_loading(
transcription_manager: State<'_, Arc<TranscriptionManager>>,
) -> Result<bool, String> {
// 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<ModelManager>>,
) -> Result<bool, String> {
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<ModelManager>>,
) -> Result<bool, String> {
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<String, String> {
// Recommend small model for first-time users
Ok("small".to_string())
}

View file

@ -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");

View file

@ -1,2 +1,3 @@
pub mod audio;
pub mod model;
pub mod transcription;

View file

@ -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<String>,
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<HashMap<String, ModelInfo>>,
}
impl ModelManager {
pub fn new(app: &App) -> Result<Self> {
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<ModelInfo> {
let models = self.available_models.lock().unwrap();
models.values().cloned().collect()
}
pub fn get_model_info(&self, model_id: &str) -> Option<ModelInfo> {
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<PathBuf> {
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))
}
}
}

View file

@ -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<String>,
pub model_name: Option<String>,
pub error: Option<String>,
}
pub struct TranscriptionManager {
state: Mutex<WhisperState>,
state: Mutex<Option<WhisperState>>,
context: Mutex<Option<WhisperContext>>,
model_manager: Arc<ModelManager>,
app_handle: AppHandle,
current_model_id: Mutex<Option<String>>,
}
impl TranscriptionManager {
pub fn new(app: &App) -> Result<Self> {
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<ModelManager>) -> Result<Self> {
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<String> {
let current_model = self.current_model_id.lock().unwrap();
current_model.clone()
}
pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {
@ -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"));

View file

@ -18,6 +18,14 @@ pub struct AppSettings {
pub bindings: HashMap<String, ShortcutBinding>,
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(),
}
}

View file

@ -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<boolean | null>(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 (
<div className="min-h-screen flex flex-col w-full">
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
<HandyTextLogo width={300} />
<Onboarding onModelSelected={handleModelSelected} />
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col w-full">
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">

View file

@ -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<ReturnType<typeof setTimeout>>();
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 (
<div className="w-full border-t border-mid-gray/20 mt-4 pt-3">
<div className="flex justify-between items-center text-xs px-4 pb-3 text-text/60">
<div className="flex items-center">
<span>v{version}</span>
<div className="flex items-center gap-4">
<ModelSelector />
</div>
<div className="flex items-center gap-3">
{isClickable ? (
<button
onClick={getStatusAction()}
disabled={isDisabled}
className={`transition-colors disabled:opacity-50 tabular-nums ${
updateAvailable
? "text-logo-primary hover:text-logo-primary/80 font-medium"
: "text-text/60 hover:text-text/80"
}`}
>
{getStatusText()}
</button>
) : (
<span className="text-text/60 tabular-nums">{getStatusText()}</span>
)}
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
<progress
value={downloadProgress}
max={100}
className="w-20 h-2 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary"
/>
)}
{/* Update Status */}
<div className="flex items-center gap-1">
<UpdateChecker />
<span></span>
<span>v{version}</span>
</div>
</div>
</div>

View file

@ -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<ModelSelectorProps> = ({ onError }) => {
const [models, setModels] = useState<ModelInfo[]>([]);
const [currentModelId, setCurrentModelId] = useState<string>("");
const [modelStatus, setModelStatus] = useState<ModelStatus>("loading");
const [modelError, setModelError] = useState<string | null>(null);
const [modelDownloadProgress, setModelDownloadProgress] = useState<
Map<string, DownloadProgress>
>(new Map());
const [showModelDropdown, setShowModelDropdown] = useState(false);
const [downloadStats, setDownloadStats] = useState<
Map<string, DownloadStats>
>(new Map());
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadModels();
loadCurrentModel();
// Listen for model state changes
const modelStateUnlisten = listen<ModelStateEvent>(
"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<DownloadProgress>(
"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<string>(
"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<ModelInfo[]>("get_available_models");
setModels(modelList);
} catch (err) {
console.error("Failed to load models:", err);
}
};
const loadCurrentModel = async () => {
try {
const current = await invoke<string>("get_current_model");
setCurrentModelId(current);
if (current) {
// Check if model is actually loaded
const transcriptionStatus = await invoke<string | null>(
"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 */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowModelDropdown(!showModelDropdown)}
className="flex items-center gap-2 hover:text-text/80 transition-colors"
title={`Model status: ${getModelDisplayText()}`}
>
<div
className={`w-2 h-2 rounded-full ${getModelStatusColor(modelStatus)}`}
/>
<span className="max-w-28 truncate">{getModelDisplayText()}</span>
<svg
className={`w-3 h-3 transition-transform ${showModelDropdown ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Model Dropdown */}
{showModelDropdown && (
<div className="absolute bottom-full left-0 mb-2 w-64 bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50">
{/* First Run Welcome */}
{isFirstRun && (
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
<div className="text-xs font-medium text-logo-primary mb-1">
Welcome to Handy!
</div>
<div className="text-xs text-text/70">
Download a model below to get started with transcription.
</div>
</div>
)}
{/* Available Models */}
{availableModels.length > 0 && (
<div>
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
Available Models
</div>
{availableModels.map((model) => (
<div
key={model.id}
onClick={() => 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"
: ""
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">{model.name}</div>
<div className="text-xs text-text/40 italic pr-4">
{model.description}
</div>
</div>
<div className="flex items-center gap-2">
{currentModelId === model.id && (
<div className="text-xs text-logo-primary">
Active
</div>
)}
{(() => {
const shouldShowDelete = currentModelId !== model.id;
return shouldShowDelete;
})() && (
<button
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
console.log(
"Delete button clicked for model:",
model.id,
);
console.log("Model info:", model);
try {
console.log("Deleting model:", model.id);
const result = await invoke("delete_model", {
modelId: model.id,
});
console.log("Delete result:", result);
// Refresh models list but keep dropdown open
await loadModels();
setModelError(null);
console.log("Model deleted and UI refreshed");
} catch (err) {
console.error("Delete failed with error:", err);
const errorMsg = `Failed to delete model: ${err}`;
setModelError(errorMsg);
setModelStatus("error");
onError?.(errorMsg);
}
}}
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
title={`Delete ${model.name}`}
>
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Downloadable Models */}
{downloadableModels.length > 0 && (
<div>
{(availableModels.length > 0 || isFirstRun) && (
<div className="border-t border-mid-gray/10 my-1" />
)}
<div className="px-3 py-1 text-xs font-medium text-text/80">
{isFirstRun ? "Choose a Model" : "Download Models"}
</div>
{downloadableModels.map((model) => (
<div
key={model.id}
onClick={(e) => {
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"
: ""
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">
{model.name}
{model.id === "small" && isFirstRun && (
<span className="ml-2 text-xs bg-logo-primary/20 text-logo-primary px-1.5 py-0.5 rounded">
Recommended
</span>
)}
</div>
<div className="text-xs text-text/40 italic pr-4">
{model.description}
</div>
</div>
{modelDownloadProgress.has(model.id) ? (
<div className="text-xs text-logo-primary tabular-nums">
{Math.max(
0,
Math.min(
100,
Math.round(
modelDownloadProgress.get(model.id)!.percentage,
),
),
)}
%
</div>
) : (
<div className="text-xs text-logo-primary">
Download
</div>
)}
</div>
{modelDownloadProgress.has(model.id) && (
<div className="mt-2">
<div className="w-full bg-mid-gray/20 rounded-full h-1.5">
<div
className="h-1.5 bg-logo-primary rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.max(0, Math.min(100, modelDownloadProgress.get(model.id)!.percentage))}%`,
}}
/>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* No Models Available */}
{availableModels.length === 0 &&
downloadableModels.length === 0 && (
<div className="px-3 py-2 text-sm text-text/60">
No models available
</div>
)}
</div>
)}
</div>
{/* Download Progress Bar for Models */}
{modelDownloadProgress.size > 0 && (
<div className="flex items-center gap-3">
{modelDownloadProgress.size === 1 ? (
// Single download - show detailed progress
(() => {
const [progress] = Array.from(modelDownloadProgress.values());
return (
<>
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary transition-all duration-500 ease-out"
style={{
width: `${Math.max(0, Math.min(100, progress.percentage))}%`,
}}
/>
</div>
<div className="text-xs text-text/60 tabular-nums min-w-fit">
{downloadStats.get(progress.model_id)?.speed &&
downloadStats.get(progress.model_id)!.speed > 0 ? (
<span>
{downloadStats.get(progress.model_id)!.speed.toFixed(1)}
MB/s
</span>
) : (
<span>Downloading...</span>
)}
</div>
</>
);
})()
) : (
// Multiple downloads - show summary
<div className="flex items-center gap-2">
<div className="flex gap-1">
{Array.from(modelDownloadProgress.values()).map(
(progress, index) => (
<div
key={progress.model_id}
className="w-3 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden"
>
<div
className="h-full bg-logo-primary transition-all duration-500 ease-out"
style={{
width: `${Math.max(0, Math.min(100, progress.percentage))}%`,
}}
/>
</div>
),
)}
</div>
<div className="text-xs text-text/60 min-w-fit">
{modelDownloadProgress.size} downloading...
</div>
</div>
)}
</div>
)}
</>
);
};
export default ModelSelector;

View file

@ -0,0 +1 @@
export { default } from './ModelSelector';

View file

@ -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<OnboardingProps> = ({ onModelSelected }) => {
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-4xl mx-auto text-center space-y-8">
<p className="text-text/70 max-w-md mx-auto">
To get started, choose a transcription model to download
</p>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:gap-6 max-w-2xl mx-auto">
{availableModels
.sort((a, b) => a.size_mb - b.size_mb)
.map((model) => (
<button
key={model.id}
onClick={() => handleDownloadModel(model.id)}
disabled={downloading}
className="relative border-2 border-mid-gray/20 rounded-xl p-4 sm:p-6 text-left hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
>
{getRecommendedBadge(model.id) && (
<div className="absolute -top-2 -right-2 bg-logo-primary text-white text-xs px-3 py-1 rounded-full font-medium shadow-md">
Recommended
</div>
)}
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-lg sm:text-xl font-semibold text-text group-hover:text-logo-primary transition-colors">
{model.name}
</h3>
<p className="text-text/60 text-xs sm:text-sm leading-relaxed">
{model.description}
</p>
</div>
</div>
</button>
))}
</div>
</div>
);
};
export default Onboarding;

View file

@ -0,0 +1 @@
export { default } from './Onboarding';

View file

@ -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<UpdateCheckerProps> = ({ 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<ReturnType<typeof setTimeout>>();
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 (
<div className={`flex items-center gap-3 ${className}`}>
{isUpdateClickable ? (
<button
onClick={getUpdateStatusAction()}
disabled={isUpdateDisabled}
className={`transition-colors disabled:opacity-50 tabular-nums ${
updateAvailable
? "text-logo-primary hover:text-logo-primary/80 font-medium"
: "text-text/60 hover:text-text/80"
}`}
>
{getUpdateStatusText()}
</button>
) : (
<span className="text-text/60 tabular-nums">
{getUpdateStatusText()}
</span>
)}
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
<progress
value={downloadProgress}
max={100}
className="w-20 h-2 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary"
/>
)}
</div>
);
};
export default UpdateChecker;

View file

@ -0,0 +1 @@
export { default } from './UpdateChecker';

186
src/hooks/useModels.ts Normal file
View file

@ -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<ModelInfo[]>([]);
const [currentModel, setCurrentModel] = useState<string>("");
const [downloadingModels, setDownloadingModels] = useState<Set<string>>(
new Set(),
);
const [downloadProgress, setDownloadProgress] = useState<
Map<string, DownloadProgress>
>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasAnyModels, setHasAnyModels] = useState(false);
const [isFirstRun, setIsFirstRun] = useState(false);
const loadModels = async () => {
try {
const modelList = await invoke<ModelInfo[]>("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<string>("get_current_model");
setCurrentModel(current);
} catch (err) {
console.error("Failed to load current model:", err);
}
};
const checkFirstRun = async () => {
try {
const hasModels = await invoke<boolean>("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<DownloadProgress>(
"model-download-progress",
(event) => {
setDownloadProgress(
(prev) => new Map(prev.set(event.payload.model_id, event.payload)),
);
},
);
// Listen for download completion
const completeUnlisten = listen<string>(
"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,
};
};

View file

@ -29,3 +29,13 @@ export type BindingResponse = z.infer<typeof BindingResponseSchema>;
export type ShortcutBinding = z.infer<typeof ShortcutBindingSchema>;
export type ShortcutBindingsMap = z.infer<typeof ShortcutBindingsMapSchema>;
export type Settings = z.infer<typeof SettingsSchema>;
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<typeof ModelInfoSchema>;