diff --git a/openless-all/app/src-tauri/src/commands/local_asr.rs b/openless-all/app/src-tauri/src/commands/local_asr.rs index c89dc960..3809e2b6 100644 --- a/openless-all/app/src-tauri/src/commands/local_asr.rs +++ b/openless-all/app/src-tauri/src/commands/local_asr.rs @@ -270,7 +270,7 @@ pub async fn local_asr_test_model( .map_err(|e| format!("{e:#}")) } -#[derive(Serialize)] +#[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct LocalAsrEngineStatus { pub loaded: bool, @@ -305,5 +305,7 @@ pub fn local_asr_set_keep_loaded_secs( ) -> Result<(), String> { let mut prefs = coord.prefs().get(); prefs.local_asr_keep_loaded_secs = seconds; - coord.prefs().set(prefs).map_err(|e| e.to_string()) + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.emit_local_asr_engine_status(); + Ok(()) } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 85e3787f..fce97be6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -529,6 +529,8 @@ impl Coordinator { } }) .await; + // 预热加载完后推一次状态,前端零轮询更新「已加载」。 + emit_local_asr_engine_status(&inner); }); } #[cfg(not(target_os = "macos"))] @@ -540,12 +542,18 @@ impl Coordinator { /// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。 pub fn release_local_asr_engine(&self) { self.inner.local_asr_cache.release_now(); + emit_local_asr_engine_status(&self.inner); } pub fn local_asr_loaded_model(&self) -> Option { self.inner.local_asr_cache.loaded_model_id() } + /// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。 + pub fn emit_local_asr_engine_status(&self) { + emit_local_asr_engine_status(&self.inner); + } + pub fn bind_app(&self, handle: AppHandle) { *self.inner.app.lock() = Some(handle); } @@ -3222,6 +3230,29 @@ fn ensure_local_qwen3_model_ready() -> Result<(), String> { Ok(()) } +/// 引擎加载/释放/keepLoadedSecs 变化时主动推给前端,前端 listen +/// `local-asr:engine-changed` 即可零轮询同步 UI(issue #470 / #6)。 +/// 只反映 Qwen3 这一路(loaded_model_id / prefs),不碰 Foundry / Sherpa。 +/// 仅用桌面端跨平台符号;Android 无本地 ASR 引擎(LocalAsrEngineStatus 不在该 target +/// 编译),单独给 no-op stub(见下),让各调用点在所有平台统一编译。 +#[cfg(not(target_os = "android"))] +fn emit_local_asr_engine_status(inner: &Arc) { + let model_id = inner.local_asr_cache.loaded_model_id(); + let keep_loaded_secs = inner.prefs.get().local_asr_keep_loaded_secs; + let status = crate::commands::LocalAsrEngineStatus { + loaded: model_id.is_some(), + model_id, + keep_loaded_secs, + }; + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("local-asr:engine-changed", &status); + } +} + +/// Android no-op:该 target 不编译 LocalAsrEngineStatus / 本地 ASR 引擎。issue #470 / #6。 +#[cfg(target_os = "android")] +fn emit_local_asr_engine_status(_inner: &Arc) {} + /// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放 /// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。 /// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。 @@ -3230,12 +3261,16 @@ fn schedule_local_asr_release(inner: &Arc) { let cache = Arc::clone(&inner.local_asr_cache); if keep_secs == 0 { cache.release_now(); + emit_local_asr_engine_status(inner); return; } let dur = std::time::Duration::from_secs(keep_secs as u64); + let inner = Arc::clone(inner); tauri::async_runtime::spawn(async move { tokio::time::sleep(dur).await; - cache.release_if_idle(dur); + if cache.release_if_idle(dur) { + emit_local_asr_engine_status(&inner); + } }); } @@ -3322,6 +3357,8 @@ async fn build_local_qwen3( let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir)) .await .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; + // 加载完成(含缓存命中刷新 last_used)后推一次状态,前端零轮询更新「已加载」。 + emit_local_asr_engine_status(inner); Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine))) } diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 5c5624a8..a5e6493e 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -180,7 +180,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const foundryRefreshTimer = useRef(null) const sherpaRefreshTimer = useRef(null) const sherpaDownloadRefreshTimer = useRef(null) - const engineStatusTimer = useRef(null) const foundrySelectionDirty = useRef(false) const selectedFoundryAliasRef = useRef("whisper-small") @@ -506,19 +505,43 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { useEffect(() => { void refresh() - // 引擎状态每 5s 轮询一次,让 UI 能看到 release 计时器到点后的状态变化 - engineStatusTimer.current = window.setInterval(() => { - void refreshEngineStatus() - }, 5000) return () => { - if (engineStatusTimer.current !== null) { - window.clearInterval(engineStatusTimer.current) - } if (scrollGuardCleanup.current) scrollGuardCleanup.current() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // 引擎状态改由后端主动 emit(加载/释放/keepLoadedSecs 变更),前端零轮询。 + // 挂载时仍拉一次初值,之后 listen `local-asr:engine-changed` 增量更新。 + // 仅 Tauri 环境(浏览器 dev mock 无事件)。 + useEffect(() => { + if (!isTauri) return + void refreshEngineStatus() + let unlisten: undefined | (() => void) + let cancelled = false + ;(async () => { + const { listen } = await import("@tauri-apps/api/event") + const off = await listen( + "local-asr:engine-changed", + (e) => { + setEngineStatus(e.payload) + }, + ) + if (cancelled) { + off() + } else { + unlisten = off + } + })().catch((err) => + console.warn("[localAsr] engine status subscribe failed", err), + ) + return () => { + cancelled = true + if (unlisten) unlisten() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // 镜像变更后重拉一次远端尺寸(不同镜像 API 返回的 size 数值是一致的, // 但请求路径不同——切镜像时强制刷新一次让用户看到新源能否访通)。 useEffect(() => { @@ -1363,9 +1386,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const handlePreload = async () => { try { + // 加载完成后后端会 emit `local-asr:engine-changed`,前端零轮询更新状态。 await preloadLocalAsr() - // 触发预加载后给后端几秒,再查状态 - window.setTimeout(() => void refreshEngineStatus(), 1500) } catch (e) { setError(e instanceof Error ? e.message : String(e)) }