Merge pull request #262 from appergb/feat/local-asr-qwen3
feat(local-asr): 加本地 Qwen3-ASR 引擎(macOS 优先)
This commit is contained in:
29 files changed
+3277
-5
No files matched your search
@@ -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
|
||||
Generated
+91
@@ -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"
|
||||
@@ -3287,6 +3320,7 @@ dependencies = [
|
||||
"arboard",
|
||||
"block2 0.5.1",
|
||||
"bytes",
|
||||
"cc",
|
||||
"chrono",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.24.0",
|
||||
@@ -3295,6 +3329,7 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"global-hotkey",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -3323,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"
|
||||
@@ -4160,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",
|
||||
@@ -4174,6 +4248,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -5559,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"
|
||||
@@ -5991,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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -24,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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 本地 Qwen3-ASR 引擎缓存。
|
||||
//!
|
||||
//! 用途:避免每次 dictation 都重加载 1.2GB+ 模型。引擎一次 load 后驻留在内存,
|
||||
//! 跨多次会话复用;用户在设置里决定"说完话即释放" / "保持 N 秒后释放" /
|
||||
//! "不释放"。
|
||||
//!
|
||||
//! 调度规则:每次会话结束后 spawn 一个 sleep+check 任务;任务在到点时检查
|
||||
//! `last_used`——如果中间又被使用过则不释放,否则 drop 引擎让 OS 回收 RAM。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::QwenAsrEngine;
|
||||
|
||||
pub struct LocalAsrCache {
|
||||
#[cfg(target_os = "macos")]
|
||||
inner: Mutex<Option<CachedEngine>>,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
_phantom: (),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct CachedEngine {
|
||||
model_id: String,
|
||||
engine: Arc<QwenAsrEngine>,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
impl Default for LocalAsrCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalAsrCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
#[cfg(target_os = "macos")]
|
||||
inner: Mutex::new(None),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
_phantom: (),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取已缓存的同 id 引擎,没有就加载(**阻塞、可能数秒**——调用方应放
|
||||
/// `spawn_blocking`)。模型 id 不同则把旧的 drop 再加载新的。
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_or_load(&self, model_id: &str, model_dir: &Path) -> Result<Arc<QwenAsrEngine>> {
|
||||
{
|
||||
let mut slot = self.inner.lock();
|
||||
if let Some(cached) = slot.as_mut() {
|
||||
if cached.model_id == model_id {
|
||||
cached.last_used = Instant::now();
|
||||
log::info!("[local-asr cache] reuse engine: {model_id}");
|
||||
return Ok(Arc::clone(&cached.engine));
|
||||
}
|
||||
log::info!(
|
||||
"[local-asr cache] active model changed {} -> {}, drop old",
|
||||
cached.model_id,
|
||||
model_id
|
||||
);
|
||||
slot.take();
|
||||
}
|
||||
}
|
||||
log::info!("[local-asr cache] loading {model_id} from {}", model_dir.display());
|
||||
let engine = Arc::new(QwenAsrEngine::load(model_dir)?);
|
||||
let mut slot = self.inner.lock();
|
||||
*slot = Some(CachedEngine {
|
||||
model_id: model_id.to_string(),
|
||||
engine: Arc::clone(&engine),
|
||||
last_used: Instant::now(),
|
||||
});
|
||||
log::info!("[local-asr cache] loaded {model_id}");
|
||||
Ok(engine)
|
||||
}
|
||||
|
||||
/// 标记最近使用时间——end_session 在调过 transcribe 之后调一下,
|
||||
/// 让 release 计时器从这一刻重新算。
|
||||
pub fn touch(&self) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(cached) = self.inner.lock().as_mut() {
|
||||
cached.last_used = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 如果空闲时长 ≥ threshold,释放引擎。返回是否真释放了。
|
||||
pub fn release_if_idle(&self, idle_threshold: Duration) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut slot = self.inner.lock();
|
||||
if let Some(cached) = slot.as_ref() {
|
||||
if cached.last_used.elapsed() >= idle_threshold {
|
||||
log::info!(
|
||||
"[local-asr cache] release engine {} after idle {:?}",
|
||||
cached.model_id,
|
||||
cached.last_used.elapsed()
|
||||
);
|
||||
slot.take();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = idle_threshold;
|
||||
false
|
||||
}
|
||||
|
||||
/// 立刻释放(用户点"立即释放"或删模型时调)。
|
||||
pub fn release_now(&self) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(cached) = self.inner.lock().take() {
|
||||
log::info!("[local-asr cache] release engine {} on demand", cached.model_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loaded_model_id(&self) -> Option<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return self.inner.lock().as_ref().map(|c| c.model_id.clone());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
//! Qwen3-ASR 模型下载管理 —— 并发分块 + 断点续传。
|
||||
//!
|
||||
//! 设计要点(与 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};
|
||||
use futures_util::StreamExt;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
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,
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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<RemoteFile>,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HfTreeEntry {
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result<RemoteInfo> {
|
||||
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 {
|
||||
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<Vec<RemoteFile>> {
|
||||
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<HfTreeEntry> = resp
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| format!("HF tree JSON 解码失败: {url}"))?;
|
||||
let files: Vec<RemoteFile> = 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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<std::collections::HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl DownloadManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, 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);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
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}"),
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self, model_id: ModelId) -> bool {
|
||||
self.cancel_flags.lock().contains_key(model_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
// native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close
|
||||
// 当致命错误。
|
||||
//
|
||||
// User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装,
|
||||
// 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA
|
||||
// (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。
|
||||
reqwest::Client::builder()
|
||||
.use_native_tls()
|
||||
.user_agent("aria2/1.36.0")
|
||||
.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: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let dir = model_dir(model_id)?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("create model dir failed: {}", dir.display()))?;
|
||||
|
||||
let client = build_client()?;
|
||||
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,
|
||||
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::Started,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
// 多文件并发(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<Vec<AtomicU64>> = 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 dest.exists() {
|
||||
// 已经下完的(目录里直接存在 dest 文件)跳过;前面 already_done_bytes 已计入
|
||||
continue;
|
||||
}
|
||||
let url = format!(
|
||||
"{}/{}/resolve/main/{}",
|
||||
mirror.base_url(),
|
||||
model_id.hf_repo(),
|
||||
file.path
|
||||
);
|
||||
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 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;
|
||||
|
||||
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<dyn Fn(u64) + Send + Sync> = 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}"))
|
||||
}));
|
||||
}
|
||||
|
||||
let mut first_err: Option<anyhow::Error> = 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
std::fs::write(&sentinel, b"")
|
||||
.with_context(|| format!("write sentinel failed: {}", sentinel.display()))?;
|
||||
|
||||
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: total_bytes,
|
||||
phase: DownloadPhase::Finished,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 这三个数贴合 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,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
total_size: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
on_progress: Arc<dyn Fn(u64) + Send + Sync>,
|
||||
) -> Result<()> {
|
||||
let partial = dest.with_extension("partial");
|
||||
let idx_path = partial.with_extension("partial.idx");
|
||||
|
||||
// 文件大小未知(HF 没给 size)→ 退化为单连接整文件下,行为同最早的实现
|
||||
if total_size == 0 {
|
||||
return single_stream_download(client, url, dest, cancel, on_progress).await;
|
||||
}
|
||||
|
||||
// 远端文件 ≤ 一个 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(());
|
||||
}
|
||||
|
||||
// 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()))?;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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<str> = 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<anyhow::Error> = 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return HashSet::new(),
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().parse::<usize>().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 + per-chunk retry。append 模式(一次性写到底,给小文件路径)。
|
||||
async fn chunk_with_retry(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
partial: &Path,
|
||||
range_start: u64,
|
||||
range_end: u64,
|
||||
cancel: &AtomicBool,
|
||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||
) -> Result<()> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=PER_CHUNK_ATTEMPTS {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
anyhow::bail!("cancelled");
|
||||
}
|
||||
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 < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
|
||||
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
|
||||
log::warn!(
|
||||
"[local-asr] small-file chunk attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
|
||||
backoff
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
|
||||
}
|
||||
|
||||
async fn try_download_range_append(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
partial: &Path,
|
||||
range_start: u64,
|
||||
range_end: u64,
|
||||
cancel: &AtomicBool,
|
||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||
) -> Result<()> {
|
||||
let mut req = client.get(url);
|
||||
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();
|
||||
if status.as_u16() != 200 && status.as_u16() != 206 {
|
||||
anyhow::bail!("HTTP {status} for {url}");
|
||||
}
|
||||
if status.as_u16() == 200 && range_start > 0 {
|
||||
let _ = std::fs::remove_file(partial);
|
||||
}
|
||||
let effective_start = if status.as_u16() == 200 { 0 } else { range_start };
|
||||
|
||||
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 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")?;
|
||||
written += bytes.len() as u64;
|
||||
on_progress(effective_start + written);
|
||||
}
|
||||
file.flush().await.ok();
|
||||
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<AtomicU64>,
|
||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||
) -> Result<()> {
|
||||
let mut last_err: Option<anyhow::Error> = 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<AtomicU64>,
|
||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||
) -> 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<AtomicBool>,
|
||||
on_progress: Arc<dyn Fn(u64) + Send + Sync>,
|
||||
) -> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_cancelled(
|
||||
app: &AppHandle,
|
||||
model_id: ModelId,
|
||||
fname: &str,
|
||||
idx: usize,
|
||||
file_count: usize,
|
||||
total: u64,
|
||||
) {
|
||||
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: total,
|
||||
phase: DownloadPhase::Cancelled,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 本地 Qwen3-ASR 在 dictation 路径上的适配器。
|
||||
//!
|
||||
//! 与 `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::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<QwenAsrEngine>,
|
||||
/// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么),在 transcribe 时再
|
||||
/// 转 f32 喂给 C 端。一次会话最多几 MB,clone 一次成本可接受。
|
||||
buffer: Mutex<Vec<u8>>,
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl LocalQwenAsr {
|
||||
pub fn new(app: AppHandle, engine: Arc<QwenAsrEngine>) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
buffer: Mutex::new(Vec::new()),
|
||||
app,
|
||||
}
|
||||
}
|
||||
|
||||
/// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时
|
||||
/// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。
|
||||
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
|
||||
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。
|
||||
// 用 tauri::async_runtime::spawn_blocking 而非 tokio 的 —— 同 download.rs 注释,
|
||||
// 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调,
|
||||
// 但保持一致更稳)。
|
||||
let engine = Arc::clone(&self.engine);
|
||||
let text = tauri::async_runtime::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::<fn(&str)>(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<f32> {
|
||||
bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| {
|
||||
let v = i16::from_le_bytes([c[0], c[1]]);
|
||||
v as f32 / 32768.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! 本地 ASR 引擎入口。
|
||||
//!
|
||||
//! 当前只在 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")]
|
||||
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};
|
||||
|
||||
/// 本地 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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 本地 Qwen3-ASR 模型注册表(仅 id / 仓库名 / 显示名)。
|
||||
//!
|
||||
//! **文件清单与尺寸不再硬编码** —— 由 `download.rs` 在下载时从
|
||||
//! `huggingface.co/api/models/<repo>/tree/main` 拉真实清单和大小。
|
||||
//! 增加新模型 = 这里加一条枚举 + 仓库名。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
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,
|
||||
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<Self> {
|
||||
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(用于拼 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型在本地的根目录(可能不存在)。
|
||||
pub fn model_dir(id: ModelId) -> Result<PathBuf> {
|
||||
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,
|
||||
};
|
||||
dir.join(READY_SENTINEL).exists()
|
||||
}
|
||||
|
||||
/// 已落盘的字节数(walk_dir 求和)。下载中也能显示真实进度。
|
||||
pub fn downloaded_bytes(id: ModelId) -> u64 {
|
||||
let dir = match model_dir(id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut total: u64 = 0;
|
||||
walk_files(&dir, &mut |size| total += size);
|
||||
total
|
||||
}
|
||||
|
||||
fn walk_files<F: FnMut(u64)>(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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelStatus {
|
||||
pub id: String,
|
||||
pub hf_repo: String,
|
||||
pub downloaded_bytes: u64,
|
||||
pub is_downloaded: bool,
|
||||
}
|
||||
|
||||
pub fn list_status() -> Vec<ModelStatus> {
|
||||
ModelId::all()
|
||||
.iter()
|
||||
.map(|&id| ModelStatus {
|
||||
id: id.as_str().to_string(),
|
||||
hf_repo: id.hf_repo().to_string(),
|
||||
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(())
|
||||
}
|
||||
@@ -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<Box<TokenHandler>>;
|
||||
|
||||
pub struct QwenAsrEngine {
|
||||
ctx: *mut QwenCtx,
|
||||
/// 持有 token 回调的所有权;C 端拿到的是 `&**handler` 派生出来的 raw ptr,
|
||||
/// 只要这个 Box 还活着,那个 raw ptr 就有效。Mutex 防止并发 set。
|
||||
token_handler: Mutex<Option<TokenHandlerBox>>,
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
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<F>(&self, handler: Option<F>)
|
||||
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<TokenHandler>` 在堆上有稳定地址;取它的 &mut 转 raw。
|
||||
let userdata = boxed.as_ref() as *const Box<TokenHandler> 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<String> {
|
||||
// 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<String> {
|
||||
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<TokenHandler>` 并转发字符串。
|
||||
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<TokenHandler>`。
|
||||
let handler: &mut Box<TokenHandler> = unsafe { &mut *(userdata as *mut Box<TokenHandler>) };
|
||||
let text = unsafe { CStr::from_ptr(piece) }.to_string_lossy();
|
||||
handler(&text);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 对 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);
|
||||
|
||||
// 用经典 `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);
|
||||
|
||||
pub fn qwen_set_token_callback(
|
||||
ctx: *mut QwenCtx,
|
||||
cb: Option<QwenTokenCb>,
|
||||
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;
|
||||
}
|
||||
@@ -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<TestResult> {
|
||||
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<TestResult> {
|
||||
anyhow::bail!("本地 ASR 引擎本期仅 macOS 可用(见 issue #256)")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn load_engine(dir: &Path) -> Result<Arc<super::QwenAsrEngine>> {
|
||||
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<Vec<f32>> {
|
||||
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<f32> = samples_i16
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||
.collect();
|
||||
Ok(samples)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! `volcengine.rs`.
|
||||
|
||||
mod frame;
|
||||
pub mod local;
|
||||
pub mod volcengine;
|
||||
pub mod whisper;
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -688,6 +693,146 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) {
|
||||
coord.qa_window_pin(pinned);
|
||||
}
|
||||
|
||||
// ─────────────────────────── local ASR (Qwen3-ASR) ───────────────────────────
|
||||
|
||||
use crate::asr::local::{
|
||||
download::{fetch_remote_info, RemoteInfo},
|
||||
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<ModelStatus> {
|
||||
crate::asr::local::models::list_status()
|
||||
}
|
||||
|
||||
/// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸;
|
||||
/// 前端在显示模型卡时调一次,避免硬编码尺寸过期。
|
||||
#[tauri::command]
|
||||
pub async fn local_asr_fetch_remote_info(
|
||||
model_id: String,
|
||||
mirror: Option<String>,
|
||||
) -> Result<RemoteInfo, 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();
|
||||
fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_download_model(
|
||||
app: AppHandle,
|
||||
manager: State<'_, Arc<DownloadManager>>,
|
||||
model_id: String,
|
||||
mirror: Option<String>,
|
||||
) -> 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<DownloadManager>>,
|
||||
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())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_asr_test_model(
|
||||
model_id: String,
|
||||
) -> Result<crate::asr::local::test_run::TestResult, String> {
|
||||
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:#}"))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalAsrEngineStatus {
|
||||
pub loaded: bool,
|
||||
pub model_id: Option<String>,
|
||||
pub keep_loaded_secs: u32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus {
|
||||
let prefs = coord.prefs().get();
|
||||
LocalAsrEngineStatus {
|
||||
loaded: coord.local_asr_loaded_model().is_some(),
|
||||
model_id: coord.local_asr_loaded_model(),
|
||||
keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_release_engine(coord: CoordinatorState<'_>) {
|
||||
coord.release_local_asr_engine();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_preload(coord: tauri::State<'_, std::sync::Arc<crate::coordinator::Coordinator>>) {
|
||||
coord.preload_local_asr_in_background();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_set_keep_loaded_secs(
|
||||
coord: CoordinatorState<'_>,
|
||||
seconds: u32,
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.local_asr_keep_loaded_secs = seconds;
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -54,6 +54,9 @@ enum SessionPhase {
|
||||
enum ActiveAsr {
|
||||
Volcengine(Arc<VolcengineStreamingASR>),
|
||||
Whisper(Arc<WhisperBatchASR>),
|
||||
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
|
||||
#[cfg(target_os = "macos")]
|
||||
Local(Arc<crate::asr::local::LocalQwenAsr>),
|
||||
}
|
||||
|
||||
struct SessionResource<T> {
|
||||
@@ -123,6 +126,9 @@ struct Inner {
|
||||
prepared_windows_ime_session: Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>,
|
||||
state: Mutex<SessionState>,
|
||||
asr: Mutex<Option<SessionResource<ActiveAsr>>>,
|
||||
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
|
||||
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
|
||||
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
|
||||
recorder: Mutex<Option<SessionResource<Recorder>>>,
|
||||
hotkey: Mutex<Option<HotkeyMonitor>>,
|
||||
hotkey_status: Mutex<HotkeyStatus>,
|
||||
@@ -229,10 +235,57 @@ impl Coordinator {
|
||||
qa_asr: Mutex::new(None),
|
||||
qa_recorder: Mutex::new(None),
|
||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台预加载本地 ASR 引擎;当用户在 UI 切到 local-qwen3 provider 时调一次。
|
||||
/// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。
|
||||
/// 模型未下载或不在 macOS 上时静默跳过。
|
||||
pub fn preload_local_asr_in_background(self: &Arc<Self>) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let inner = Arc::clone(&self.inner);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let prefs = inner.prefs.get();
|
||||
let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
|
||||
Some(m) => m,
|
||||
None => return,
|
||||
};
|
||||
if !crate::asr::local::models::is_downloaded(model_id) {
|
||||
log::info!("[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str());
|
||||
return;
|
||||
}
|
||||
let dir = match crate::asr::local::models::model_dir(model_id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
};
|
||||
let cache = Arc::clone(&inner.local_asr_cache);
|
||||
let mid = model_id.as_str().to_string();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = cache.get_or_load(&mid, &dir) {
|
||||
log::warn!("[coord] local ASR preload failed: {e:#}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
|
||||
pub fn release_local_asr_engine(&self) {
|
||||
self.inner.local_asr_cache.release_now();
|
||||
}
|
||||
|
||||
pub fn local_asr_loaded_model(&self) -> Option<String> {
|
||||
self.inner.local_asr_cache.loaded_model_id()
|
||||
}
|
||||
|
||||
pub fn bind_app(&self, handle: AppHandle) {
|
||||
*self.inner.app.lock() = Some(handle);
|
||||
}
|
||||
@@ -798,6 +851,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 +1063,37 @@ async fn begin_session(inner: &Arc<Inner>) -> 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).await {
|
||||
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<dyn crate::recorder::AudioConsumer> = 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));
|
||||
@@ -1445,6 +1531,52 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
ActiveAsr::Local(local) => {
|
||||
// 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。
|
||||
// 注:缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
|
||||
// 提前完成,所以 15s 给 transcribe 本身足够。
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
||||
inner.local_asr_cache.touch();
|
||||
schedule_local_asr_release(inner);
|
||||
match result {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(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());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] local Qwen3-ASR 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
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("local global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
|
||||
@@ -1922,6 +2054,19 @@ fn ensure_microphone_permission(_inner: &Arc<Inner>) -> 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()
|
||||
@@ -1941,6 +2086,63 @@ fn ensure_asr_credentials() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ensure_local_qwen3_model_ready() -> Result<(), String> {
|
||||
let prefs = || -> Result<crate::types::UserPreferences, String> {
|
||||
// 这里没法拿到 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(())
|
||||
}
|
||||
|
||||
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
|
||||
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
|
||||
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
|
||||
fn schedule_local_asr_release(inner: &Arc<Inner>) {
|
||||
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
||||
let cache = Arc::clone(&inner.local_asr_cache);
|
||||
if keep_secs == 0 {
|
||||
cache.release_now();
|
||||
return;
|
||||
}
|
||||
let dur = std::time::Duration::from_secs(keep_secs as u64);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(dur).await;
|
||||
cache.release_if_idle(dur);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
|
||||
let prefs = inner.prefs.get();
|
||||
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
|
||||
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
|
||||
let dir = crate::asr::local::models::model_dir(model_id)?;
|
||||
let app = inner
|
||||
.app
|
||||
.lock()
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
|
||||
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
|
||||
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
|
||||
let cache = Arc::clone(&inner.local_asr_cache);
|
||||
let mid = model_id.as_str().to_string();
|
||||
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
|
||||
}
|
||||
|
||||
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
|
||||
/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。
|
||||
/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。
|
||||
|
||||
@@ -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,19 @@ 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_fetch_remote_info,
|
||||
commands::local_asr_download_model,
|
||||
commands::local_asr_cancel_download,
|
||||
commands::local_asr_delete_model,
|
||||
commands::local_asr_test_model,
|
||||
commands::local_asr_engine_status,
|
||||
commands::local_asr_release_engine,
|
||||
commands::local_asr_preload,
|
||||
commands::local_asr_set_keep_loaded_secs,
|
||||
restart_app,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
|
||||
@@ -82,6 +82,15 @@ fn ensure_dir(dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 本地 ASR 模型根目录:`<data_dir>/models/qwen3-asr/`。
|
||||
/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 antirez `download_model.sh`
|
||||
/// 列出的 5–7 个文件。
|
||||
pub fn local_models_root() -> Result<PathBuf> {
|
||||
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() {
|
||||
|
||||
@@ -123,6 +123,30 @@ 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,
|
||||
/// 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
|
||||
/// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 一天 ≈ 永不释放。
|
||||
/// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。
|
||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||
pub local_asr_keep_loaded_secs: u32,
|
||||
}
|
||||
|
||||
fn default_local_asr_model() -> String {
|
||||
"qwen3-asr-0.6b".into()
|
||||
}
|
||||
|
||||
fn default_local_asr_mirror() -> String {
|
||||
"huggingface".into()
|
||||
}
|
||||
|
||||
fn default_local_asr_keep_loaded_secs() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_qa_hotkey() -> Option<QaHotkeyBinding> {
|
||||
@@ -154,6 +178,9 @@ 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(),
|
||||
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
Submodule openless-all/app/src-tauri/vendor/qwen-asr added at b00b789b17.
@@ -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,
|
||||
@@ -28,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 {
|
||||
@@ -45,6 +46,7 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
|
||||
{ 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';
|
||||
@@ -134,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);
|
||||
|
||||
@@ -58,6 +58,7 @@ export const en: typeof zhCN = {
|
||||
style: 'Style',
|
||||
translation: 'Translation',
|
||||
selectionAsk: 'Ask',
|
||||
localAsr: 'Models',
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: 'Recording shortcut',
|
||||
@@ -302,11 +303,20 @@ 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',
|
||||
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',
|
||||
@@ -512,4 +522,46 @@ 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',
|
||||
resume: 'Resume',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
setActive: 'Set as default',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
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.',
|
||||
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',
|
||||
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)',
|
||||
},
|
||||
};
|
||||
@@ -56,6 +56,7 @@ export const zhCN = {
|
||||
style: '风格',
|
||||
translation: '翻译',
|
||||
selectionAsk: '划词追问',
|
||||
localAsr: '模型设置',
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: '录音快捷键',
|
||||
@@ -300,11 +301,20 @@ export const zhCN = {
|
||||
asrZhipu: '智谱 GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
asrWhisper: 'OpenAI Whisper(兼容)',
|
||||
asrLocalQwen3: '本地 Qwen3-ASR',
|
||||
},
|
||||
volcengineAppKeyLabel: 'APP ID',
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
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 密钥',
|
||||
@@ -510,4 +520,46 @@ 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: '下载',
|
||||
resume: '继续下载',
|
||||
cancel: '取消',
|
||||
delete: '删除',
|
||||
setActive: '设为默认',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
files: '文件',
|
||||
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: '测试失败',
|
||||
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: '不释放(始终保留)',
|
||||
},
|
||||
};
|
||||
@@ -58,6 +58,7 @@ export const zhTW: typeof zhCN = {
|
||||
style: '風格',
|
||||
translation: '翻譯',
|
||||
selectionAsk: '劃詞追問',
|
||||
localAsr: '模型設置',
|
||||
},
|
||||
shell: {
|
||||
shortcutLabel: '錄音快捷鍵',
|
||||
@@ -302,11 +303,20 @@ export const zhTW: typeof zhCN = {
|
||||
asrZhipu: '智譜 GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
asrWhisper: 'OpenAI Whisper(兼容)',
|
||||
asrLocalQwen3: '本地 Qwen3-ASR',
|
||||
},
|
||||
volcengineAppKeyLabel: 'APP ID',
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
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。適用場景:離線 / 隱私敏感 / 不願付費雲 API。',
|
||||
localAsrReady: '{{model}} 已下載',
|
||||
localAsrNotReady: '{{model}} 未下載',
|
||||
localAsrGoDownload: '前往模型設置下載',
|
||||
localAsrManage: '前往模型設置',
|
||||
localAsrDownloadedTitle: '已下載模型',
|
||||
localAsrDelete: '刪除',
|
||||
fillDefault: '填入默認值',
|
||||
readFailed: '讀取失敗',
|
||||
apiKeyLabel: 'API 密鑰',
|
||||
@@ -512,4 +522,46 @@ export const zhTW: typeof 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: '下載',
|
||||
resume: '繼續下載',
|
||||
cancel: '取消',
|
||||
delete: '刪除',
|
||||
setActive: '設為默認',
|
||||
failed: '失敗',
|
||||
cancelled: '已取消',
|
||||
files: '文件',
|
||||
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: '測試失敗',
|
||||
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: '不釋放(始終保留)',
|
||||
},
|
||||
};
|
||||
@@ -53,6 +53,9 @@ const mockSettings: UserPreferences = {
|
||||
translationTargetLanguage: '',
|
||||
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
|
||||
qaSaveHistory: false,
|
||||
localAsrActiveModel: 'qwen3-asr-0.6b',
|
||||
localAsrMirror: 'huggingface',
|
||||
localAsrKeepLoadedSecs: 300,
|
||||
};
|
||||
|
||||
const mockHotkeyCapability: HotkeyCapability = {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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<LocalAsrSettings> {
|
||||
return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS);
|
||||
}
|
||||
|
||||
export function setLocalAsrActiveModel(modelId: string): Promise<void> {
|
||||
return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined);
|
||||
}
|
||||
|
||||
export function setLocalAsrMirror(mirror: string): Promise<void> {
|
||||
return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined);
|
||||
}
|
||||
|
||||
export function listLocalAsrModels(): Promise<LocalAsrModelStatus[]> {
|
||||
return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS);
|
||||
}
|
||||
|
||||
export function fetchLocalAsrRemoteInfo(
|
||||
modelId: string,
|
||||
mirror?: string,
|
||||
): Promise<LocalAsrRemoteInfo> {
|
||||
return invokeOrMock(
|
||||
'local_asr_fetch_remote_info',
|
||||
{ modelId, mirror },
|
||||
() => ({
|
||||
modelId,
|
||||
mirror: mirror ?? 'huggingface',
|
||||
files: [],
|
||||
totalBytes: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadLocalAsrModel(
|
||||
modelId: string,
|
||||
mirror?: string,
|
||||
): Promise<void> {
|
||||
return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined);
|
||||
}
|
||||
|
||||
export function cancelLocalAsrDownload(modelId: string): Promise<void> {
|
||||
return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined);
|
||||
}
|
||||
|
||||
export function deleteLocalAsrModel(modelId: string): Promise<void> {
|
||||
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<LocalAsrTestResult> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface LocalAsrEngineStatus {
|
||||
loaded: boolean;
|
||||
modelId: string | null;
|
||||
keepLoadedSecs: number;
|
||||
}
|
||||
|
||||
export function getLocalAsrEngineStatus(): Promise<LocalAsrEngineStatus> {
|
||||
return invokeOrMock('local_asr_engine_status', undefined, () => ({
|
||||
loaded: false,
|
||||
modelId: null,
|
||||
keepLoadedSecs: 300,
|
||||
}));
|
||||
}
|
||||
|
||||
export function releaseLocalAsrEngine(): Promise<void> {
|
||||
return invokeOrMock('local_asr_release_engine', undefined, () => undefined);
|
||||
}
|
||||
|
||||
export function preloadLocalAsr(): Promise<void> {
|
||||
return invokeOrMock('local_asr_preload', undefined, () => undefined);
|
||||
}
|
||||
|
||||
export function setLocalAsrKeepLoadedSecs(seconds: number): Promise<void> {
|
||||
return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined);
|
||||
}
|
||||
@@ -124,6 +124,13 @@ 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;
|
||||
/** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
|
||||
* 300 = 默认 5 分钟;86400 ≈ 不释放(保持加载)。 */
|
||||
localAsrKeepLoadedSecs: number;
|
||||
}
|
||||
|
||||
/** Rust 通过 `qa:state` 事件下发的 payload。
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
// 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,
|
||||
getLocalAsrEngineStatus,
|
||||
getLocalAsrSettings,
|
||||
listLocalAsrModels,
|
||||
preloadLocalAsr,
|
||||
releaseLocalAsrEngine,
|
||||
setLocalAsrActiveModel,
|
||||
setLocalAsrKeepLoadedSecs,
|
||||
setLocalAsrMirror,
|
||||
testLocalAsrModel,
|
||||
type LocalAsrDownloadProgress,
|
||||
type LocalAsrEngineStatus,
|
||||
type LocalAsrModelStatus,
|
||||
type LocalAsrSettings,
|
||||
type LocalAsrTestResult,
|
||||
} 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<LocalAsrSettings | null>(null);
|
||||
const [models, setModels] = useState<LocalAsrModelStatus[]>([]);
|
||||
const [progress, setProgress] = useState<Record<string, LocalAsrDownloadProgress>>({});
|
||||
const [remoteSizes, setRemoteSizes] = useState<Record<string, RemoteSize>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyModelId, setBusyModelId] = useState<string | null>(null);
|
||||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, LocalAsrTestResult | { error: string }>>({});
|
||||
const [engineStatus, setEngineStatus] = useState<LocalAsrEngineStatus | null>(null);
|
||||
const refreshTimer = useRef<number | null>(null);
|
||||
const engineStatusTimer = useRef<number | null>(null);
|
||||
|
||||
const refreshEngineStatus = async () => {
|
||||
try {
|
||||
const status = await getLocalAsrEngineStatus();
|
||||
setEngineStatus(status);
|
||||
} catch (err) {
|
||||
console.warn('[localAsr] engine status query failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]);
|
||||
setSettings(s);
|
||||
setModels(list);
|
||||
void refreshEngineStatus();
|
||||
// 拉远端真实尺寸(每个模型一次,结果留缓存)
|
||||
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();
|
||||
// 引擎状态每 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
|
||||
}, []);
|
||||
|
||||
// 镜像变更后重拉一次远端尺寸(不同镜像 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<LocalAsrDownloadProgress>('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 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 => {
|
||||
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);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const engineAvailable = settings?.engineAvailable ?? false;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 28px 32px', overflowY: 'auto', height: '100%' }}>
|
||||
<PageHeader
|
||||
kicker={t('localAsr.kicker')}
|
||||
title={t('localAsr.title')}
|
||||
desc={t('localAsr.desc')}
|
||||
/>
|
||||
|
||||
{/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 215, 130, 0.18)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
|
||||
⚠️ {t('localAsr.performanceWarning')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!engineAvailable && (
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 235, 200, 0.4)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)' }}>
|
||||
{t('localAsr.engineUnavailable')}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
|
||||
{t('localAsr.mirrorLabel')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
|
||||
{t('localAsr.mirrorDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={settings?.mirror ?? 'huggingface'}
|
||||
onChange={e => void handleMirrorChange(e.target.value)}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 200,
|
||||
}}>
|
||||
<option value="huggingface">{t('localAsr.mirrorHuggingface')}</option>
|
||||
<option value="hf-mirror">{t('localAsr.mirrorHfMirror')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */}
|
||||
{engineAvailable && (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
|
||||
{t('localAsr.engineStatusLabel')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
|
||||
{engineStatus?.loaded
|
||||
? t('localAsr.engineLoaded', { model: engineStatus.modelId ?? '' })
|
||||
: t('localAsr.engineUnloaded')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{engineStatus?.loaded ? (
|
||||
<Btn variant="ghost" size="sm" onClick={() => void handleReleaseEngine()}>
|
||||
{t('localAsr.releaseNow')}
|
||||
</Btn>
|
||||
) : (
|
||||
<Btn variant="ghost" size="sm" onClick={() => void handlePreload()}>
|
||||
{t('localAsr.loadNow')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
|
||||
{t('localAsr.keepLoadedLabel')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5 }}>
|
||||
{t('localAsr.keepLoadedDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={engineStatus?.keepLoadedSecs ?? 300}
|
||||
onChange={e => void handleKeepLoadedChange(Number(e.target.value))}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 200,
|
||||
}}>
|
||||
<option value={0}>{t('localAsr.keepImmediate')}</option>
|
||||
<option value={60}>{t('localAsr.keep1min')}</option>
|
||||
<option value={300}>{t('localAsr.keep5min')}</option>
|
||||
<option value={1800}>{t('localAsr.keep30min')}</option>
|
||||
<option value={86400}>{t('localAsr.keepForever')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 220, 220, 0.5)' }}>
|
||||
<div style={{ fontSize: 13, color: '#9b2c2c' }}>{error}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{models.map(model => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
remoteSize={remoteSizes[model.id]}
|
||||
progress={progress[model.id]}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelRowProps {
|
||||
model: LocalAsrModelStatus;
|
||||
remoteSize?: RemoteSize;
|
||||
progress?: LocalAsrDownloadProgress;
|
||||
isActive: boolean;
|
||||
engineAvailable: boolean;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
testResult?: LocalAsrTestResult | { error: string };
|
||||
onDownload: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onSetActive: () => void;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
model,
|
||||
remoteSize,
|
||||
progress,
|
||||
isActive,
|
||||
engineAvailable,
|
||||
disabled,
|
||||
testing,
|
||||
testResult,
|
||||
onDownload,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onSetActive,
|
||||
onTest,
|
||||
}: 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;
|
||||
// 进度条要保留:有 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')
|
||||
: remoteSize?.error
|
||||
? t('localAsr.sizeUnknown')
|
||||
: remoteSize && remoteSize.totalBytes > 0
|
||||
? `${formatBytes(remoteSize.totalBytes)} · ${remoteSize.fileCount} ${t('localAsr.files')}`
|
||||
: t('localAsr.sizeUnknown');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{model.id}</div>
|
||||
{isActive && <Pill tone="blue" size="sm">{t('localAsr.activeBadge')}</Pill>}
|
||||
{model.isDownloaded && <Pill tone="ok" size="sm">{t('localAsr.downloadedBadge')}</Pill>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>
|
||||
{model.hfRepo} · {sizeLabel}
|
||||
</div>
|
||||
{showProgress && (
|
||||
<div style={{ marginTop: 10, maxWidth: 420 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: `${ratio * 100}%`,
|
||||
height: '100%',
|
||||
background:
|
||||
progress?.phase === 'failed'
|
||||
? '#d04545'
|
||||
: progress?.phase === 'cancelled'
|
||||
? 'var(--ol-ink-4)'
|
||||
: 'var(--ol-accent-blue, #2c5cff)',
|
||||
transition: 'width 120ms linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>
|
||||
{progress?.phase === 'failed'
|
||||
? `${t('localAsr.failed')}: ${progress.error ?? ''}`
|
||||
: progress?.phase === 'cancelled'
|
||||
? t('localAsr.cancelled')
|
||||
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
|
||||
(progress?.file ? ` · ${progress.file}` : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 360 }}>
|
||||
{model.isDownloaded ? (
|
||||
<>
|
||||
{!isActive && (
|
||||
<Btn
|
||||
variant="blue"
|
||||
size="sm"
|
||||
disabled={disabled || !engineAvailable}
|
||||
onClick={onSetActive}>
|
||||
{t('localAsr.setActive')}
|
||||
</Btn>
|
||||
)}
|
||||
<Btn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={disabled || testing || !engineAvailable}
|
||||
onClick={onTest}>
|
||||
{testing ? t('localAsr.testRunning') : t('localAsr.test')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" size="sm" disabled={disabled || testing} onClick={onDelete}>
|
||||
{t('localAsr.delete')}
|
||||
</Btn>
|
||||
</>
|
||||
) : isDownloading ? (
|
||||
<Btn variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('localAsr.cancel')}
|
||||
</Btn>
|
||||
) : (
|
||||
<>
|
||||
<Btn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={disabled || !engineAvailable}
|
||||
onClick={onDownload}>
|
||||
{hasPartial ? t('localAsr.resume') : t('localAsr.download')}
|
||||
</Btn>
|
||||
{hasPartial && (
|
||||
<Btn variant="ghost" size="sm" disabled={disabled} onClick={onDelete}>
|
||||
{t('localAsr.delete')}
|
||||
</Btn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{testResult && <TestResultBlock result={testResult} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TestResultBlock({ result }: { result: LocalAsrTestResult | { error: string } }) {
|
||||
const { t } = useTranslation();
|
||||
const hasError = 'error' in result;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: '10px 12px',
|
||||
background: hasError ? 'rgba(255, 220, 220, 0.5)' : 'rgba(0, 0, 0, 0.04)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12.5,
|
||||
color: hasError ? '#9b2c2c' : 'var(--ol-ink-2)',
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
{hasError ? (
|
||||
<div>
|
||||
<strong>{t('localAsr.testFailed')}: </strong>{result.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
|
||||
{t('localAsr.testHeading')}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>{t('localAsr.testExpected')}: </span>
|
||||
{result.expectedText}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>{t('localAsr.testActual')}: </span>
|
||||
<strong>{result.transcribedText || '(空)'}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('localAsr.testStats', {
|
||||
audio: (result.audioMs / 1000).toFixed(1),
|
||||
load: (result.loadMs / 1000).toFixed(1),
|
||||
transcribe: (result.transcribeMs / 1000).toFixed(1),
|
||||
backend: result.backend,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -42,6 +42,17 @@ import i18n, {
|
||||
type SupportedLocale,
|
||||
} from '../i18n';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
import {
|
||||
deleteLocalAsrModel,
|
||||
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;
|
||||
@@ -382,6 +393,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'];
|
||||
@@ -554,6 +567,8 @@ function ProvidersSection() {
|
||||
{t('settings.providers.volcengineMappingNote')}
|
||||
</div>
|
||||
</>
|
||||
) : committedAsrProvider === 'local-qwen3' ? (
|
||||
<LocalAsrProviderHint />
|
||||
) : (
|
||||
<>
|
||||
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
|
||||
@@ -1227,3 +1242,137 @@ 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<LocalAsrSettings | null>(null);
|
||||
const [models, setModels] = useState<LocalAsrModelStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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(() => {
|
||||
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 (
|
||||
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--ol-ink-4)' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active = models.find(m => m.id === settings?.activeModel);
|
||||
const isReady = active?.isDownloaded ?? false;
|
||||
const downloaded = models.filter(m => m.isDownloaded);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0 4px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(255, 215, 130, 0.18)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12.5,
|
||||
color: 'var(--ol-ink-2)',
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
⚠️ {t('settings.providers.localAsrPerformanceWarning')}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
|
||||
{t('settings.providers.localAsrHint')}
|
||||
</div>
|
||||
|
||||
{/* 当前激活模型状态 + 跳转按钮 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<Pill tone={isReady ? 'ok' : 'outline'} size="sm">
|
||||
{isReady
|
||||
? t('settings.providers.localAsrReady', { model: active?.id ?? '' })
|
||||
: t('settings.providers.localAsrNotReady', { model: settings?.activeModel ?? '' })}
|
||||
</Pill>
|
||||
<Btn variant={isReady ? 'ghost' : 'primary'} size="sm" onClick={goToLocalAsr}>
|
||||
{isReady
|
||||
? t('settings.providers.localAsrManage')
|
||||
: t('settings.providers.localAsrGoDownload')}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{/* 已下载模型列表 + 删除按钮(用户:已下载的项目要在旁边显示 + 提供删除) */}
|
||||
{downloaded.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--ol-ink-4)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
|
||||
{t('settings.providers.localAsrDownloadedTitle')}
|
||||
</div>
|
||||
{downloaded.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
fontSize: 12.5,
|
||||
color: 'var(--ol-ink-2)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 500 }}>{m.id}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{formatBytes(m.downloadedBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deletingId === m.id}
|
||||
onClick={() => void handleDelete(m.id)}>
|
||||
{t('settings.providers.localAsrDelete')}
|
||||
</Btn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user