feat(local-asr): 引擎内存常驻 + 切到本地时预加载 + 释放时机可配

用户痛点:之前每次按 hotkey 都重加载 1.2GB 模型 → 首句词延迟 3-5s。

新流程:
- 新 LocalAsrCache:模型一次 load 后驻留内存,跨多次会话复用
- LocalQwenAsr::new 改成接受 Arc<QwenAsrEngine>(引擎所有权挪到 cache)
- build_local_qwen3 改成 async + spawn_blocking 走 cache.get_or_load()
- 切换 active model 时自动 drop 旧引擎再加载新的
- end_session 后调 cache.touch() + schedule_local_asr_release
  - keep_loaded_secs == 0 → 立即释放
  - keep_loaded_secs > 0 → spawn 一个 sleep+check 任务,到点查 last_used,
    若中间又被使用过则跳过释放

预加载:
- commands::set_active_asr_provider("local-qwen3") 触发 preload_in_background
- 用户在「模型设置」页可手动「立即加载」/「立即释放」
- 实时显示「内存中的引擎: <model id>(约占 1.2-3.4 GB 内存)」
- 5 秒轮询刷新状态

新设置项:
- prefs.local_asr_keep_loaded_secs (u32,默认 300)
- UI 选项:说完话立即释放 / 1 min / 5 min(默认)/ 30 min / 始终保留
- 4 个新 IPC:local_asr_engine_status / release_engine / preload /
  set_keep_loaded_secs
This commit is contained in:
baiqing committed 2026-05-05 12:09:55 +08:00
1 parent 8ed674ecaf
commit 520787b0f7
13 files changed
+468 -31

No files matched your search

@@ -0,0 +1,132 @@
//! 本地 Qwen3-ASR 引擎缓存。
//!
//! 用途:避免每次 dictation 都重加载 1.2GB+ 模型。引擎一次 load 后驻留在内存,
//! 跨多次会话复用;用户在设置里决定"说完话即释放" / "保持 N 秒后释放" /
//! "不释放"。
//!
//! 调度规则:每次会话结束后 spawn 一个 sleep+check 任务;任务在到点时检查
//! `last_used`——如果中间又被使用过则不释放,否则 drop 引擎让 OS 回收 RAM。
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use parking_lot::Mutex;
#[cfg(target_os = "macos")]
use super::QwenAsrEngine;
pub struct LocalAsrCache {
#[cfg(target_os = "macos")]
inner: Mutex<Option<CachedEngine>>,
#[cfg(not(target_os = "macos"))]
_phantom: (),
}
#[cfg(target_os = "macos")]
struct CachedEngine {
model_id: String,
engine: Arc<QwenAsrEngine>,
last_used: Instant,
}
impl Default for LocalAsrCache {
fn default() -> Self {
Self::new()
}
}
impl LocalAsrCache {
pub fn new() -> Self {
Self {
#[cfg(target_os = "macos")]
inner: Mutex::new(None),
#[cfg(not(target_os = "macos"))]
_phantom: (),
}
}
/// 取已缓存的同 id 引擎,没有就加载(**阻塞、可能数秒**——调用方应放
/// `spawn_blocking`)。模型 id 不同则把旧的 drop 再加载新的。
#[cfg(target_os = "macos")]
pub fn get_or_load(&self, model_id: &str, model_dir: &Path) -> Result<Arc<QwenAsrEngine>> {
{
let mut slot = self.inner.lock();
if let Some(cached) = slot.as_mut() {
if cached.model_id == model_id {
cached.last_used = Instant::now();
log::info!("[local-asr cache] reuse engine: {model_id}");
return Ok(Arc::clone(&cached.engine));
}
log::info!(
"[local-asr cache] active model changed {} -> {}, drop old",
cached.model_id,
model_id
);
slot.take();
}
}
log::info!("[local-asr cache] loading {model_id} from {}", model_dir.display());
let engine = Arc::new(QwenAsrEngine::load(model_dir)?);
let mut slot = self.inner.lock();
*slot = Some(CachedEngine {
model_id: model_id.to_string(),
engine: Arc::clone(&engine),
last_used: Instant::now(),
});
log::info!("[local-asr cache] loaded {model_id}");
Ok(engine)
}
/// 标记最近使用时间——end_session 在调过 transcribe 之后调一下,
/// 让 release 计时器从这一刻重新算。
pub fn touch(&self) {
#[cfg(target_os = "macos")]
{
if let Some(cached) = self.inner.lock().as_mut() {
cached.last_used = Instant::now();
}
}
}
/// 如果空闲时长 ≥ threshold,释放引擎。返回是否真释放了。
pub fn release_if_idle(&self, idle_threshold: Duration) -> bool {
#[cfg(target_os = "macos")]
{
let mut slot = self.inner.lock();
if let Some(cached) = slot.as_ref() {
if cached.last_used.elapsed() >= idle_threshold {
log::info!(
"[local-asr cache] release engine {} after idle {:?}",
cached.model_id,
cached.last_used.elapsed()
);
slot.take();
return true;
}
}
}
let _ = idle_threshold;
false
}
/// 立刻释放(用户点"立即释放"或删模型时调)。
pub fn release_now(&self) {
#[cfg(target_os = "macos")]
{
if let Some(cached) = self.inner.lock().take() {
log::info!("[local-asr cache] release engine {} on demand", cached.model_id);
}
}
}
pub fn loaded_model_id(&self) -> Option<String> {
#[cfg(target_os = "macos")]
{
return self.inner.lock().as_ref().map(|c| c.model_id.clone());
}
#[cfg(not(target_os = "macos"))]
None
}
}
@@ -3,9 +3,10 @@
//! 与 `WhisperBatchASR` 形状对齐:实现 `AudioConsumer` 缓冲 PCM,stop 时
//! 调 `transcribe_stream`,期间每个稳定 token 通过 Tauri 事件
//! `local-asr-token` 推到前端胶囊做实时显示。
//!
//! engine 现在由 `LocalAsrCache` 提供——Coordinator 在 build_local_qwen3 里
//! 取已缓存的引擎再传进来,避免每次会话都重加载 1.2GB+ 模型。
#[cfg(target_os = "macos")]
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use std::sync::Arc;
@@ -32,14 +33,12 @@ pub struct LocalQwenAsr {
#[cfg(target_os = "macos")]
impl LocalQwenAsr {
pub fn new(app: AppHandle, model_dir: &PathBuf) -> Result<Self> {
let engine = QwenAsrEngine::load(model_dir)
.with_context(|| format!("加载本地模型失败:{}", model_dir.display()))?;
Ok(Self {
engine: Arc::new(engine),
pub fn new(app: AppHandle, engine: Arc<QwenAsrEngine>) -> Self {
Self {
engine,
buffer: Mutex::new(Vec::new()),
app,
})
}
}
/// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时
@@ -3,11 +3,14 @@
//! 当前只在 macOS 编入 antirez/qwen-asr (纯 C + Accelerate);Windows 端
//! 的本地推理路径见 issue #256,本期不实现。
pub mod cache;
pub mod download;
mod local_provider;
pub mod models;
pub mod test_run;
pub use cache::LocalAsrCache;
#[cfg(target_os = "macos")]
mod qwen_engine;
#[cfg(target_os = "macos")]
+45 -2
View File
@@ -121,8 +121,13 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> {
}
#[tauri::command]
pub fn set_active_asr_provider(provider: String) -> Result<(), String> {
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_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())?;
// 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。
if provider == crate::asr::local::PROVIDER_ID {
coord.preload_local_asr_in_background();
}
Ok(())
}
#[tauri::command]
@@ -790,6 +795,44 @@ pub async fn local_asr_test_model(
.map_err(|e| format!("{e:#}"))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAsrEngineStatus {
pub loaded: bool,
pub model_id: Option<String>,
pub keep_loaded_secs: u32,
}
#[tauri::command]
pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus {
let prefs = coord.prefs().get();
LocalAsrEngineStatus {
loaded: coord.local_asr_loaded_model().is_some(),
model_id: coord.local_asr_loaded_model(),
keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
}
}
#[tauri::command]
pub fn local_asr_release_engine(coord: CoordinatorState<'_>) {
coord.release_local_asr_engine();
}
#[tauri::command]
pub fn local_asr_preload(coord: tauri::State<'_, std::sync::Arc<crate::coordinator::Coordinator>>) {
coord.preload_local_asr_in_background();
}
#[tauri::command]
pub fn local_asr_set_keep_loaded_secs(
coord: CoordinatorState<'_>,
seconds: u32,
) -> 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())
}
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
#[allow(dead_code)]
+100 -20
View File
@@ -126,6 +126,9 @@ struct Inner {
prepared_windows_ime_session: Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
@@ -232,10 +235,57 @@ impl Coordinator {
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
}),
}
}
/// 后台预加载本地 ASR 引擎;当用户在 UI 切到 local-qwen3 provider 时调一次。
/// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。
/// 模型未下载或不在 macOS 上时静默跳过。
pub fn preload_local_asr_in_background(self: &Arc<Self>) {
#[cfg(target_os = "macos")]
{
let inner = Arc::clone(&self.inner);
tauri::async_runtime::spawn(async move {
let prefs = inner.prefs.get();
let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
Some(m) => m,
None => return,
};
if !crate::asr::local::models::is_downloaded(model_id) {
log::info!("[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str());
return;
}
let dir = match crate::asr::local::models::model_dir(model_id) {
Ok(d) => d,
Err(_) => return,
};
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
let _ = tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = cache.get_or_load(&mid, &dir) {
log::warn!("[coord] local ASR preload failed: {e:#}");
}
})
.await;
});
}
#[cfg(not(target_os = "macos"))]
{
// no-op
}
}
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
pub fn release_local_asr_engine(&self) {
self.inner.local_asr_cache.release_now();
}
pub fn local_asr_loaded_model(&self) -> Option<String> {
self.inner.local_asr_cache.loaded_model_id()
}
pub fn bind_app(&self, handle: AppHandle) {
*self.inner.app.lock() = Some(handle);
}
@@ -1015,7 +1065,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(&active_asr) {
let local = match build_local_qwen3(inner) {
let local = match build_local_qwen3(inner).await {
Ok(l) => l,
Err(e) => {
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
@@ -1437,24 +1487,30 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
},
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => match local.transcribe().await {
Ok(r) => r,
Err(e) => {
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(format!("本地识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
ActiveAsr::Local(local) => {
let result = local.transcribe().await;
// 无论成功失败都触一次缓存的 last_used + 调度释放
inner.local_asr_cache.touch();
schedule_local_asr_release(inner);
match result {
Ok(r) => r,
Err(e) => {
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(format!("本地识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
}
},
}
};
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
@@ -1983,8 +2039,25 @@ fn ensure_local_qwen3_model_ready() -> Result<(), String> {
Ok(())
}
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
fn schedule_local_asr_release(inner: &Arc<Inner>) {
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
let cache = Arc::clone(&inner.local_asr_cache);
if keep_secs == 0 {
cache.release_now();
return;
}
let dur = std::time::Duration::from_secs(keep_secs as u64);
tauri::async_runtime::spawn(async move {
tokio::time::sleep(dur).await;
cache.release_if_idle(dur);
});
}
#[cfg(target_os = "macos")]
fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
async fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
let prefs = inner.prefs.get();
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
@@ -1994,7 +2067,14 @@ fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local
.lock()
.clone()
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, &dir)?))
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
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:#}"))??;
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
}
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
+4
View File
@@ -239,6 +239,10 @@ pub fn run() {
commands::local_asr_cancel_download,
commands::local_asr_delete_model,
commands::local_asr_test_model,
commands::local_asr_engine_status,
commands::local_asr_release_engine,
commands::local_asr_preload,
commands::local_asr_set_keep_loaded_secs,
restart_app,
])
.build(tauri::generate_context!())
+10
View File
@@ -130,6 +130,11 @@ pub struct UserPreferences {
/// 本地模型下载源镜像("huggingface" / "hf-mirror")。
#[serde(default = "default_local_asr_mirror")]
pub local_asr_mirror: String,
/// 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
/// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 一天 ≈ 永不释放。
/// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。
#[serde(default = "default_local_asr_keep_loaded_secs")]
pub local_asr_keep_loaded_secs: u32,
}
fn default_local_asr_model() -> String {
@@ -140,6 +145,10 @@ fn default_local_asr_mirror() -> String {
"huggingface".into()
}
fn default_local_asr_keep_loaded_secs() -> u32 {
300
}
fn default_qa_hotkey() -> Option<QaHotkeyBinding> {
Some(QaHotkeyBinding::default())
}
@@ -171,6 +180,7 @@ impl Default for UserPreferences {
qa_save_history: false,
local_asr_active_model: default_local_asr_model(),
local_asr_mirror: default_local_asr_mirror(),
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
}
}
}
+12
View File
@@ -550,5 +550,17 @@ export const en: typeof zhCN = {
testActual: 'Got',
testStats: 'Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}',
testFailed: 'Test failed',
engineStatusLabel: 'Engine in memory',
engineLoaded: 'Loaded: {{model}} (~1.2-3.4 GB RAM)',
engineUnloaded: 'Not loaded (first transcription will load it, ~3-5 s)',
loadNow: 'Load now',
releaseNow: 'Release now',
keepLoadedLabel: 'Keep loaded for',
keepLoadedDesc: 'How long the engine stays in memory after the last use, before being freed.',
keepImmediate: 'Release immediately',
keep1min: '1 minute after last use',
keep5min: '5 minutes after last use (default)',
keep30min: '30 minutes after last use',
keepForever: 'Never release (always loaded)',
},
};
+12
View File
@@ -548,5 +548,17 @@ export const zhCN = {
testActual: '识别',
testStats: '音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}',
testFailed: '测试失败',
engineStatusLabel: '内存中的引擎',
engineLoaded: '已加载:{{model}}(约占 1.2-3.4 GB 内存)',
engineUnloaded: '未加载(首次听写需先加载,约 3-5 秒)',
loadNow: '立即加载',
releaseNow: '立即释放',
keepLoadedLabel: '保持加载多久',
keepLoadedDesc: '决定本地 ASR 用完后多久从内存释放,避免长期占用 1+ GB RAM。',
keepImmediate: '说完话立即释放',
keep1min: '上次使用后 1 分钟',
keep5min: '上次使用后 5 分钟(默认)',
keep30min: '上次使用后 30 分钟',
keepForever: '不释放(始终保留)',
},
};
+1
View File
@@ -55,6 +55,7 @@ const mockSettings: UserPreferences = {
qaSaveHistory: false,
localAsrActiveModel: 'qwen3-asr-0.6b',
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
};
const mockHotkeyCapability: HotkeyCapability = {
+26
View File
@@ -149,3 +149,29 @@ export function testLocalAsrModel(modelId: string): Promise<LocalAsrTestResult>
}),
);
}
export interface LocalAsrEngineStatus {
loaded: boolean;
modelId: string | null;
keepLoadedSecs: number;
}
export function getLocalAsrEngineStatus(): Promise<LocalAsrEngineStatus> {
return invokeOrMock('local_asr_engine_status', undefined, () => ({
loaded: false,
modelId: null,
keepLoadedSecs: 300,
}));
}
export function releaseLocalAsrEngine(): Promise<void> {
return invokeOrMock('local_asr_release_engine', undefined, () => undefined);
}
export function preloadLocalAsr(): Promise<void> {
return invokeOrMock('local_asr_preload', undefined, () => undefined);
}
export function setLocalAsrKeepLoadedSecs(seconds: number): Promise<void> {
return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined);
}
+3
View File
@@ -128,6 +128,9 @@ export interface UserPreferences {
localAsrActiveModel: string;
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
localAsrMirror: string;
/** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
* 300 = 默认 5 分钟;86400 ≈ 不释放(保持加载)。 */
localAsrKeepLoadedSecs: number;
}
/** Rust 通过 `qa:state` 事件下发的 payload。
+113 -1
View File
@@ -15,12 +15,17 @@ import {
deleteLocalAsrModel,
downloadLocalAsrModel,
fetchLocalAsrRemoteInfo,
getLocalAsrEngineStatus,
getLocalAsrSettings,
listLocalAsrModels,
preloadLocalAsr,
releaseLocalAsrEngine,
setLocalAsrActiveModel,
setLocalAsrKeepLoadedSecs,
setLocalAsrMirror,
testLocalAsrModel,
type LocalAsrDownloadProgress,
type LocalAsrEngineStatus,
type LocalAsrModelStatus,
type LocalAsrSettings,
type LocalAsrTestResult,
@@ -44,7 +49,18 @@ export function LocalAsr() {
const [busyModelId, setBusyModelId] = useState<string | null>(null);
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, LocalAsrTestResult | { error: string }>>({});
const [engineStatus, setEngineStatus] = useState<LocalAsrEngineStatus | null>(null);
const refreshTimer = useRef<number | null>(null);
const engineStatusTimer = useRef<number | null>(null);
const refreshEngineStatus = async () => {
try {
const status = await getLocalAsrEngineStatus();
setEngineStatus(status);
} catch (err) {
console.warn('[localAsr] engine status query failed', err);
}
};
const refresh = async () => {
try {
@@ -52,6 +68,7 @@ export function LocalAsr() {
const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]);
setSettings(s);
setModels(list);
void refreshEngineStatus();
// 拉远端真实尺寸(每个模型一次,结果留缓存)
void Promise.all(
list.map(async m => {
@@ -94,7 +111,15 @@ export function LocalAsr() {
useEffect(() => {
void refresh();
// refresh 内部已 fan-out 拉远端尺寸,不需要额外 effect
// 引擎状态每 5s 轮询一次,让 UI 能看到 release 计时器到点后的状态变化
engineStatusTimer.current = window.setInterval(() => {
void refreshEngineStatus();
}, 5000);
return () => {
if (engineStatusTimer.current !== null) {
window.clearInterval(engineStatusTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -194,6 +219,34 @@ export function LocalAsr() {
}
};
const handleKeepLoadedChange = async (seconds: number) => {
try {
await setLocalAsrKeepLoadedSecs(seconds);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleReleaseEngine = async () => {
try {
await releaseLocalAsrEngine();
await refreshEngineStatus();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handlePreload = async () => {
try {
await preloadLocalAsr();
// 触发预加载后给后端几秒,再查状态
window.setTimeout(() => void refreshEngineStatus(), 1500);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleTest = async (modelId: string) => {
setTestingModelId(modelId);
setTestResults(prev => {
@@ -274,6 +327,65 @@ export function LocalAsr() {
</div>
</Card>
{/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */}
{engineAvailable && (
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('localAsr.engineStatusLabel')}
</div>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
{engineStatus?.loaded
? t('localAsr.engineLoaded', { model: engineStatus.modelId ?? '' })
: t('localAsr.engineUnloaded')}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{engineStatus?.loaded ? (
<Btn variant="ghost" size="sm" onClick={() => void handleReleaseEngine()}>
{t('localAsr.releaseNow')}
</Btn>
) : (
<Btn variant="ghost" size="sm" onClick={() => void handlePreload()}>
{t('localAsr.loadNow')}
</Btn>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('localAsr.keepLoadedLabel')}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5 }}>
{t('localAsr.keepLoadedDesc')}
</div>
</div>
<select
value={engineStatus?.keepLoadedSecs ?? 300}
onChange={e => void handleKeepLoadedChange(Number(e.target.value))}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value={0}>{t('localAsr.keepImmediate')}</option>
<option value={60}>{t('localAsr.keep1min')}</option>
<option value={300}>{t('localAsr.keep5min')}</option>
<option value={1800}>{t('localAsr.keep30min')}</option>
<option value={86400}>{t('localAsr.keepForever')}</option>
</select>
</div>
</div>
</Card>
)}
{error && (
<Card style={{ marginBottom: 16, background: 'rgba(255, 220, 220, 0.5)' }}>
<div style={{ fontSize: 13, color: '#9b2c2c' }}>{error}</div>