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:
parent
79f28f515c
commit
30a940629a
22 changed files with 181 additions and 29 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
©_last_transcript_i,
|
||||
&separator(),
|
||||
&model_submenu,
|
||||
&unload_model_i,
|
||||
&separator(),
|
||||
&settings_i,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "...التحقق من وجود تحديثات",
|
||||
"copyLastTranscript": "نسخ آخر نص تم تفريغه",
|
||||
"unloadModel": "تفريغ النموذج",
|
||||
"model": "النموذج",
|
||||
"quit": "إنهاء",
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Zkontrolovat aktualizace...",
|
||||
"copyLastTranscript": "Zkopírovat poslední přepis",
|
||||
"unloadModel": "Uvolnit model",
|
||||
"model": "Model",
|
||||
"quit": "Ukončit",
|
||||
"cancel": "Zrušit"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Nach Updates suchen...",
|
||||
"copyLastTranscript": "Letzte Transkription kopieren",
|
||||
"unloadModel": "Modell entladen",
|
||||
"model": "Modell",
|
||||
"quit": "Beenden",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Check for Updates...",
|
||||
"copyLastTranscript": "Copy Last Transcript",
|
||||
"unloadModel": "Unload Model",
|
||||
"model": "Model",
|
||||
"quit": "Quit",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Buscar actualizaciones...",
|
||||
"copyLastTranscript": "Copiar la última transcripción",
|
||||
"unloadModel": "Descargar modelo",
|
||||
"model": "Modelo",
|
||||
"quit": "Salir",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Verifica aggiornamenti...",
|
||||
"copyLastTranscript": "Copia l'ultima trascrizione",
|
||||
"unloadModel": "Scarica modello",
|
||||
"model": "Modello",
|
||||
"quit": "Esci",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "アップデートを確認...",
|
||||
"copyLastTranscript": "最新の文字起こしをコピー",
|
||||
"unloadModel": "モデルをアンロード",
|
||||
"model": "モデル",
|
||||
"quit": "終了",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "업데이트 확인...",
|
||||
"copyLastTranscript": "마지막 녹음 내용 복사",
|
||||
"unloadModel": "모델 언로드",
|
||||
"model": "모델",
|
||||
"quit": "종료",
|
||||
"cancel": "취소"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Sprawdź aktualizacje...",
|
||||
"copyLastTranscript": "Kopiuj ostatnią transkrypcję",
|
||||
"unloadModel": "Zwolnij model",
|
||||
"model": "Model",
|
||||
"quit": "Zamknij",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Verificar Atualizações...",
|
||||
"copyLastTranscript": "Copiar última transcrição",
|
||||
"unloadModel": "Descarregar modelo",
|
||||
"model": "Modelo",
|
||||
"quit": "Sair",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Проверить обновления...",
|
||||
"copyLastTranscript": "Скопировать последнюю транскрипцию",
|
||||
"unloadModel": "Выгрузить модель",
|
||||
"model": "Модель",
|
||||
"quit": "Выход",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Güncellemeleri Kontrol Et...",
|
||||
"copyLastTranscript": "Son transkripti kopyala",
|
||||
"unloadModel": "Modeli boşalt",
|
||||
"model": "Model",
|
||||
"quit": "Çıkış",
|
||||
"cancel": "İptal"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "Перевірити оновлення...",
|
||||
"copyLastTranscript": "Скопіювати останню транскрипцію",
|
||||
"unloadModel": "Вивантажити модель",
|
||||
"model": "Модель",
|
||||
"quit": "Вийти",
|
||||
"cancel": "Скасувати"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "檢查更新...",
|
||||
"copyLastTranscript": "複製最新轉錄",
|
||||
"unloadModel": "卸載模型",
|
||||
"model": "模型",
|
||||
"quit": "結束",
|
||||
"cancel": "取消"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"checkUpdates": "检查更新...",
|
||||
"copyLastTranscript": "复制最新转录",
|
||||
"unloadModel": "卸载模型",
|
||||
"model": "模型",
|
||||
"quit": "退出",
|
||||
"cancel": "取消"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue