From 702e09405e752bf90187d461f438c7757e595200 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 16:33:47 +0800 Subject: [PATCH] feat(asr): expose Foundry local ASR status --- openless-all/app/src-tauri/src/commands.rs | 165 +++++++++++++++++++-- openless-all/app/src-tauri/src/lib.rs | 6 + 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 17b243f3..a8e3d885 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -7,6 +7,11 @@ use serde::Serialize; use serde_json::Value; use tauri::{AppHandle, Emitter, State}; +use crate::asr::local::foundry::{ + model_alias_is_known, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, + PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, +}; +use crate::asr::local::FoundryLocalRuntime; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; @@ -124,9 +129,7 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo if provider == "volcengine" { return volcengine_configured(snap); } - if provider == crate::asr::local::PROVIDER_ID - || crate::asr::local::foundry::is_foundry_local_whisper(provider) - { + if provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID { // 本地 ASR 不依赖云端凭据。 return true; } @@ -155,7 +158,10 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> { } #[tauri::command] -pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> { +pub fn set_active_asr_provider( + coord: CoordinatorState<'_>, + provider: String, +) -> Result<(), String> { CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; if provider == crate::asr::local::PROVIDER_ID { // 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。 @@ -282,6 +288,11 @@ async fn validate_llm_provider() -> Result<(), String> { } async fn validate_asr_provider() -> Result<(), String> { + let active_asr = CredentialsVault::get_active_asr(); + if active_asr_is_keyless_for_validation(&active_asr) { + return Ok(()); + } + let config = read_openai_provider_config("asr")?; let model = CredentialsVault::get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? @@ -290,6 +301,10 @@ async fn validate_asr_provider() -> Result<(), String> { validate_asr_transcription(&config, model.trim()).await } +fn active_asr_is_keyless_for_validation(provider: &str) -> bool { + provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID +} + async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024; let url = asr_transcriptions_url(&config.base_url)?; @@ -769,7 +784,10 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { } #[tauri::command] -pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { +pub fn local_asr_set_active_model( + coord: CoordinatorState<'_>, + model_id: String, +) -> Result<(), String> { if ModelId::from_str(&model_id).is_none() { return Err(format!("unknown model id: {model_id}")); } @@ -827,10 +845,7 @@ pub fn local_asr_cancel_download( } #[tauri::command] -pub fn local_asr_delete_model( - coord: CoordinatorState<'_>, - model_id: String, -) -> Result<(), String> { +pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; // 如果内存里加载的就是要删的这个模型,先释放:否则 mmap 残留指向已 unlink 的文件, // 且 RAM 直到下次切模型 / 用户手动按"释放"才回收。 @@ -888,6 +903,76 @@ pub fn local_asr_set_keep_loaded_secs( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +// ───────────────────── Windows local ASR (Foundry Local Whisper) ───────────────────── + +fn active_foundry_model_from_prefs(prefs: &UserPreferences) -> String { + if model_alias_is_known(&prefs.foundry_local_asr_model) { + prefs.foundry_local_asr_model.clone() + } else { + DEFAULT_MODEL_ALIAS.to_string() + } +} + +fn validate_foundry_model_alias(model_alias: &str) -> Result<(), String> { + if model_alias_is_known(model_alias) { + Ok(()) + } else { + Err(format!( + "unknown Foundry Whisper model alias: {model_alias}" + )) + } +} + +fn normalize_foundry_language_hint(language_hint: &str) -> Result { + let normalized = language_hint.trim().to_string(); + if normalized.is_empty() + || (normalized.len() == 2 && normalized.bytes().all(|b| b.is_ascii_lowercase())) + { + Ok(normalized) + } else { + Err("language hint must be empty or ISO 639-1 lowercase code".to_string()) + } +} + +#[tauri::command] +pub fn foundry_local_asr_status( + coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, +) -> FoundryRuntimeStatus { + let prefs = coord.prefs().get(); + let active_model = active_foundry_model_from_prefs(&prefs); + runtime.status_snapshot(&active_model) +} + +#[tauri::command] +pub fn foundry_local_asr_set_model( + coord: CoordinatorState<'_>, + model_alias: String, +) -> Result<(), String> { + validate_foundry_model_alias(&model_alias)?; + let mut prefs = coord.prefs().get(); + prefs.foundry_local_asr_model = model_alias; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn foundry_local_asr_set_language_hint( + coord: CoordinatorState<'_>, + language_hint: String, +) -> Result<(), String> { + let normalized = normalize_foundry_language_hint(&language_hint)?; + let mut prefs = coord.prefs().get(); + prefs.foundry_local_asr_language_hint = normalized; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn foundry_local_asr_release( + runtime: State<'_, Arc>, +) -> Result<(), String> { + runtime.release_now().await.map_err(|e| format!("{e:#}")) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] @@ -896,9 +981,11 @@ fn _ensure_snapshot_used(_: CredentialsSnapshot) {} #[cfg(test)] mod tests { use super::{ + active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - llm_configured_for_snapshot, models_url, parse_model_ids, persist_settings, - ProviderConfig, SettingsWriter, + llm_configured_for_snapshot, models_url, + normalize_foundry_language_hint, parse_model_ids, persist_settings, + validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -956,6 +1043,62 @@ mod tests { )); } + #[test] + fn credentials_status_treats_foundry_local_asr_as_configured() { + assert!(asr_configured_for_provider( + crate::asr::local::foundry::PROVIDER_ID, + &CredentialsSnapshot::default() + )); + } + + #[test] + fn local_asr_providers_skip_external_validation() { + assert!(active_asr_is_keyless_for_validation( + crate::asr::local::PROVIDER_ID + )); + assert!(active_asr_is_keyless_for_validation( + crate::asr::local::foundry::PROVIDER_ID + )); + assert!(!active_asr_is_keyless_for_validation("volcengine")); + assert!(!active_asr_is_keyless_for_validation("whisper")); + } + + #[test] + fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() { + assert_eq!(normalize_foundry_language_hint("").unwrap(), ""); + assert_eq!(normalize_foundry_language_hint(" ").unwrap(), ""); + assert_eq!(normalize_foundry_language_hint("zh").unwrap(), "zh"); + assert_eq!(normalize_foundry_language_hint(" en ").unwrap(), "en"); + } + + #[test] + fn foundry_language_hint_rejects_non_lowercase_iso_639_1() { + assert!(normalize_foundry_language_hint("ZH").is_err()); + assert!(normalize_foundry_language_hint("zho").is_err()); + assert!(normalize_foundry_language_hint("z1").is_err()); + } + + #[test] + fn foundry_model_alias_validation_rejects_unknown_alias() { + assert!( + validate_foundry_model_alias(crate::asr::local::foundry::DEFAULT_MODEL_ALIAS).is_ok() + ); + assert!(validate_foundry_model_alias("whisper-large").is_err()); + } + + #[test] + fn foundry_active_model_pref_falls_back_to_default_for_unknown_alias() { + let prefs = UserPreferences { + foundry_local_asr_model: "whisper-large".to_string(), + ..Default::default() + }; + + assert_eq!( + active_foundry_model_from_prefs(&prefs), + crate::asr::local::foundry::DEFAULT_MODEL_ALIAS + ); + } + #[test] fn credentials_status_accepts_keyless_llm_with_endpoint_and_model() { let keyless_ready = CredentialsSnapshot { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index fd18b20b..bec4af5e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ pub fn run() { let coordinator = Arc::new(coordinator::Coordinator::new()); let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new()); + let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); tauri::Builder::default() // 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。 @@ -71,6 +72,7 @@ pub fn run() { )) .manage(coordinator.clone()) .manage(local_asr_download_manager.clone()) + .manage(foundry_local_runtime.clone()) .setup(move |app| { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 @@ -244,6 +246,10 @@ pub fn run() { commands::local_asr_release_engine, commands::local_asr_preload, commands::local_asr_set_keep_loaded_secs, + commands::foundry_local_asr_status, + commands::foundry_local_asr_set_model, + commands::foundry_local_asr_set_language_hint, + commands::foundry_local_asr_release, restart_app, ]) .build(tauri::generate_context!())