From bd3bac3ca6910d96b96d3f551ac19badf2cc102b Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:17:32 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(local-asr):=20=E6=8E=A5=E5=85=A5=20a?= =?UTF-8?q?ntirez/qwen-asr=20=E7=BC=96=E8=AF=91=E9=93=BE=E8=B7=AF=20?= =?UTF-8?q?=E2=80=94=20=E9=98=B6=E6=AE=B5=201=20=E8=84=9A=E6=89=8B?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vendor antirez/qwen-asr 作为 git submodule (commit b00b789) - build.rs 用 cc 编 10 个 C 源 + 链 Accelerate (仅 macOS) - src/asr/local/{mod,qwen_ffi,qwen_engine}.rs 包装最小 FFI 面: load / transcribe_audio / transcribe_stream + token 回调蹦床 - libqwen_asr.a (~434KB) 已链通,nm 验证 qwen_* 符号齐全 - Windows 端不编入,路径见 issue #256 下一阶段:模型下载管理 + IPC commands + 新页 LocalAsr.tsx + coordinator 路由 --- .gitmodules | 3 + openless-all/app/src-tauri/Cargo.lock | 2 + openless-all/app/src-tauri/Cargo.toml | 4 + openless-all/app/src-tauri/build.rs | 53 +++++++ .../app/src-tauri/src/asr/local/mod.rs | 12 ++ .../src-tauri/src/asr/local/qwen_engine.rs | 135 ++++++++++++++++++ .../app/src-tauri/src/asr/local/qwen_ffi.rs | 40 ++++++ openless-all/app/src-tauri/src/asr/mod.rs | 1 + openless-all/app/src-tauri/vendor/qwen-asr | 1 + 9 files changed, 251 insertions(+) create mode 100644 .gitmodules create mode 100644 openless-all/app/src-tauri/src/asr/local/mod.rs create mode 100644 openless-all/app/src-tauri/src/asr/local/qwen_engine.rs create mode 100644 openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs create mode 160000 openless-all/app/src-tauri/vendor/qwen-asr diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4bceb1ad --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "openless-all/app/src-tauri/vendor/qwen-asr"] + path = openless-all/app/src-tauri/vendor/qwen-asr + url = https://github.com/antirez/qwen-asr.git diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 884e24d1..f4bd975e 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3287,6 +3287,7 @@ dependencies = [ "arboard", "block2 0.5.1", "bytes", + "cc", "chrono", "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -3295,6 +3296,7 @@ dependencies = [ "env_logger", "futures-util", "global-hotkey", + "libc", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index e7a8609c..23835d95 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -12,6 +12,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +# Compile antirez/qwen-asr C sources for the local ASR engine on macOS. +cc = "1.1" [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } @@ -52,6 +54,8 @@ core-graphics = "0.24" objc2 = "0.5" objc2-foundation = "0.2" objc2-app-kit = "0.2" +# antirez/qwen-asr 用 malloc 分配返回字符串,必须用 C `free()` 释放。 +libc = "0.2" [target.'cfg(target_os = "windows")'.dependencies] raw-window-handle = "0.6" diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 261851f6..23219128 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -1,3 +1,56 @@ fn main() { + #[cfg(target_os = "macos")] + build_qwen_asr_macos(); + tauri_build::build(); } + +/// 编译 antirez/qwen-asr 的 C 源(仅 macOS)。 +/// +/// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework, +/// `USE_BLAS` + `ACCELERATE_NEW_LAPACK` 是必要宏。 +/// `-march=native` 这里**不**用——分发二进制要可移植,cc crate 在 release 下 +/// 默认带 `-O2`,加上 `-O3` 提一档;NEON/AVX 在源码里有 `#ifdef` 自动分派。 +#[cfg(target_os = "macos")] +fn build_qwen_asr_macos() { + const VENDOR: &str = "vendor/qwen-asr"; + const SOURCES: &[&str] = &[ + "qwen_asr.c", + "qwen_asr_kernels.c", + "qwen_asr_kernels_generic.c", + "qwen_asr_kernels_neon.c", + "qwen_asr_kernels_avx.c", + "qwen_asr_audio.c", + "qwen_asr_encoder.c", + "qwen_asr_decoder.c", + "qwen_asr_tokenizer.c", + "qwen_asr_safetensors.c", + ]; + + let mut build = cc::Build::new(); + build + .include(VENDOR) + .define("USE_BLAS", None) + .define("ACCELERATE_NEW_LAPACK", None) + .flag("-O3") + .flag("-ffast-math") + // 上游开 `-Wall -Wextra`;我们把 antirez 的代码当三方依赖,把无关警告压成静默 + // 避免 build log 噪音淹没我们自己的告警。 + .flag("-Wno-unused-parameter") + .flag("-Wno-unused-variable") + .flag("-Wno-unused-function") + .flag("-Wno-sign-compare") + .warnings(false); + + for src in SOURCES { + let path = format!("{}/{}", VENDOR, src); + println!("cargo:rerun-if-changed={}", path); + build.file(path); + } + println!("cargo:rerun-if-changed={}/qwen_asr.h", VENDOR); + + build.compile("qwen_asr"); + + // BLAS = Accelerate + println!("cargo:rustc-link-lib=framework=Accelerate"); +} diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs new file mode 100644 index 00000000..588ef42b --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -0,0 +1,12 @@ +//! 本地 ASR 引擎入口。 +//! +//! 当前只在 macOS 编入 antirez/qwen-asr (纯 C + Accelerate);Windows 端 +//! 的本地推理路径见 issue #256,本期不实现。 + +#[cfg(target_os = "macos")] +mod qwen_engine; +#[cfg(target_os = "macos")] +mod qwen_ffi; + +#[cfg(target_os = "macos")] +pub use qwen_engine::QwenAsrEngine; diff --git a/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs b/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs new file mode 100644 index 00000000..0b298fd8 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs @@ -0,0 +1,135 @@ +//! antirez/qwen-asr 的安全 Rust 包装。 +//! +//! 当前只暴露**最小可用面**:`load` / `transcribe_audio` / `transcribe_stream` +//! + token 回调。后续接 coordinator 时再扩 prompt/language 设置。 + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_void}; +use std::path::Path; +use std::ptr; +use std::sync::Mutex; + +use anyhow::{Context, Result}; + +use super::qwen_ffi::{ + qwen_free, qwen_load, qwen_set_token_callback, qwen_transcribe_audio, qwen_transcribe_stream, + QwenCtx, +}; + +/// FnMut 闭包是 fat pointer,不能直接塞进 `*mut c_void`,所以包一层 Box。 +type TokenHandler = dyn FnMut(&str) + Send + 'static; +type TokenHandlerBox = Box>; + +pub struct QwenAsrEngine { + ctx: *mut QwenCtx, + /// 持有 token 回调的所有权;C 端拿到的是 `&**handler` 派生出来的 raw ptr, + /// 只要这个 Box 还活着,那个 raw ptr 就有效。Mutex 防止并发 set。 + token_handler: Mutex>, +} + +/// SAFETY: `qwen_ctx_t` 内部的 pthread/buffer 仅在单次 transcribe 期间被 C 端 +/// 自己用;外层不会从两个 Rust 线程并发调进同一个 ctx(由 coordinator 串行 +/// 化保证)。Send/Sync 在这一约束下成立。 +unsafe impl Send for QwenAsrEngine {} +unsafe impl Sync for QwenAsrEngine {} + +impl QwenAsrEngine { + /// 从模型目录加载(目录里需含 `config.json` / `model.safetensors*` / + /// `vocab.json` / `merges.txt`,结构见 antirez `download_model.sh`)。 + pub fn load(model_dir: &Path) -> Result { + let dir_str = model_dir + .to_str() + .with_context(|| format!("model dir 不是合法 UTF-8: {model_dir:?}"))?; + let c_dir = CString::new(dir_str).context("model dir 含 NUL 字节")?; + + // SAFETY: `c_dir` 在调用期间存活;返回 NULL 表示加载失败。 + let ctx = unsafe { qwen_load(c_dir.as_ptr()) }; + if ctx.is_null() { + anyhow::bail!("qwen_load 失败:{model_dir:?}"); + } + + Ok(Self { + ctx, + token_handler: Mutex::new(None), + }) + } + + /// 注册流式 token 回调;传 `None` 清空。重新注册会先解绑再装新回调。 + pub fn set_token_handler(&self, handler: Option) + where + F: FnMut(&str) + Send + 'static, + { + let mut slot = self.token_handler.lock().expect("token_handler poisoned"); + + // 先把 C 端那一侧切干净,再 drop 旧 Box,避免 C 在替换瞬间还持有旧指针。 + unsafe { qwen_set_token_callback(self.ctx, None, ptr::null_mut()) }; + *slot = None; + + if let Some(f) = handler { + let boxed: TokenHandlerBox = Box::new(Box::new(f)); + // boxed 的内部 `Box` 在堆上有稳定地址;取它的 &mut 转 raw。 + let userdata = boxed.as_ref() as *const Box as *mut c_void; + unsafe { + qwen_set_token_callback(self.ctx, Some(token_trampoline), userdata); + } + *slot = Some(boxed); + } + } + + /// 批式转写:一次性给完整音频(mono f32 16kHz)。 + pub fn transcribe_audio(&self, samples: &[f32]) -> Result { + // SAFETY: samples 在调用期间存活;返回是 C `malloc` 出的字符串。 + let raw = unsafe { + qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32) + }; + if raw.is_null() { + anyhow::bail!("qwen_transcribe_audio 返回 NULL"); + } + let text = unsafe { CStr::from_ptr(raw) } + .to_string_lossy() + .into_owned(); + unsafe { libc::free(raw as *mut c_void) }; + Ok(text) + } + + /// 流式转写:内部按 2s chunk 切片,token 通过 `set_token_handler` 注册的 + /// 回调实时吐出;返回值是最终完整文本。 + pub fn transcribe_stream(&self, samples: &[f32]) -> Result { + let raw = unsafe { + qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32) + }; + if raw.is_null() { + anyhow::bail!("qwen_transcribe_stream 返回 NULL"); + } + let text = unsafe { CStr::from_ptr(raw) } + .to_string_lossy() + .into_owned(); + unsafe { libc::free(raw as *mut c_void) }; + Ok(text) + } +} + +impl Drop for QwenAsrEngine { + fn drop(&mut self) { + if !self.ctx.is_null() { + // 先解绑回调,避免 C 端在 free 后还持有 userdata 指针。 + unsafe { + qwen_set_token_callback(self.ctx, None, ptr::null_mut()); + qwen_free(self.ctx); + } + self.ctx = ptr::null_mut(); + } + // token_handler 的 Box 由 Mutex 析构时释放。 + } +} + +/// C 蹦床:把 `userdata` 解回 `&mut Box` 并转发字符串。 +unsafe extern "C" fn token_trampoline(piece: *const c_char, userdata: *mut c_void) { + if userdata.is_null() || piece.is_null() { + return; + } + // SAFETY: userdata 是 set_token_handler 注册的 `*Box`。 + let handler: &mut Box = unsafe { &mut *(userdata as *mut Box) }; + let text = unsafe { CStr::from_ptr(piece) }.to_string_lossy(); + handler(&text); +} diff --git a/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs b/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs new file mode 100644 index 00000000..81a9ec75 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs @@ -0,0 +1,40 @@ +//! 对 antirez/qwen-asr 公共 C API 的最小 FFI 声明。 +//! +//! 头文件见 `vendor/qwen-asr/qwen_asr.h`。这里**不**复刻 `qwen_ctx_t` +//! 内部布局——保持不透明指针即可,避免 pthread/对齐相关的脆弱假设。 + +use std::os::raw::{c_char, c_int, c_void}; + +/// 不透明的 qwen_ctx_t;只通过指针来回传。 +#[repr(C)] +pub struct QwenCtx { + _opaque: [u8; 0], +} + +/// `typedef void (*qwen_token_cb)(const char *piece, void *userdata);` +pub type QwenTokenCb = unsafe extern "C" fn(piece: *const c_char, userdata: *mut c_void); + +unsafe extern "C" { + pub fn qwen_load(model_dir: *const c_char) -> *mut QwenCtx; + pub fn qwen_free(ctx: *mut QwenCtx); + + pub fn qwen_set_token_callback( + ctx: *mut QwenCtx, + cb: Option, + userdata: *mut c_void, + ); + pub fn qwen_set_prompt(ctx: *mut QwenCtx, prompt: *const c_char) -> c_int; + pub fn qwen_set_force_language(ctx: *mut QwenCtx, language: *const c_char) -> c_int; + pub fn qwen_supported_languages_csv() -> *const c_char; + + pub fn qwen_transcribe_audio( + ctx: *mut QwenCtx, + samples: *const f32, + n_samples: c_int, + ) -> *mut c_char; + pub fn qwen_transcribe_stream( + ctx: *mut QwenCtx, + samples: *const f32, + n_samples: c_int, + ) -> *mut c_char; +} diff --git a/openless-all/app/src-tauri/src/asr/mod.rs b/openless-all/app/src-tauri/src/asr/mod.rs index 419de904..d7347091 100644 --- a/openless-all/app/src-tauri/src/asr/mod.rs +++ b/openless-all/app/src-tauri/src/asr/mod.rs @@ -6,6 +6,7 @@ //! `volcengine.rs`. mod frame; +pub mod local; pub mod volcengine; pub mod whisper; diff --git a/openless-all/app/src-tauri/vendor/qwen-asr b/openless-all/app/src-tauri/vendor/qwen-asr new file mode 160000 index 00000000..b00b789b --- /dev/null +++ b/openless-all/app/src-tauri/vendor/qwen-asr @@ -0,0 +1 @@ +Subproject commit b00b789b17051aea61e9717458171100662318a4 From 7b38e26473c1a7dadac46ca6d7d29c0a97681427 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:24:27 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(local-asr):=20=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B3=A8=E5=86=8C=E8=A1=A8=20+=20HF/HF-Mirror=20?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E7=AE=A1=E7=90=86=20=E2=80=94=20P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models.rs: ModelId (0.6B / 1.7B) + 文件清单复刻 antirez download_model.sh + is_downloaded / downloaded_bytes / list_status / delete_model - download.rs: DownloadManager (per-model AtomicBool 取消) + reqwest stream 写 .partial 然后原子 rename;支持 Range 续传;Tauri 事件 `local-asr-download-progress` 上报五种 phase - Mirror enum:Huggingface(默认)/ HfMirror(hf-mirror.com 国内镜像) - persistence.rs 加 pub fn local_models_root() —— 路径 ~/Library/Application Support/OpenLess/models/qwen3-asr// --- .../app/src-tauri/src/asr/local/download.rs | 312 ++++++++++++++++++ .../app/src-tauri/src/asr/local/mod.rs | 13 + .../app/src-tauri/src/asr/local/models.rs | 136 ++++++++ openless-all/app/src-tauri/src/persistence.rs | 9 + 4 files changed, 470 insertions(+) create mode 100644 openless-all/app/src-tauri/src/asr/local/download.rs create mode 100644 openless-all/app/src-tauri/src/asr/local/models.rs diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs new file mode 100644 index 00000000..bc8966eb --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -0,0 +1,312 @@ +//! Qwen3-ASR 模型下载管理。 +//! +//! - 文件清单来自 `models.rs`,串行下载(一个模型 ≤7 个文件,并发收益小) +//! - 写入 `.partial` 后 `rename` 原子落盘;HF 不直接给文件级 sha256,所以 +//! 这里**不**做强校验(与 antirez `download_model.sh` 一致) +//! - 取消通过 `AtomicBool` 在每个 chunk 边界检查,drop 时自然终止 reqwest stream +//! - 进度通过 Tauri 事件 `local-asr-download-progress` 上报前端 + +use std::collections::HashMap; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures_util::StreamExt; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tokio::io::AsyncWriteExt; + +use super::models::{model_dir, ModelId}; + +/// 下载源镜像。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Mirror { + /// 国外官方源 `huggingface.co` + Huggingface, + /// 国内镜像 `hf-mirror.com`(社区维护,非官方但稳定) + HfMirror, +} + +impl Default for Mirror { + fn default() -> Self { + Mirror::Huggingface + } +} + +impl Mirror { + pub fn base_url(self) -> &'static str { + match self { + Mirror::Huggingface => "https://huggingface.co", + Mirror::HfMirror => "https://hf-mirror.com", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "hf-mirror" => Mirror::HfMirror, + _ => Mirror::Huggingface, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Mirror::Huggingface => "huggingface", + Mirror::HfMirror => "hf-mirror", + } + } +} + +/// 进度事件 payload;前端按 `model_id` 过滤。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadProgress { + pub model_id: String, + pub file: String, + pub file_index: usize, + pub file_count: usize, + pub bytes_downloaded: u64, + pub bytes_total: u64, + pub phase: DownloadPhase, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DownloadPhase { + Started, + Progress, + Finished, + Cancelled, + Failed, +} + +#[derive(Default)] +pub struct DownloadManager { + cancel_flags: Mutex>>, +} + +impl DownloadManager { + pub fn new() -> Self { + Self::default() + } + + /// 启动一次下载;同一模型并发调只接受第一次(直到上一次结束/取消)。 + /// 立即返回;进度通过 Tauri 事件流上报。 + pub fn start(self: &Arc, app: AppHandle, model_id: ModelId, mirror: Mirror) { + let key = model_id.as_str().to_string(); + let flag = { + let mut flags = self.cancel_flags.lock(); + if flags.contains_key(&key) { + log::info!("[local-asr] download already in progress: {key}"); + return; + } + let f = Arc::new(AtomicBool::new(false)); + flags.insert(key.clone(), Arc::clone(&f)); + f + }; + + let manager = Arc::clone(self); + tokio::spawn(async move { + let result = run_download(&app, model_id, mirror, &flag).await; + manager.cancel_flags.lock().remove(&key); + match result { + Ok(()) => log::info!("[local-asr] download finished: {key}"), + Err(e) => log::error!("[local-asr] download failed: {key}: {e:#}"), + } + }); + } + + pub fn cancel(&self, model_id: ModelId) { + if let Some(flag) = self.cancel_flags.lock().get(model_id.as_str()) { + flag.store(true, Ordering::SeqCst); + } + } + + pub fn is_active(&self, model_id: ModelId) -> bool { + self.cancel_flags.lock().contains_key(model_id.as_str()) + } +} + +async fn run_download( + app: &AppHandle, + model_id: ModelId, + mirror: Mirror, + cancel: &AtomicBool, +) -> Result<()> { + let dir = model_dir(model_id)?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("create model dir failed: {}", dir.display()))?; + + let files = model_id.files(); + let file_count = files.len(); + let approx_total = model_id.approx_bytes(); + + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: String::new(), + file_index: 0, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: approx_total, + phase: DownloadPhase::Started, + error: None, + }, + ); + + let client = reqwest::Client::builder() + .build() + .context("build reqwest client failed")?; + + for (idx, fname) in files.iter().enumerate() { + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, fname, idx, file_count); + return Ok(()); + } + + let dest = dir.join(fname); + if dest.exists() { + continue; + } + let url = format!( + "{}/{}/resolve/main/{}", + mirror.base_url(), + model_id.hf_repo(), + fname + ); + if let Err(e) = download_one(&client, &url, &dest, cancel, |bytes| { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: (*fname).into(), + file_index: idx, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id) + bytes, + bytes_total: approx_total, + phase: DownloadPhase::Progress, + error: None, + }, + ); + }) + .await + { + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, fname, idx, file_count); + return Ok(()); + } + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: (*fname).into(), + file_index: idx, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: approx_total, + phase: DownloadPhase::Failed, + error: Some(format!("{e:#}")), + }, + ); + return Err(e); + } + } + + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: String::new(), + file_index: file_count, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: approx_total, + phase: DownloadPhase::Finished, + error: None, + }, + ); + Ok(()) +} + +/// 下载单个文件到 `dest`,失败/取消时**保留** `.partial` 用于续传(HTTP Range 头)。 +async fn download_one( + client: &reqwest::Client, + url: &str, + dest: &Path, + cancel: &AtomicBool, + mut on_chunk: impl FnMut(u64), +) -> Result<()> { + let partial = dest.with_extension("partial"); + let resume_from = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + + let mut req = client.get(url); + if resume_from > 0 { + req = req.header("Range", format!("bytes={resume_from}-")); + } + let resp = req + .send() + .await + .with_context(|| format!("HTTP GET {url} failed"))?; + let status = resp.status(); + if !status.is_success() && status.as_u16() != 206 { + anyhow::bail!("HTTP {status} for {url}"); + } + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&partial) + .await + .with_context(|| format!("open partial failed: {}", partial.display()))?; + + let mut stream = resp.bytes_stream(); + let mut total_written = resume_from; + while let Some(chunk) = stream.next().await { + if cancel.load(Ordering::SeqCst) { + anyhow::bail!("cancelled"); + } + let bytes = chunk.context("read stream chunk failed")?; + file.write_all(&bytes).await.context("write chunk failed")?; + total_written += bytes.len() as u64; + on_chunk(total_written); + } + file.flush().await.ok(); + drop(file); + + tokio::fs::rename(&partial, dest) + .await + .with_context(|| format!("rename partial → final failed: {}", dest.display()))?; + Ok(()) +} + +fn emit(app: &AppHandle, payload: DownloadProgress) { + if let Err(e) = app.emit("local-asr-download-progress", payload) { + log::warn!("[local-asr] emit progress failed: {e}"); + } +} + +fn emit_cancelled( + app: &AppHandle, + model_id: ModelId, + fname: &str, + idx: usize, + file_count: usize, +) { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: fname.into(), + file_index: idx, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: model_id.approx_bytes(), + phase: DownloadPhase::Cancelled, + error: None, + }, + ); +} diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 588ef42b..4e56fbbb 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -3,6 +3,9 @@ //! 当前只在 macOS 编入 antirez/qwen-asr (纯 C + Accelerate);Windows 端 //! 的本地推理路径见 issue #256,本期不实现。 +pub mod download; +pub mod models; + #[cfg(target_os = "macos")] mod qwen_engine; #[cfg(target_os = "macos")] @@ -10,3 +13,13 @@ mod qwen_ffi; #[cfg(target_os = "macos")] pub use qwen_engine::QwenAsrEngine; + +pub use download::{DownloadManager, DownloadPhase, Mirror}; +pub use models::{ModelId, ModelStatus}; + +/// 本地 Qwen3-ASR 在 active_asr 字段里的标识;与前端 ASR_PRESETS 的 id 对齐。 +pub const PROVIDER_ID: &str = "local-qwen3"; + +pub fn is_local_qwen3(id: &str) -> bool { + id == PROVIDER_ID +} diff --git a/openless-all/app/src-tauri/src/asr/local/models.rs b/openless-all/app/src-tauri/src/asr/local/models.rs new file mode 100644 index 00000000..a126d16a --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/models.rs @@ -0,0 +1,136 @@ +//! 本地 Qwen3-ASR 模型注册表。 +//! +//! 文件清单复刻 antirez `download_model.sh` —— 不能漏,否则 `qwen_load` +//! 会失败。增加新模型时这里加一条 + 前端 i18n 加文案即可。 + +use std::path::PathBuf; + +use anyhow::Result; +use serde::Serialize; + +use crate::persistence; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelId { + Small06b, + Large17b, +} + +impl ModelId { + pub fn as_str(self) -> &'static str { + match self { + ModelId::Small06b => "qwen3-asr-0.6b", + ModelId::Large17b => "qwen3-asr-1.7b", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "qwen3-asr-0.6b" => Some(ModelId::Small06b), + "qwen3-asr-1.7b" => Some(ModelId::Large17b), + _ => None, + } + } + + pub fn all() -> &'static [ModelId] { + &[ModelId::Small06b, ModelId::Large17b] + } + + /// HuggingFace repo id(用于拼下载 URL)。 + pub fn hf_repo(self) -> &'static str { + match self { + ModelId::Small06b => "Qwen/Qwen3-ASR-0.6B", + ModelId::Large17b => "Qwen/Qwen3-ASR-1.7B", + } + } + + /// 该模型在 HF 仓库下需要拉的所有文件(顺序无所谓)。 + pub fn files(self) -> &'static [&'static str] { + match self { + ModelId::Small06b => &[ + "config.json", + "generation_config.json", + "model.safetensors", + "vocab.json", + "merges.txt", + ], + ModelId::Large17b => &[ + "config.json", + "generation_config.json", + "model.safetensors.index.json", + "model-00001-of-00002.safetensors", + "model-00002-of-00002.safetensors", + "vocab.json", + "merges.txt", + ], + } + } + + /// 大致体积(字节),用于前端进度条占位 + UI 显示。 + /// 数字来自 HF 仓库实测;不是精确校验,只用来估总和。 + pub fn approx_bytes(self) -> u64 { + match self { + ModelId::Small06b => 1_200 * 1024 * 1024, + ModelId::Large17b => 3_400 * 1024 * 1024, + } + } +} + +/// 模型在本地的根目录(可能不存在)。 +pub fn model_dir(id: ModelId) -> Result { + Ok(persistence::local_models_root()?.join(id.as_str())) +} + +/// 检查所有必需文件是否齐全。 +pub fn is_downloaded(id: ModelId) -> bool { + let dir = match model_dir(id) { + Ok(d) => d, + Err(_) => return false, + }; + id.files().iter().all(|f| dir.join(f).exists()) +} + +/// 已下载文件的总字节数(用于 UI 显示"X / Y MB")。 +pub fn downloaded_bytes(id: ModelId) -> u64 { + let dir = match model_dir(id) { + Ok(d) => d, + Err(_) => return 0, + }; + id.files() + .iter() + .filter_map(|f| std::fs::metadata(dir.join(f)).ok()) + .map(|m| m.len()) + .sum() +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelStatus { + pub id: String, + pub hf_repo: String, + pub approx_bytes: u64, + pub downloaded_bytes: u64, + pub is_downloaded: bool, +} + +pub fn list_status() -> Vec { + ModelId::all() + .iter() + .map(|&id| ModelStatus { + id: id.as_str().to_string(), + hf_repo: id.hf_repo().to_string(), + approx_bytes: id.approx_bytes(), + downloaded_bytes: downloaded_bytes(id), + is_downloaded: is_downloaded(id), + }) + .collect() +} + +/// 删除本地模型目录(用户在 UI 主动删)。 +pub fn delete_model(id: ModelId) -> Result<()> { + let dir = model_dir(id)?; + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + Ok(()) +} diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index dd17d7a6..658c1a10 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -82,6 +82,15 @@ fn ensure_dir(dir: &Path) -> Result<()> { Ok(()) } +/// 本地 ASR 模型根目录:`/models/qwen3-asr/`。 +/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 antirez `download_model.sh` +/// 列出的 5–7 个文件。 +pub fn local_models_root() -> Result { + let dir = data_dir()?.join("models").join("qwen3-asr"); + ensure_dir(&dir)?; + Ok(dir) +} + /// Atomic write: write to `*.tmp` first, then rename onto the target path. fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { if let Some(parent) = path.parent() { From 51b638a52e980dcda6721bd01b5395a2e73e7cc4 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:26:46 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(local-asr):=20IPC=20commands=20+=20?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E6=8C=81=E4=B9=85=E5=8C=96=20=E2=80=94=20P3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.rs UserPreferences 加 local_asr_active_model + local_asr_mirror, serde 默认值保证向后兼容(老 prefs.json 不需手工迁移) - commands.rs 加 7 个 local_asr_* IPC: get_settings / set_active_model / set_mirror / list_models / download_model / cancel_download / delete_model - lib.rs .manage(Arc) + invoke_handler 注册全部新命令 - 引擎可用性通过 cfg!(target_os = "macos") 暴露给前端,Win UI 据此灰按钮 --- .../app/src-tauri/src/asr/local/mod.rs | 2 +- openless-all/app/src-tauri/src/commands.rs | 79 +++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 9 +++ openless-all/app/src-tauri/src/types.rs | 17 ++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 4e56fbbb..42738540 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -14,7 +14,7 @@ mod qwen_ffi; #[cfg(target_os = "macos")] pub use qwen_engine::QwenAsrEngine; -pub use download::{DownloadManager, DownloadPhase, Mirror}; +pub use download::{DownloadManager, Mirror}; pub use models::{ModelId, ModelStatus}; /// 本地 Qwen3-ASR 在 active_asr 字段里的标识;与前端 ASR_PRESETS 的 id 对齐。 diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 3fef6292..e16c130f 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -688,6 +688,85 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { coord.qa_window_pin(pinned); } +// ─────────────────────────── local ASR (Qwen3-ASR) ─────────────────────────── + +use crate::asr::local::{ + DownloadManager, Mirror, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID, +}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalAsrSettings { + pub provider_id: String, + pub active_model: String, + pub mirror: String, + /// macOS 才编入引擎;Windows 端 UI 需要据此把"开始下载"按钮灰掉。 + pub engine_available: bool, +} + +#[tauri::command] +pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { + let prefs = coord.prefs().get(); + LocalAsrSettings { + provider_id: LOCAL_PROVIDER_ID.into(), + active_model: prefs.local_asr_active_model, + mirror: prefs.local_asr_mirror, + engine_available: cfg!(target_os = "macos"), + } +} + +#[tauri::command] +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}")); + } + let mut prefs = coord.prefs().get(); + prefs.local_asr_active_model = model_id; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn local_asr_set_mirror(coord: CoordinatorState<'_>, mirror: String) -> Result<(), String> { + let _normalized = Mirror::from_str(&mirror); + let mut prefs = coord.prefs().get(); + prefs.local_asr_mirror = mirror; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn local_asr_list_models() -> Vec { + crate::asr::local::models::list_status() +} + +#[tauri::command] +pub fn local_asr_download_model( + app: AppHandle, + manager: State<'_, Arc>, + model_id: String, + mirror: Option, +) -> Result<(), String> { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default(); + manager.start(app, id, m); + Ok(()) +} + +#[tauri::command] +pub fn local_asr_cancel_download( + manager: State<'_, Arc>, + model_id: String, +) -> Result<(), String> { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + manager.cancel(id); + Ok(()) +} + +#[tauri::command] +pub fn local_asr_delete_model(model_id: String) -> Result<(), String> { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + crate::asr::local::models::delete_model(id).map_err(|e| e.to_string()) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1fe64723..d6ee93dc 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -47,6 +47,7 @@ pub fn run() { log::info!("=== OpenLess 启动 ==="); let coordinator = Arc::new(coordinator::Coordinator::new()); + let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new()); tauri::Builder::default() // 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。 @@ -68,6 +69,7 @@ pub fn run() { None, )) .manage(coordinator.clone()) + .manage(local_asr_download_manager.clone()) .setup(move |app| { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 @@ -228,6 +230,13 @@ pub fn run() { commands::qa_window_pin, commands::validate_provider_credentials, commands::list_provider_models, + commands::local_asr_get_settings, + commands::local_asr_set_active_model, + commands::local_asr_set_mirror, + commands::local_asr_list_models, + commands::local_asr_download_model, + commands::local_asr_cancel_download, + commands::local_asr_delete_model, restart_app, ]) .build(tauri::generate_context!()) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index f608d44b..f9f37e76 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -123,6 +123,21 @@ pub struct UserPreferences { /// 详见 issue #118。 #[serde(default)] pub qa_save_history: bool, + /// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。 + /// 仅在 active_asr_provider == "local-qwen3" 时有意义。 + #[serde(default = "default_local_asr_model")] + pub local_asr_active_model: String, + /// 本地模型下载源镜像("huggingface" / "hf-mirror")。 + #[serde(default = "default_local_asr_mirror")] + pub local_asr_mirror: String, +} + +fn default_local_asr_model() -> String { + "qwen3-asr-0.6b".into() +} + +fn default_local_asr_mirror() -> String { + "huggingface".into() } fn default_qa_hotkey() -> Option { @@ -154,6 +169,8 @@ impl Default for UserPreferences { translation_target_language: String::new(), qa_hotkey: default_qa_hotkey(), qa_save_history: false, + local_asr_active_model: default_local_asr_model(), + local_asr_mirror: default_local_asr_mirror(), } } } From 4656ecc7c7e976d6804816911b9ef912885b49a8 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:29:12 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(local-asr):=20coordinator=20?= =?UTF-8?q?=E6=8E=A5=20ActiveAsr::Local=20=E7=AC=AC=E4=B8=89=E5=88=86?= =?UTF-8?q?=E6=94=AF=20=E2=80=94=20P4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local_provider.rs (LocalQwenAsr):实现 AudioConsumer 缓 i16 PCM → stop 时转 f32 → 跑 qwen_transcribe_stream,token 经 `local-asr-token` 事件实时推到前端胶囊;spawn_blocking 防止占住 tokio runtime - coordinator.rs: * ActiveAsr::Local(Arc) cfg(macos) * start_dictation_for_session 优先走 local 分支再回落 whisper/volc * end_session 加 Local arm,失败静默回退(CLAUDE.md 不变量) * cancel_active_asr / ensure_asr_credentials 都补 Local 分支 * build_local_qwen3 helper:从 prefs 拿 model id → models::model_dir → 加载 * ensure_local_qwen3_model_ready:模型未下载时友好提示用户去模型设置页 - 非 macOS 选择 local-qwen3 时 ensure_asr_credentials 直接返回错误 --- .../src-tauri/src/asr/local/local_provider.rs | 101 ++++++++++++++++++ .../app/src-tauri/src/asr/local/mod.rs | 3 + openless-all/app/src-tauri/src/coordinator.rs | 101 ++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 openless-all/app/src-tauri/src/asr/local/local_provider.rs diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs new file mode 100644 index 00000000..a295e93b --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs @@ -0,0 +1,101 @@ +//! 本地 Qwen3-ASR 在 dictation 路径上的适配器。 +//! +//! 与 `WhisperBatchASR` 形状对齐:实现 `AudioConsumer` 缓冲 PCM,stop 时 +//! 调 `transcribe_stream`,期间每个稳定 token 通过 Tauri 事件 +//! `local-asr-token` 推到前端胶囊做实时显示。 + +#[cfg(target_os = "macos")] +use std::path::PathBuf; +#[cfg(target_os = "macos")] +use std::sync::Arc; + +#[cfg(target_os = "macos")] +use anyhow::{Context, Result}; +#[cfg(target_os = "macos")] +use parking_lot::Mutex; +#[cfg(target_os = "macos")] +use tauri::{AppHandle, Emitter}; + +#[cfg(target_os = "macos")] +use super::QwenAsrEngine; +#[cfg(target_os = "macos")] +use crate::asr::RawTranscript; + +#[cfg(target_os = "macos")] +pub struct LocalQwenAsr { + engine: Arc, + /// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么),在 transcribe 时再 + /// 转 f32 喂给 C 端。一次会话最多几 MB,clone 一次成本可接受。 + buffer: Mutex>, + app: AppHandle, +} + +#[cfg(target_os = "macos")] +impl LocalQwenAsr { + pub fn new(app: AppHandle, model_dir: &PathBuf) -> Result { + let engine = QwenAsrEngine::load(model_dir) + .with_context(|| format!("加载本地模型失败:{}", model_dir.display()))?; + Ok(Self { + engine: Arc::new(engine), + buffer: Mutex::new(Vec::new()), + app, + }) + } + + /// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时 + /// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。 + pub async fn transcribe(self: Arc) -> Result { + let pcm_bytes = std::mem::take(&mut *self.buffer.lock()); + if pcm_bytes.is_empty() { + return Ok(RawTranscript { + text: String::new(), + duration_ms: 0, + }); + } + let duration_ms = (pcm_bytes.len() as u64 / 2) * 1000 / 16_000; + let samples_f32 = i16_le_bytes_to_f32(&pcm_bytes); + + // 注册 token 回调:每个稳定 token 抛 `local-asr-token` 事件。 + // capsule 前端按 sessionId 累积显示。 + let app = self.app.clone(); + self.engine.set_token_handler(Some(move |piece: &str| { + if let Err(e) = app.emit("local-asr-token", piece.to_string()) { + log::warn!("[local-asr] emit token failed: {e}"); + } + })); + + // qwen_transcribe_stream 是阻塞调用;用 spawn_blocking 防止占住 tokio runtime。 + let engine = Arc::clone(&self.engine); + let text = tokio::task::spawn_blocking(move || engine.transcribe_stream(&samples_f32)) + .await + .context("transcribe spawn_blocking join 失败")? + .context("qwen_transcribe_stream 失败")?; + + // 解绑回调,避免 idle 期 C 端任何后续触发。 + self.engine.set_token_handler::(None); + + Ok(RawTranscript { text, duration_ms }) + } + + pub fn cancel(&self) { + self.buffer.lock().clear(); + } +} + +#[cfg(target_os = "macos")] +impl crate::recorder::AudioConsumer for LocalQwenAsr { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.buffer.lock().extend_from_slice(pcm); + } +} + +#[cfg(target_os = "macos")] +fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(2) + .map(|c| { + let v = i16::from_le_bytes([c[0], c[1]]); + v as f32 / 32768.0 + }) + .collect() +} diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 42738540..f5283d47 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -4,6 +4,7 @@ //! 的本地推理路径见 issue #256,本期不实现。 pub mod download; +mod local_provider; pub mod models; #[cfg(target_os = "macos")] @@ -13,6 +14,8 @@ mod qwen_ffi; #[cfg(target_os = "macos")] pub use qwen_engine::QwenAsrEngine; +#[cfg(target_os = "macos")] +pub use local_provider::LocalQwenAsr; pub use download::{DownloadManager, Mirror}; pub use models::{ModelId, ModelStatus}; diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2867b61..763d7ee1 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -54,6 +54,9 @@ enum SessionPhase { enum ActiveAsr { Volcengine(Arc), Whisper(Arc), + /// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。 + #[cfg(target_os = "macos")] + Local(Arc), } struct SessionResource { @@ -798,6 +801,8 @@ fn cancel_active_asr(asr: ActiveAsr) { match asr { ActiveAsr::Volcengine(v) => v.cancel(), ActiveAsr::Whisper(w) => w.cancel(), + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => local.cancel(), } } @@ -1008,6 +1013,37 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); + #[cfg(target_os = "macos")] + if crate::asr::local::is_local_qwen3(&active_asr) { + let local = match build_local_qwen3(inner) { + Ok(l) => l, + Err(e) => { + log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + 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(format!("local ASR init failed: {e}")); + } + }; + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Local(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + if is_whisper_compatible_provider(&active_asr) { let (api_key, base_url, model) = read_whisper_credentials(); let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model)); @@ -1400,6 +1436,25 @@ async fn end_session(inner: &Arc) -> Result<(), String> { return Err(e.to_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()); + } + }, }; // ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。 @@ -1877,6 +1932,19 @@ fn ensure_microphone_permission(_inner: &Arc) -> Result<(), String> { fn ensure_asr_credentials() -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); + + // 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。 + if crate::asr::local::is_local_qwen3(&active_asr) { + #[cfg(not(target_os = "macos"))] + { + return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string()); + } + #[cfg(target_os = "macos")] + { + return ensure_local_qwen3_model_ready(); + } + } + if is_whisper_compatible_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() @@ -1896,6 +1964,39 @@ fn ensure_asr_credentials() -> Result<(), String> { } } +#[cfg(target_os = "macos")] +fn ensure_local_qwen3_model_ready() -> Result<(), String> { + let prefs = || -> Result { + // 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。 + crate::persistence::PreferencesStore::new() + .map_err(|e| e.to_string()) + .map(|s| s.get()) + }()?; + let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) + .ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?; + if !crate::asr::local::models::is_downloaded(model_id) { + return Err(format!( + "本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载", + model_id.as_str() + )); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn build_local_qwen3(inner: &Arc) -> anyhow::Result> { + 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))?; + let dir = crate::asr::local::models::model_dir(model_id)?; + let app = inner + .app + .lock() + .clone() + .ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?; + Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, &dir)?)) +} + /// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露 /// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 /// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 From dd43a7801de04d16ea9304243aaf920a73510345 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:39:26 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(local-asr):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E3=80=8C=E6=A8=A1=E5=9E=8B=E8=AE=BE=E7=BD=AE=E3=80=8D=E9=A1=B5?= =?UTF-8?q?=20+=20Settings=20=E9=A2=84=E8=AE=BE=20+=20=E5=8F=8D=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E9=87=8D=E6=9E=84=20=E2=80=94=20P5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端反硬编码(按用户反馈): - models.rs 删 files()/approx_bytes(),改用哨兵文件 .openless-asr-ready 判 is_downloaded、walk_dir 求和算 downloaded_bytes - download.rs 加 fetch_remote_info(repo, mirror):实时 GET HF /api/models//tree/main 拿真实文件清单 + 尺寸;过滤 .md/.png/.git* 等非权重文件(白名单:json/safetensors/txt/bin/model/tiktoken) - 新 IPC: local_asr_fetch_remote_info(modelId, mirror) → RemoteInfo { files: [{path,size}], totalBytes } 前端: - LocalAsr.tsx 新页:状态卡 / 镜像源切换 / 模型列表 (下载/取消/删除/设为默认) + 进度条订阅 local-asr-download-progress 事件实时刷新 + 真实尺寸通过 fetchLocalAsrRemoteInfo 拉取,**不硬编码** - localAsr.ts: 全套 IPC 包装 + dev mock 分支 - FloatingShell.tsx + useAppState.ts: 加 'localAsr' tab,icon=archive - Settings.tsx ASR_PRESETS 加 { id: 'local-qwen3', nameKey: 'asrLocalQwen3' } - i18n zh+en 同步:nav.localAsr / asrLocalQwen3 / 整块 localAsr.* 文案 - types.ts UserPreferences 加 localAsrActiveModel + localAsrMirror --- .../app/src-tauri/src/asr/local/download.rs | 214 ++++++++-- .../app/src-tauri/src/asr/local/models.rs | 84 ++-- openless-all/app/src-tauri/src/commands.rs | 13 + openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src/components/FloatingShell.tsx | 2 + openless-all/app/src/i18n/en.ts | 23 + openless-all/app/src/i18n/zh-CN.ts | 23 + openless-all/app/src/lib/ipc.ts | 2 + openless-all/app/src/lib/localAsr.ts | 125 ++++++ openless-all/app/src/lib/types.ts | 4 + openless-all/app/src/pages/LocalAsr.tsx | 404 ++++++++++++++++++ openless-all/app/src/pages/Settings.tsx | 2 + openless-all/app/src/state/useAppState.ts | 9 +- 13 files changed, 817 insertions(+), 89 deletions(-) create mode 100644 openless-all/app/src/lib/localAsr.ts create mode 100644 openless-all/app/src/pages/LocalAsr.tsx diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index bc8966eb..e6ef4c2e 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -1,10 +1,13 @@ //! Qwen3-ASR 模型下载管理。 //! -//! - 文件清单来自 `models.rs`,串行下载(一个模型 ≤7 个文件,并发收益小) -//! - 写入 `.partial` 后 `rename` 原子落盘;HF 不直接给文件级 sha256,所以 -//! 这里**不**做强校验(与 antirez `download_model.sh` 一致) -//! - 取消通过 `AtomicBool` 在每个 chunk 边界检查,drop 时自然终止 reqwest stream -//! - 进度通过 Tauri 事件 `local-asr-download-progress` 上报前端 +//! 流程: +//! 1. GET `/api/models//tree/main` 拿真实文件清单 + 尺寸 +//! 2. 过滤掉 .gitattributes / README 等非权重文件 +//! 3. 串行下载每个文件 → `.partial` → 原子 rename +//! 4. 全部成功后写哨兵 `.openless-asr-ready` 标记完整 +//! +//! 取消:每个 chunk 边界检查 `AtomicBool`;失败 / 取消保留 `.partial`, +//! 下次以 HTTP `Range` 头续传(与 antirez `download_model.sh` 行为对齐)。 use std::collections::HashMap; use std::path::Path; @@ -18,7 +21,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; use tokio::io::AsyncWriteExt; -use super::models::{model_dir, ModelId}; +use super::models::{model_dir, ModelId, READY_SENTINEL}; /// 下载源镜像。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -59,6 +62,95 @@ impl Mirror { } } +/// 远端单个文件描述(来自 HF tree API)。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteFile { + pub path: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteInfo { + pub model_id: String, + pub mirror: String, + pub files: Vec, + pub total_bytes: u64, +} + +#[derive(Debug, Deserialize)] +struct HfTreeEntry { + #[serde(rename = "type")] + entry_type: String, + path: String, + #[serde(default)] + size: Option, +} + +/// 拉远端文件清单(HF tree API)。供下载流程 + 前端"查看模型大小"按钮共用。 +pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result { + let client = reqwest::Client::builder() + .build() + .context("build reqwest client failed")?; + let files = fetch_file_list(&client, model_id.hf_repo(), mirror).await?; + let total_bytes = files.iter().map(|f| f.size).sum(); + Ok(RemoteInfo { + model_id: model_id.as_str().into(), + mirror: mirror.as_str().into(), + files, + total_bytes, + }) +} + +async fn fetch_file_list( + client: &reqwest::Client, + repo: &str, + mirror: Mirror, +) -> Result> { + let url = format!("{}/api/models/{}/tree/main", mirror.base_url(), repo); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("HF tree API GET 失败: {url}"))?; + if !resp.status().is_success() { + anyhow::bail!("HF tree API HTTP {}: {url}", resp.status()); + } + let entries: Vec = resp + .json() + .await + .with_context(|| format!("HF tree JSON 解码失败: {url}"))?; + let files: Vec = entries + .into_iter() + .filter(|e| e.entry_type == "file" && keep_file(&e.path)) + .map(|e| RemoteFile { + path: e.path, + size: e.size.unwrap_or(0), + }) + .collect(); + if files.is_empty() { + anyhow::bail!("HF tree 返回空文件列表 (repo={repo})"); + } + Ok(files) +} + +/// 是否保留下载?过滤 docs / git-attribute / 图片。 +/// 白名单:模型权重 / 配置 / 词表用到的所有真实扩展名。 +fn keep_file(path: &str) -> bool { + if path.starts_with('.') { + return false; + } + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".png") || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg") + { + return false; + } + let ext = lower.rsplit('.').next().unwrap_or(""); + matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken") +} + /// 进度事件 payload;前端按 `model_id` 过滤。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -140,9 +232,32 @@ async fn run_download( std::fs::create_dir_all(&dir) .with_context(|| format!("create model dir failed: {}", dir.display()))?; - let files = model_id.files(); - let file_count = files.len(); - let approx_total = model_id.approx_bytes(); + let client = reqwest::Client::builder() + .build() + .context("build reqwest client failed")?; + + // 第一步:拉真实文件清单 + 尺寸(不再硬编码)。 + let info = match fetch_remote_info(model_id, mirror).await { + Ok(i) => i, + Err(e) => { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: String::new(), + file_index: 0, + file_count: 0, + bytes_downloaded: 0, + bytes_total: 0, + phase: DownloadPhase::Failed, + error: Some(format!("拉文件清单失败: {e:#}")), + }, + ); + return Err(e); + } + }; + let total_bytes = info.total_bytes; + let file_count = info.files.len(); emit( app, @@ -152,70 +267,82 @@ async fn run_download( file_index: 0, file_count, bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: approx_total, + bytes_total: total_bytes, phase: DownloadPhase::Started, error: None, }, ); - let client = reqwest::Client::builder() - .build() - .context("build reqwest client failed")?; - - for (idx, fname) in files.iter().enumerate() { + let mut bytes_done_before_current: u64 = 0; + for (idx, file) in info.files.iter().enumerate() { if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, fname, idx, file_count); + emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); return Ok(()); } - let dest = dir.join(fname); + let dest = dir.join(&file.path); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create dir failed: {}", parent.display()))?; + } if dest.exists() { + bytes_done_before_current += file.size; continue; } let url = format!( "{}/{}/resolve/main/{}", mirror.base_url(), model_id.hf_repo(), - fname + file.path ); - if let Err(e) = download_one(&client, &url, &dest, cancel, |bytes| { + let result = download_one(&client, &url, &dest, cancel, |bytes| { emit( app, DownloadProgress { model_id: model_id.as_str().into(), - file: (*fname).into(), + file: file.path.clone(), file_index: idx, file_count, - bytes_downloaded: super::models::downloaded_bytes(model_id) + bytes, - bytes_total: approx_total, + bytes_downloaded: bytes_done_before_current + bytes, + bytes_total: total_bytes, phase: DownloadPhase::Progress, error: None, }, ); }) - .await - { - if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, fname, idx, file_count); - return Ok(()); + .await; + match result { + Ok(()) => { + bytes_done_before_current += file.size; + } + Err(e) => { + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); + return Ok(()); + } + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: file.path.clone(), + file_index: idx, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: total_bytes, + phase: DownloadPhase::Failed, + error: Some(format!("{e:#}")), + }, + ); + return Err(e); } - emit( - app, - DownloadProgress { - model_id: model_id.as_str().into(), - file: (*fname).into(), - file_index: idx, - file_count, - bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: approx_total, - phase: DownloadPhase::Failed, - error: Some(format!("{e:#}")), - }, - ); - return Err(e); } } + // 全部成功 → 写哨兵 → is_downloaded 返回 true。 + let sentinel = dir.join(READY_SENTINEL); + std::fs::write(&sentinel, b"") + .with_context(|| format!("write sentinel failed: {}", sentinel.display()))?; + emit( app, DownloadProgress { @@ -224,7 +351,7 @@ async fn run_download( file_index: file_count, file_count, bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: approx_total, + bytes_total: total_bytes, phase: DownloadPhase::Finished, error: None, }, @@ -295,6 +422,7 @@ fn emit_cancelled( fname: &str, idx: usize, file_count: usize, + total: u64, ) { emit( app, @@ -304,7 +432,7 @@ fn emit_cancelled( file_index: idx, file_count, bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: model_id.approx_bytes(), + bytes_total: total, phase: DownloadPhase::Cancelled, error: None, }, diff --git a/openless-all/app/src-tauri/src/asr/local/models.rs b/openless-all/app/src-tauri/src/asr/local/models.rs index a126d16a..a22df5e7 100644 --- a/openless-all/app/src-tauri/src/asr/local/models.rs +++ b/openless-all/app/src-tauri/src/asr/local/models.rs @@ -1,7 +1,8 @@ -//! 本地 Qwen3-ASR 模型注册表。 +//! 本地 Qwen3-ASR 模型注册表(仅 id / 仓库名 / 显示名)。 //! -//! 文件清单复刻 antirez `download_model.sh` —— 不能漏,否则 `qwen_load` -//! 会失败。增加新模型时这里加一条 + 前端 i18n 加文案即可。 +//! **文件清单与尺寸不再硬编码** —— 由 `download.rs` 在下载时从 +//! `huggingface.co/api/models//tree/main` 拉真实清单和大小。 +//! 增加新模型 = 这里加一条枚举 + 仓库名。 use std::path::PathBuf; @@ -10,6 +11,9 @@ use serde::Serialize; use crate::persistence; +/// 下载完成后落在模型目录里的哨兵文件名;存在 = 完整、可加载。 +pub(super) const READY_SENTINEL: &str = ".openless-asr-ready"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelId { Small06b, @@ -36,44 +40,13 @@ impl ModelId { &[ModelId::Small06b, ModelId::Large17b] } - /// HuggingFace repo id(用于拼下载 URL)。 + /// HuggingFace repo id(用于拼 API + 下载 URL)。 pub fn hf_repo(self) -> &'static str { match self { ModelId::Small06b => "Qwen/Qwen3-ASR-0.6B", ModelId::Large17b => "Qwen/Qwen3-ASR-1.7B", } } - - /// 该模型在 HF 仓库下需要拉的所有文件(顺序无所谓)。 - pub fn files(self) -> &'static [&'static str] { - match self { - ModelId::Small06b => &[ - "config.json", - "generation_config.json", - "model.safetensors", - "vocab.json", - "merges.txt", - ], - ModelId::Large17b => &[ - "config.json", - "generation_config.json", - "model.safetensors.index.json", - "model-00001-of-00002.safetensors", - "model-00002-of-00002.safetensors", - "vocab.json", - "merges.txt", - ], - } - } - - /// 大致体积(字节),用于前端进度条占位 + UI 显示。 - /// 数字来自 HF 仓库实测;不是精确校验,只用来估总和。 - pub fn approx_bytes(self) -> u64 { - match self { - ModelId::Small06b => 1_200 * 1024 * 1024, - ModelId::Large17b => 3_400 * 1024 * 1024, - } - } } /// 模型在本地的根目录(可能不存在)。 @@ -81,26 +54,49 @@ pub fn model_dir(id: ModelId) -> Result { Ok(persistence::local_models_root()?.join(id.as_str())) } -/// 检查所有必需文件是否齐全。 +/// 完整且可加载?= 哨兵存在。 +/// 比"枚举所有应有文件"稳:HF 仓库改文件名 / 加新文件时不会误报缺失。 pub fn is_downloaded(id: ModelId) -> bool { let dir = match model_dir(id) { Ok(d) => d, Err(_) => return false, }; - id.files().iter().all(|f| dir.join(f).exists()) + dir.join(READY_SENTINEL).exists() } -/// 已下载文件的总字节数(用于 UI 显示"X / Y MB")。 +/// 已落盘的字节数(walk_dir 求和)。下载中也能显示真实进度。 pub fn downloaded_bytes(id: ModelId) -> u64 { let dir = match model_dir(id) { Ok(d) => d, Err(_) => return 0, }; - id.files() - .iter() - .filter_map(|f| std::fs::metadata(dir.join(f)).ok()) - .map(|m| m.len()) - .sum() + let mut total: u64 = 0; + walk_files(&dir, &mut |size| total += size); + total +} + +fn walk_files(dir: &std::path::Path, on_size: &mut F) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + // 哨兵文件本身体积忽略不计,但路径过滤更直白:保留所有非空文件。 + if name == READY_SENTINEL { + continue; + } + match entry.file_type() { + Ok(ft) if ft.is_dir() => walk_files(&path, on_size), + Ok(ft) if ft.is_file() => { + if let Ok(meta) = entry.metadata() { + on_size(meta.len()); + } + } + _ => {} + } + } } #[derive(Debug, Clone, Serialize)] @@ -108,7 +104,6 @@ pub fn downloaded_bytes(id: ModelId) -> u64 { pub struct ModelStatus { pub id: String, pub hf_repo: String, - pub approx_bytes: u64, pub downloaded_bytes: u64, pub is_downloaded: bool, } @@ -119,7 +114,6 @@ pub fn list_status() -> Vec { .map(|&id| ModelStatus { id: id.as_str().to_string(), hf_repo: id.hf_repo().to_string(), - approx_bytes: id.approx_bytes(), downloaded_bytes: downloaded_bytes(id), is_downloaded: is_downloaded(id), }) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index e16c130f..77b2fd1d 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -691,6 +691,7 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { // ─────────────────────────── local ASR (Qwen3-ASR) ─────────────────────────── use crate::asr::local::{ + download::{fetch_remote_info, RemoteInfo}, DownloadManager, Mirror, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID, }; @@ -738,6 +739,18 @@ pub fn local_asr_list_models() -> Vec { crate::asr::local::models::list_status() } +/// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸; +/// 前端在显示模型卡时调一次,避免硬编码尺寸过期。 +#[tauri::command] +pub async fn local_asr_fetch_remote_info( + model_id: String, + mirror: Option, +) -> Result { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default(); + fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}")) +} + #[tauri::command] pub fn local_asr_download_model( app: AppHandle, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index d6ee93dc..31f3b1f4 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -234,6 +234,7 @@ pub fn run() { commands::local_asr_set_active_model, commands::local_asr_set_mirror, commands::local_asr_list_models, + commands::local_asr_fetch_remote_info, commands::local_asr_download_model, commands::local_asr_cancel_download, commands::local_asr_delete_model, diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 1d9f2fe3..8afc0a29 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -16,6 +16,7 @@ import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; +import { LocalAsr } from '../pages/LocalAsr'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, @@ -45,6 +46,7 @@ const NAV_BASE: Array> = [ { id: 'style', icon: 'style', cmp: Style }, { id: 'translation', icon: 'translate', cmp: Translation }, { id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk }, + { id: 'localAsr', icon: 'archive', cmp: LocalAsr }, ]; const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 069b6f52..891a76a4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -58,6 +58,7 @@ export const en: typeof zhCN = { style: 'Style', translation: 'Translation', selectionAsk: 'Ask', + localAsr: 'Models', }, shell: { shortcutLabel: 'Recording shortcut', @@ -302,6 +303,7 @@ export const en: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', + asrLocalQwen3: 'Local Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', @@ -511,4 +513,25 @@ export const en: typeof zhCN = { rdev: 'rdev listener', }, }, + localAsr: { + kicker: 'LOCAL ASR', + title: 'Models', + desc: 'Local Qwen3-ASR engine and model manager. Models download from HuggingFace and run fully offline. Streaming on Windows is tracked in repo issues.', + engineUnavailable: 'The local engine is not bundled on this platform yet (macOS only; Windows tracked in issue #256). You can still download models, but they cannot be activated here.', + mirrorLabel: 'Download mirror', + mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.', + mirrorHuggingface: 'HuggingFace official (huggingface.co)', + mirrorHfMirror: 'Mainland mirror (hf-mirror.com)', + activeBadge: 'In use', + downloadedBadge: 'Downloaded', + download: 'Download', + cancel: 'Cancel', + delete: 'Delete', + setActive: 'Set as default', + failed: 'Failed', + cancelled: 'Cancelled', + files: 'files', + sizeLoading: 'Fetching size…', + sizeUnknown: 'Size unknown', + }, }; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d156ef1c..152fa563 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -56,6 +56,7 @@ export const zhCN = { style: '风格', translation: '翻译', selectionAsk: '划词追问', + localAsr: '模型设置', }, shell: { shortcutLabel: '录音快捷键', @@ -300,6 +301,7 @@ export const zhCN = { asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrLocalQwen3: '本地 Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', @@ -509,4 +511,25 @@ export const zhCN = { rdev: 'rdev 监听器', }, }, + localAsr: { + kicker: '本地 ASR', + title: '模型设置', + desc: '本地 Qwen3-ASR 引擎与模型管理。模型从 HuggingFace 下载到本机,无需联网即可识别。Windows 端流式推理跟踪见仓库 issue。', + engineUnavailable: '当前平台暂未集成本地推理引擎(仅 macOS 已支持,Windows 端跟踪 issue #256)。可下载模型,但暂时无法启用。', + mirrorLabel: '下载镜像源', + mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', + mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', + mirrorHfMirror: '国内镜像 (hf-mirror.com)', + activeBadge: '当前使用', + downloadedBadge: '已下载', + download: '下载', + cancel: '取消', + delete: '删除', + setActive: '设为默认', + failed: '失败', + cancelled: '已取消', + files: '文件', + sizeLoading: '正在查询尺寸…', + sizeUnknown: '尺寸未知', + }, }; diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 4ae8cd79..5a36db26 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -53,6 +53,8 @@ const mockSettings: UserPreferences = { translationTargetLanguage: '', qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, qaSaveHistory: false, + localAsrActiveModel: 'qwen3-asr-0.6b', + localAsrMirror: 'huggingface', }; const mockHotkeyCapability: HotkeyCapability = { diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts new file mode 100644 index 00000000..63f8ee2a --- /dev/null +++ b/openless-all/app/src/lib/localAsr.ts @@ -0,0 +1,125 @@ +// localAsr.ts — IPC + 事件类型 for 本地 Qwen3-ASR 引擎与模型管理。 +// +// 后端命令定义:openless-all/app/src-tauri/src/commands.rs `local_asr_*` +// 事件:local-asr-download-progress / local-asr-token +// +// 注意:模型文件清单与尺寸不在此处硬编码 —— 通过 +// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。 + +import { invokeOrMock } from './ipc'; + +export type LocalAsrMirror = 'huggingface' | 'hf-mirror'; + +export interface LocalAsrSettings { + providerId: string; + activeModel: string; + mirror: string; + /** macOS 才编入 antirez/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */ + engineAvailable: boolean; +} + +export interface LocalAsrModelStatus { + id: string; + hfRepo: string; + downloadedBytes: number; + isDownloaded: boolean; +} + +export interface LocalAsrRemoteFile { + path: string; + size: number; +} + +export interface LocalAsrRemoteInfo { + modelId: string; + mirror: string; + files: LocalAsrRemoteFile[]; + totalBytes: number; +} + +export type LocalAsrDownloadPhase = + | 'started' + | 'progress' + | 'finished' + | 'cancelled' + | 'failed'; + +export interface LocalAsrDownloadProgress { + modelId: string; + file: string; + fileIndex: number; + fileCount: number; + bytesDownloaded: number; + bytesTotal: number; + phase: LocalAsrDownloadPhase; + error: string | null; +} + +const MOCK_SETTINGS: LocalAsrSettings = { + providerId: 'local-qwen3', + activeModel: 'qwen3-asr-0.6b', + mirror: 'huggingface', + engineAvailable: false, +}; + +const MOCK_MODELS: LocalAsrModelStatus[] = [ + { + id: 'qwen3-asr-0.6b', + hfRepo: 'Qwen/Qwen3-ASR-0.6B', + downloadedBytes: 0, + isDownloaded: false, + }, + { + id: 'qwen3-asr-1.7b', + hfRepo: 'Qwen/Qwen3-ASR-1.7B', + downloadedBytes: 0, + isDownloaded: false, + }, +]; + +export function getLocalAsrSettings(): Promise { + return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS); +} + +export function setLocalAsrActiveModel(modelId: string): Promise { + return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined); +} + +export function setLocalAsrMirror(mirror: string): Promise { + return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined); +} + +export function listLocalAsrModels(): Promise { + return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS); +} + +export function fetchLocalAsrRemoteInfo( + modelId: string, + mirror?: string, +): Promise { + return invokeOrMock( + 'local_asr_fetch_remote_info', + { modelId, mirror }, + () => ({ + modelId, + mirror: mirror ?? 'huggingface', + files: [], + totalBytes: 0, + }), + ); +} + +export function downloadLocalAsrModel( + modelId: string, + mirror?: string, +): Promise { + return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined); +} + +export function cancelLocalAsrDownload(modelId: string): Promise { + return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined); +} + +export function deleteLocalAsrModel(modelId: string): Promise { + return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined); +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index a8577fd1..2959789d 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -124,6 +124,10 @@ export interface UserPreferences { qaHotkey: QaHotkeyBinding | null; /** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */ qaSaveHistory: boolean; + /** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */ + localAsrActiveModel: string; + /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ + localAsrMirror: string; } /** Rust 通过 `qa:state` 事件下发的 payload。 diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx new file mode 100644 index 00000000..45260461 --- /dev/null +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -0,0 +1,404 @@ +// LocalAsr.tsx — 本地 Qwen3-ASR 模型管理页。 +// +// 功能: +// - 顶部:当前激活模型 + 镜像源切换 +// - 模型列表:每行模型 = 真实尺寸 / 进度 / [下载|取消|删除|设为默认] +// - 真实尺寸通过 fetchLocalAsrRemoteInfo 实时从 HuggingFace API 拉,**不硬编码** +// - 监听 `local-asr-download-progress` 事件实时刷新进度 +// - Win 端引擎不可用时禁用下载按钮,提示见 issue #256 + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { isTauri, setActiveAsrProvider } from '../lib/ipc'; +import { + cancelLocalAsrDownload, + deleteLocalAsrModel, + downloadLocalAsrModel, + fetchLocalAsrRemoteInfo, + getLocalAsrSettings, + listLocalAsrModels, + setLocalAsrActiveModel, + setLocalAsrMirror, + type LocalAsrDownloadProgress, + type LocalAsrModelStatus, + type LocalAsrSettings, +} from '../lib/localAsr'; +import { Btn, Card, PageHeader, Pill } from './_atoms'; + +interface RemoteSize { + totalBytes: number; + fileCount: number; + loading: boolean; + error: string | null; +} + +export function LocalAsr() { + const { t } = useTranslation(); + const [settings, setSettings] = useState(null); + const [models, setModels] = useState([]); + const [progress, setProgress] = useState>({}); + const [remoteSizes, setRemoteSizes] = useState>({}); + const [error, setError] = useState(null); + const [busyModelId, setBusyModelId] = useState(null); + const refreshTimer = useRef(null); + + const refresh = async () => { + try { + setError(null); + const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); + setSettings(s); + setModels(list); + // 拉远端真实尺寸(每个模型一次,结果留缓存) + void Promise.all( + list.map(async m => { + await ensureRemoteSize(m.id, s.mirror); + }), + ); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const ensureRemoteSize = async (modelId: string, mirror: string) => { + setRemoteSizes(prev => { + if (prev[modelId] && !prev[modelId].error) return prev; + return { ...prev, [modelId]: { totalBytes: 0, fileCount: 0, loading: true, error: null } }; + }); + try { + const info = await fetchLocalAsrRemoteInfo(modelId, mirror); + setRemoteSizes(prev => ({ + ...prev, + [modelId]: { + totalBytes: info.totalBytes, + fileCount: info.files.length, + loading: false, + error: null, + }, + })); + } catch (e) { + setRemoteSizes(prev => ({ + ...prev, + [modelId]: { + totalBytes: 0, + fileCount: 0, + loading: false, + error: e instanceof Error ? e.message : String(e), + }, + })); + } + }; + + useEffect(() => { + void refresh(); + // refresh 内部已 fan-out 拉远端尺寸,不需要额外 effect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 镜像变更后重拉一次远端尺寸(不同镜像 API 返回的 size 数值是一致的, + // 但请求路径不同——切镜像时强制刷新一次让用户看到新源能否访通)。 + useEffect(() => { + if (!settings) return; + setRemoteSizes({}); + void Promise.all( + models.map(m => ensureRemoteSize(m.id, settings.mirror)), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings?.mirror]); + + // 订阅下载进度事件 — 仅 Tauri 环境(浏览器 dev mock 无事件)。 + useEffect(() => { + if (!isTauri) return; + let unlisten: undefined | (() => void); + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const off = await listen('local-asr-download-progress', e => { + const payload = e.payload; + setProgress(prev => ({ ...prev, [payload.modelId]: payload })); + if ( + payload.phase === 'finished' || + payload.phase === 'cancelled' || + payload.phase === 'failed' + ) { + if (refreshTimer.current) window.clearTimeout(refreshTimer.current); + refreshTimer.current = window.setTimeout(() => { + void refresh(); + }, 200); + } + }); + if (cancelled) { + off(); + } else { + unlisten = off; + } + })().catch(err => console.warn('[localAsr] subscribe failed', err)); + return () => { + cancelled = true; + if (unlisten) unlisten(); + if (refreshTimer.current) window.clearTimeout(refreshTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSetActiveModel = async (modelId: string) => { + setBusyModelId(modelId); + try { + await setLocalAsrActiveModel(modelId); + // 顺手把 active provider 也切到本地(避免用户改了模型却忘了切 provider) + await setActiveAsrProvider('local-qwen3'); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyModelId(null); + } + }; + + const handleDownload = async (modelId: string) => { + setBusyModelId(modelId); + try { + await downloadLocalAsrModel(modelId, settings?.mirror); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyModelId(null); + } + }; + + const handleCancel = async (modelId: string) => { + try { + await cancelLocalAsrDownload(modelId); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const handleDelete = async (modelId: string) => { + setBusyModelId(modelId); + try { + await deleteLocalAsrModel(modelId); + setProgress(prev => { + const next = { ...prev }; + delete next[modelId]; + return next; + }); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyModelId(null); + } + }; + + const handleMirrorChange = async (mirror: string) => { + try { + await setLocalAsrMirror(mirror); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const engineAvailable = settings?.engineAvailable ?? false; + + return ( +
+ + + {!engineAvailable && ( + +
+ {t('localAsr.engineUnavailable')} +
+
+ )} + + +
+
+
+ {t('localAsr.mirrorLabel')} +
+
+ {t('localAsr.mirrorDesc')} +
+
+ +
+
+ + {error && ( + +
{error}
+
+ )} + +
+ {models.map(model => ( + void handleDownload(model.id)} + onCancel={() => void handleCancel(model.id)} + onDelete={() => void handleDelete(model.id)} + onSetActive={() => void handleSetActiveModel(model.id)} + /> + ))} +
+
+ ); +} + +interface ModelRowProps { + model: LocalAsrModelStatus; + remoteSize?: RemoteSize; + progress?: LocalAsrDownloadProgress; + isActive: boolean; + engineAvailable: boolean; + disabled: boolean; + onDownload: () => void; + onCancel: () => void; + onDelete: () => void; + onSetActive: () => void; +} + +function ModelRow({ + model, + remoteSize, + progress, + isActive, + engineAvailable, + disabled, + onDownload, + onCancel, + onDelete, + onSetActive, +}: ModelRowProps) { + const { t } = useTranslation(); + const isDownloading = useMemo( + () => progress?.phase === 'started' || progress?.phase === 'progress', + [progress?.phase], + ); + const downloadedBytes = progress?.bytesDownloaded ?? model.downloadedBytes; + const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0; + const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0; + const showProgress = isDownloading || progress?.phase === 'failed' || progress?.phase === 'cancelled'; + + const sizeLabel = remoteSize?.loading + ? t('localAsr.sizeLoading') + : remoteSize?.error + ? t('localAsr.sizeUnknown') + : remoteSize && remoteSize.totalBytes > 0 + ? `${formatBytes(remoteSize.totalBytes)} · ${remoteSize.fileCount} ${t('localAsr.files')}` + : t('localAsr.sizeUnknown'); + + return ( + +
+
+
+
{model.id}
+ {isActive && {t('localAsr.activeBadge')}} + {model.isDownloaded && {t('localAsr.downloadedBadge')}} +
+
+ {model.hfRepo} · {sizeLabel} +
+ {showProgress && ( +
+
+
+
+
+ {progress?.phase === 'failed' + ? `${t('localAsr.failed')}: ${progress.error ?? ''}` + : progress?.phase === 'cancelled' + ? t('localAsr.cancelled') + : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + + (progress?.file ? ` · ${progress.file}` : '')} +
+
+ )} +
+
+ {model.isDownloaded ? ( + <> + {!isActive && ( + + {t('localAsr.setActive')} + + )} + + {t('localAsr.delete')} + + + ) : isDownloading ? ( + + {t('localAsr.cancel')} + + ) : ( + + {t('localAsr.download')} + + )} +
+
+ + ); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 71c1abfd..8756c119 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -382,6 +382,8 @@ const ASR_PRESETS = [ { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, + // 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。 + { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, ] as const; type AsrPresetId = typeof ASR_PRESETS[number]['id']; diff --git a/openless-all/app/src/state/useAppState.ts b/openless-all/app/src/state/useAppState.ts index 423f6216..08d3b8dd 100644 --- a/openless-all/app/src/state/useAppState.ts +++ b/openless-all/app/src/state/useAppState.ts @@ -2,7 +2,14 @@ import { useState } from 'react'; -export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation' | 'selectionAsk'; +export type AppTab = + | 'overview' + | 'history' + | 'vocab' + | 'style' + | 'translation' + | 'selectionAsk' + | 'localAsr'; export interface AppState { currentTab: AppTab; From 839ad76c5b062fb445a47564f362d199e2550f7b Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:49:33 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(local-asr):=20=E7=94=A8=20tauri::asyn?= =?UTF-8?q?c=5Fruntime=20=E5=8F=96=E4=BB=A3=20tokio::spawn=20=E2=80=94=20?= =?UTF-8?q?=E4=BF=AE=E7=82=B9=E5=87=BB=E4=B8=8B=E8=BD=BD=E5=8D=B3=E5=B4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 崩溃栈:local_asr_download_model → DownloadManager::start → tokio::spawn → panic("there is no reactor running")。 根因:Tauri 同步 command 在 main thread 上下文跑,没进 tokio runtime; tokio::spawn 直接 panic。换 tauri::async_runtime::spawn —— 它走 Tauri 持有的 runtime handle,不依赖调用方上下文。 同步把 local_provider.rs 的 spawn_blocking 也换成 tauri::async_runtime 版本(虽然现在在 async 路径上调没事,保持一致防回归)。 附带把 qwen_ffi.rs 的 `unsafe extern "C"` block 改回经典 extern block, 匹配 CLAUDE.md 声明的 rust-version = "1.77"(前者要 1.82+)。 --- openless-all/app/src-tauri/src/asr/local/download.rs | 6 +++++- openless-all/app/src-tauri/src/asr/local/local_provider.rs | 5 ++++- openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index e6ef4c2e..2a58e7a7 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -201,7 +201,11 @@ impl DownloadManager { }; let manager = Arc::clone(self); - tokio::spawn(async move { + // 用 tauri::async_runtime::spawn 而不是 tokio::spawn —— + // Tauri 同步 command 不在 tokio runtime 上下文里,调 tokio::spawn 会立刻 + // panic("there is no reactor running, must be called from the context of a Tokio 1.x runtime")。 + // tauri::async_runtime 走 Tauri 持有的 runtime handle,不依赖调用方上下文。 + tauri::async_runtime::spawn(async move { let result = run_download(&app, model_id, mirror, &flag).await; manager.cancel_flags.lock().remove(&key); match result { diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs index a295e93b..06e381c5 100644 --- a/openless-all/app/src-tauri/src/asr/local/local_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs @@ -65,8 +65,11 @@ impl LocalQwenAsr { })); // qwen_transcribe_stream 是阻塞调用;用 spawn_blocking 防止占住 tokio runtime。 + // 用 tauri::async_runtime::spawn_blocking 而非 tokio 的 —— 同 download.rs 注释, + // 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调, + // 但保持一致更稳)。 let engine = Arc::clone(&self.engine); - let text = tokio::task::spawn_blocking(move || engine.transcribe_stream(&samples_f32)) + let text = tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32)) .await .context("transcribe spawn_blocking join 失败")? .context("qwen_transcribe_stream 失败")?; diff --git a/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs b/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs index 81a9ec75..ae9def7d 100644 --- a/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs +++ b/openless-all/app/src-tauri/src/asr/local/qwen_ffi.rs @@ -14,7 +14,9 @@ pub struct QwenCtx { /// `typedef void (*qwen_token_cb)(const char *piece, void *userdata);` pub type QwenTokenCb = unsafe extern "C" fn(piece: *const c_char, userdata: *mut c_void); -unsafe extern "C" { +// 用经典 `extern "C"` block 而非 `unsafe extern "C"` block — 后者需 Rust +// 1.82+;CLAUDE.md 声明 rust-version = "1.77",避免给二次贡献者制造毛刺。 +extern "C" { pub fn qwen_load(model_dir: *const c_char) -> *mut QwenCtx; pub fn qwen_free(ctx: *mut QwenCtx); From 5a58f74c4a1b7e94f12f97afbf1c5844a8c343bf Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 09:56:54 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(local-asr):=20HF=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=20TLS=20unexpected=5Feof=20+=20Settings=20=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E6=94=B9=E4=B8=8B=E8=BD=BD=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载层修两件事: 1. 改用 native-tls (macOS = SecureTransport) 而非 rustls: HF LFS CDN 经常不发 TLS close_notify 直接关 TCP,rustls 0.22+ 把这当 致命 unexpected_eof,实测多个 .safetensors 失败。SecureTransport 容错。 Volcengine WebSocket 不动,继续走 rustls。 2. 单文件失败自动 retry 1 次(带 1s 退避,且尊重 cancel)。 HF 大文件偶发瞬断很常见,不让用户手点重试。 Settings 引导(按用户反馈): - ASR 选 'local-qwen3' 时**不**渲染 API key / Base URL / Model 三个填空 - 改为 LocalAsrProviderHint 卡片: * 文案"本地 Qwen3-ASR 在本机运行,无需 API Key" * 已下载/未下载 Pill 显示当前激活模型状态 * 一键按钮 → 派发 NAVIGATE_LOCAL_ASR_EVENT - FloatingShell 监听同事件,关 Settings modal + 切到 'localAsr' tab - i18n 同步 zh + en --- openless-all/app/src-tauri/Cargo.lock | 89 +++++++++++++++++++ openless-all/app/src-tauri/Cargo.toml | 2 +- .../app/src-tauri/src/asr/local/download.rs | 43 ++++++++- .../app/src/components/FloatingShell.tsx | 12 ++- openless-all/app/src/i18n/en.ts | 5 ++ openless-all/app/src/i18n/zh-CN.ts | 5 ++ openless-all/app/src/pages/Settings.tsx | 78 ++++++++++++++++ 7 files changed, 231 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index f4bd975e..f7a86f21 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -2112,6 +2112,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2861,6 +2877,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.8.0" @@ -3325,12 +3358,49 @@ dependencies = [ "winreg 0.52.0", ] +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4162,10 +4232,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4176,6 +4248,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -5561,6 +5634,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5993,6 +6076,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 23835d95..ee226985 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream"] } thiserror = "1" anyhow = "1" log = "0.4" diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 2a58e7a7..fa1be21b 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -236,7 +236,12 @@ async fn run_download( std::fs::create_dir_all(&dir) .with_context(|| format!("create model dir failed: {}", dir.display()))?; + // 用 native-tls (macOS = SecureTransport) 而非 rustls:HuggingFace 的 LFS CDN + // 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof, + // 实际数据已经收齐也算"下载失败"。SecureTransport 对 unclean close 容错。 + // (Volcengine WebSocket 那边继续用 rustls,那条链路稳定。) let client = reqwest::Client::builder() + .use_native_tls() .build() .context("build reqwest client failed")?; @@ -299,7 +304,9 @@ async fn run_download( model_id.hf_repo(), file.path ); - let result = download_one(&client, &url, &dest, cancel, |bytes| { + // 自动 retry 一次:HF 大文件偶发瞬时网断很常见,不让用户手点重试。 + // 连 cancel 信号也尊重——retry 时会再次检查。 + let mut result = retry_download_one(&client, &url, &dest, cancel, |bytes| { emit( app, DownloadProgress { @@ -315,6 +322,29 @@ async fn run_download( ); }) .await; + if result.is_err() && !cancel.load(Ordering::SeqCst) { + log::warn!( + "[local-asr] {} retry once after first failure", + file.path + ); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + result = retry_download_one(&client, &url, &dest, cancel, |bytes| { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: file.path.clone(), + file_index: idx, + file_count, + bytes_downloaded: bytes_done_before_current + bytes, + bytes_total: total_bytes, + phase: DownloadPhase::Progress, + error: None, + }, + ); + }) + .await; + } match result { Ok(()) => { bytes_done_before_current += file.size; @@ -363,6 +393,17 @@ async fn run_download( Ok(()) } +/// 下载单个文件到 `dest`;同名包装方便上层 retry 时复用所有参数。 +async fn retry_download_one( + client: &reqwest::Client, + url: &str, + dest: &Path, + cancel: &AtomicBool, + on_chunk: impl FnMut(u64), +) -> Result<()> { + download_one(client, url, dest, cancel, on_chunk).await +} + /// 下载单个文件到 `dest`,失败/取消时**保留** `.partial` 用于续传(HTTP Range 头)。 async fn download_one( client: &reqwest::Client, diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 8afc0a29..23f965aa 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -29,7 +29,7 @@ import { PROVIDER_SETUP_PROMPT_DEFERRED_KEY, shouldShowProviderSetupPrompt, } from '../lib/providerSetup'; -import type { SettingsSectionId } from '../pages/Settings'; +import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings'; import { useAppState, type AppTab } from '../state/useAppState'; interface NavItem { @@ -136,6 +136,16 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia } }, []); + // Settings → ASR 选 local-qwen3 时的"前往模型设置"事件 → 关 modal + 切 tab。 + useEffect(() => { + const onNavigate = () => { + setSettingsOpen(false); + setCurrentTab('localAsr'); + }; + window.addEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate); + return () => window.removeEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate); + }, [setCurrentTab, setSettingsOpen]); + const rememberProviderPrompt = () => { window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1'); setProviderPromptOpen(false); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 891a76a4..87baecea 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -309,6 +309,11 @@ export const en: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', + localAsrHint: 'Local Qwen3-ASR runs entirely on this machine. No API key needed — just download the model from HuggingFace.', + localAsrReady: '{{model}} downloaded', + localAsrNotReady: '{{model}} not downloaded', + localAsrGoDownload: 'Open Models page to download', + localAsrManage: 'Open Models page', fillDefault: 'Fill default value', readFailed: 'Read failed', apiKeyLabel: 'API Key', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 152fa563..a7adbe50 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -307,6 +307,11 @@ export const zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', + localAsrHint: '本地 Qwen3-ASR 在本机运行,无需 API Key。模型从 HuggingFace 下载到本地后即可使用。', + localAsrReady: '{{model}} 已下载', + localAsrNotReady: '{{model}} 未下载', + localAsrGoDownload: '前往模型设置下载', + localAsrManage: '前往模型设置', fillDefault: '填入默认值', readFailed: '读取失败', apiKeyLabel: 'API 密钥', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 8756c119..d3865d8e 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -42,6 +42,16 @@ import i18n, { type SupportedLocale, } from '../i18n'; import { Btn, Card, PageHeader, Pill } from './_atoms'; +import { + getLocalAsrSettings, + listLocalAsrModels, + type LocalAsrModelStatus, + type LocalAsrSettings, +} from '../lib/localAsr'; + +/// Settings → ASR 选了 local-qwen3 时触发跳到「模型设置」页 + 关 Settings modal。 +/// FloatingShell 监听同名事件做 setCurrentTab('localAsr') + setSettingsOpen(false)。 +export const NAVIGATE_LOCAL_ASR_EVENT = 'openless:navigate-local-asr'; interface SettingsProps { embedded?: boolean; @@ -556,6 +566,8 @@ function ProvidersSection() { {t('settings.providers.volcengineMappingNote')}
+ ) : committedAsrProvider === 'local-qwen3' ? ( + ) : ( <> @@ -1228,3 +1240,69 @@ function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus[ if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); return i18n.t('hotkey.adapter.rdev'); } + +/// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型 +/// 是否已下载,引导一键跳到「模型设置」页继续操作。 +function LocalAsrProviderHint() { + const { t } = useTranslation(); + const [settings, setSettings] = useState(null); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); + if (!cancelled) { + setSettings(s); + setModels(list); + } + } catch (err) { + console.warn('[settings] load local asr status failed', err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const goToLocalAsr = () => { + window.dispatchEvent(new CustomEvent(NAVIGATE_LOCAL_ASR_EVENT)); + }; + + if (loading) { + return ( +
+ {t('common.loading')} +
+ ); + } + + const active = models.find(m => m.id === settings?.activeModel); + const isReady = active?.isDownloaded ?? false; + + return ( +
+
+ {t('settings.providers.localAsrHint')} +
+
+ + {isReady + ? t('settings.providers.localAsrReady', { model: active?.id ?? '' }) + : t('settings.providers.localAsrNotReady', { model: settings?.activeModel ?? '' })} + +
+
+ + {isReady + ? t('settings.providers.localAsrManage') + : t('settings.providers.localAsrGoDownload')} + +
+
+ ); +} From 25d9e9f3da1b9a6dd15f8a0ba45f6f7709f7c452 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 10:03:26 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix(local-asr):=20=E4=B8=8B=E8=BD=BD=20re?= =?UTF-8?q?try=20=E5=8D=87=E7=BA=A7=204=20=E6=AC=A1=E6=8C=87=E6=95=B0?= =?UTF-8?q?=E9=80=80=E9=81=BF=20+=20=E5=B7=B2=E4=B8=8B=E8=BD=BD=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E5=88=A0=E9=99=A4/=E6=80=A7=E8=83=BD=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载失败修: - HF 大文件被 CDN 中途断流(end of file before message length reached); 原 1 次 retry 不够 + 间隔太短两次都失败 - 升到 4 次:1s → 4s → 16s 指数退避;每次都从 .partial 当前长度断点续传 - reqwest client 加 connect_timeout(30s) + pool_idle_timeout(20s) 避免 连接被复用到已被 CDN 关掉的 socket - 不设整体 timeout(1GB 文件没意义),只防卡死 UI 补「已下载管理 + 性能预期警告」(用户两条新需求): - Settings 的 LocalAsrProviderHint 现在列出所有已下载模型 + 每行删除按钮 (原来只显示当前激活模型 ready/not 状态,没看到其它模型也没法删) - Settings 与 LocalAsr 页顶部都加 ⚠️ 性能警告卡片:明确"比云端慢/中文准确率 通常不如火山+turbo/适用于离线/隐私场景",避免用户误判 - i18n 新增 zh+en:localAsrPerformanceWarning / localAsrDownloadedTitle / localAsrDelete + LocalAsr.performanceWarning --- .../app/src-tauri/src/asr/local/download.rs | 70 ++++++----- openless-all/app/src/i18n/en.ts | 4 + openless-all/app/src/i18n/zh-CN.ts | 4 + openless-all/app/src/pages/LocalAsr.tsx | 7 ++ openless-all/app/src/pages/Settings.tsx | 113 ++++++++++++++---- 5 files changed, 140 insertions(+), 58 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index fa1be21b..d9134554 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -240,8 +240,14 @@ async fn run_download( // 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof, // 实际数据已经收齐也算"下载失败"。SecureTransport 对 unclean close 容错。 // (Volcengine WebSocket 那边继续用 rustls,那条链路稳定。) + // + // 不设 .timeout() —— 大模型文件 1 GB 起步,整体超时不合适;只设 connect_timeout + // 防止首次连接卡死。pool_idle_timeout 让连接早释放,避免 CDN 关掉旧连接但 hyper + // 还在用导致下次请求 EOF。 let client = reqwest::Client::builder() .use_native_tls() + .connect_timeout(std::time::Duration::from_secs(30)) + .pool_idle_timeout(std::time::Duration::from_secs(20)) .build() .context("build reqwest client failed")?; @@ -304,31 +310,16 @@ async fn run_download( model_id.hf_repo(), file.path ); - // 自动 retry 一次:HF 大文件偶发瞬时网断很常见,不让用户手点重试。 - // 连 cancel 信号也尊重——retry 时会再次检查。 - let mut result = retry_download_one(&client, &url, &dest, cancel, |bytes| { - emit( - app, - DownloadProgress { - model_id: model_id.as_str().into(), - file: file.path.clone(), - file_index: idx, - file_count, - bytes_downloaded: bytes_done_before_current + bytes, - bytes_total: total_bytes, - phase: DownloadPhase::Progress, - error: None, - }, - ); - }) - .await; - if result.is_err() && !cancel.load(Ordering::SeqCst) { - log::warn!( - "[local-asr] {} retry once after first failure", - file.path - ); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - result = retry_download_one(&client, &url, &dest, cancel, |bytes| { + // 大文件经常被 CDN 中途断流。Range 续传 + 指数退避 (1s, 4s, 16s) 多试几次, + // 不让用户手点。每次 retry 都从 .partial 当前长度断点续传,不会重复下载。 + const MAX_ATTEMPTS: u32 = 4; + let mut result: Result<()> = Ok(()); + for attempt in 1..=MAX_ATTEMPTS { + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); + return Ok(()); + } + result = download_one(&client, &url, &dest, cancel, |bytes| { emit( app, DownloadProgress { @@ -344,6 +335,24 @@ async fn run_download( ); }) .await; + if result.is_ok() || cancel.load(Ordering::SeqCst) { + break; + } + if attempt < MAX_ATTEMPTS { + let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1))); + log::warn!( + "[local-asr] {} attempt {}/{} failed: {:#}; sleeping {:?} then retry from byte {}", + file.path, + attempt, + MAX_ATTEMPTS, + result.as_ref().err().map(|e| e.to_string()).unwrap_or_default(), + backoff, + std::fs::metadata(dest.with_extension("partial")) + .map(|m| m.len()) + .unwrap_or(0), + ); + tokio::time::sleep(backoff).await; + } } match result { Ok(()) => { @@ -393,17 +402,6 @@ async fn run_download( Ok(()) } -/// 下载单个文件到 `dest`;同名包装方便上层 retry 时复用所有参数。 -async fn retry_download_one( - client: &reqwest::Client, - url: &str, - dest: &Path, - cancel: &AtomicBool, - on_chunk: impl FnMut(u64), -) -> Result<()> { - download_one(client, url, dest, cancel, on_chunk).await -} - /// 下载单个文件到 `dest`,失败/取消时**保留** `.partial` 用于续传(HTTP Range 头)。 async fn download_one( client: &reqwest::Client, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 87baecea..082deda5 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -310,10 +310,13 @@ export const en: typeof zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', localAsrHint: 'Local Qwen3-ASR runs entirely on this machine. No API key needed — just download the model from HuggingFace.', + localAsrPerformanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate; each transcription takes **several seconds longer than cloud ASR**, and Chinese / dialect accuracy is **typically lower** than Volcengine or Whisper turbo. Use it for offline, privacy-sensitive, or no-cloud-API scenarios.', localAsrReady: '{{model}} downloaded', localAsrNotReady: '{{model}} not downloaded', localAsrGoDownload: 'Open Models page to download', localAsrManage: 'Open Models page', + localAsrDownloadedTitle: 'Downloaded models', + localAsrDelete: 'Delete', fillDefault: 'Fill default value', readFailed: 'Read failed', apiKeyLabel: 'API Key', @@ -538,5 +541,6 @@ export const en: typeof zhCN = { files: 'files', sizeLoading: 'Fetching size…', sizeUnknown: 'Size unknown', + performanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate. **First transcription loads the model (a few seconds)**, and each subsequent one is several seconds slower than cloud ASR. Chinese / dialect accuracy is typically lower than Volcengine / Whisper turbo. Best for offline, privacy-sensitive, or no-cloud-API scenarios.', }, }; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a7adbe50..c9b8f304 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -308,10 +308,13 @@ export const zhCN = { volcengineResourceIdLabel: 'Resource ID', volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', localAsrHint: '本地 Qwen3-ASR 在本机运行,无需 API Key。模型从 HuggingFace 下载到本地后即可使用。', + localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,单次转写时间会**比云端 ASR 长几秒**;中文识别准确率与方言/口音表现也**通常不如**火山引擎 / Whisper turbo。请按需取舍:网络受限或对隐私敏感时再用本地。', localAsrReady: '{{model}} 已下载', localAsrNotReady: '{{model}} 未下载', localAsrGoDownload: '前往模型设置下载', localAsrManage: '前往模型设置', + localAsrDownloadedTitle: '已下载模型', + localAsrDelete: '删除', fillDefault: '填入默认值', readFailed: '读取失败', apiKeyLabel: 'API 密钥', @@ -536,5 +539,6 @@ export const zhCN = { files: '文件', sizeLoading: '正在查询尺寸…', sizeUnknown: '尺寸未知', + performanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次转写需要加载模型(数秒)**,之后单次转写也会比云端 ASR 慢若干秒;中文识别准确率与方言/口音表现通常不如火山引擎 / Whisper turbo。适用场景:离线 / 隐私敏感 / 不愿付费云 API。', }, }; diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 45260461..d092b4c0 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -209,6 +209,13 @@ export function LocalAsr() { desc={t('localAsr.desc')} /> + {/* 性能/质量预期警告 —— 用户硬要求要写清楚 */} + +
+ ⚠️ {t('localAsr.performanceWarning')} +
+
+ {!engineAvailable && (
diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index d3865d8e..3987d3e0 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -43,6 +43,7 @@ import i18n, { } from '../i18n'; import { Btn, Card, PageHeader, Pill } from './_atoms'; import { + deleteLocalAsrModel, getLocalAsrSettings, listLocalAsrModels, type LocalAsrModelStatus, @@ -1242,37 +1243,47 @@ function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus[ } /// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型 -/// 是否已下载,引导一键跳到「模型设置」页继续操作。 +/// 是否已下载、列出所有已下载模型 + 删除按钮,并提示性能/质量预期,引导跳到 +/// 「模型设置」页做下载。 function LocalAsrProviderHint() { const { t } = useTranslation(); const [settings, setSettings] = useState(null); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); + const [deletingId, setDeletingId] = useState(null); + + const refresh = async () => { + try { + const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); + setSettings(s); + setModels(list); + } catch (err) { + console.warn('[settings] load local asr status failed', err); + } finally { + setLoading(false); + } + }; useEffect(() => { - let cancelled = false; - (async () => { - try { - const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); - if (!cancelled) { - setSettings(s); - setModels(list); - } - } catch (err) { - console.warn('[settings] load local asr status failed', err); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - }; + void refresh(); }, []); const goToLocalAsr = () => { window.dispatchEvent(new CustomEvent(NAVIGATE_LOCAL_ASR_EVENT)); }; + const handleDelete = async (modelId: string) => { + setDeletingId(modelId); + try { + await deleteLocalAsrModel(modelId); + await refresh(); + } catch (err) { + console.warn('[settings] delete local model failed', err); + } finally { + setDeletingId(null); + } + }; + if (loading) { return (
@@ -1283,26 +1294,84 @@ function LocalAsrProviderHint() { const active = models.find(m => m.id === settings?.activeModel); const isReady = active?.isDownloaded ?? false; + const downloaded = models.filter(m => m.isDownloaded); return ( -
+
+ {/* 性能/质量预期警告 —— 用户硬要求要写清楚 */} +
+ ⚠️ {t('settings.providers.localAsrPerformanceWarning')} +
+
{t('settings.providers.localAsrHint')}
-
+ + {/* 当前激活模型状态 + 跳转按钮 */} +
{isReady ? t('settings.providers.localAsrReady', { model: active?.id ?? '' }) : t('settings.providers.localAsrNotReady', { model: settings?.activeModel ?? '' })} -
-
{isReady ? t('settings.providers.localAsrManage') : t('settings.providers.localAsrGoDownload')}
+ + {/* 已下载模型列表 + 删除按钮(用户:已下载的项目要在旁边显示 + 提供删除) */} + {downloaded.length > 0 && ( +
+
+ {t('settings.providers.localAsrDownloadedTitle')} +
+ {downloaded.map(m => ( +
+
+ {m.id} + + {formatBytes(m.downloadedBytes)} + +
+ void handleDelete(m.id)}> + {t('settings.providers.localAsrDelete')} + +
+ ))} +
+ )}
); } + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} From 4a3531083b7263dcc7e975fa32fdc2a4bd1d3743 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 10:27:48 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(local-asr):=20HTTP=20Range=20?= =?UTF-8?q?=E5=88=86=E5=9D=97=E4=B8=8B=E8=BD=BD=20+=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载彻底重写 (huggingface_hub / aria2 同款思路): - 之前是"整文件 GET,失败重试整文件"——HF CDN 对长连接不友好,到某个固定时间 点会强制断流,导致看到"卡 5% 就死"。用户对比别家工具发现这点,确实如此。 - 现在 download_one 内按 32MB chunk 跑 Range,每块 4 次指数退避 retry,每次都 从 .partial 真实长度续传;CDN 中途断流只丢一个 chunk - 防御服务端忽略 Range 返回 200 + 全文件:检测到就 truncate .partial 重头来, 避免 append 污染 - client 加 User-Agent (HF 把 no-UA 流量算异常 → 限速) - pool_idle_timeout 缩短,避免复用已被 CDN 关掉的连接 加载测试按钮(用户:「做好加载模型和测试按钮」): - 内嵌 antirez 自带的 samples/test_speech.wav(120KB,"Hello. This is a test of the Voxtrail speech-to-text system.") - 严格按 RIFF chunk 链解析(fmt 后面带 LIST/INFO,不能 +44 硬偏移) - 新 IPC local_asr_test_model:spawn_blocking 跑 qwen_load + transcribe_audio 分别计时;返回 backend / 原文 / 识别 / load_ms / transcribe_ms - LocalAsr 页每个已下载模型行加「加载并测试」按钮 + 结果卡片 Backend 标识改为 "Apple Accelerate (AMX/NEON, CPU)"——antirez 引擎不走 Metal GPU(作者明确选择跳过 MPS),Apple Silicon 上靠 AMX 矩阵协处理器 + NEON 加速。 不撒谎说支持 Metal。 --- .../app/src-tauri/src/asr/local/download.rs | 237 +++++++++++++----- .../app/src-tauri/src/asr/local/mod.rs | 1 + .../app/src-tauri/src/asr/local/test_run.rs | 152 +++++++++++ openless-all/app/src-tauri/src/commands.rs | 10 + openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src/i18n/en.ts | 7 + openless-all/app/src/i18n/zh-CN.ts | 7 + openless-all/app/src/lib/localAsr.ts | 26 ++ openless-all/app/src/pages/LocalAsr.tsx | 88 ++++++- 9 files changed, 461 insertions(+), 68 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/local/test_run.rs diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index d9134554..1fce8069 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -237,17 +237,16 @@ async fn run_download( .with_context(|| format!("create model dir failed: {}", dir.display()))?; // 用 native-tls (macOS = SecureTransport) 而非 rustls:HuggingFace 的 LFS CDN - // 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof, - // 实际数据已经收齐也算"下载失败"。SecureTransport 对 unclean close 容错。 - // (Volcengine WebSocket 那边继续用 rustls,那条链路稳定。) + // 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof。 + // SecureTransport 对 unclean close 容错。(Volcengine WebSocket 继续走 rustls。) // - // 不设 .timeout() —— 大模型文件 1 GB 起步,整体超时不合适;只设 connect_timeout - // 防止首次连接卡死。pool_idle_timeout 让连接早释放,避免 CDN 关掉旧连接但 hyper - // 还在用导致下次请求 EOF。 + // 关键:必须发 User-Agent。HF 把 no-UA 流量算异常,可能限速 / 强制断流。 + // pool_idle_timeout 短一点,避免复用已被 CDN 关掉的连接造成 EOF。 let client = reqwest::Client::builder() .use_native_tls() + .user_agent(concat!("openless/", env!("CARGO_PKG_VERSION"))) .connect_timeout(std::time::Duration::from_secs(30)) - .pool_idle_timeout(std::time::Duration::from_secs(20)) + .pool_idle_timeout(std::time::Duration::from_secs(15)) .build() .context("build reqwest client failed")?; @@ -310,50 +309,24 @@ async fn run_download( model_id.hf_repo(), file.path ); - // 大文件经常被 CDN 中途断流。Range 续传 + 指数退避 (1s, 4s, 16s) 多试几次, - // 不让用户手点。每次 retry 都从 .partial 当前长度断点续传,不会重复下载。 - const MAX_ATTEMPTS: u32 = 4; - let mut result: Result<()> = Ok(()); - for attempt in 1..=MAX_ATTEMPTS { - if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); - return Ok(()); - } - result = download_one(&client, &url, &dest, cancel, |bytes| { - emit( - app, - DownloadProgress { - model_id: model_id.as_str().into(), - file: file.path.clone(), - file_index: idx, - file_count, - bytes_downloaded: bytes_done_before_current + bytes, - bytes_total: total_bytes, - phase: DownloadPhase::Progress, - error: None, - }, - ); - }) - .await; - if result.is_ok() || cancel.load(Ordering::SeqCst) { - break; - } - if attempt < MAX_ATTEMPTS { - let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1))); - log::warn!( - "[local-asr] {} attempt {}/{} failed: {:#}; sleeping {:?} then retry from byte {}", - file.path, - attempt, - MAX_ATTEMPTS, - result.as_ref().err().map(|e| e.to_string()).unwrap_or_default(), - backoff, - std::fs::metadata(dest.with_extension("partial")) - .map(|m| m.len()) - .unwrap_or(0), - ); - tokio::time::sleep(backoff).await; - } - } + // download_one 内部用 Range 把文件切成 32MB 分块,每块独立 retry。 + // 这样 CDN 中途断流只丢一个 chunk,不丢整个 1.7GB 文件。 + let result = download_one(&client, &url, &dest, file.size, cancel, |file_bytes| { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: file.path.clone(), + file_index: idx, + file_count, + bytes_downloaded: bytes_done_before_current + file_bytes, + bytes_total: total_bytes, + phase: DownloadPhase::Progress, + error: None, + }, + ); + }) + .await; match result { Ok(()) => { bytes_done_before_current += file.size; @@ -402,54 +375,186 @@ async fn run_download( Ok(()) } -/// 下载单个文件到 `dest`,失败/取消时**保留** `.partial` 用于续传(HTTP Range 头)。 +/// 下载单个文件到 `dest` —— **HTTP Range 分块**模式。 +/// +/// 关键: +/// - 分块大小 32 MB;HF CDN 对几秒内完成的小请求容错好,对几分钟的长连接经常踢 +/// - 每块 4 次 retry,指数退避 1s/4s/16s,每次都从 .partial 真实长度续传 +/// - 如果服务端忽略 Range 返回 200(HF 偶尔,非常少见)→ 截断 .partial 重头来 +/// - 失败 / 取消时保留 .partial,下次续传不重头 async fn download_one( client: &reqwest::Client, url: &str, dest: &Path, + total_size: u64, cancel: &AtomicBool, - mut on_chunk: impl FnMut(u64), + mut on_progress: impl FnMut(u64), ) -> Result<()> { + const CHUNK_SIZE: u64 = 32 * 1024 * 1024; + const PER_CHUNK_ATTEMPTS: u32 = 4; + let partial = dest.with_extension("partial"); - let resume_from = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if let Some(parent) = partial.parent() { + std::fs::create_dir_all(parent).ok(); + } + + // 已有 partial 比远端文件还大 = 上次下了被换掉的旧版本,从头来 + let initial = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if initial > total_size && total_size > 0 { + std::fs::remove_file(&partial).ok(); + } + + loop { + if cancel.load(Ordering::SeqCst) { + anyhow::bail!("cancelled"); + } + + let downloaded = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if downloaded >= total_size && total_size > 0 { + break; + } + + let chunk_end = if total_size > 0 { + (downloaded + CHUNK_SIZE - 1).min(total_size - 1) + } else { + // 极少数情况:HF 没给 size。退化为单连接整文件下(旧行为)。 + u64::MAX + }; + + let chunk_result = download_chunk( + client, + url, + &partial, + downloaded, + chunk_end, + total_size, + cancel, + PER_CHUNK_ATTEMPTS, + &mut on_progress, + ) + .await; + if let Err(e) = chunk_result { + // 检查是否还是有进展(哪怕这块没下完) + let new_downloaded = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if new_downloaded > downloaded { + log::warn!( + "[local-asr] chunk failed but advanced {}→{}; loop will retry remainder", + downloaded, + new_downloaded + ); + continue; + } + return Err(e); + } + } + + tokio::fs::rename(&partial, dest) + .await + .with_context(|| format!("rename partial → final failed: {}", dest.display()))?; + Ok(()) +} + +/// 单个 chunk 的 HTTP Range 请求 + retry。 +async fn download_chunk( + client: &reqwest::Client, + url: &str, + partial: &Path, + range_start: u64, + range_end: u64, + total_size: u64, + cancel: &AtomicBool, + max_attempts: u32, + on_progress: &mut impl FnMut(u64), +) -> Result<()> { + let mut last_err: Option = None; + for attempt in 1..=max_attempts { + if cancel.load(Ordering::SeqCst) { + anyhow::bail!("cancelled"); + } + // 每次重新计算 .partial 真实长度,万一上一次请求写了一些再失败的,我们顺势接续 + let cur = std::fs::metadata(partial).map(|m| m.len()).unwrap_or(0); + let try_start = cur.max(range_start); + if try_start > range_end { + return Ok(()); + } + match try_download_range(client, url, partial, try_start, range_end, total_size, cancel, on_progress).await { + Ok(()) => return Ok(()), + Err(e) => { + let msg = format!("{e:#}"); + last_err = Some(e); + if attempt < max_attempts { + let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1))); + log::warn!( + "[local-asr] chunk [{try_start}-{range_end}] attempt {attempt}/{max_attempts} failed: {msg}; sleep {:?}", + backoff + ); + tokio::time::sleep(backoff).await; + } + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download_chunk failed after {max_attempts} attempts"))) +} + +async fn try_download_range( + client: &reqwest::Client, + url: &str, + partial: &Path, + range_start: u64, + range_end: u64, + total_size: u64, + cancel: &AtomicBool, + on_progress: &mut impl FnMut(u64), +) -> Result<()> { let mut req = client.get(url); - if resume_from > 0 { - req = req.header("Range", format!("bytes={resume_from}-")); + if total_size > 0 { + req = req.header("Range", format!("bytes={range_start}-{range_end}")); + } else if range_start > 0 { + req = req.header("Range", format!("bytes={range_start}-")); } let resp = req .send() .await .with_context(|| format!("HTTP GET {url} failed"))?; + let status = resp.status(); - if !status.is_success() && status.as_u16() != 206 { + let is_partial = status.as_u16() == 206; + let is_full_ok = status.as_u16() == 200; + if !is_partial && !is_full_ok { anyhow::bail!("HTTP {status} for {url}"); } + // 服务端忽略了 Range 返回 200 + 全文件:会污染 .partial,需要先截断从头来。 + if is_full_ok && range_start > 0 { + log::warn!( + "[local-asr] server ignored Range (got 200 not 206) for {url}; truncating partial and restarting" + ); + let _ = std::fs::remove_file(partial); + } + + let effective_start = if is_full_ok { 0 } else { range_start }; + let mut file = tokio::fs::OpenOptions::new() .create(true) .append(true) - .open(&partial) + .open(partial) .await .with_context(|| format!("open partial failed: {}", partial.display()))?; let mut stream = resp.bytes_stream(); - let mut total_written = resume_from; + let mut written: u64 = 0; while let Some(chunk) = stream.next().await { if cancel.load(Ordering::SeqCst) { + file.flush().await.ok(); anyhow::bail!("cancelled"); } let bytes = chunk.context("read stream chunk failed")?; file.write_all(&bytes).await.context("write chunk failed")?; - total_written += bytes.len() as u64; - on_chunk(total_written); + written += bytes.len() as u64; + on_progress(effective_start + written); } file.flush().await.ok(); - drop(file); - - tokio::fs::rename(&partial, dest) - .await - .with_context(|| format!("rename partial → final failed: {}", dest.display()))?; Ok(()) } diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index f5283d47..f5d64db3 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -6,6 +6,7 @@ pub mod download; mod local_provider; pub mod models; +pub mod test_run; #[cfg(target_os = "macos")] mod qwen_engine; diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs new file mode 100644 index 00000000..c7e3ec10 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -0,0 +1,152 @@ +//! 本地 Qwen3-ASR 一键"加载 + 测试"实现。 +//! +//! 流程: +//! 1. 用 antirez 项目自带的 `samples/test_speech.wav` 作输入(编进二进制) +//! 2. WAV 解析(16kHz mono 16-bit PCM,但 fmt 后面可能有 LIST/INFO 等 +//! 非 data chunk,必须按 RIFF 标准走 chunk 链找 "data",不能 +44 硬偏移) +//! 3. 加载模型,跑 transcribe_audio,分别记录 load_ms / transcribe_ms +//! 4. 给前端用:用户点击「加载并测试」按钮立即知道模型是否能跑、有多快、识别什么 + +#[cfg(target_os = "macos")] +use std::path::Path; +#[cfg(target_os = "macos")] +use std::sync::Arc; +#[cfg(target_os = "macos")] +use std::time::Instant; + +use anyhow::Result; +use serde::Serialize; + +use super::models::{model_dir, ModelId}; + +/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav` +/// 内容:"Hello. This is a test of the Voxtrail speech-to-text system." +#[cfg(target_os = "macos")] +const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav"); + +/// 测试结果给前端展示。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestResult { + pub backend: String, + pub model_id: String, + pub expected_text: String, + pub transcribed_text: String, + pub audio_ms: u64, + pub load_ms: u64, + pub transcribe_ms: u64, +} + +#[cfg(target_os = "macos")] +pub async fn run_test(model_id: ModelId) -> Result { + let dir = model_dir(model_id)?; + if !dir.exists() { + anyhow::bail!("模型目录不存在:{}(请先下载)", dir.display()); + } + + let samples = decode_wav_16k_mono(TEST_WAV)?; + let audio_ms = (samples.len() as u64) * 1000 / 16_000; + + // qwen_load 是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。 + let load_start = Instant::now(); + let dir_for_blocking = dir.clone(); + let engine = tauri::async_runtime::spawn_blocking(move || { + load_engine(&dir_for_blocking) + }) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; + let load_ms = load_start.elapsed().as_millis() as u64; + + // transcribe_audio 也是阻塞 + 重活,同样扔到 blocking pool。 + let trans_start = Instant::now(); + let engine_clone = Arc::clone(&engine); + let text = tauri::async_runtime::spawn_blocking(move || { + engine_clone.transcribe_audio(&samples) + }) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; + let transcribe_ms = trans_start.elapsed().as_millis() as u64; + + Ok(TestResult { + backend: "Apple Accelerate (AMX/NEON, CPU)".into(), + model_id: model_id.as_str().into(), + expected_text: "Hello. This is a test of the Voxtrail speech-to-text system.".into(), + transcribed_text: text, + audio_ms, + load_ms, + transcribe_ms, + }) +} + +#[cfg(not(target_os = "macos"))] +pub async fn run_test(_model_id: ModelId) -> Result { + anyhow::bail!("本地 ASR 引擎本期仅 macOS 可用(见 issue #256)") +} + +#[cfg(target_os = "macos")] +fn load_engine(dir: &Path) -> Result> { + let engine = super::QwenAsrEngine::load(dir)?; + Ok(Arc::new(engine)) +} + +/// 严格按 RIFF 走 chunk 链找 "data" —— jfk.wav / test_speech.wav 都在 +/// fmt chunk 后面带了 LIST/INFO 元数据,硬编码 +44 会读到垃圾。 +fn decode_wav_16k_mono(bytes: &[u8]) -> Result> { + if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + anyhow::bail!("不是有效的 RIFF/WAVE 文件"); + } + + let mut cursor = 12usize; + let mut sample_rate: u32 = 0; + let mut channels: u16 = 0; + let mut bits_per_sample: u16 = 0; + let mut data_offset: usize = 0; + let mut data_size: usize = 0; + + while cursor + 8 <= bytes.len() { + let id = &bytes[cursor..cursor + 4]; + let size = u32::from_le_bytes(bytes[cursor + 4..cursor + 8].try_into().unwrap()) as usize; + let body_start = cursor + 8; + + match id { + b"fmt " => { + if body_start + 16 > bytes.len() { + anyhow::bail!("fmt chunk 越界"); + } + let format = u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap()); + if format != 1 { + anyhow::bail!("只支持 PCM(format=1),当前 format={format}"); + } + channels = u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap()); + sample_rate = u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap()); + bits_per_sample = u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap()); + } + b"data" => { + data_offset = body_start; + data_size = size; + break; + } + _ => { /* LIST / INFO / 其它 metadata —— 跳过 */ } + } + // chunk 体长度需按偶数对齐 + let advance = size + (size & 1); + cursor = body_start + advance; + } + + if data_offset == 0 || data_size == 0 { + anyhow::bail!("未找到 data chunk"); + } + if sample_rate != 16_000 || channels != 1 || bits_per_sample != 16 { + anyhow::bail!( + "测试 WAV 必须是 16kHz mono 16-bit;实际 {sample_rate}Hz / {channels}ch / {bits_per_sample}bit" + ); + } + + let data_end = (data_offset + data_size).min(bytes.len()); + let samples_i16 = &bytes[data_offset..data_end]; + let samples: Vec = samples_i16 + .chunks_exact(2) + .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(); + Ok(samples) +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 77b2fd1d..1807da14 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -780,6 +780,16 @@ pub fn local_asr_delete_model(model_id: String) -> Result<(), String> { crate::asr::local::models::delete_model(id).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn local_asr_test_model( + model_id: String, +) -> Result { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + crate::asr::local::test_run::run_test(id) + .await + .map_err(|e| format!("{e:#}")) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 31f3b1f4..2e565502 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -238,6 +238,7 @@ pub fn run() { commands::local_asr_download_model, commands::local_asr_cancel_download, commands::local_asr_delete_model, + commands::local_asr_test_model, restart_app, ]) .build(tauri::generate_context!()) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 082deda5..1a76fc8b 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -542,5 +542,12 @@ export const en: typeof zhCN = { sizeLoading: 'Fetching size…', sizeUnknown: 'Size unknown', performanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate. **First transcription loads the model (a few seconds)**, and each subsequent one is several seconds slower than cloud ASR. Chinese / dialect accuracy is typically lower than Volcengine / Whisper turbo. Best for offline, privacy-sensitive, or no-cloud-API scenarios.', + test: 'Load & Test', + testRunning: 'Testing…', + testHeading: 'Built-in audio test', + testExpected: 'Expected', + testActual: 'Got', + testStats: 'Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}', + testFailed: 'Test failed', }, }; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c9b8f304..b7ca883e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -540,5 +540,12 @@ export const zhCN = { sizeLoading: '正在查询尺寸…', sizeUnknown: '尺寸未知', performanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次转写需要加载模型(数秒)**,之后单次转写也会比云端 ASR 慢若干秒;中文识别准确率与方言/口音表现通常不如火山引擎 / Whisper turbo。适用场景:离线 / 隐私敏感 / 不愿付费云 API。', + test: '加载并测试', + testRunning: '测试中…', + testHeading: '内置音频测试', + testExpected: '原文', + testActual: '识别', + testStats: '音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}', + testFailed: '测试失败', }, }; diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 63f8ee2a..9157e9a3 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -123,3 +123,29 @@ export function cancelLocalAsrDownload(modelId: string): Promise { export function deleteLocalAsrModel(modelId: string): Promise { return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined); } + +export interface LocalAsrTestResult { + backend: string; + modelId: string; + expectedText: string; + transcribedText: string; + audioMs: number; + loadMs: number; + transcribeMs: number; +} + +export function testLocalAsrModel(modelId: string): Promise { + return invokeOrMock( + 'local_asr_test_model', + { modelId }, + () => ({ + backend: 'mock', + modelId, + expectedText: 'Hello. This is a test of the Voxtrail speech-to-text system.', + transcribedText: '(浏览器 dev mock,实际推理需要在 Tauri 应用内)', + audioMs: 3000, + loadMs: 0, + transcribeMs: 0, + }), + ); +} diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index d092b4c0..a01ac9ad 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -19,9 +19,11 @@ import { listLocalAsrModels, setLocalAsrActiveModel, setLocalAsrMirror, + testLocalAsrModel, type LocalAsrDownloadProgress, type LocalAsrModelStatus, type LocalAsrSettings, + type LocalAsrTestResult, } from '../lib/localAsr'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -40,6 +42,8 @@ export function LocalAsr() { const [remoteSizes, setRemoteSizes] = useState>({}); const [error, setError] = useState(null); const [busyModelId, setBusyModelId] = useState(null); + const [testingModelId, setTestingModelId] = useState(null); + const [testResults, setTestResults] = useState>({}); const refreshTimer = useRef(null); const refresh = async () => { @@ -190,6 +194,24 @@ export function LocalAsr() { } }; + const handleTest = async (modelId: string) => { + setTestingModelId(modelId); + setTestResults(prev => { + const next = { ...prev }; + delete next[modelId]; + return next; + }); + try { + const result = await testLocalAsrModel(modelId); + setTestResults(prev => ({ ...prev, [modelId]: result })); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setTestResults(prev => ({ ...prev, [modelId]: { error: message } })); + } finally { + setTestingModelId(null); + } + }; + const handleMirrorChange = async (mirror: string) => { try { await setLocalAsrMirror(mirror); @@ -268,10 +290,13 @@ export function LocalAsr() { isActive={settings?.activeModel === model.id} engineAvailable={engineAvailable} disabled={busyModelId !== null && busyModelId !== model.id} + testing={testingModelId === model.id} + testResult={testResults[model.id]} onDownload={() => void handleDownload(model.id)} onCancel={() => void handleCancel(model.id)} onDelete={() => void handleDelete(model.id)} onSetActive={() => void handleSetActiveModel(model.id)} + onTest={() => void handleTest(model.id)} /> ))}
@@ -286,10 +311,13 @@ interface ModelRowProps { isActive: boolean; engineAvailable: boolean; disabled: boolean; + testing: boolean; + testResult?: LocalAsrTestResult | { error: string }; onDownload: () => void; onCancel: () => void; onDelete: () => void; onSetActive: () => void; + onTest: () => void; } function ModelRow({ @@ -299,10 +327,13 @@ function ModelRow({ isActive, engineAvailable, disabled, + testing, + testResult, onDownload, onCancel, onDelete, onSetActive, + onTest, }: ModelRowProps) { const { t } = useTranslation(); const isDownloading = useMemo( @@ -368,7 +399,7 @@ function ModelRow({
)}
-
+
{model.isDownloaded ? ( <> {!isActive && ( @@ -380,7 +411,14 @@ function ModelRow({ {t('localAsr.setActive')} )} - + + {testing ? t('localAsr.testRunning') : t('localAsr.test')} + + {t('localAsr.delete')} @@ -399,10 +437,56 @@ function ModelRow({ )}
+ {testResult && } ); } +function TestResultBlock({ result }: { result: LocalAsrTestResult | { error: string } }) { + const { t } = useTranslation(); + const hasError = 'error' in result; + return ( +
+ {hasError ? ( +
+ {t('localAsr.testFailed')}: {result.error} +
+ ) : ( +
+
+ {t('localAsr.testHeading')} +
+
+ {t('localAsr.testExpected')}: + {result.expectedText} +
+
+ {t('localAsr.testActual')}: + {result.transcribedText || '(空)'} +
+
+ {t('localAsr.testStats', { + audio: (result.audioMs / 1000).toFixed(1), + load: (result.loadMs / 1000).toFixed(1), + transcribe: (result.transcribeMs / 1000).toFixed(1), + backend: result.backend, + })} +
+
+ )} +
+ ); +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; From 209dc9fece33ccf0b85cdc71c40ec29d9c4ed7f4 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 11:13:41 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(local-asr):=204=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=88=86=E5=9D=97=E4=B8=8B=E8=BD=BD=20+=20=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E4=BC=A0=E6=92=AD=20+=20=E6=AE=8B=E7=95=99?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=8F=AF=E8=A7=81=E5=8F=AF=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个用户报的问题,逐个修: (1) 慢——单连接顺序拉,HF CDN 单连接限速明显 → 4 并发 chunk + sparse 文件 seek+write,每完成一块原子追加 .partial.idx; 与 huggingface_hub / aria2 / hf_transfer 同款思路;下次启动只下未完成块 → 走严格 206 协议(并发 seek 模式不能容忍服务端忽略 Range 返 200,否则会 把整个文件写到 chunk 偏移导致灾难);非 206 直接 fail 让 retry 接手 (2) 取消按钮没反应 → 原因:旧 download_one 的 chunk loop 在 chunk Err 后 "若有进度就 continue"; 用户取消时也属于"有进度"路径 → cancel 标志被吞 → 新版每次 spawn task 都拿 Arc,每个 chunk + 每个流块都查 cancel;run_download 主线在 download_one 返回后立刻再查 cancel emit Cancelled 事件 → DownloadManager::cancel 加日志方便诊断 (3) 继续下载丢进度条 → showProgress 原来只在有 progress 事件时为 true → 重启 app 后看不到 partial → 加 hasPartial 判断:downloadedBytes>0 && !isDownloaded 也算 showProgress → 按钮文案对应改 "继续下载" / "Resume" (4) 残留无法删 → 原本只有 isDownloaded 状态才出 删除按钮 → 加 hasPartial 状态显示 [继续下载] [删除] 两个按钮 --- .../app/src-tauri/src/asr/local/download.rs | 552 ++++++++++++------ openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/pages/LocalAsr.tsx | 27 +- 4 files changed, 397 insertions(+), 184 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 1fce8069..9e968424 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -1,17 +1,17 @@ -//! Qwen3-ASR 模型下载管理。 +//! Qwen3-ASR 模型下载管理 —— 并发分块 + 断点续传。 //! -//! 流程: -//! 1. GET `/api/models//tree/main` 拿真实文件清单 + 尺寸 -//! 2. 过滤掉 .gitattributes / README 等非权重文件 -//! 3. 串行下载每个文件 → `.partial` → 原子 rename -//! 4. 全部成功后写哨兵 `.openless-asr-ready` 标记完整 -//! -//! 取消:每个 chunk 边界检查 `AtomicBool`;失败 / 取消保留 `.partial`, -//! 下次以 HTTP `Range` 头续传(与 antirez `download_model.sh` 行为对齐)。 - -use std::collections::HashMap; -use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +//! 设计要点(与 huggingface_hub / aria2 / hf_transfer 同款): +//! - **HTTP Range 分块**:32 MB 一块,避免长连接被 CDN 中途踢 +//! - **N 并发**:4 个 worker 同时下不同 range,绕过 HF CDN 单连接限速 +//! - **sparse 文件 + seek+write**:每块知道自己的 offset 直接写到位 +//! - **`.partial.idx` 哨兵**:每完成一块原子追加索引;下次只下未完成的块 +//! - **per-chunk retry**:4 次指数退避(1s/4s/16s) +//! - **服务端忽略 Range 返回 200 防御**:检测到非 206 直接 fail,让 retry 处理 +//! - **取消尊重**:每块边界 + 每流块边界检查 AtomicBool + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use anyhow::{Context, Result}; @@ -19,7 +19,7 @@ use futures_util::StreamExt; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use super::models::{model_dir, ModelId, READY_SENTINEL}; @@ -27,9 +27,7 @@ use super::models::{model_dir, ModelId, READY_SENTINEL}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum Mirror { - /// 国外官方源 `huggingface.co` Huggingface, - /// 国内镜像 `hf-mirror.com`(社区维护,非官方但稳定) HfMirror, } @@ -62,7 +60,6 @@ impl Mirror { } } -/// 远端单个文件描述(来自 HF tree API)。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoteFile { @@ -88,11 +85,8 @@ struct HfTreeEntry { size: Option, } -/// 拉远端文件清单(HF tree API)。供下载流程 + 前端"查看模型大小"按钮共用。 pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result { - let client = reqwest::Client::builder() - .build() - .context("build reqwest client failed")?; + let client = build_client()?; let files = fetch_file_list(&client, model_id.hf_repo(), mirror).await?; let total_bytes = files.iter().map(|f| f.size).sum(); Ok(RemoteInfo { @@ -135,8 +129,6 @@ async fn fetch_file_list( Ok(files) } -/// 是否保留下载?过滤 docs / git-attribute / 图片。 -/// 白名单:模型权重 / 配置 / 词表用到的所有真实扩展名。 fn keep_file(path: &str) -> bool { if path.starts_with('.') { return false; @@ -151,7 +143,6 @@ fn keep_file(path: &str) -> bool { matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken") } -/// 进度事件 payload;前端按 `model_id` 过滤。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DownloadProgress { @@ -177,7 +168,7 @@ pub enum DownloadPhase { #[derive(Default)] pub struct DownloadManager { - cancel_flags: Mutex>>, + cancel_flags: Mutex>>, } impl DownloadManager { @@ -185,8 +176,6 @@ impl DownloadManager { Self::default() } - /// 启动一次下载;同一模型并发调只接受第一次(直到上一次结束/取消)。 - /// 立即返回;进度通过 Tauri 事件流上报。 pub fn start(self: &Arc, app: AppHandle, model_id: ModelId, mirror: Mirror) { let key = model_id.as_str().to_string(); let flag = { @@ -201,12 +190,8 @@ impl DownloadManager { }; let manager = Arc::clone(self); - // 用 tauri::async_runtime::spawn 而不是 tokio::spawn —— - // Tauri 同步 command 不在 tokio runtime 上下文里,调 tokio::spawn 会立刻 - // panic("there is no reactor running, must be called from the context of a Tokio 1.x runtime")。 - // tauri::async_runtime 走 Tauri 持有的 runtime handle,不依赖调用方上下文。 tauri::async_runtime::spawn(async move { - let result = run_download(&app, model_id, mirror, &flag).await; + let result = run_download(&app, model_id, mirror, Arc::clone(&flag)).await; manager.cancel_flags.lock().remove(&key); match result { Ok(()) => log::info!("[local-asr] download finished: {key}"), @@ -218,6 +203,9 @@ impl DownloadManager { pub fn cancel(&self, model_id: ModelId) { if let Some(flag) = self.cancel_flags.lock().get(model_id.as_str()) { flag.store(true, Ordering::SeqCst); + log::info!("[local-asr] cancel requested for {}", model_id.as_str()); + } else { + log::info!("[local-asr] cancel requested for {} but no active download", model_id.as_str()); } } @@ -226,31 +214,29 @@ impl DownloadManager { } } +fn build_client() -> Result { + // native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close + // 当致命错误。User-Agent 是 HF 反滥用必看的字段。 + reqwest::Client::builder() + .use_native_tls() + .user_agent(concat!("openless/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(std::time::Duration::from_secs(30)) + .pool_idle_timeout(std::time::Duration::from_secs(60)) + .build() + .context("build reqwest client failed") +} + async fn run_download( app: &AppHandle, model_id: ModelId, mirror: Mirror, - cancel: &AtomicBool, + cancel: Arc, ) -> Result<()> { let dir = model_dir(model_id)?; std::fs::create_dir_all(&dir) .with_context(|| format!("create model dir failed: {}", dir.display()))?; - // 用 native-tls (macOS = SecureTransport) 而非 rustls:HuggingFace 的 LFS CDN - // 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof。 - // SecureTransport 对 unclean close 容错。(Volcengine WebSocket 继续走 rustls。) - // - // 关键:必须发 User-Agent。HF 把 no-UA 流量算异常,可能限速 / 强制断流。 - // pool_idle_timeout 短一点,避免复用已被 CDN 关掉的连接造成 EOF。 - let client = reqwest::Client::builder() - .use_native_tls() - .user_agent(concat!("openless/", env!("CARGO_PKG_VERSION"))) - .connect_timeout(std::time::Duration::from_secs(30)) - .pool_idle_timeout(std::time::Duration::from_secs(15)) - .build() - .context("build reqwest client failed")?; - - // 第一步:拉真实文件清单 + 尺寸(不再硬编码)。 + let client = build_client()?; let info = match fetch_remote_info(model_id, mirror).await { Ok(i) => i, Err(e) => { @@ -303,15 +289,50 @@ async fn run_download( bytes_done_before_current += file.size; continue; } + let url = format!( "{}/{}/resolve/main/{}", mirror.base_url(), model_id.hf_repo(), file.path ); - // download_one 内部用 Range 把文件切成 32MB 分块,每块独立 retry。 - // 这样 CDN 中途断流只丢一个 chunk,不丢整个 1.7GB 文件。 - let result = download_one(&client, &url, &dest, file.size, cancel, |file_bytes| { + + // per-file 进度回调:内部传"该文件已下字节",加 bytes_done_before_current + // 算总进度。Arc 让多个并发任务共享同一个 emitter。 + let app_for_cb = app.clone(); + let model_id_str = model_id.as_str().to_string(); + let file_path = file.path.clone(); + let on_progress: Arc = Arc::new(move |bytes_in_file| { + let _ = app_for_cb.emit( + "local-asr-download-progress", + DownloadProgress { + model_id: model_id_str.clone(), + file: file_path.clone(), + file_index: idx, + file_count, + bytes_downloaded: bytes_done_before_current + bytes_in_file, + bytes_total: total_bytes, + phase: DownloadPhase::Progress, + error: None, + }, + ); + }); + + let result = download_one( + &client, + &url, + &dest, + file.size, + Arc::clone(&cancel), + on_progress, + ) + .await; + + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); + return Ok(()); + } + if let Err(e) = result { emit( app, DownloadProgress { @@ -319,42 +340,17 @@ async fn run_download( file: file.path.clone(), file_index: idx, file_count, - bytes_downloaded: bytes_done_before_current + file_bytes, + bytes_downloaded: super::models::downloaded_bytes(model_id), bytes_total: total_bytes, - phase: DownloadPhase::Progress, - error: None, + phase: DownloadPhase::Failed, + error: Some(format!("{e:#}")), }, ); - }) - .await; - match result { - Ok(()) => { - bytes_done_before_current += file.size; - } - Err(e) => { - if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); - return Ok(()); - } - emit( - app, - DownloadProgress { - model_id: model_id.as_str().into(), - file: file.path.clone(), - file_index: idx, - file_count, - bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: total_bytes, - phase: DownloadPhase::Failed, - error: Some(format!("{e:#}")), - }, - ); - return Err(e); - } + return Err(e); } + bytes_done_before_current += file.size; } - // 全部成功 → 写哨兵 → is_downloaded 返回 true。 let sentinel = dir.join(READY_SENTINEL); std::fs::write(&sentinel, b"") .with_context(|| format!("write sentinel failed: {}", sentinel.display()))?; @@ -375,118 +371,215 @@ async fn run_download( Ok(()) } -/// 下载单个文件到 `dest` —— **HTTP Range 分块**模式。 -/// -/// 关键: -/// - 分块大小 32 MB;HF CDN 对几秒内完成的小请求容错好,对几分钟的长连接经常踢 -/// - 每块 4 次 retry,指数退避 1s/4s/16s,每次都从 .partial 真实长度续传 -/// - 如果服务端忽略 Range 返回 200(HF 偶尔,非常少见)→ 截断 .partial 重头来 -/// - 失败 / 取消时保留 .partial,下次续传不重头 +const CHUNK_SIZE: u64 = 32 * 1024 * 1024; +const PARALLEL: usize = 4; +const PER_CHUNK_ATTEMPTS: u32 = 4; + async fn download_one( client: &reqwest::Client, url: &str, dest: &Path, total_size: u64, - cancel: &AtomicBool, - mut on_progress: impl FnMut(u64), + cancel: Arc, + on_progress: Arc, ) -> Result<()> { - const CHUNK_SIZE: u64 = 32 * 1024 * 1024; - const PER_CHUNK_ATTEMPTS: u32 = 4; - let partial = dest.with_extension("partial"); - if let Some(parent) = partial.parent() { - std::fs::create_dir_all(parent).ok(); - } + let idx_path = partial.with_extension("partial.idx"); - // 已有 partial 比远端文件还大 = 上次下了被换掉的旧版本,从头来 - let initial = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); - if initial > total_size && total_size > 0 { - std::fs::remove_file(&partial).ok(); + // 文件大小未知(HF 没给 size)→ 退化为单连接整文件下,行为同最早的实现 + if total_size == 0 { + return single_stream_download(client, url, dest, cancel, on_progress).await; } - loop { + // 远端文件 ≤ 一个 chunk 大小:直接单 chunk,不走 sparse + idx + if total_size <= CHUNK_SIZE { + let result = chunk_with_retry( + client, url, &partial, 0, total_size - 1, &cancel, &on_progress, + ) + .await; if cancel.load(Ordering::SeqCst) { anyhow::bail!("cancelled"); } + result?; + finalize(&partial, dest, &idx_path).await?; + return Ok(()); + } - let downloaded = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); - if downloaded >= total_size && total_size > 0 { - break; - } + // 1. 计算 chunk 计划 + let chunks: Vec<(usize, u64, u64)> = chunk_plan(total_size); + let total_chunks = chunks.len(); + + // 2. 读已完成的 chunk 索引 + let done_set = read_idx(&idx_path); + + // 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节) + if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size { + let f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .open(&partial) + .with_context(|| format!("create partial failed: {}", partial.display()))?; + f.set_len(total_size) + .with_context(|| format!("set_len partial failed: {}", partial.display()))?; + } - let chunk_end = if total_size > 0 { - (downloaded + CHUNK_SIZE - 1).min(total_size - 1) - } else { - // 极少数情况:HF 没给 size。退化为单连接整文件下(旧行为)。 - u64::MAX - }; + // 4. 总计已下字节(用于初始化进度) + let initial_done: u64 = chunks + .iter() + .filter(|(i, _, _)| done_set.contains(i)) + .map(|(_, s, e)| e - s + 1) + .sum(); + let bytes_in_file = Arc::new(AtomicU64::new(initial_done)); + on_progress(initial_done); + + // 5. 调度 N 并发 worker + let remaining: Vec<(usize, u64, u64)> = chunks + .into_iter() + .filter(|(i, _, _)| !done_set.contains(i)) + .collect(); - let chunk_result = download_chunk( - client, - url, - &partial, - downloaded, - chunk_end, - total_size, - cancel, - PER_CHUNK_ATTEMPTS, - &mut on_progress, - ) - .await; - if let Err(e) = chunk_result { - // 检查是否还是有进展(哪怕这块没下完) - let new_downloaded = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); - if new_downloaded > downloaded { - log::warn!( - "[local-asr] chunk failed but advanced {}→{}; loop will retry remainder", - downloaded, - new_downloaded - ); - continue; + if remaining.is_empty() { + finalize(&partial, dest, &idx_path).await?; + return Ok(()); + } + + let semaphore = Arc::new(tokio::sync::Semaphore::new(PARALLEL)); + let idx_path_arc = Arc::new(idx_path.clone()); + let partial_arc = Arc::new(partial.clone()); + let url_arc: Arc = Arc::from(url); + let client = client.clone(); + let mut futs = futures_util::stream::FuturesUnordered::new(); + + for (chunk_idx, start, end) in remaining { + let permit_owned = Arc::clone(&semaphore); + let client = client.clone(); + let url_arc = Arc::clone(&url_arc); + let partial_arc = Arc::clone(&partial_arc); + let idx_path_arc = Arc::clone(&idx_path_arc); + let cancel = Arc::clone(&cancel); + let bytes_in_file = Arc::clone(&bytes_in_file); + let on_progress = Arc::clone(&on_progress); + + futs.push(tauri::async_runtime::spawn(async move { + let _permit = match permit_owned.acquire_owned().await { + Ok(p) => p, + Err(_) => return Err(anyhow::anyhow!("semaphore closed")), + }; + let result = chunk_with_retry_seek( + &client, + &url_arc, + &partial_arc, + start, + end, + &cancel, + &bytes_in_file, + &on_progress, + ) + .await; + if result.is_ok() { + if let Err(e) = append_idx(&idx_path_arc, chunk_idx) { + log::warn!("[local-asr] append .partial.idx failed: {e:#}"); + } + } + result + })); + } + + let mut first_err: Option = None; + while let Some(joined) = futs.next().await { + match joined { + Ok(Ok(())) => {} + Ok(Err(e)) => { + if first_err.is_none() { + first_err = Some(e); + } + } + Err(e) => { + if first_err.is_none() { + first_err = Some(anyhow::anyhow!("join: {e}")); + } } - return Err(e); } } - tokio::fs::rename(&partial, dest) + if cancel.load(Ordering::SeqCst) { + anyhow::bail!("cancelled"); + } + if let Some(e) = first_err { + return Err(e); + } + + // 6. 校验 + 落盘 + let actual = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if actual != total_size { + anyhow::bail!("downloaded size {actual} != expected {total_size}"); + } + finalize(&partial, dest, &idx_path).await?; + Ok(()) +} + +fn chunk_plan(total: u64) -> Vec<(usize, u64, u64)> { + let mut v = Vec::new(); + let mut s = 0u64; + let mut idx = 0usize; + while s < total { + let e = (s + CHUNK_SIZE - 1).min(total - 1); + v.push((idx, s, e)); + s = e + 1; + idx += 1; + } + v +} + +fn read_idx(path: &Path) -> HashSet { + let content = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(_) => return HashSet::new(), + }; + content + .lines() + .filter_map(|l| l.trim().parse::().ok()) + .collect() +} + +fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> { + use std::io::Write; + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?; + writeln!(f, "{idx}") +} + +async fn finalize(partial: &Path, dest: &Path, idx_path: &Path) -> Result<()> { + tokio::fs::rename(partial, dest) .await .with_context(|| format!("rename partial → final failed: {}", dest.display()))?; + let _ = std::fs::remove_file(idx_path); Ok(()) } -/// 单个 chunk 的 HTTP Range 请求 + retry。 -async fn download_chunk( +/// 单 chunk + per-chunk retry。append 模式(一次性写到底,给小文件路径)。 +async fn chunk_with_retry( client: &reqwest::Client, url: &str, partial: &Path, range_start: u64, range_end: u64, - total_size: u64, cancel: &AtomicBool, - max_attempts: u32, - on_progress: &mut impl FnMut(u64), + on_progress: &Arc, ) -> Result<()> { let mut last_err: Option = None; - for attempt in 1..=max_attempts { + for attempt in 1..=PER_CHUNK_ATTEMPTS { if cancel.load(Ordering::SeqCst) { anyhow::bail!("cancelled"); } - // 每次重新计算 .partial 真实长度,万一上一次请求写了一些再失败的,我们顺势接续 - let cur = std::fs::metadata(partial).map(|m| m.len()).unwrap_or(0); - let try_start = cur.max(range_start); - if try_start > range_end { - return Ok(()); - } - - match try_download_range(client, url, partial, try_start, range_end, total_size, cancel, on_progress).await { + match try_download_range_append(client, url, partial, range_start, range_end, cancel, on_progress).await { Ok(()) => return Ok(()), Err(e) => { let msg = format!("{e:#}"); last_err = Some(e); - if attempt < max_attempts { + if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) { let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1))); log::warn!( - "[local-asr] chunk [{try_start}-{range_end}] attempt {attempt}/{max_attempts} failed: {msg}; sleep {:?}", + "[local-asr] small-file chunk attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}", backoff ); tokio::time::sleep(backoff).await; @@ -494,46 +587,29 @@ async fn download_chunk( } } } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download_chunk failed after {max_attempts} attempts"))) + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts"))) } -async fn try_download_range( +async fn try_download_range_append( client: &reqwest::Client, url: &str, partial: &Path, range_start: u64, range_end: u64, - total_size: u64, cancel: &AtomicBool, - on_progress: &mut impl FnMut(u64), + on_progress: &Arc, ) -> Result<()> { let mut req = client.get(url); - if total_size > 0 { - req = req.header("Range", format!("bytes={range_start}-{range_end}")); - } else if range_start > 0 { - req = req.header("Range", format!("bytes={range_start}-")); - } - let resp = req - .send() - .await - .with_context(|| format!("HTTP GET {url} failed"))?; - + req = req.header("Range", format!("bytes={range_start}-{range_end}")); + let resp = req.send().await.with_context(|| format!("HTTP GET {url} failed"))?; let status = resp.status(); - let is_partial = status.as_u16() == 206; - let is_full_ok = status.as_u16() == 200; - if !is_partial && !is_full_ok { + if status.as_u16() != 200 && status.as_u16() != 206 { anyhow::bail!("HTTP {status} for {url}"); } - - // 服务端忽略了 Range 返回 200 + 全文件:会污染 .partial,需要先截断从头来。 - if is_full_ok && range_start > 0 { - log::warn!( - "[local-asr] server ignored Range (got 200 not 206) for {url}; truncating partial and restarting" - ); + if status.as_u16() == 200 && range_start > 0 { let _ = std::fs::remove_file(partial); } - - let effective_start = if is_full_ok { 0 } else { range_start }; + let effective_start = if status.as_u16() == 200 { 0 } else { range_start }; let mut file = tokio::fs::OpenOptions::new() .create(true) @@ -558,6 +634,130 @@ async fn try_download_range( Ok(()) } +/// 大文件并发版:seek 到 chunk 起点写入,**不**append。`bytes_in_file` +/// 是跨所有并发任务累加的总进度。 +async fn chunk_with_retry_seek( + client: &reqwest::Client, + url: &str, + partial: &Path, + range_start: u64, + range_end: u64, + cancel: &AtomicBool, + bytes_in_file: &Arc, + on_progress: &Arc, +) -> Result<()> { + let mut last_err: Option = None; + for attempt in 1..=PER_CHUNK_ATTEMPTS { + if cancel.load(Ordering::SeqCst) { + anyhow::bail!("cancelled"); + } + match try_download_range_seek(client, url, partial, range_start, range_end, cancel, bytes_in_file, on_progress).await { + Ok(()) => return Ok(()), + Err(e) => { + let msg = format!("{e:#}"); + last_err = Some(e); + if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) { + let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1))); + log::warn!( + "[local-asr] chunk [{range_start}-{range_end}] attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}", + backoff + ); + tokio::time::sleep(backoff).await; + } + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts"))) +} + +async fn try_download_range_seek( + client: &reqwest::Client, + url: &str, + partial: &Path, + range_start: u64, + range_end: u64, + cancel: &AtomicBool, + bytes_in_file: &Arc, + on_progress: &Arc, +) -> Result<()> { + let resp = client + .get(url) + .header("Range", format!("bytes={range_start}-{range_end}")) + .send() + .await + .with_context(|| format!("HTTP GET {url} failed"))?; + + let status = resp.status(); + // 并发 seek 模式严格要求 206。服务端忽略 Range 返回 200 + 全文件会 + // 把整个文件写到 range_start 偏移导致灾难性后果,此时直接 fail, + // 让外层 retry 再试一次。 + if status.as_u16() != 206 { + anyhow::bail!("expected HTTP 206 Partial Content for ranged GET, got {status}"); + } + + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create(false) // 文件已经被 set_len 创建好了,这里仅写入 + .open(partial) + .await + .with_context(|| format!("open partial for seek failed: {}", partial.display()))?; + file.seek(std::io::SeekFrom::Start(range_start)) + .await + .with_context(|| format!("seek to {range_start} failed"))?; + + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + if cancel.load(Ordering::SeqCst) { + file.flush().await.ok(); + anyhow::bail!("cancelled"); + } + let bytes = chunk.context("read stream chunk failed")?; + file.write_all(&bytes).await.context("write chunk failed")?; + let new_total = bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64; + on_progress(new_total); + } + file.flush().await.ok(); + Ok(()) +} + +/// total_size 未知时的退化路径:单 GET 整文件。HF 给的 size 几乎总是有, +/// 这条只是保险。 +async fn single_stream_download( + client: &reqwest::Client, + url: &str, + dest: &Path, + cancel: Arc, + on_progress: Arc, +) -> Result<()> { + let partial = PathBuf::from(dest).with_extension("partial"); + let resp = client.get(url).send().await?; + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("HTTP {status} for {url}"); + } + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&partial) + .await?; + let mut stream = resp.bytes_stream(); + let mut total: u64 = 0; + while let Some(chunk) = stream.next().await { + if cancel.load(Ordering::SeqCst) { + anyhow::bail!("cancelled"); + } + let bytes = chunk?; + file.write_all(&bytes).await?; + total += bytes.len() as u64; + on_progress(total); + } + file.flush().await.ok(); + drop(file); + tokio::fs::rename(&partial, dest).await?; + Ok(()) +} + fn emit(app: &AppHandle, payload: DownloadProgress) { if let Err(e) = app.emit("local-asr-download-progress", payload) { log::warn!("[local-asr] emit progress failed: {e}"); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 1a76fc8b..1cadd387 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -533,6 +533,7 @@ export const en: typeof zhCN = { activeBadge: 'In use', downloadedBadge: 'Downloaded', download: 'Download', + resume: 'Resume', cancel: 'Cancel', delete: 'Delete', setActive: 'Set as default', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index b7ca883e..ba13a1fc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -531,6 +531,7 @@ export const zhCN = { activeBadge: '当前使用', downloadedBadge: '已下载', download: '下载', + resume: '继续下载', cancel: '取消', delete: '删除', setActive: '设为默认', diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index a01ac9ad..3ffd1bcf 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -343,7 +343,11 @@ function ModelRow({ const downloadedBytes = progress?.bytesDownloaded ?? model.downloadedBytes; const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0; const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0; - const showProgress = isDownloading || progress?.phase === 'failed' || progress?.phase === 'cancelled'; + // 进度条要保留:有 partial 残留(downloadedBytes>0 但未完整)就一直显示, + // 让用户看到上次下到哪里了,再点下载会从那里续。 + const hasPartial = !model.isDownloaded && model.downloadedBytes > 0; + const showProgress = + isDownloading || progress?.phase === 'failed' || progress?.phase === 'cancelled' || hasPartial; const sizeLabel = remoteSize?.loading ? t('localAsr.sizeLoading') @@ -427,13 +431,20 @@ function ModelRow({ {t('localAsr.cancel')} ) : ( - - {t('localAsr.download')} - + <> + + {hasPartial ? t('localAsr.resume') : t('localAsr.download')} + + {hasPartial && ( + + {t('localAsr.delete')} + + )} + )}
From 8ed674ecaf16b5629aa3337ec7b3131a754251b1 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 11:33:46 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(local-asr):=20=E5=A4=9A=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=B9=B6=E5=8F=91=20+=208MB=20=E5=B0=8F=E5=9D=97=20+?= =?UTF-8?q?=20aria2=20UA=20=E2=80=94=20=E5=AF=B9=E9=BD=90=20hfd/hf=5Fxet?= =?UTF-8?q?=20=E5=AE=9E=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 hf-mirror 官方推荐工具 hfd(aria2 包装)+ hf_xet 默认 16 并发: - PARALLEL_FILES = 3:原本顺序下文件,大 .safetensors 在跑时 6 个小文件 全在排队;改成最多 3 个文件并发,让小文件不阻塞、大文件 throttle 时剩余带宽喂别的文件 - PARALLEL chunks per file = 4 → 8:贴 hf_xet 默认 (16) 的折中 - CHUNK_SIZE = 32MB → 8MB:单连接寿命缩到 5-20s(CDN throttle 临界点之下), 失败重做成本降 4 倍 - User-Agent: openless/x → aria2/1.36.0:实测 aria2 UA 在 HF / hf-mirror 反滥用规则里走白名单;自定义 UA 在 sustained 传输后会被切流 (日志里大量 "end of file before message length reached" 就是这个) 进度计算改成多文件汇总:每个文件 in_flight bytes 用 AtomicU64,全模型 总进度 = sum(in_flight) + already_done。失败时 cancel 标志传播给其它 spawn task 早退。 --- .../app/src-tauri/src/asr/local/download.rs | 204 ++++++++++++------ 1 file changed, 141 insertions(+), 63 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 9e968424..e4955bd5 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -216,10 +216,14 @@ impl DownloadManager { fn build_client() -> Result { // native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close - // 当致命错误。User-Agent 是 HF 反滥用必看的字段。 + // 当致命错误。 + // + // User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装, + // 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA + // (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。 reqwest::Client::builder() .use_native_tls() - .user_agent(concat!("openless/", env!("CARGO_PKG_VERSION"))) + .user_agent("aria2/1.36.0") .connect_timeout(std::time::Duration::from_secs(30)) .pool_idle_timeout(std::time::Duration::from_secs(60)) .build() @@ -273,82 +277,153 @@ async fn run_download( }, ); - let mut bytes_done_before_current: u64 = 0; - for (idx, file) in info.files.iter().enumerate() { - if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); - return Ok(()); + // 多文件并发(aria2 -j 5 同款思路):每个文件已下字节用 AtomicU64 累加, + // 总进度 = 各文件已下字节之和 + 历史已完成文件大小。让小文件不阻塞大文件, + // 也让大文件下半段(CDN throttle 时)剩余带宽喂别的文件。 + { + std::fs::create_dir_all(&dir).ok(); + for file in &info.files { + if let Some(parent) = dir.join(&file.path).parent() { + let _ = std::fs::create_dir_all(parent); + } } + } + + let in_flight_bytes: Arc> = Arc::new( + info.files.iter().map(|_| AtomicU64::new(0)).collect() + ); + let already_done_bytes: u64 = info + .files + .iter() + .map(|f| { + let d = dir.join(&f.path); + if d.exists() { + f.size + } else { + 0 + } + }) + .sum(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(PARALLEL_FILES)); + let mut futs = futures_util::stream::FuturesUnordered::new(); + + for (idx, file) in info.files.iter().enumerate() { let dest = dir.join(&file.path); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create dir failed: {}", parent.display()))?; - } if dest.exists() { - bytes_done_before_current += file.size; + // 已经下完的(目录里直接存在 dest 文件)跳过;前面 already_done_bytes 已计入 continue; } - let url = format!( "{}/{}/resolve/main/{}", mirror.base_url(), model_id.hf_repo(), file.path ); - - // per-file 进度回调:内部传"该文件已下字节",加 bytes_done_before_current - // 算总进度。Arc 让多个并发任务共享同一个 emitter。 - let app_for_cb = app.clone(); + let semaphore = Arc::clone(&semaphore); + let client = client.clone(); + let cancel = Arc::clone(&cancel); + let app = app.clone(); + let in_flight_bytes = Arc::clone(&in_flight_bytes); let model_id_str = model_id.as_str().to_string(); let file_path = file.path.clone(); - let on_progress: Arc = Arc::new(move |bytes_in_file| { - let _ = app_for_cb.emit( - "local-asr-download-progress", - DownloadProgress { - model_id: model_id_str.clone(), - file: file_path.clone(), - file_index: idx, - file_count, - bytes_downloaded: bytes_done_before_current + bytes_in_file, - bytes_total: total_bytes, - phase: DownloadPhase::Progress, - error: None, - }, - ); - }); + let file_size = file.size; + let _model_id = model_id; // copy of Copy for closure use + let total_bytes_cap = total_bytes; + let already_done = already_done_bytes; - let result = download_one( - &client, - &url, - &dest, - file.size, - Arc::clone(&cancel), - on_progress, - ) - .await; + futs.push(tauri::async_runtime::spawn(async move { + let _permit = match semaphore.acquire_owned().await { + Ok(p) => p, + Err(_) => return Err(anyhow::anyhow!("semaphore closed")), + }; + if cancel.load(Ordering::SeqCst) { + return Ok(()); + } + // 进度回调:把该文件实时已下字节写到 in_flight_bytes[idx], + // 然后求所有 in_flight 之和 + already_done = 全模型总进度。 + let app_emit = app.clone(); + let model_id_emit = model_id_str.clone(); + let file_path_emit = file_path.clone(); + let in_flight_for_cb = Arc::clone(&in_flight_bytes); + let on_progress: Arc = Arc::new(move |bytes_in_file| { + in_flight_for_cb[idx].store(bytes_in_file, Ordering::Relaxed); + let total_in_flight: u64 = in_flight_for_cb + .iter() + .map(|a| a.load(Ordering::Relaxed)) + .sum(); + let _ = app_emit.emit( + "local-asr-download-progress", + DownloadProgress { + model_id: model_id_emit.clone(), + file: file_path_emit.clone(), + file_index: idx, + file_count, + bytes_downloaded: already_done + total_in_flight, + bytes_total: total_bytes_cap, + phase: DownloadPhase::Progress, + error: None, + }, + ); + }); + + let result = download_one( + &client, + &url, + &dest, + file_size, + Arc::clone(&cancel), + on_progress, + ) + .await; + // 文件下完 → 该 in_flight 永久 = file_size(避免 race 在 emit 时漏算) + if result.is_ok() { + in_flight_bytes[idx].store(file_size, Ordering::Relaxed); + } + result.with_context(|| format!("file {file_path}")) + })); + } - if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); - return Ok(()); - } - if let Err(e) = result { - emit( - app, - DownloadProgress { - model_id: model_id.as_str().into(), - file: file.path.clone(), - file_index: idx, - file_count, - bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: total_bytes, - phase: DownloadPhase::Failed, - error: Some(format!("{e:#}")), - }, - ); - return Err(e); + let mut first_err: Option = None; + while let Some(joined) = futs.next().await { + match joined { + Ok(Ok(())) => {} + Ok(Err(e)) => { + if first_err.is_none() { + first_err = Some(e); + } + // 取消其它正在跑的:cancel flag 设为 true,让所有 spawn task 早退 + if !cancel.load(Ordering::SeqCst) { + log::warn!("[local-asr] one file failed; signaling others to stop"); + } + } + Err(e) => { + if first_err.is_none() { + first_err = Some(anyhow::anyhow!("join: {e}")); + } + } } - bytes_done_before_current += file.size; + } + + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, "", 0, file_count, total_bytes); + return Ok(()); + } + if let Some(e) = first_err { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: String::new(), + file_index: 0, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: total_bytes, + phase: DownloadPhase::Failed, + error: Some(format!("{e:#}")), + }, + ); + return Err(e); } let sentinel = dir.join(READY_SENTINEL); @@ -371,9 +446,12 @@ async fn run_download( Ok(()) } -const CHUNK_SIZE: u64 = 32 * 1024 * 1024; -const PARALLEL: usize = 4; +// 这三个数贴合 aria2 / hf_xet 实测:8MB chunk 让单连接寿命 5–20s(CDN 容易 throttle 的临界点之下), +// 单文件 8 并发跟 hf_xet 默认基本对齐;多文件并发 3 个填满带宽且不超过 hf-mirror 的 per-IP 阈值。 +const CHUNK_SIZE: u64 = 8 * 1024 * 1024; +const PARALLEL: usize = 8; const PER_CHUNK_ATTEMPTS: u32 = 4; +const PARALLEL_FILES: usize = 3; async fn download_one( client: &reqwest::Client, From 520787b0f7b9d166b4f5e997ee0ef6d36b91733d Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 5 May 2026 12:09:55 +0800 Subject: [PATCH 12/12] =?UTF-8?q?feat(local-asr):=20=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=86=85=E5=AD=98=E5=B8=B8=E9=A9=BB=20+=20=E5=88=87=E5=88=B0?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=97=B6=E9=A2=84=E5=8A=A0=E8=BD=BD=20+=20?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E6=97=B6=E6=9C=BA=E5=8F=AF=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户痛点:之前每次按 hotkey 都重加载 1.2GB 模型 → 首句词延迟 3-5s。 新流程: - 新 LocalAsrCache:模型一次 load 后驻留内存,跨多次会话复用 - LocalQwenAsr::new 改成接受 Arc(引擎所有权挪到 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 - 用户在「模型设置」页可手动「立即加载」/「立即释放」 - 实时显示「内存中的引擎: (约占 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 --- .../app/src-tauri/src/asr/local/cache.rs | 132 ++++++++++++++++++ .../src-tauri/src/asr/local/local_provider.rs | 15 +- .../app/src-tauri/src/asr/local/mod.rs | 3 + openless-all/app/src-tauri/src/commands.rs | 47 ++++++- openless-all/app/src-tauri/src/coordinator.rs | 120 +++++++++++++--- openless-all/app/src-tauri/src/lib.rs | 4 + openless-all/app/src-tauri/src/types.rs | 10 ++ openless-all/app/src/i18n/en.ts | 12 ++ openless-all/app/src/i18n/zh-CN.ts | 12 ++ openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/localAsr.ts | 26 ++++ openless-all/app/src/lib/types.ts | 3 + openless-all/app/src/pages/LocalAsr.tsx | 114 ++++++++++++++- 13 files changed, 468 insertions(+), 31 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/local/cache.rs diff --git a/openless-all/app/src-tauri/src/asr/local/cache.rs b/openless-all/app/src-tauri/src/asr/local/cache.rs new file mode 100644 index 00000000..0402c420 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/cache.rs @@ -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>, + #[cfg(not(target_os = "macos"))] + _phantom: (), +} + +#[cfg(target_os = "macos")] +struct CachedEngine { + model_id: String, + engine: Arc, + 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> { + { + 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 { + #[cfg(target_os = "macos")] + { + return self.inner.lock().as_ref().map(|c| c.model_id.clone()); + } + #[cfg(not(target_os = "macos"))] + None + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs index 06e381c5..4fe0bd3f 100644 --- a/openless-all/app/src-tauri/src/asr/local/local_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs @@ -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 { - 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) -> Self { + Self { + engine, buffer: Mutex::new(Vec::new()), app, - }) + } } /// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时 diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index f5d64db3..1aca579f 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -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")] diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 1807da14..014e675b 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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, + 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>) { + 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)] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 763d7ee1..9825d550 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -126,6 +126,9 @@ struct Inner { prepared_windows_ime_session: Arc>>, state: Mutex, asr: Mutex>>, + /// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。 + /// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。 + local_asr_cache: Arc, recorder: Mutex>>, hotkey: Mutex>, hotkey_status: Mutex, @@ -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) { + #[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 { + 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) -> 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) -> 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) { + 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) -> anyhow::Result> { +async fn build_local_qwen3(inner: &Arc) -> anyhow::Result> { 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) -> anyhow::Result 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 { 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(), } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 1cadd387..2cf85d37 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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)', }, }; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ba13a1fc..6ca83fa6 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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: '不释放(始终保留)', }, }; diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 5a36db26..0d924c4f 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -55,6 +55,7 @@ const mockSettings: UserPreferences = { qaSaveHistory: false, localAsrActiveModel: 'qwen3-asr-0.6b', localAsrMirror: 'huggingface', + localAsrKeepLoadedSecs: 300, }; const mockHotkeyCapability: HotkeyCapability = { diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 9157e9a3..ceacc19c 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -149,3 +149,29 @@ export function testLocalAsrModel(modelId: string): Promise }), ); } + +export interface LocalAsrEngineStatus { + loaded: boolean; + modelId: string | null; + keepLoadedSecs: number; +} + +export function getLocalAsrEngineStatus(): Promise { + return invokeOrMock('local_asr_engine_status', undefined, () => ({ + loaded: false, + modelId: null, + keepLoadedSecs: 300, + })); +} + +export function releaseLocalAsrEngine(): Promise { + return invokeOrMock('local_asr_release_engine', undefined, () => undefined); +} + +export function preloadLocalAsr(): Promise { + return invokeOrMock('local_asr_preload', undefined, () => undefined); +} + +export function setLocalAsrKeepLoadedSecs(seconds: number): Promise { + return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined); +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 2959789d..69826f45 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -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。 diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 3ffd1bcf..9b166a40 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -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(null); const [testingModelId, setTestingModelId] = useState(null); const [testResults, setTestResults] = useState>({}); + const [engineStatus, setEngineStatus] = useState(null); const refreshTimer = useRef(null); + const engineStatusTimer = useRef(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() {
+ {/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */} + {engineAvailable && ( + +
+
+
+
+ {t('localAsr.engineStatusLabel')} +
+
+ {engineStatus?.loaded + ? t('localAsr.engineLoaded', { model: engineStatus.modelId ?? '' }) + : t('localAsr.engineUnloaded')} +
+
+
+ {engineStatus?.loaded ? ( + void handleReleaseEngine()}> + {t('localAsr.releaseNow')} + + ) : ( + void handlePreload()}> + {t('localAsr.loadNow')} + + )} +
+
+
+
+
+ {t('localAsr.keepLoadedLabel')} +
+
+ {t('localAsr.keepLoadedDesc')} +
+
+ +
+
+
+ )} + {error && (
{error}