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
This commit is contained in:
CJ Pais 2026-03-14 12:19:06 +08:00 committed by GitHub
parent 79f28f515c
commit 30a940629a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 181 additions and 29 deletions

View file

@ -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<ModelManager>>,
transcription_manager: State<'_, Arc<TranscriptionManager>>,
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::<Arc<ModelManager>>();
let transcription_manager = app.state::<Arc<TranscriptionManager>>();
// 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<ModelManager>>,
_transcription_manager: State<'_, Arc<TranscriptionManager>>,
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<String, String> {

View file

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

View file

@ -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<Mutex<bool>>,
loading_condvar: Arc<Condvar>,
}
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<Mutex<Option<LoadedEngine>>>,
@ -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<LoadingGuard> {
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");

View file

@ -16,6 +16,9 @@ pub struct ModelStateEvent {
pub error: Option<String>,
}
/// 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<LoadingGuard> {
Some(LoadingGuard)
}
pub fn unload_model(&self) -> Result<()> {
Ok(())
}

View file

@ -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::<Arc<TranscriptionManager>>().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::<Arc<ModelManager>>();
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(),
&copy_last_transcript_i,
&separator(),
&model_submenu,
&unload_model_i,
&separator(),
&settings_i,

View file

@ -4,6 +4,7 @@
"checkUpdates": "...التحقق من وجود تحديثات",
"copyLastTranscript": "نسخ آخر نص تم تفريغه",
"unloadModel": "تفريغ النموذج",
"model": "النموذج",
"quit": "إنهاء",
"cancel": "إلغاء"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Zkontrolovat aktualizace...",
"copyLastTranscript": "Zkopírovat poslední přepis",
"unloadModel": "Uvolnit model",
"model": "Model",
"quit": "Ukončit",
"cancel": "Zrušit"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Nach Updates suchen...",
"copyLastTranscript": "Letzte Transkription kopieren",
"unloadModel": "Modell entladen",
"model": "Modell",
"quit": "Beenden",
"cancel": "Abbrechen"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Check for Updates...",
"copyLastTranscript": "Copy Last Transcript",
"unloadModel": "Unload Model",
"model": "Model",
"quit": "Quit",
"cancel": "Cancel"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Buscar actualizaciones...",
"copyLastTranscript": "Copiar la última transcripción",
"unloadModel": "Descargar modelo",
"model": "Modelo",
"quit": "Salir",
"cancel": "Cancelar"
},

View file

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

View file

@ -4,6 +4,7 @@
"checkUpdates": "Verifica aggiornamenti...",
"copyLastTranscript": "Copia l'ultima trascrizione",
"unloadModel": "Scarica modello",
"model": "Modello",
"quit": "Esci",
"cancel": "Annulla"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "アップデートを確認...",
"copyLastTranscript": "最新の文字起こしをコピー",
"unloadModel": "モデルをアンロード",
"model": "モデル",
"quit": "終了",
"cancel": "キャンセル"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "업데이트 확인...",
"copyLastTranscript": "마지막 녹음 내용 복사",
"unloadModel": "모델 언로드",
"model": "모델",
"quit": "종료",
"cancel": "취소"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Sprawdź aktualizacje...",
"copyLastTranscript": "Kopiuj ostatnią transkrypcję",
"unloadModel": "Zwolnij model",
"model": "Model",
"quit": "Zamknij",
"cancel": "Anuluj"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Verificar Atualizações...",
"copyLastTranscript": "Copiar última transcrição",
"unloadModel": "Descarregar modelo",
"model": "Modelo",
"quit": "Sair",
"cancel": "Cancelar"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Проверить обновления...",
"copyLastTranscript": "Скопировать последнюю транскрипцию",
"unloadModel": "Выгрузить модель",
"model": "Модель",
"quit": "Выход",
"cancel": "Отмена"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Güncellemeleri Kontrol Et...",
"copyLastTranscript": "Son transkripti kopyala",
"unloadModel": "Modeli boşalt",
"model": "Model",
"quit": ıkış",
"cancel": "İptal"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "Перевірити оновлення...",
"copyLastTranscript": "Скопіювати останню транскрипцію",
"unloadModel": "Вивантажити модель",
"model": "Модель",
"quit": "Вийти",
"cancel": "Скасувати"
},

View file

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

View file

@ -4,6 +4,7 @@
"checkUpdates": "檢查更新...",
"copyLastTranscript": "複製最新轉錄",
"unloadModel": "卸載模型",
"model": "模型",
"quit": "結束",
"cancel": "取消"
},

View file

@ -4,6 +4,7 @@
"checkUpdates": "检查更新...",
"copyLastTranscript": "复制最新转录",
"unloadModel": "卸载模型",
"model": "模型",
"quit": "退出",
"cancel": "取消"
},