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::model::{ModelInfo, ModelManager};
use crate::managers::transcription::TranscriptionManager; use crate::managers::transcription::{ModelStateEvent, TranscriptionManager};
use crate::settings::{get_settings, write_settings}; use crate::settings::{get_settings, write_settings, ModelUnloadTimeout};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, State}; use tauri::{AppHandle, Emitter, Manager, State};
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@ -58,36 +58,85 @@ pub async fn delete_model(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] /// Shared logic for switching the active model, used by both the Tauri command
#[specta::specta] /// and the tray menu handler.
pub async fn set_active_model( ///
app_handle: AppHandle, /// Validates the model, updates the persisted setting, and loads the model
model_manager: State<'_, Arc<ModelManager>>, /// unless the unload timeout is set to "Immediately" (in which case the model
transcription_manager: State<'_, Arc<TranscriptionManager>>, /// will be loaded on-demand during the next transcription).
model_id: String, pub fn switch_active_model(app: &AppHandle, model_id: &str) -> Result<(), String> {
) -> 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 // Check if model exists and is available
let model_info = model_manager let model_info = model_manager
.get_model_info(&model_id) .get_model_info(model_id)
.ok_or_else(|| format!("Model not found: {}", model_id))?; .ok_or_else(|| format!("Model not found: {}", model_id))?;
if !model_info.is_downloaded { if !model_info.is_downloaded {
return Err(format!("Model not downloaded: {}", model_id)); return Err(format!("Model not downloaded: {}", model_id));
} }
// Load the model in the transcription manager let settings = get_settings(app);
transcription_manager let unload_timeout = settings.model_unload_timeout;
.load_model(&model_id) let old_model = settings.selected_model.clone();
.map_err(|e| e.to_string())?;
// Update settings // Persist the new selection early so the frontend sees the correct model
let mut settings = get_settings(&app_handle); // when it reacts to events emitted by load_model.
settings.selected_model = model_id.clone(); let mut settings = settings;
write_settings(&app_handle, 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(()) 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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_current_model(app_handle: AppHandle) -> Result<String, String> { 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" => { "quit" => {
app.exit(0); 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) .build(app_handle)

View file

@ -46,6 +46,21 @@ enum LoadedEngine {
GigaAM(GigaAMEngine), 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)] #[derive(Clone)]
pub struct TranscriptionManager { pub struct TranscriptionManager {
engine: Arc<Mutex<Option<LoadedEngine>>>, engine: Arc<Mutex<Option<LoadedEngine>>>,
@ -96,11 +111,6 @@ impl TranscriptionManager {
let timeout_seconds = settings.model_unload_timeout.to_seconds(); let timeout_seconds = settings.model_unload_timeout.to_seconds();
if let Some(limit_seconds) = timeout_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 last = manager_cloned.last_activity.load(Ordering::Relaxed);
let now_ms = SystemTime::now() let now_ms = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
@ -154,6 +164,22 @@ impl TranscriptionManager {
engine.is_some() 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<()> { pub fn unload_model(&self) -> Result<()> {
let unload_start = std::time::Instant::now(); let unload_start = std::time::Instant::now();
debug!("Starting to unload model"); debug!("Starting to unload model");

View file

@ -16,6 +16,9 @@ pub struct ModelStateEvent {
pub error: Option<String>, pub error: Option<String>,
} }
/// RAII guard that is a no-op in the mock — mirrors the real `LoadingGuard`.
pub struct LoadingGuard;
#[derive(Clone)] #[derive(Clone)]
pub struct TranscriptionManager { pub struct TranscriptionManager {
#[allow(dead_code)] #[allow(dead_code)]
@ -33,6 +36,10 @@ impl TranscriptionManager {
false false
} }
pub fn try_start_loading(&self) -> Option<LoadingGuard> {
Some(LoadingGuard)
}
pub fn unload_model(&self) -> Result<()> { pub fn unload_model(&self) -> Result<()> {
Ok(()) Ok(())
} }

View file

@ -1,11 +1,12 @@
use crate::managers::history::{HistoryEntry, HistoryManager}; use crate::managers::history::{HistoryEntry, HistoryManager};
use crate::managers::model::ModelManager;
use crate::managers::transcription::TranscriptionManager; use crate::managers::transcription::TranscriptionManager;
use crate::settings; use crate::settings;
use crate::tray_i18n::get_tray_translations; use crate::tray_i18n::get_tray_translations;
use log::{error, info, warn}; use log::{error, info, warn};
use std::sync::Arc; use std::sync::Arc;
use tauri::image::Image; use tauri::image::Image;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::tray::TrayIcon; use tauri::tray::TrayIcon;
use tauri::{AppHandle, Manager, Theme}; use tauri::{AppHandle, Manager, Theme};
use tauri_plugin_clipboard_manager::ClipboardExt; 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"); .expect("failed to create copy last transcript item");
let model_loaded = app.state::<Arc<TranscriptionManager>>().is_model_loaded(); 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( let unload_model_i = MenuItem::with_id(
app, app,
"unload_model", "unload_model",
@ -133,9 +168,6 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<&
None::<&str>, None::<&str>,
) )
.expect("failed to create unload model item"); .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 { let menu = match state {
TrayIconState::Recording | TrayIconState::Transcribing => { TrayIconState::Recording | TrayIconState::Transcribing => {
@ -164,6 +196,8 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<&
&version_i, &version_i,
&separator(), &separator(),
&copy_last_transcript_i, &copy_last_transcript_i,
&separator(),
&model_submenu,
&unload_model_i, &unload_model_i,
&separator(), &separator(),
&settings_i, &settings_i,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@
"checkUpdates": "Rechercher des mises à jour...", "checkUpdates": "Rechercher des mises à jour...",
"copyLastTranscript": "Copier la dernière transcription", "copyLastTranscript": "Copier la dernière transcription",
"unloadModel": "Décharger le modèle", "unloadModel": "Décharger le modèle",
"model": "Modèle",
"quit": "Quitter", "quit": "Quitter",
"cancel": "Annuler" "cancel": "Annuler"
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@
"checkUpdates": "Kiểm tra cập nhật...", "checkUpdates": "Kiểm tra cập nhật...",
"copyLastTranscript": "Sao chép bản chép lời mới nhất", "copyLastTranscript": "Sao chép bản chép lời mới nhất",
"unloadModel": "Dỡ mô hình", "unloadModel": "Dỡ mô hình",
"model": "Mô hình",
"quit": "Thoát", "quit": "Thoát",
"cancel": "Hủy" "cancel": "Hủy"
}, },

View file

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

View file

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