From 30a940629a5fb74e2cfa1ae23a00a2de7872989c Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 14 Mar 2026 12:19:06 +0800 Subject: [PATCH] be able to change models from the tray (#1002) * be able to change models from the tray * persist at the right times * fixes * fix tests --- src-tauri/src/commands/models.rs | 89 +++++++++++++++----- src-tauri/src/lib.rs | 19 +++++ src-tauri/src/managers/transcription.rs | 36 ++++++-- src-tauri/src/managers/transcription_mock.rs | 7 ++ src-tauri/src/tray.rs | 42 ++++++++- src/i18n/locales/ar/translation.json | 1 + src/i18n/locales/cs/translation.json | 1 + src/i18n/locales/de/translation.json | 1 + src/i18n/locales/en/translation.json | 1 + src/i18n/locales/es/translation.json | 1 + src/i18n/locales/fr/translation.json | 1 + src/i18n/locales/it/translation.json | 1 + src/i18n/locales/ja/translation.json | 1 + src/i18n/locales/ko/translation.json | 1 + src/i18n/locales/pl/translation.json | 1 + src/i18n/locales/pt/translation.json | 1 + src/i18n/locales/ru/translation.json | 1 + src/i18n/locales/tr/translation.json | 1 + src/i18n/locales/uk/translation.json | 1 + src/i18n/locales/vi/translation.json | 1 + src/i18n/locales/zh-TW/translation.json | 1 + src/i18n/locales/zh/translation.json | 1 + 22 files changed, 181 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index cb48984..a3197e3 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -1,8 +1,8 @@ use crate::managers::model::{ModelInfo, ModelManager}; -use crate::managers::transcription::TranscriptionManager; -use crate::settings::{get_settings, write_settings}; +use crate::managers::transcription::{ModelStateEvent, TranscriptionManager}; +use crate::settings::{get_settings, write_settings, ModelUnloadTimeout}; use std::sync::Arc; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, Manager, State}; #[tauri::command] #[specta::specta] @@ -58,36 +58,85 @@ pub async fn delete_model( .map_err(|e| e.to_string()) } -#[tauri::command] -#[specta::specta] -pub async fn set_active_model( - app_handle: AppHandle, - model_manager: State<'_, Arc>, - transcription_manager: State<'_, Arc>, - model_id: String, -) -> Result<(), String> { +/// Shared logic for switching the active model, used by both the Tauri command +/// and the tray menu handler. +/// +/// Validates the model, updates the persisted setting, and loads the model +/// unless the unload timeout is set to "Immediately" (in which case the model +/// will be loaded on-demand during the next transcription). +pub fn switch_active_model(app: &AppHandle, model_id: &str) -> Result<(), String> { + let model_manager = app.state::>(); + let transcription_manager = app.state::>(); + + // Atomically claim the loading slot — prevents concurrent model loads + // from tray double-clicks or overlapping commands. The guard resets the + // flag on drop (including early returns, errors, and panics). + let _loading_guard = transcription_manager + .try_start_loading() + .ok_or_else(|| "Model load already in progress".to_string())?; + // Check if model exists and is available let model_info = model_manager - .get_model_info(&model_id) + .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())?; + let settings = get_settings(app); + let unload_timeout = settings.model_unload_timeout; + let old_model = settings.selected_model.clone(); - // Update settings - let mut settings = get_settings(&app_handle); - settings.selected_model = model_id.clone(); - write_settings(&app_handle, settings); + // Persist the new selection early so the frontend sees the correct model + // when it reacts to events emitted by load_model. + let mut settings = settings; + settings.selected_model = model_id.to_string(); + write_settings(app, settings); + + // Skip eager loading if unload is set to "Immediately" — the model + // will be loaded on-demand during the next transcription. + if unload_timeout == ModelUnloadTimeout::Immediately { + // Notify frontend — load_model won't be called so no events + // would otherwise be emitted. + let _ = app.emit( + "model-state-changed", + ModelStateEvent { + event_type: "selection_changed".to_string(), + model_id: Some(model_id.to_string()), + model_name: Some(model_info.name.clone()), + error: None, + }, + ); + log::info!( + "Model selection changed to {} (not loading — unload set to Immediately).", + model_id + ); + return Ok(()); + } + + // Load the model. On failure, revert the persisted selection. + if let Err(e) = transcription_manager.load_model(model_id) { + let mut settings = get_settings(app); + settings.selected_model = old_model; + write_settings(app, settings); + return Err(e.to_string()); + } Ok(()) } +#[tauri::command] +#[specta::specta] +pub async fn set_active_model( + app_handle: AppHandle, + _model_manager: State<'_, Arc>, + _transcription_manager: State<'_, Arc>, + model_id: String, +) -> Result<(), String> { + switch_active_model(&app_handle, &model_id) +} + #[tauri::command] #[specta::specta] pub async fn get_current_model(app_handle: AppHandle) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index affcef8..92ba93f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -234,6 +234,25 @@ fn initialize_core_logic(app_handle: &AppHandle) { "quit" => { app.exit(0); } + id if id.starts_with("model_select:") => { + let model_id = id.strip_prefix("model_select:").unwrap().to_string(); + let current_model = settings::get_settings(app).selected_model; + if model_id == current_model { + return; + } + let app_clone = app.clone(); + std::thread::spawn(move || { + match commands::models::switch_active_model(&app_clone, &model_id) { + Ok(()) => { + log::info!("Model switched to {} via tray.", model_id); + } + Err(e) => { + log::error!("Failed to switch model via tray: {}", e); + } + } + tray::update_tray_menu(&app_clone, &tray::TrayIconState::Idle, None); + }); + } _ => {} }) .build(app_handle) diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 2beaea9..afedc49 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -46,6 +46,21 @@ enum LoadedEngine { GigaAM(GigaAMEngine), } +/// RAII guard that clears the `is_loading` flag and notifies waiters on drop. +/// Ensures the loading flag is always reset, even on early returns or panics. +pub struct LoadingGuard { + is_loading: Arc>, + loading_condvar: Arc, +} + +impl Drop for LoadingGuard { + fn drop(&mut self) { + let mut is_loading = self.is_loading.lock().unwrap(); + *is_loading = false; + self.loading_condvar.notify_all(); + } +} + #[derive(Clone)] pub struct TranscriptionManager { engine: Arc>>, @@ -96,11 +111,6 @@ impl TranscriptionManager { let timeout_seconds = settings.model_unload_timeout.to_seconds(); if let Some(limit_seconds) = timeout_seconds { - // Skip polling-based unloading for immediate timeout since it's handled directly in transcribe() - if settings.model_unload_timeout == ModelUnloadTimeout::Immediately { - continue; - } - let last = manager_cloned.last_activity.load(Ordering::Relaxed); let now_ms = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -154,6 +164,22 @@ impl TranscriptionManager { engine.is_some() } + /// Atomically check whether a model load is in progress and, if not, mark + /// one as starting. Returns a [`LoadingGuard`] whose [`Drop`] impl will + /// clear the flag and wake waiters. Returns `None` if a load is already in + /// progress. + pub fn try_start_loading(&self) -> Option { + let mut is_loading = self.is_loading.lock().unwrap(); + if *is_loading { + return None; + } + *is_loading = true; + Some(LoadingGuard { + is_loading: self.is_loading.clone(), + loading_condvar: self.loading_condvar.clone(), + }) + } + pub fn unload_model(&self) -> Result<()> { let unload_start = std::time::Instant::now(); debug!("Starting to unload model"); diff --git a/src-tauri/src/managers/transcription_mock.rs b/src-tauri/src/managers/transcription_mock.rs index 63c92ca..4976105 100644 --- a/src-tauri/src/managers/transcription_mock.rs +++ b/src-tauri/src/managers/transcription_mock.rs @@ -16,6 +16,9 @@ pub struct ModelStateEvent { pub error: Option, } +/// RAII guard that is a no-op in the mock — mirrors the real `LoadingGuard`. +pub struct LoadingGuard; + #[derive(Clone)] pub struct TranscriptionManager { #[allow(dead_code)] @@ -33,6 +36,10 @@ impl TranscriptionManager { false } + pub fn try_start_loading(&self) -> Option { + Some(LoadingGuard) + } + pub fn unload_model(&self) -> Result<()> { Ok(()) } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 31e0eff..f74cb40 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,11 +1,12 @@ use crate::managers::history::{HistoryEntry, HistoryManager}; +use crate::managers::model::ModelManager; use crate::managers::transcription::TranscriptionManager; use crate::settings; use crate::tray_i18n::get_tray_translations; use log::{error, info, warn}; use std::sync::Arc; use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::TrayIcon; use tauri::{AppHandle, Manager, Theme}; use tauri_plugin_clipboard_manager::ClipboardExt; @@ -125,6 +126,40 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<& ) .expect("failed to create copy last transcript item"); let model_loaded = app.state::>().is_model_loaded(); + let quit_i = MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator) + .expect("failed to create quit item"); + let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); + + // Build model submenu — label is the active model name + let model_manager = app.state::>(); + let models = model_manager.get_available_models(); + let current_model_id = &settings.selected_model; + + let mut downloaded: Vec<_> = models.into_iter().filter(|m| m.is_downloaded).collect(); + downloaded.sort_by(|a, b| a.name.cmp(&b.name)); + + let submenu_label = downloaded + .iter() + .find(|m| m.id == *current_model_id) + .map(|m| m.name.clone()) + .unwrap_or_else(|| strings.model.clone()); + + let model_submenu = { + let submenu = Submenu::with_id(app, "model_submenu", &submenu_label, true) + .expect("failed to create model submenu"); + + for model in &downloaded { + let is_active = model.id == *current_model_id; + let item_id = format!("model_select:{}", model.id); + let item = + CheckMenuItem::with_id(app, &item_id, &model.name, true, is_active, None::<&str>) + .expect("failed to create model item"); + let _ = submenu.append(&item); + } + + submenu + }; + let unload_model_i = MenuItem::with_id( app, "unload_model", @@ -133,9 +168,6 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<& None::<&str>, ) .expect("failed to create unload model item"); - let quit_i = MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator) - .expect("failed to create quit item"); - let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); let menu = match state { TrayIconState::Recording | TrayIconState::Transcribing => { @@ -164,6 +196,8 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<& &version_i, &separator(), ©_last_transcript_i, + &separator(), + &model_submenu, &unload_model_i, &separator(), &settings_i, diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 2372fe1..7fda3c0 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "...التحقق من وجود تحديثات", "copyLastTranscript": "نسخ آخر نص تم تفريغه", "unloadModel": "تفريغ النموذج", + "model": "النموذج", "quit": "إنهاء", "cancel": "إلغاء" }, diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 9ba2990..b8003ba 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Zkontrolovat aktualizace...", "copyLastTranscript": "Zkopírovat poslední přepis", "unloadModel": "Uvolnit model", + "model": "Model", "quit": "Ukončit", "cancel": "Zrušit" }, diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index bc77bd3..651a5af 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Nach Updates suchen...", "copyLastTranscript": "Letzte Transkription kopieren", "unloadModel": "Modell entladen", + "model": "Modell", "quit": "Beenden", "cancel": "Abbrechen" }, diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 275e1b5..b199e57 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Check for Updates...", "copyLastTranscript": "Copy Last Transcript", "unloadModel": "Unload Model", + "model": "Model", "quit": "Quit", "cancel": "Cancel" }, diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index cb3a580..259b7fa 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Buscar actualizaciones...", "copyLastTranscript": "Copiar la última transcripción", "unloadModel": "Descargar modelo", + "model": "Modelo", "quit": "Salir", "cancel": "Cancelar" }, diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 1de838a..c8609f8 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Rechercher des mises à jour...", "copyLastTranscript": "Copier la dernière transcription", "unloadModel": "Décharger le modèle", + "model": "Modèle", "quit": "Quitter", "cancel": "Annuler" }, diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 5b00e2b..71dcb82 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Verifica aggiornamenti...", "copyLastTranscript": "Copia l'ultima trascrizione", "unloadModel": "Scarica modello", + "model": "Modello", "quit": "Esci", "cancel": "Annulla" }, diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index a73e363..68cb0e9 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "アップデートを確認...", "copyLastTranscript": "最新の文字起こしをコピー", "unloadModel": "モデルをアンロード", + "model": "モデル", "quit": "終了", "cancel": "キャンセル" }, diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index dbbde1f..0970283 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "업데이트 확인...", "copyLastTranscript": "마지막 녹음 내용 복사", "unloadModel": "모델 언로드", + "model": "모델", "quit": "종료", "cancel": "취소" }, diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 9b4adf9..64d2445 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Sprawdź aktualizacje...", "copyLastTranscript": "Kopiuj ostatnią transkrypcję", "unloadModel": "Zwolnij model", + "model": "Model", "quit": "Zamknij", "cancel": "Anuluj" }, diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 7b0c91b..1c822a7 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Verificar Atualizações...", "copyLastTranscript": "Copiar última transcrição", "unloadModel": "Descarregar modelo", + "model": "Modelo", "quit": "Sair", "cancel": "Cancelar" }, diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index b60eb3d..4f3dfc6 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Проверить обновления...", "copyLastTranscript": "Скопировать последнюю транскрипцию", "unloadModel": "Выгрузить модель", + "model": "Модель", "quit": "Выход", "cancel": "Отмена" }, diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index cd06aa8..aa7ab9d 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Güncellemeleri Kontrol Et...", "copyLastTranscript": "Son transkripti kopyala", "unloadModel": "Modeli boşalt", + "model": "Model", "quit": "Çıkış", "cancel": "İptal" }, diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 438d1e1..0aeedcb 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Перевірити оновлення...", "copyLastTranscript": "Скопіювати останню транскрипцію", "unloadModel": "Вивантажити модель", + "model": "Модель", "quit": "Вийти", "cancel": "Скасувати" }, diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 6365439..be4f92c 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "Kiểm tra cập nhật...", "copyLastTranscript": "Sao chép bản chép lời mới nhất", "unloadModel": "Dỡ mô hình", + "model": "Mô hình", "quit": "Thoát", "cancel": "Hủy" }, diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 81bd9de..1152ecd 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "檢查更新...", "copyLastTranscript": "複製最新轉錄", "unloadModel": "卸載模型", + "model": "模型", "quit": "結束", "cancel": "取消" }, diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index fb528a7..ca352b1 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -4,6 +4,7 @@ "checkUpdates": "检查更新...", "copyLastTranscript": "复制最新转录", "unloadModel": "卸载模型", + "model": "模型", "quit": "退出", "cancel": "取消" },