diff --git a/docs/linux-egui-command-event-baseline.json b/docs/linux-egui-command-event-baseline.json index 786bbbff5..a9bf73ac2 100644 --- a/docs/linux-egui-command-event-baseline.json +++ b/docs/linux-egui-command-event-baseline.json @@ -63,6 +63,7 @@ "foundry_local_asr_release", "foundry_local_asr_reveal_model_dir", "foundry_local_asr_set_language_hint", + "foundry_local_asr_set_keep_loaded_secs", "foundry_local_asr_set_model", "foundry_local_asr_set_runtime_source", "foundry_local_asr_status", diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 94f66b7c9..ae7b25c17 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -317,6 +317,7 @@ pub use settings::*; pub use shared_types::{ CapsulePayload, CapsuleState, CapsuleStyle, CredentialsStatus, HotkeyMode, HotkeyStatus, PendingCorrection, PlatformCapabilities, SelectionPolishOutputMode, UserPreferences, + LOCAL_ASR_KEEP_LOADED_FOREVER_SECS, }; pub use shortcut_types::{ binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index c6c50adf7..a19a1cc5d 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -19,6 +19,9 @@ pub use crate::android_types::{ pub use crate::types::{HistorySource, PolishMode}; +/// 本地 ASR 保持加载设置的兼容值:不自动释放,仅由显式操作或进程退出卸载。 +pub const LOCAL_ASR_KEEP_LOADED_FOREVER_SECS: u32 = 86_400; + /// 识别管线模式(issue #902):`traditional` = 两段式 ASR + LLM 润色; /// `multimodal` = 单个多模态模型一步完成「音频 + 提示词 → 最终文本」。 /// 两套配置在凭据库中完全隔离,运行时只读当前模式,切换不删除另一套配置。 @@ -530,7 +533,7 @@ pub struct UserPreferences { #[serde(default = "default_local_asr_mirror")] pub local_asr_mirror: String, /// 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放; - /// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 一天 ≈ 永不释放。 + /// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 永不自动释放。 /// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。 #[serde(default = "default_local_asr_keep_loaded_secs")] pub local_asr_keep_loaded_secs: u32, diff --git a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs index 14064a92b..ded492624 100644 --- a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs @@ -1458,6 +1458,8 @@ async fn backend_local_asr_service_owns_preferences_and_change_events() { assert_eq!(preferences.sherpa_onnx_language_hint, "zh-hans"); assert_eq!(preferences.foundry_local_runtime_source, "ort-nightly"); assert_eq!(preferences.foundry_local_asr_keep_loaded_secs, 42); + assert_eq!(preferences.local_asr_keep_loaded_secs, 300); + assert_eq!(preferences.sherpa_onnx_keep_loaded_secs, 300); assert_eq!( runtime.invalidated.lock().unwrap().as_slice(), [LocalAsrRuntime::Foundry, LocalAsrRuntime::Foundry] diff --git a/openless-all/app/scripts/local-asr-polling-contract.test.mjs b/openless-all/app/scripts/local-asr-polling-contract.test.mjs index bbb507f84..184024819 100644 --- a/openless-all/app/scripts/local-asr-polling-contract.test.mjs +++ b/openless-all/app/scripts/local-asr-polling-contract.test.mjs @@ -81,6 +81,22 @@ for (const contract of [ } } +for (const contract of [ + 'setFoundryLocalAsrKeepLoadedSecs', + 'foundryStatus?.keepLoadedSecs ?? 300', +]) { + if (!source.includes(contract)) { + throw new Error(`Foundry keep-loaded UI contract is missing: ${contract}`); + } +} + +const keepLoadedOptionUses = source.match(/options=\{keepLoadedOptions\}/g) ?? []; +if (keepLoadedOptionUses.length !== 2) { + throw new Error( + `Generic and Foundry keep-loaded selectors must share the options, found ${keepLoadedOptionUses.length}`, + ); +} + console.log( 'LocalAsr keeps one refresh poller, pauses it for the download dialog, and preserves stable JSX component types', ); diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 6338a93e2..16676d8cd 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -130,9 +130,12 @@ mod imp { } use anyhow::{Context, Result}; - use foundry_local_sdk::{DeviceType, FoundryLocalConfig, FoundryLocalManager, Model}; + use foundry_local_sdk::{ + AudioTranscriptionResponse, DeviceType, FoundryLocalConfig, FoundryLocalManager, Model, + }; + use futures_util::{Stream, StreamExt}; use parking_lot::Mutex; - use tokio::sync::Mutex as AsyncMutex; + use tokio::sync::{Mutex as AsyncMutex, OnceCell}; use super::{ FoundryCpuFallbackTerminalError, FoundryFallbackNotice, FoundryFallbackNoticeCallback, @@ -493,6 +496,19 @@ mod imp { .ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper total timeout exhausted")) } + async fn collect_foundry_transcription_text( + mut stream: S, + ) -> std::result::Result + where + S: Stream> + Unpin, + { + let mut text = String::new(); + while let Some(chunk) = stream.next().await { + text.push_str(&chunk?.text); + } + Ok(text) + } + struct FoundrySdkExecution<'a> { runtime: &'a FoundryLocalRuntime, manager: &'static FoundryLocalManager, @@ -546,16 +562,19 @@ mod imp { client = client.language(language_hint); } let model_id = self.loaded.model_id.clone(); - let result = tokio::time::timeout(timeout, client.transcribe(audio_path)) - .await - .with_context(|| { - format!( - "transcribe audio with Foundry model {model_id} timed out after {} seconds", - timeout.as_secs() - ) - })? - .with_context(|| format!("transcribe audio with Foundry model {model_id}"))?; - Ok(result.text) + let result = tokio::time::timeout(timeout, async { + let stream = client.transcribe_streaming(audio_path).await?; + collect_foundry_transcription_text(stream).await + }) + .await + .with_context(|| { + format!( + "transcribe audio with Foundry model {model_id} timed out after {} seconds", + timeout.as_secs() + ) + })? + .with_context(|| format!("transcribe audio with Foundry model {model_id}"))?; + Ok(result) } async fn switch_to_cpu( @@ -692,6 +711,8 @@ mod imp { /// 仍可中断(`cancel_prepare` + `check_prepare_cancelled`)。若未来要缩小粒度, /// 可让下载阶段不持锁、下载完成后重新校验 route epoch 再持锁加载/推理。 lifecycle: AsyncMutex<()>, + /// EP 注册会使 SDK 的模型目录缓存失效;成功后本进程不再重复注册。 + execution_providers_ready: OnceCell<()>, cancel_prepare: Arc, temporary_cpu_fallback_sequence: AtomicU64, route_epoch: AtomicU64, @@ -708,6 +729,7 @@ mod imp { pub fn new() -> Self { Self { lifecycle: AsyncMutex::new(()), + execution_providers_ready: OnceCell::new(), cancel_prepare: Arc::new(AtomicBool::new(false)), temporary_cpu_fallback_sequence: AtomicU64::new(0), route_epoch: AtomicU64::new(0), @@ -1084,24 +1106,33 @@ mod imp { )); let runtime_progress = Arc::clone(&progress); let runtime_alias = alias.to_string(); - manager - .download_and_register_eps_with_progress( - None, - move |ep_name: &str, percent: f64| { - let label = if ep_name.trim().is_empty() { - "Foundry Local runtime components".to_string() - } else { - format!("Foundry Local runtime component: {ep_name}") - }; - runtime_progress.as_ref()(FoundryPrepareProgressPayload::runtime( - runtime_alias.clone(), - label, - percent, - )); - }, - ) - .await - .context("download/register Foundry execution providers")?; + let cancel_prepare = Arc::clone(&self.cancel_prepare); + self.execution_providers_ready + .get_or_try_init(|| async move { + manager + .download_and_register_eps_with_progress( + None, + move |ep_name: &str, percent: f64| { + let label = if ep_name.trim().is_empty() { + "Foundry Local runtime components".to_string() + } else { + format!("Foundry Local runtime component: {ep_name}") + }; + runtime_progress.as_ref()(FoundryPrepareProgressPayload::runtime( + runtime_alias.clone(), + label, + percent, + )); + }, + ) + .await + .context("download/register Foundry execution providers")?; + if cancel_prepare.load(Ordering::SeqCst) { + anyhow::bail!("Foundry Local Whisper prepare cancelled"); + } + Ok::<(), anyhow::Error>(()) + }) + .await?; progress.as_ref()(FoundryPrepareProgressPayload::runtime( alias, "Foundry Local runtime components", @@ -1662,15 +1693,16 @@ mod imp { } use super::{ - cpu_load_completion, foundry_native_dir_candidates, is_cuda_cudnn_failure, - is_cuda_fallback_candidate, may_reuse_loaded_model, normalized_language_hint, - select_cpu_variant_id, select_foundry_native_dir, + collect_foundry_transcription_text, cpu_load_completion, foundry_native_dir_candidates, + is_cuda_cudnn_failure, is_cuda_fallback_candidate, may_reuse_loaded_model, + normalized_language_hint, select_cpu_variant_id, select_foundry_native_dir, should_release_temporary_cpu_fallback, transcribe_recording_with_adapter, FoundryCpuLoadCompletion, FoundryCpuSwitch, FoundryExecutionAdapter, FoundryExecutionDevice, FoundryFallbackNotice, FoundryFallbackNoticeCallback, FoundryLocalRuntime, FoundryVariantDescriptor, }; use anyhow::Result; + use foundry_local_sdk::AudioTranscriptionResponse; use std::{ collections::VecDeque, fs, @@ -1823,6 +1855,45 @@ mod imp { (callback, received) } + fn transcription_response(text: &str) -> AudioTranscriptionResponse { + AudioTranscriptionResponse { + text: text.to_string(), + language: None, + duration: None, + segments: None, + words: None, + } + } + + #[tokio::test] + async fn foundry_streaming_transcription_preserves_ordered_unicode_chunks() { + let stream = futures_util::stream::iter([ + Ok::<_, &'static str>(transcription_response("中文")), + Ok(transcription_response("")), + Ok(transcription_response("转写完成")), + ]); + + assert_eq!( + collect_foundry_transcription_text(stream).await.unwrap(), + "中文转写完成" + ); + } + + #[tokio::test] + async fn foundry_streaming_transcription_propagates_chunk_errors() { + let stream = futures_util::stream::iter([ + Ok(transcription_response("partial")), + Err("stream failed"), + ]); + + assert_eq!( + collect_foundry_transcription_text(stream) + .await + .unwrap_err(), + "stream failed" + ); + } + #[tokio::test] async fn cuda_failure_retries_the_failed_chunk_once_on_cpu_and_keeps_cpu_for_later_chunks() { diff --git a/openless-all/app/src-tauri/src/commands/foundry_asr.rs b/openless-all/app/src-tauri/src/commands/foundry_asr.rs index 7847d62c8..ae7220ba3 100644 --- a/openless-all/app/src-tauri/src/commands/foundry_asr.rs +++ b/openless-all/app/src-tauri/src/commands/foundry_asr.rs @@ -38,6 +38,7 @@ pub struct FoundryStatusWire { pub runtime_source: String, pub active_model: String, pub loaded_model_id: Option, + pub keep_loaded_secs: u32, pub endpoint: Option, pub error: Option, } @@ -51,6 +52,7 @@ impl From for FoundryStatusWire { runtime_source: status.runtime_source.unwrap_or_default().as_str().into(), active_model: status.active_model, loaded_model_id: status.model_id, + keep_loaded_secs: status.keep_loaded_secs, endpoint: status.endpoint, error: status.error, } @@ -140,6 +142,19 @@ pub async fn foundry_local_asr_set_runtime_source( .map_err(core_error) } +#[tauri::command] +pub async fn foundry_local_asr_set_keep_loaded_secs( + backend: CoreState<'_>, + seconds: u32, +) -> Result<(), String> { + backend + .services() + .local_asr + .set_keep_loaded_secs(LocalAsrRuntime::Foundry, seconds) + .await + .map_err(core_error) +} + #[tauri::command] pub async fn foundry_local_asr_prepare( backend: CoreState<'_>, @@ -248,6 +263,7 @@ mod wire_contract_tests { "runtimeSource": "ort-nightly", "activeModel": "whisper-small", "loadedModelId": null, + "keepLoadedSecs": 300, "endpoint": null, "error": null, }) diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 2ef82fa7f..6d711b6db 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2256,6 +2256,12 @@ fn pcm_duration_ms(bytes: &[u8]) -> u64 { (bytes.len() as u64 / 2).saturating_mul(1_000) / 16_000 } +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", test))] +fn local_asr_release_delay(keep_loaded_secs: u32) -> Option { + (keep_loaded_secs != openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS) + .then(|| std::time::Duration::from_secs(keep_loaded_secs as u64)) +} + #[cfg(target_os = "windows")] fn schedule_foundry_release( runtime: Arc, @@ -2279,8 +2285,11 @@ fn schedule_foundry_release( } } } - if keep_loaded_secs > 0 { - tokio::time::sleep(std::time::Duration::from_secs(keep_loaded_secs as u64)).await; + let Some(delay) = local_asr_release_delay(keep_loaded_secs) else { + return; + }; + if !delay.is_zero() { + tokio::time::sleep(delay).await; } if current_generation.load(Ordering::Acquire) != generation { return; @@ -2308,9 +2317,12 @@ fn schedule_sherpa_release( generation: u64, current_generation: Arc, ) { + let Some(delay) = local_asr_release_delay(keep_loaded_secs) else { + return; + }; tauri::async_runtime::spawn(async move { - if keep_loaded_secs > 0 { - tokio::time::sleep(std::time::Duration::from_secs(keep_loaded_secs as u64)).await; + if !delay.is_zero() { + tokio::time::sleep(delay).await; } if current_generation.load(Ordering::Acquire) == generation { if let Err(error) = runtime @@ -2329,8 +2341,10 @@ fn schedule_qwen_release( engine: std::sync::Weak, keep_loaded_secs: u32, ) { + let Some(threshold) = local_asr_release_delay(keep_loaded_secs) else { + return; + }; tauri::async_runtime::spawn(async move { - let threshold = std::time::Duration::from_secs(keep_loaded_secs as u64); if !threshold.is_zero() { tokio::time::sleep(threshold).await; } @@ -2344,8 +2358,10 @@ fn schedule_whisper_release( engine: std::sync::Weak, keep_loaded_secs: u32, ) { + let Some(threshold) = local_asr_release_delay(keep_loaded_secs) else { + return; + }; tauri::async_runtime::spawn(async move { - let threshold = std::time::Duration::from_secs(keep_loaded_secs as u64); if !threshold.is_zero() { tokio::time::sleep(threshold).await; } @@ -3354,6 +3370,22 @@ mod tests { struct IgnoreTextStreamSink; + #[test] + fn local_asr_keep_loaded_delay_distinguishes_immediate_finite_and_forever() { + assert_eq!( + super::local_asr_release_delay(0), + Some(std::time::Duration::ZERO) + ); + assert_eq!( + super::local_asr_release_delay(300), + Some(std::time::Duration::from_secs(300)) + ); + assert_eq!( + super::local_asr_release_delay(openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS), + None + ); + } + #[cfg(target_os = "windows")] #[tokio::test] async fn windows_preload_requires_the_requested_model_to_be_prepared() { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 13a9dc5fa..67dc98f4f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -367,6 +367,7 @@ macro_rules! app_invoke_handler_desktop { commands::foundry_local_asr_set_model, commands::foundry_local_asr_set_language_hint, commands::foundry_local_asr_set_runtime_source, + commands::foundry_local_asr_set_keep_loaded_secs, commands::foundry_local_asr_prepare, commands::foundry_local_asr_cancel_prepare, commands::foundry_local_asr_release, diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 9c159bf1d..e121d7694 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -2209,7 +2209,7 @@ export const de: typeof zhCN = { releaseNow: 'Jetzt entladen', keepLoadedLabel: 'Geladen halten für', keepLoadedDesc: - 'Wie lange Qwen3-ASR nach der letzten Verwendung im Arbeitsspeicher bleibt, bevor es entladen wird.', + 'Wie lange die aktuelle lokale ASR-Engine nach der nächsten Transkription geladen bleibt; „Nie entladen“ gilt bis zum manuellen Entladen oder Beenden.', keepImmediate: 'Sofort entladen', keep1min: '1 Minute nach letzter Verwendung', keep5min: '5 Minuten nach letzter Verwendung (Standard)', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cd366b356..f0b37da87 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -2134,7 +2134,8 @@ export const en: typeof zhCN = { loadNow: 'Load now', releaseNow: 'Release now', keepLoadedLabel: 'Keep loaded for', - keepLoadedDesc: 'How long Qwen3-ASR stays in memory after the last use, before being freed.', + keepLoadedDesc: + 'How long the current local ASR engine stays loaded after the next transcription; Never release keeps it loaded until manual release or exit.', keepImmediate: 'Release immediately', keep1min: '1 minute after last use', keep5min: '5 minutes after last use (default)', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index f719eec48..08ef5c10d 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -2190,7 +2190,7 @@ export const es: typeof zhCN = { releaseNow: 'Liberar ahora', keepLoadedLabel: 'Mantener cargado durante', keepLoadedDesc: - 'Tiempo que Qwen3-ASR permanece en memoria después del último uso antes de liberarse.', + 'Tiempo que el motor ASR local actual permanece cargado tras la siguiente transcripción; «No liberar nunca» se mantiene hasta liberarlo manualmente o salir.', keepImmediate: 'Liberar inmediatamente', keep1min: '1 minuto tras el último uso', keep5min: '5 minutos tras el último uso (predeterminado)', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 8e13d997f..19fc0b3f3 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -2219,7 +2219,7 @@ export const fr: typeof zhCN = { releaseNow: 'Libérer maintenant', keepLoadedLabel: 'Conserver en mémoire pendant', keepLoadedDesc: - 'Durée pendant laquelle Qwen3-ASR reste en mémoire après sa dernière utilisation avant d’être libéré.', + 'Durée de chargement du moteur ASR local actuel après la prochaine transcription ; « Ne jamais libérer » le conserve jusqu’à une libération manuelle ou la fermeture.', keepImmediate: 'Libérer immédiatement', keep1min: '1 minute après la dernière utilisation', keep5min: '5 minutes après la dernière utilisation (par défaut)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2382eed9a..039a1b452 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -2103,7 +2103,7 @@ export const ja: typeof zhCN = { releaseNow: '今すぐ解放', keepLoadedLabel: 'ロード保持時間', keepLoadedDesc: - 'ローカル ASR を使用後、何分でメモリから解放するかを決定。1+ GB の RAM 占有を回避。', + '現在のローカル ASR を次回の文字起こし後に保持する時間を指定します。「解放しない」は手動解放または終了まで保持します。', keepImmediate: '使用直後に解放', keep1min: '最終使用から 1 分', keep5min: '最終使用から 5 分(既定)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0488e61ad..4e9cf745f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -2086,7 +2086,7 @@ export const ko: typeof zhCN = { releaseNow: '지금 해제', keepLoadedLabel: '로드 유지 시간', keepLoadedDesc: - '로컬 ASR 사용 후 메모리에서 해제되기까지의 시간을 결정. 1+ GB RAM 장기 점유 회피.', + '현재 로컬 ASR 엔진을 다음 전사 후 얼마나 유지할지 정합니다. 해제하지 않음은 수동 해제 또는 종료까지 유지합니다.', keepImmediate: '말하기 직후 해제', keep1min: '마지막 사용 후 1분', keep5min: '마지막 사용 후 5분(기본)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 017969003..73defa3b8 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2019,7 +2019,8 @@ export const zhCN = { loadNow: '立即加载', releaseNow: '立即释放', keepLoadedLabel: '保持加载多久', - keepLoadedDesc: '决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。', + keepLoadedDesc: + '决定当前本地 ASR 引擎在下次转写后保持加载多久;“不释放”会持续驻留至手动释放或退出。', keepImmediate: '说完话立即释放', keep1min: '上次使用后 1 分钟', keep5min: '上次使用后 5 分钟(默认)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 8e984df8f..a637eaee8 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -2005,7 +2005,8 @@ export const zhTW: typeof zhCN = { loadNow: '立即加載', releaseNow: '立即釋放', keepLoadedLabel: '保持加載多久', - keepLoadedDesc: '決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。', + keepLoadedDesc: + '決定目前本地 ASR 引擎在下次轉寫後保持載入多久;「不釋放」會持續駐留至手動釋放或退出。', keepImmediate: '說完話立即釋放', keep1min: '上次使用後 1 分鐘', keep5min: '上次使用後 5 分鐘(默認)', diff --git a/openless-all/app/src/lib/localAsr.test.ts b/openless-all/app/src/lib/localAsr.test.ts index df6cf2835..7003bdd99 100644 --- a/openless-all/app/src/lib/localAsr.test.ts +++ b/openless-all/app/src/lib/localAsr.test.ts @@ -1,4 +1,4 @@ -import { isLocalAsrModelSupportedOnOs } from './localAsr'; +import { LOCAL_ASR_KEEP_LOADED_OPTIONS, isLocalAsrModelSupportedOnOs } from './localAsr'; function assertEqual(actual: boolean, expected: boolean, name: string) { if (actual !== expected) { @@ -30,3 +30,8 @@ assertEqual( true, 'Whisper is available on macOS', ); + +const keepLoadedSeconds = LOCAL_ASR_KEEP_LOADED_OPTIONS.map((option) => option.seconds); +if (JSON.stringify(keepLoadedSeconds) !== JSON.stringify([0, 60, 300, 1800, 86400])) { + throw new Error(`unexpected keep-loaded options: ${keepLoadedSeconds.join(', ')}`); +} diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 088db7667..71d00625e 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -104,10 +104,19 @@ export interface FoundryLocalAsrStatus { runtimeSource: FoundryRuntimeSource; activeModel: string; loadedModelId: string | null; + keepLoadedSecs: number; endpoint: string | null; error: string | null; } +export const LOCAL_ASR_KEEP_LOADED_OPTIONS = [ + { seconds: 0, labelKey: 'localAsr.keepImmediate' }, + { seconds: 60, labelKey: 'localAsr.keep1min' }, + { seconds: 300, labelKey: 'localAsr.keep5min' }, + { seconds: 1800, labelKey: 'localAsr.keep30min' }, + { seconds: 86400, labelKey: 'localAsr.keepForever' }, +] as const; + export const FOUNDRY_LOCAL_ASR_MODEL_ALIASES = [ 'whisper-small', 'whisper-medium', @@ -390,6 +399,7 @@ export function getFoundryLocalAsrStatus(): Promise { runtimeSource: 'auto', activeModel: 'whisper-small', loadedModelId: null, + keepLoadedSecs: 300, endpoint: null, error: null, })); @@ -411,6 +421,10 @@ export function setFoundryLocalRuntimeSource(source: string): Promise { return invokeOrMock('foundry_local_asr_set_runtime_source', { source }, () => undefined); } +export function setFoundryLocalAsrKeepLoadedSecs(seconds: number): Promise { + return invokeOrMock('foundry_local_asr_set_keep_loaded_secs', { seconds }, () => undefined); +} + export function prepareFoundryLocalAsr(modelAlias: string): Promise { return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`); } diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index f7c6dc890..a66118839 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -445,7 +445,7 @@ export interface UserPreferences { /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ localAsrMirror: string; /** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放; - * 300 = 默认 5 分钟;86400 ≈ 不释放(保持加载)。 */ + * 300 = 默认 5 分钟;86400 = 不自动释放(保持加载)。 */ localAsrKeepLoadedSecs: number; /** Windows Foundry Local Whisper 当前激活的模型 alias。 */ foundryLocalAsrModel: string; diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 83ace27ca..e93863f68 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -14,6 +14,7 @@ import { isTauri } from '../../lib/ipc'; import { useLayoutStack } from '../../lib/useMobileLayout'; import { FOUNDRY_LOCAL_ASR_MODELS, + LOCAL_ASR_KEEP_LOADED_OPTIONS, SHERPA_ONNX_ASR_MODELS, activateLocalAsr, cancelFoundryLocalAsrPrepare, @@ -47,6 +48,7 @@ import { revealSherpaOnnxAsrModelDir, setLocalAsrModelsBaseDir, setFoundryLocalAsrLanguageHint, + setFoundryLocalAsrKeepLoadedSecs, setFoundryLocalRuntimeSource, setLocalAsrKeepLoadedSecs, setLocalAsrMirror, @@ -159,6 +161,10 @@ type RefreshGuard = () => boolean; export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const { t } = useTranslation(); + const keepLoadedOptions = LOCAL_ASR_KEEP_LOADED_OPTIONS.map(({ seconds, labelKey }) => ({ + value: String(seconds), + label: t(labelKey), + })); const stackLayout = useLayoutStack(1000); const { prefs, updatePrefs } = useHotkeySettings(); const [settings, setSettings] = useState(null); @@ -349,6 +355,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { runtimeSource: selectedFoundryRuntimeSource, activeModel: selectedFoundryAlias, loadedModelId: null, + keepLoadedSecs: 300, endpoint: null, error: message, }); @@ -935,6 +942,18 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } }; + const handleFoundryKeepLoadedChange = async (seconds: number, restoreScroll?: () => void) => { + try { + setError(null); + await setFoundryLocalAsrKeepLoadedSecs(seconds); + await refreshFoundryStatus(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + restoreScroll?.(); + } + }; + const handleEnableFoundry = async (aliasOverride?: FoundryLocalAsrModelAlias) => { if (!foundryAvailable) return; const alias = aliasOverride ?? selectedFoundryAlias; @@ -2131,13 +2150,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { value={String(engineStatus?.keepLoadedSecs ?? 300)} onChange={(v) => void handleKeepLoadedChange(Number(v))} ariaLabel={t('localAsr.keepLoadedLabel')} - options={[ - { value: '0', label: t('localAsr.keepImmediate') }, - { value: '60', label: t('localAsr.keep1min') }, - { value: '300', label: t('localAsr.keep5min') }, - { value: '1800', label: t('localAsr.keep30min') }, - { value: '86400', label: t('localAsr.keepForever') }, - ]} + options={keepLoadedOptions} style={{ fontSize: 13, height: 31, minWidth: 200 }} /> @@ -2491,6 +2504,33 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }} /> + @@ -2550,6 +2590,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { {foundryStatus?.loadedModelId ?? t('localAsr.foundryNotLoaded')} +
{t('localAsr.keepLoadedDesc')}
{foundryStatus?.error && (
{t('localAsr.foundryError')}: