Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions openless-all/app/src-tauri/src/commands/local_asr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(())
}
39 changes: 38 additions & 1 deletion openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ impl Coordinator {
}
})
.await;
// 预热加载完后推一次状态,前端零轮询更新「已加载」。
emit_local_asr_engine_status(&inner);
});
}
#[cfg(not(target_os = "macos"))]
Expand All @@ -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<String> {
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);
}
Expand Down Expand Up @@ -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<Inner>) {
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<Inner>) {}

/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
Expand All @@ -3230,12 +3261,16 @@ fn schedule_local_asr_release(inner: &Arc<Inner>) {
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);
}
});
}

Expand Down Expand Up @@ -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)))
}

Expand Down
42 changes: 32 additions & 10 deletions openless-all/app/src/pages/LocalAsr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const foundryRefreshTimer = useRef<number | null>(null)
const sherpaRefreshTimer = useRef<number | null>(null)
const sherpaDownloadRefreshTimer = useRef<number | null>(null)
const engineStatusTimer = useRef<number | null>(null)
const foundrySelectionDirty = useRef(false)
const selectedFoundryAliasRef =
useRef<FoundryLocalAsrModelAlias>("whisper-small")
Expand Down Expand Up @@ -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<LocalAsrEngineStatus>(
"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(() => {
Expand Down Expand Up @@ -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))
}
Expand Down
Loading