diff --git a/docs/windows-sherpa-onnx-asr-plan.md b/docs/windows-sherpa-onnx-asr-plan.md new file mode 100644 index 00000000..bd85f8a7 --- /dev/null +++ b/docs/windows-sherpa-onnx-asr-plan.md @@ -0,0 +1,421 @@ +# Windows sherpa-onnx 本地 ASR 实施规划 + +> 状态:草案 / 待评审 +> 日期:2026-05-12 +> 范围:仅 Windows;不替换 macOS `local-qwen3`;不替换 Windows `foundry-local-whisper` + +按 OpenLess 现有架构(Coordinator 单一拥有者、ASR provider 独立模块、`AudioConsumer` +接口)来做,**不重写主链路、不动 macOS、不替换 Foundry**,新增一个 Windows 实验 +provider。 + +--- + +## 1. 目标与非目标 + +### 目标 + +- **Windows 新增本地 ASR provider**:`sherpa-onnx-local` +- **复用现有听写主链路**:Recorder / Coordinator / polish / insert / history +- **支持中文为主,中英混合可用** +- **第一阶段 batch,第二阶段流式** +- **可与 `foundry-local-whisper` 并存切换** + +### 非目标 + +- 不替换 macOS `local-qwen3` +- 不替换 Windows `foundry-local-whisper`,仅作为新选项 +- 不做 Linux 支持(本期) +- 不做语者分离、长会议转写、字幕导出 +- 不做云端模型,不做模型自训 + +### 明确边界 + +- **不动 Coordinator 的 phase enum / hotkey 流程** +- **不动 polish / insertion / history** +- **sherpa runtime 只通过 `AudioConsumer` + 转写函数对外暴露** +- **任何 sherpa 错误必须降级**:不能让用户的话丢失(与现有 ASR 失败语义一致) + +--- + +## 2. 架构定位 + +按现有结构对齐 Foundry 路径: + +``` +asr/local/ + mod.rs # 增加 sherpa provider id 与 helper + foundry_provider.rs # 保留 + foundry_runtime.rs # 保留 + sherpa_provider.rs # 新增:AudioConsumer + transcribe() + sherpa_runtime.rs # 新增:模型加载 / 推理调用 / 生命周期 + sherpa_models.rs # 新增:模型 catalog 静态表 +``` + +主要扩展点: + +- `ActiveAsr::SherpaOnnxLocal(Arc)` +- `coordinator/dictation.rs`:`begin_session` / `end_session` 增加 + `#[cfg(target_os = "windows")]` 分支 +- `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令 +- `types.rs`:增加 `UserPreferences` 字段 +- 前端 Settings 高级页:在 Windows 下新增第三个本地 ASR toggle + +--- + +## 3. 模型策略 + +### 第一批模型(重点是中文) + +| 模型 | 用途 | 备注 | +|---|---|---| +| **SenseVoice small (zh/en/ja/ko/yue, int8)** | 中文 + 多语言默认 | 体验通常优于 Whisper small;包小、速度快 | +| **Paraformer (zh, int8)** | 中文专用强力档 | 中文听写更稳;不擅长英文 | +| **Whisper small (multilingual, int8)** | 英文/通用 fallback | 与 Foundry Whisper 体验对齐基准 | + +模型形态全部用: + +- **ONNX** +- **量化 int8** +- **CPU 推理优先** + +后续可选: + +- **streaming Zipformer (zh)**:第二阶段流式使用 + +### 模型分发策略 + +- **不打进安装包** +- **首次启用时下载** +- **下载源带镜像**:HuggingFace / 镜像 / 自托管 CDN +- **校验 SHA-256** +- **存放路径**: + ``` + %APPDATA%\OpenLess\models\sherpa-onnx\\ + ``` + +--- + +## 4. 模块设计 + +### 4.1 `sherpa_models.rs` + +静态目录 + alias 解析,模仿 `foundry.rs::MODELS`: + +```rust +pub const PROVIDER_ID: &str = "sherpa-onnx-local"; +pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh"; + +pub struct SherpaModel { + pub alias: &'static str, + pub display_name: &'static str, + pub family: SherpaFamily, // SenseVoice / Paraformer / Whisper / Zipformer + pub languages: &'static [&'static str], + pub mode: SherpaMode, // Offline / Online + pub files: &'static [SherpaModelFile], // name + sha256 + size + url +} +``` + +边界: + +- **不在这里写下载逻辑** +- **不依赖 sherpa-onnx 类型**,纯描述 + +### 4.2 `sherpa_runtime.rs` + +只这一处依赖 `sherpa-onnx` crate。 + +职责: + +- **初始化 OfflineRecognizer / OnlineRecognizer** +- **缓存当前已加载的 recognizer** +- **暴露**: + - `ensure_loaded(alias) -> Result` + - `transcribe_pcm(pcm: &[i16]) -> Result`(offline) + - `create_stream() -> SherpaStream`(online,第二阶段) + - `release_now()` + - `status_snapshot()` +- **生命周期**: + - `lifecycle: AsyncMutex<()>`(与 Foundry 一致,串行化加载/释放) + - 闲时延迟释放(参考 `local_asr_keep_loaded_secs` 模式) + +边界: + +- **不知道 Coordinator** +- **不知道 Recorder** +- **不动 UI** +- **不发 Tauri 事件** + +错误统统返回 `anyhow::Error`,由上层翻译为前端文案。 + +### 4.3 `sherpa_provider.rs` + +形状与 `foundry_provider.rs` 完全对齐: + +```rust +pub struct SherpaOnnxAsr { + runtime: Arc, + model_alias: String, + language_hint: Option, + buffer: Mutex>, // PCM s16le 16kHz mono + cancel_generation: AtomicU64, +} + +impl AudioConsumer for SherpaOnnxAsr { + fn consume_pcm_chunk(&self, pcm: &[u8]) { ... } +} + +impl SherpaOnnxAsr { + pub async fn transcribe(&self, timeout: Duration) -> Result { ... } + pub fn cancel(&self) { ... } +} +``` + +边界: + +- **batch 阶段不做实时 token 回调** +- **流式阶段独立加 `transcribe_stream(on_token)`,不破坏 batch API** + +### 4.4 `coordinator/dictation.rs` 集成 + +新增分支,**完全 mirror 现有 foundry 分支**: + +`begin_session`: + +```rust +#[cfg(target_os = "windows")] +if sherpa::is_sherpa_onnx_local(&active_asr) { + let local = Arc::new(SherpaOnnxAsr::new(...)); + store_asr_for_session(inner, sid, ActiveAsr::SherpaOnnxLocal(Arc::clone(&local))); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, sid, &active_asr, consumer).await?; + return Ok(()); +} +``` + +`end_session`: + +```rust +#[cfg(target_os = "windows")] +ActiveAsr::SherpaOnnxLocal(local) => { + match local.transcribe(sherpa_transcribe_timeout()).await { + Ok(r) => { schedule_sherpa_release(...); r } + Err(e) => { /* 与 foundry 失败分支同形 */ } + } +} +``` + +边界: + +- **不修改 Foundry 分支** +- **不修改 macOS Qwen3 分支** +- **复用 `RawTranscript` / `polish` / `insertion`** + +### 4.5 `commands.rs` + +新增命令(与 Foundry 同形,方便前端代码复用模式): + +- `sherpa_asr_status` +- `sherpa_asr_prepare` +- `sherpa_asr_release` +- `sherpa_asr_catalog` +- `sherpa_asr_set_model` + +只在 `#[cfg(target_os = "windows")]` 下注册。 + +### 4.6 `types.rs` + +新增字段(默认值 Windows = SenseVoice 中文,其他平台不可用): + +```rust +#[serde(default = "default_sherpa_model_alias")] +pub sherpa_onnx_model: String, + +#[serde(default)] +pub sherpa_onnx_language_hint: String, + +#[serde(default = "default_local_asr_keep_loaded_secs")] +pub sherpa_onnx_keep_loaded_secs: u32, +``` + +**不改 `default_active_asr_provider()`**:Windows 默认仍是 +`foundry-local-whisper`,sherpa 通过高级开关启用。 + +### 4.7 前端 + +在 Windows 高级页加第三个 toggle 行: + +- Foundry Local Whisper +- **Sherpa-Onnx Local(新增,实验)** +- 模型选择 / 准备 / 删除 / 路径 + +复用现有 `LocalAsr` UI 模式。i18n key 用 zh-CN 源 + en 镜像(按 AGENTS.md 规则)。 + +--- + +## 5. 依赖与打包 + +### 5.1 Rust crate + +```toml +[target.'cfg(target_os = "windows")'.dependencies] +sherpa-onnx = "..." # 选最新稳定版,feature 关闭非必要后端 +``` + +注意: + +- **关掉 CUDA / DirectML 等 feature**(v1 只用 CPU) +- **避免依赖 dynamic ONNX Runtime**:优先静态或随包附带 DLL +- **不要引入新的 native build chain**:保证 GH Actions Windows runner 能编 + +### 5.2 DLL / native 资源 + +如果 sherpa-onnx crate 自带 `onnxruntime.dll` / `sherpa-onnx.dll`: + +- 通过 `build.rs` copy 到 target dir +- 由 Tauri bundler 一同打进 NSIS / MSI +- WiX 的 `Component` 落到 `INSTALLDIR` +- **严格遵守 AGENTS.md 的 Windows CI 红线**: + - 两轮 NSIS / MSI + - bash shell + - `-sice:ICE80` + - 不动 Repair 步骤 + +如果 crate 不带 DLL: + +- 第一次启用时从镜像下载,与模型同目录 +- 用 LoadLibrary delay-load + +### 5.3 模型下载 + +复用现有 `LocalAsr` 模型管理 UX: + +- 镜像选择 +- 进度 + 取消 +- SHA-256 校验 +- 失败重试 + +--- + +## 6. 实施里程碑 + +### M1 Provider 骨架 (0.5 周) + +- `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_models.rs` 文件结构 +- `ActiveAsr::SherpaOnnxLocal` +- `commands.rs` 桩函数 +- 前端 toggle + i18n +- **不实际推理**,先打通主链路(mock transcribe 返回空串或固定字符串) + +### M2 Batch 推理可用 (1.5 周) + +- 接 `sherpa-onnx` crate +- offline recognizer 加载 +- WAV/PCM → text +- 模型:先只接 **SenseVoice small zh** +- 错误降级:失败回到 Foundry / Volcengine +- Windows 本机 smoke test + +### M3 模型管理 + 多模型 (1 周) + +- 加 Paraformer / Whisper small +- 模型下载 / 校验 / 删除 +- 镜像源切换 +- 模型切换不需要重启 + +### M4 性能与稳定性 (1 周) + +- 启动时延、首次加载时延 +- 内存占用 +- 长录音稳定性 +- 取消(hotkey 再次按下)行为正确 +- DLL 缺失 / 模型损坏 / 路径含中文 / 路径含空格 全部覆盖 + +### M5 流式 ASR(可选,二阶段) + +- 接 OnlineRecognizer +- 边录边 partial → `local-asr-token` 事件 +- 与现有 macOS Qwen3 stream UX 对齐 + +### M6 发布 + +- 高级页打开为实验 +- 收集真实用户反馈 +- 满足质量门槛后再决定是否提升为 Windows 默认 + +--- + +## 7. 风险与对策 + +| 风险 | 对策 | +|---|---| +| sherpa-onnx Windows 打包带 native DLL,触发 WiX / NSIS 兼容问题 | 严格走 AGENTS.md 的两轮 bundle + `-sice:ICE80`;早期就在 CI 跑 | +| ONNX Runtime 版本冲突 | 锁版本;不和其他 crate 共享 ORT | +| 模型体积大,下载失败 | 强制镜像 + 断点续传 + SHA-256 + 明确错误文案 | +| 安装路径含中文/空格导致模型加载失败 | 用 `\\?\` 长路径前缀 + 单元测试覆盖 | +| 首次加载耗时长(用户以为卡死) | 加载阶段发 Tauri 进度事件;胶囊显示"准备模型"态 | +| CPU 性能不足机器卡顿 | 默认 SenseVoice small int8;提供更小模型;超时降级 | +| 推理 panic 干扰主进程 | 推理放 `spawn_blocking`,错误 → anyhow,绝不 panic 向上 | +| 与 Foundry / Qwen3 并存导致状态混乱 | 切换 provider 时强制 release 另一边;测试覆盖 | +| 取消语义不一致 | 严格按现有 `cancel_generation` 模式实现 | +| macOS / Linux 编译被影响 | 全部 sherpa 代码 `#[cfg(target_os = "windows")]` 包裹 | + +--- + +## 8. 验收标准 + +### 功能 + +- Windows 用户能在高级页启用 `sherpa-onnx-local` +- 默认模型 SenseVoice small zh 可下载、加载、转写 +- 中文短句听写质量明显优于 Foundry Whisper small(盲测) +- 失败时不丢用户的话(自动降级或留 raw) +- 取消、重复触发、连按热键不崩 + +### 工程 + +- 不动 macOS 编译产物 +- 不动 Foundry 路径 +- 不引入新的 CI 红线 +- Windows MSI / NSIS 两轮构建仍然通过 +- 包体增量在可接受范围(建议 < 50MB,不含模型) + +### 测试 + +- `cargo test` Windows 通过 +- 手测脚本: + - 中文短句 + - 中文长句(30s+) + - 中英混合 + - 安静 / 噪音 + - 取消 + - 切换模型 + - 切换 provider + - 卸载模型 + - 无网络再次启动 + +--- + +## 9. 不做什么(再次明确) + +- **不重构 ASR trait 体系** +- **不引入 ASR 中间层抽象** +- **不替换 Foundry** +- **不动 macOS Qwen3** +- **不做 Linux** +- **不做云端 fallback 改动** +- **不做模型微调** +- **不做多 provider 自动选择** + +--- + +## 10. 相关参考 + +- 现有 Windows 本地 ASR 实现: + - `openless-all/app/src-tauri/src/asr/local/foundry.rs` + - `openless-all/app/src-tauri/src/asr/local/foundry_provider.rs` + - `openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs` +- 现有 macOS 本地 ASR 实现: + - `openless-all/app/src-tauri/src/asr/local/local_provider.rs` +- 主听写链路集成点: + - `openless-all/app/src-tauri/src/coordinator/dictation.rs` +- Windows CI / 打包红线:见仓库根 `AGENTS.md`「Windows CI 红线」一节 diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 6b76e4f4..8ce1cd8d 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -554,6 +554,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + [[package]] name = "bzip2" version = "0.5.2" @@ -1708,7 +1718,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "ureq", + "ureq 3.3.0", "urlencoding", "zip 2.4.2", ] @@ -2302,7 +2312,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -3072,7 +3082,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3672,6 +3682,7 @@ dependencies = [ "arboard", "block2 0.5.1", "bytes", + "bzip2 0.4.4", "cc", "chrono", "core-foundation 0.10.1", @@ -3696,7 +3707,10 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2", + "sherpa-onnx", "simplelog", + "tar", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -4502,7 +4516,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -5088,6 +5102,28 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sherpa-onnx" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70620e4fa58e4cb1acf4e0a9c2cbc7496ea8284f80e55be23d443b92e563e49" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f3fe4987367b162336027b5d1ffca6dcd627bee6a324e46f80e82dfcb4365b" +dependencies = [ + "bzip2 0.4.4", + "tar", + "ureq 2.12.1", +] + [[package]] name = "shlex" version = "1.3.0" @@ -6237,7 +6273,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6360,6 +6396,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "ureq" version = "3.3.0" @@ -6374,7 +6426,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -6737,6 +6789,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -7961,7 +8022,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" dependencies = [ "aes", "arbitrary", - "bzip2", + "bzip2 0.5.2", "constant_time_eq", "crc32fast", "crossbeam-utils", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 6fe2b1a9..c0031dde 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -26,6 +26,9 @@ tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" +bzip2 = "0.4" +tar = "0.4" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" @@ -81,6 +84,7 @@ libc = "0.2" [target.'cfg(target_os = "windows")'.dependencies] foundry-local-sdk = { version = "1.1.0", features = ["winml"] } raw-window-handle = "0.6" +sherpa-onnx = { version = "1.13.2", default-features = false, features = ["static"] } windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_Globalization", diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index 08ef2f2a..a3760ccb 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -22,6 +22,15 @@ mod asr { } } } + + pub mod sherpa { + pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh"; + pub const PROVIDER_ID: &str = "sherpa-onnx-local"; + + pub fn is_sherpa_onnx_local(id: &str) -> bool { + id == PROVIDER_ID + } + } } } diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 2c97b041..925bf8b0 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -1,10 +1,32 @@ fn main() { + #[cfg(target_os = "windows")] + link_windows_common_controls_v6_manifest_dependency(); + #[cfg(target_os = "macos")] build_qwen_asr_macos(); tauri_build::build(); } +#[cfg(target_os = "windows")] +fn link_windows_common_controls_v6_manifest_dependency() { + let mut source_path = std::path::PathBuf::from( + std::env::var_os("OUT_DIR").expect("OUT_DIR must be set by Cargo"), + ); + source_path.push("common-controls-v6-manifest-dependency.c"); + std::fs::write( + &source_path, + r#"#pragma comment(linker, "/manifestdependency:\"type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"") +int openless_common_controls_v6_manifest_dependency_anchor = 0; +"#, + ) + .expect("write common controls manifest dependency source"); + cc::Build::new() + .file(&source_path) + .compile("openless_common_controls_v6_manifest_dependency"); + println!("cargo:rustc-link-arg=/INCLUDE:openless_common_controls_v6_manifest_dependency_anchor"); +} + /// 编译 vendored Open-Less/qwen-asr 的 C 源(仅 macOS)。 /// /// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework, diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index b3ed1361..0733f9bf 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -224,7 +224,7 @@ impl DownloadManager { } } -fn build_client() -> Result { +pub(crate) fn build_client() -> Result { // native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close // 当致命错误。 // @@ -476,6 +476,9 @@ const PARALLEL_FILES: usize = 3; pub fn partial_actual_size(partial: &Path) -> u64 { let total_size = match std::fs::metadata(partial) { Ok(m) => m.len(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return 0; + } Err(e) => { eprintln!( "[local-asr] partial_actual_size: stat partial failed ({}): {}", @@ -525,7 +528,7 @@ pub fn partial_actual_size(partial: &Path) -> u64 { total } -async fn download_one( +pub(crate) async fn download_one( client: &reqwest::Client, url: &str, dest: &Path, diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_native.rs b/openless-all/app/src-tauri/src/asr/local/foundry_native.rs index 285c4337..f52c8e6a 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_native.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_native.rs @@ -600,12 +600,12 @@ if ($readyForFoundryX64) { exit 0 } else { exit 1 } fn windows_app_runtime_detection_requires_complete_package_set() { let script = super::windows_app_runtime_detection_script(); - assert!(script.contains("Microsoft.WindowsAppRuntime.1.8")); + assert!(script.contains("Microsoft\\.WindowsAppRuntime\\.1\\.8")); assert!(script.contains("frameworkX86")); assert!(script.contains("frameworkX64")); assert!(script.contains("readyForFoundryX64")); assert!(script.contains("completeX64MachineRuntime")); - assert!(script.contains("Main.1.8")); + assert!(script.contains("Main\\.1\\.8")); assert!(script.contains("Singleton")); assert!(script.contains("ddlmX86")); assert!(script.contains("ddlmX64")); diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 9832d406..5412fb19 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -1,7 +1,9 @@ //! 本地 ASR 引擎入口。 //! -//! 当前只在 macOS 编入 vendored Open-Less/qwen-asr (纯 C + Accelerate);Windows 端 -//! 的本地推理路径见 issue #256,本期不实现。 +//! 当前本地引擎: +//! - **macOS**:`antirez/qwen-asr` 纯 C + Accelerate(`local_provider` / `qwen_engine`) +//! - **Windows**:Foundry Local Whisper(`foundry_*`),以及 sherpa-onnx-local +//! 实验 provider(`sherpa*`,M1 仅骨架,详见 `docs/windows-sherpa-onnx-asr-plan.md`) pub mod cache; pub mod download; @@ -11,6 +13,10 @@ pub mod foundry_provider; pub mod foundry_runtime; mod local_provider; pub mod models; +pub mod sherpa; +pub mod sherpa_download; +pub mod sherpa_provider; +pub mod sherpa_runtime; pub mod test_run; pub use cache::LocalAsrCache; @@ -18,6 +24,10 @@ pub use cache::LocalAsrCache; pub use foundry_provider::FoundryLocalWhisperAsr; #[allow(unused_imports)] pub use foundry_runtime::FoundryLocalRuntime; +#[allow(unused_imports)] +pub use sherpa_provider::SherpaOnnxAsr; +#[allow(unused_imports)] +pub use sherpa_runtime::SherpaOnnxRuntime; #[cfg(target_os = "macos")] mod qwen_engine; diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa.rs b/openless-all/app/src-tauri/src/asr/local/sherpa.rs new file mode 100644 index 00000000..974c83a1 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/sherpa.rs @@ -0,0 +1,351 @@ +//! Windows sherpa-onnx 本地 ASR 的常量、catalog 与事件载荷。 +//! +//! M1 阶段:纯描述层;不依赖 `sherpa-onnx` crate,不做实际推理。 +//! 与 `foundry.rs` 形状对齐,便于前端命令链路与 Foundry 同形复用。 +//! +//! 推理接入见 `sherpa_runtime.rs`(M2)。 + +use std::path::PathBuf; + +use anyhow::Result; +use serde::Serialize; + +pub const PROVIDER_ID: &str = "sherpa-onnx-local"; +pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub enum SherpaFamily { + SenseVoice, + Paraformer, + Whisper, + Qwen3Asr, + Zipformer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub enum SherpaMode { + /// 录音停止后整段 PCM 一次性识别。 + Offline, + /// 边录边识别 partial / final segment。M5 才接。 + Online, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct SherpaModel { + pub alias: &'static str, + pub display_name: &'static str, + pub family: SherpaFamily, + pub mode: SherpaMode, + /// 表征长度,使用 ISO 639-1 / BCP-47 风格小写串。 + pub languages: &'static [&'static str], + pub quality_tier: &'static str, +} + +/// M1 catalog 三档:默认 SenseVoice,中文专用 Paraformer,多语 Whisper 兜底。 +/// 文件清单 + 校验和会在 M3 模型管理阶段补全;M1 只暴露元数据驱动 UI。 +#[allow(dead_code)] +pub const MODELS: &[SherpaModel] = &[ + SherpaModel { + alias: "sense-voice-small-zh", + display_name: "SenseVoice Small (zh/en/ja/ko/yue)", + family: SherpaFamily::SenseVoice, + mode: SherpaMode::Offline, + languages: &["zh", "en", "ja", "ko", "yue"], + quality_tier: "balanced", + }, + SherpaModel { + alias: "paraformer-zh", + display_name: "Paraformer (zh)", + family: SherpaFamily::Paraformer, + mode: SherpaMode::Offline, + languages: &["zh"], + quality_tier: "chinese-strong", + }, + SherpaModel { + alias: "whisper-small-multi", + display_name: "Whisper Small (multilingual)", + family: SherpaFamily::Whisper, + mode: SherpaMode::Offline, + languages: &["multi"], + quality_tier: "english-fallback", + }, + SherpaModel { + alias: "qwen3-asr-0.6b-int8", + display_name: "Qwen3-ASR 0.6B INT8", + family: SherpaFamily::Qwen3Asr, + mode: SherpaMode::Offline, + languages: &["multi"], + quality_tier: "qwen3-balanced", + }, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SherpaReleaseArchive { + pub url: &'static str, + pub file_name: &'static str, + pub root_dir: &'static str, +} + +#[allow(dead_code)] +pub fn is_sherpa_onnx_local(id: &str) -> bool { + id == PROVIDER_ID +} + +#[allow(dead_code)] +pub fn model_alias_is_known(alias: &str) -> bool { + MODELS.iter().any(|model| model.alias == alias) +} + +pub fn hf_repo_for_alias(alias: &str) -> Result<&'static str> { + match alias { + "sense-voice-small-zh" => { + Ok("csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17") + } + "paraformer-zh" => Ok("csukuangfj/sherpa-onnx-paraformer-zh-2024-03-09"), + "whisper-small-multi" => Ok("csukuangfj/sherpa-onnx-whisper-small"), + _ => anyhow::bail!("unknown sherpa-onnx model alias: {alias}"), + } +} + +pub fn required_files_for_alias(alias: &str) -> Result<&'static [&'static str]> { + match alias { + "sense-voice-small-zh" => Ok(&["model.int8.onnx", "tokens.txt"]), + "paraformer-zh" => Ok(&["model.int8.onnx", "tokens.txt"]), + "whisper-small-multi" => Ok(&["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"]), + "qwen3-asr-0.6b-int8" => Ok(&[ + "conv_frontend.onnx", + "encoder.int8.onnx", + "decoder.int8.onnx", + "tokenizer", + ]), + _ => anyhow::bail!("unknown sherpa-onnx model alias: {alias}"), + } +} + +pub fn download_files_for_alias(alias: &str) -> Result<&'static [(&'static str, &'static str)]> { + match alias { + "sense-voice-small-zh" => Ok(&[ + ("model.int8.onnx", "model.int8.onnx"), + ("tokens.txt", "tokens.txt"), + ]), + "paraformer-zh" => Ok(&[ + ("model.int8.onnx", "model.int8.onnx"), + ("tokens.txt", "tokens.txt"), + ]), + "whisper-small-multi" => Ok(&[ + ("small-encoder.int8.onnx", "encoder.int8.onnx"), + ("small-decoder.int8.onnx", "decoder.int8.onnx"), + ("small-tokens.txt", "tokens.txt"), + ]), + _ => anyhow::bail!("unknown sherpa-onnx model alias: {alias}"), + } +} + +pub fn release_archive_for_alias(alias: &str) -> Option { + match alias { + "qwen3-asr-0.6b-int8" => Some(SherpaReleaseArchive { + url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2", + file_name: "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2", + root_dir: "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25", + }), + _ => None, + } +} + +pub fn model_dir_for_alias(alias: &str) -> Result { + if !model_alias_is_known(alias) { + anyhow::bail!("unknown sherpa-onnx model alias: {alias}"); + } + #[cfg(target_os = "windows")] + { + Ok(crate::persistence::sherpa_onnx_models_root()?.join(alias)) + } + #[cfg(not(target_os = "windows"))] + { + Ok(std::env::temp_dir() + .join("openless-sherpa-onnx") + .join(alias)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct SherpaCatalogModel { + pub alias: String, + pub display_name: String, + pub family: SherpaFamily, + pub mode: SherpaMode, + pub languages: Vec, + pub cached: bool, + pub file_size_mb: Option, +} + +impl SherpaCatalogModel { + #[allow(dead_code)] + pub fn from_static(model: &SherpaModel) -> Self { + Self { + alias: model.alias.to_string(), + display_name: model.display_name.to_string(), + family: model.family, + mode: model.mode, + languages: model.languages.iter().map(|s| s.to_string()).collect(), + cached: false, + file_size_mb: None, + } + } +} + +#[allow(dead_code)] +pub fn static_catalog_models() -> Vec { + MODELS.iter().map(SherpaCatalogModel::from_static).collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub enum SherpaPreparePhase { + Runtime, + Model, + Load, + Finished, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct SherpaPrepareProgressPayload { + pub phase: SherpaPreparePhase, + pub model_alias: String, + pub label: String, + pub percent: Option, + pub error: Option, +} + +impl SherpaPrepareProgressPayload { + #[allow(dead_code)] + pub fn new( + phase: SherpaPreparePhase, + model_alias: impl Into, + label: impl Into, + percent: Option, + error: Option, + ) -> Self { + Self { + phase, + model_alias: model_alias.into(), + label: label.into(), + percent: percent.map(|value| value.clamp(0.0, 100.0)), + error, + } + } + + #[allow(dead_code)] + pub fn failed( + model_alias: impl Into, + label: impl Into, + error: impl Into, + ) -> Self { + Self::new( + SherpaPreparePhase::Failed, + model_alias, + label, + None, + Some(error.into()), + ) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct SherpaRuntimeStatus { + pub provider_id: String, + /// M1 阶段恒为 false:sherpa-onnx crate 尚未接入。 + pub available: bool, + /// 当前模型是否已加载到内存。 + pub runtime_ready: bool, + pub active_model: String, + pub loaded_model_id: Option, + pub error: Option, +} + +impl SherpaRuntimeStatus { + #[allow(dead_code)] + pub fn unavailable(active_model: String, error: impl Into) -> Self { + Self { + provider_id: PROVIDER_ID.into(), + available: false, + runtime_ready: false, + active_model, + loaded_model_id: None, + error: Some(error.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_id_is_stable() { + assert!(is_sherpa_onnx_local("sherpa-onnx-local")); + assert!(!is_sherpa_onnx_local("foundry-local-whisper")); + assert!(!is_sherpa_onnx_local("local-qwen3")); + } + + #[test] + fn default_model_is_registered() { + assert!(model_alias_is_known(DEFAULT_MODEL_ALIAS)); + } + + #[test] + fn static_catalog_preserves_ui_order() { + let catalog = static_catalog_models(); + assert_eq!( + catalog.iter().map(|m| m.alias.as_str()).collect::>(), + vec![ + "sense-voice-small-zh", + "paraformer-zh", + "whisper-small-multi", + "qwen3-asr-0.6b-int8", + ] + ); + assert!(catalog.iter().all(|m| !m.cached)); + } + + #[test] + fn unavailable_status_uses_provider_id() { + let status = SherpaRuntimeStatus::unavailable("paraformer-zh".into(), "not ready"); + assert_eq!(status.provider_id, PROVIDER_ID); + assert!(!status.available); + assert!(!status.runtime_ready); + assert_eq!(status.active_model, "paraformer-zh"); + assert_eq!(status.error.as_deref(), Some("not ready")); + } + + #[test] + fn prepare_progress_payload_uses_expected_event_shape() { + let payload = SherpaPrepareProgressPayload::new( + SherpaPreparePhase::Model, + "sense-voice-small-zh", + "download model", + Some(42.4), + None, + ); + let value = serde_json::to_value(payload).unwrap(); + assert_eq!(value["phase"], "model"); + assert_eq!(value["modelAlias"], "sense-voice-small-zh"); + assert_eq!(value["label"], "download model"); + assert_eq!(value["percent"], 42.4); + assert_eq!(value["error"], serde_json::Value::Null); + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs b/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs new file mode 100644 index 00000000..797099c9 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs @@ -0,0 +1,788 @@ +use std::collections::HashMap; +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 sha2::{Digest, Sha256}; +use tauri::{AppHandle, Emitter}; + +use super::download::{ + build_client, download_one, partial_actual_size, DownloadPhase, DownloadProgress, Mirror, +}; +use super::sherpa; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SherpaRemoteFile { + pub path: String, + pub local_path: String, + pub size: u64, + pub sha256: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SherpaRemoteInfo { + pub model_alias: String, + pub mirror: String, + pub files: Vec, + pub total_bytes: u64, +} + +#[derive(Debug, Deserialize)] +struct HfTreeEntry { + #[serde(rename = "type")] + entry_type: String, + path: String, + #[serde(default)] + size: Option, + #[serde(default)] + lfs: Option, +} + +#[derive(Debug, Deserialize)] +struct HfLfsInfo { + oid: String, + #[serde(default)] + size: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubReleaseAsset { + name: String, + size: u64, + #[serde(default)] + digest: Option, +} + +#[derive(Default)] +pub struct SherpaDownloadManager { + cancel_flags: Mutex>>, +} + +impl SherpaDownloadManager { + pub fn new() -> Self { + Self::default() + } + + pub fn start(self: &Arc, app: AppHandle, model_alias: String, mirror: Mirror) { + let key = model_alias.clone(); + let flag = { + let mut flags = self.cancel_flags.lock(); + if flags.contains_key(&key) { + log::info!("[sherpa-asr] 模型下载已在进行中: {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_alias, mirror, Arc::clone(&flag)).await; + manager.cancel_flags.lock().remove(&key); + match result { + Ok(()) => log::info!("[sherpa-asr] 模型下载完成: {key}"), + Err(error) => log::error!("[sherpa-asr] 模型下载失败: {key}: {error:#}"), + } + }); + } + + pub fn cancel(&self, model_alias: &str) { + if let Some(flag) = self.cancel_flags.lock().get(model_alias) { + flag.store(true, Ordering::SeqCst); + log::info!("[sherpa-asr] 已请求取消模型下载: {model_alias}"); + } else { + log::info!("[sherpa-asr] 请求取消模型下载,但没有活跃任务: {model_alias}"); + } + } +} + +pub async fn fetch_remote_info(model_alias: &str, mirror: Mirror) -> Result { + if let Some(archive) = sherpa::release_archive_for_alias(model_alias) { + return fetch_release_archive_info(model_alias, archive).await; + } + let client = build_client()?; + let repo = sherpa::hf_repo_for_alias(model_alias)?; + let url = format!("{}/api/models/{}/tree/main", mirror.base_url(), repo); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("HF tree API GET 失败: {url}"))?; + if !resp.status().is_success() { + anyhow::bail!("HF tree API HTTP {}: {url}", resp.status()); + } + let entries: Vec = resp + .json() + .await + .with_context(|| format!("HF tree JSON 解码失败: {url}"))?; + + let mut files = Vec::new(); + for (remote_path, local_path) in sherpa::download_files_for_alias(model_alias)? { + let entry = entries + .iter() + .find(|entry| entry.entry_type == "file" && entry.path == *remote_path) + .with_context(|| format!("Sherpa 模型文件清单缺少: {remote_path}"))?; + let size = entry + .lfs + .as_ref() + .and_then(|lfs| lfs.size) + .or(entry.size) + .unwrap_or(0); + let sha256 = entry + .lfs + .as_ref() + .map(|lfs| lfs.oid.clone()) + .filter(|oid| is_sha256_hex(oid)); + files.push(SherpaRemoteFile { + path: (*remote_path).to_string(), + local_path: (*local_path).to_string(), + size, + sha256, + }); + } + + let total_bytes = files.iter().map(|file| file.size).sum(); + Ok(SherpaRemoteInfo { + model_alias: model_alias.to_string(), + mirror: mirror.as_str().to_string(), + files, + total_bytes, + }) +} + +async fn fetch_release_archive_info( + model_alias: &str, + archive: sherpa::SherpaReleaseArchive, +) -> Result { + let client = build_client()?; + let (size, sha256) = match fetch_release_archive_asset_info(&client, archive).await { + Ok(info) => info, + Err(error) => { + log::warn!("[sherpa-asr] GitHub release API 获取包大小失败,回退 HEAD: {error:#}"); + let resp = client + .head(archive.url) + .send() + .await + .with_context(|| format!("GitHub release HEAD 失败: {}", archive.url))?; + if !resp.status().is_success() { + anyhow::bail!("GitHub release HTTP {}: {}", resp.status(), archive.url); + } + (resp.content_length().unwrap_or(0), None) + } + }; + Ok(SherpaRemoteInfo { + model_alias: model_alias.to_string(), + mirror: "github-release".to_string(), + files: vec![SherpaRemoteFile { + path: archive.file_name.to_string(), + local_path: archive.file_name.to_string(), + size, + sha256, + }], + total_bytes: size, + }) +} + +async fn fetch_release_archive_asset_info( + client: &reqwest::Client, + archive: sherpa::SherpaReleaseArchive, +) -> Result<(u64, Option)> { + let url = "https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/asr-models"; + let resp = client + .get(url) + .send() + .await + .with_context(|| format!("GitHub release API GET 失败: {url}"))?; + if !resp.status().is_success() { + anyhow::bail!("GitHub release API HTTP {}: {url}", resp.status()); + } + let release: GithubRelease = resp + .json() + .await + .with_context(|| format!("GitHub release API JSON 解码失败: {url}"))?; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name == archive.file_name) + .with_context(|| format!("GitHub release asset 缺少: {}", archive.file_name))?; + let sha256 = asset + .digest + .as_deref() + .and_then(|digest| digest.strip_prefix("sha256:")) + .filter(|digest| is_sha256_hex(digest)) + .map(str::to_string); + Ok((asset.size, sha256)) +} + +pub fn downloaded_bytes(model_alias: &str) -> u64 { + let Ok(dir) = sherpa::model_dir_for_alias(model_alias) else { + return 0; + }; + if let Some(archive) = sherpa::release_archive_for_alias(model_alias) { + return downloaded_release_archive_bytes(&dir, model_alias, archive); + } + let Ok(files) = sherpa::download_files_for_alias(model_alias) else { + return 0; + }; + files + .iter() + .map(|(_, local_path)| { + let dest = dir.join(local_path); + if let Ok(meta) = std::fs::metadata(&dest) { + meta.len() + } else { + partial_actual_size(&dest.with_extension("partial")) + } + }) + .sum() +} + +fn downloaded_release_archive_bytes( + dir: &Path, + model_alias: &str, + archive: sherpa::SherpaReleaseArchive, +) -> u64 { + let dest = dir.join(archive.file_name); + if let Ok(meta) = std::fs::metadata(&dest) { + return meta.len(); + } + let partial = partial_actual_size(&dest.with_extension("partial")); + if partial > 0 { + return partial; + } + if let Ok(files) = sherpa::required_files_for_alias(model_alias) { + let total: u64 = files + .iter() + .map(|f| path_size_recursive(&dir.join(f))) + .sum(); + if total > 0 { + return total; + } + } + 0 +} + +fn path_size_recursive(path: &Path) -> u64 { + match std::fs::metadata(path) { + Ok(meta) if meta.is_file() => meta.len(), + Ok(meta) if meta.is_dir() => { + let mut total: u64 = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + total += path_size_recursive(&entry.path()); + } + } + total + } + _ => 0, + } +} + +async fn run_download( + app: &AppHandle, + model_alias: &str, + mirror: Mirror, + cancel: Arc, +) -> Result<()> { + let dir = sherpa::model_dir_for_alias(model_alias)?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("create sherpa model dir failed: {}", dir.display()))?; + if let Some(archive) = sherpa::release_archive_for_alias(model_alias) { + return run_release_archive_download(app, model_alias, archive, &dir, cancel).await; + } + + let client = build_client()?; + let info = match fetch_remote_info(model_alias, mirror).await { + Ok(info) => info, + Err(error) => { + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: 0, + file_count: 0, + bytes_downloaded: 0, + bytes_total: 0, + phase: DownloadPhase::Failed, + error: Some(format!("拉文件清单失败: {error:#}")), + }, + ); + return Err(error); + } + }; + let repo = sherpa::hf_repo_for_alias(model_alias)?; + let total_bytes = info.total_bytes; + let file_count = info.files.len(); + + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: 0, + file_count, + bytes_downloaded: downloaded_bytes(model_alias), + bytes_total: total_bytes, + phase: DownloadPhase::Started, + error: None, + }, + ); + + for file in &info.files { + if let Some(parent) = dir.join(&file.local_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + } + + let in_flight_bytes: Arc> = + Arc::new(info.files.iter().map(|_| AtomicU64::new(0)).collect()); + let already_done_bytes: u64 = info + .files + .iter() + .map(|file| { + let dest = dir.join(&file.local_path); + if file_is_verified(&dest, file) { + file.size + } else { + 0 + } + }) + .sum(); + + let semaphore = Arc::new(tokio::sync::Semaphore::new(3)); + let mut futs = futures_util::stream::FuturesUnordered::new(); + + for (idx, file) in info.files.iter().cloned().enumerate() { + let dest = dir.join(&file.local_path); + if file_is_verified(&dest, &file) { + continue; + } + if dest.exists() { + let _ = std::fs::remove_file(&dest); + } + let url = format!("{}/{}/resolve/main/{}", mirror.base_url(), 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_alias_emit = model_alias.to_string(); + let file_path_emit = file.local_path.clone(); + let file_size = file.size; + 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(permit) => permit, + Err(_) => return Err(anyhow::anyhow!("semaphore closed")), + }; + if cancel.load(Ordering::SeqCst) { + return Ok(()); + } + let app_emit = app.clone(); + let in_flight_for_cb = Arc::clone(&in_flight_bytes); + let on_progress: Arc = Arc::new(move |bytes_in_file| { + in_flight_for_cb[idx].store(bytes_in_file, Ordering::Relaxed); + let total_in_flight: u64 = in_flight_for_cb + .iter() + .map(|bytes| bytes.load(Ordering::Relaxed)) + .sum(); + let _ = app_emit.emit( + "sherpa-onnx-asr-download-progress", + DownloadProgress { + model_id: model_alias_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; + if result.is_ok() { + verify_file(&dest, &file)?; + in_flight_bytes[idx].store(file_size, Ordering::Relaxed); + } + result.with_context(|| format!("file {}", file.local_path)) + })); + } + + let mut first_err: Option = None; + let mut self_aborted = false; + while let Some(joined) = futs.next().await { + match joined { + Ok(Ok(())) => {} + Ok(Err(error)) => { + if first_err.is_none() { + first_err = Some(error); + } + if !cancel.load(Ordering::SeqCst) { + log::warn!("[sherpa-asr] 单文件下载失败,正在中止其它任务"); + cancel.store(true, Ordering::SeqCst); + self_aborted = true; + } + } + Err(error) => { + if first_err.is_none() { + first_err = Some(anyhow::anyhow!("join: {error}")); + } + } + } + } + + if cancel.load(Ordering::SeqCst) && !self_aborted { + emit_cancelled(app, model_alias, file_count, total_bytes); + return Ok(()); + } + if let Some(error) = first_err { + emit_failed(app, model_alias, file_count, total_bytes, &error); + return Err(error); + } + + for file in &info.files { + verify_file(&dir.join(&file.local_path), file)?; + } + + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: file_count, + file_count, + bytes_downloaded: downloaded_bytes(model_alias), + bytes_total: total_bytes, + phase: DownloadPhase::Finished, + error: None, + }, + ); + Ok(()) +} + +async fn run_release_archive_download( + app: &AppHandle, + model_alias: &str, + archive: sherpa::SherpaReleaseArchive, + dir: &Path, + cancel: Arc, +) -> Result<()> { + let client = build_client()?; + let info = match fetch_release_archive_info(model_alias, archive).await { + Ok(info) => info, + Err(error) => { + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: 0, + file_count: 0, + bytes_downloaded: 0, + bytes_total: 0, + phase: DownloadPhase::Failed, + error: Some(format!("拉 release 包信息失败: {error:#}")), + }, + ); + return Err(error); + } + }; + let total_bytes = info.total_bytes; + let file_count = info.files.len(); + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: 0, + file_count, + bytes_downloaded: downloaded_bytes(model_alias), + bytes_total: total_bytes, + phase: DownloadPhase::Started, + error: None, + }, + ); + let archive_path = dir.join(archive.file_name); + let app_emit = app.clone(); + let model_alias_emit = model_alias.to_string(); + let file_name_emit = archive.file_name.to_string(); + let on_progress: Arc = Arc::new(move |bytes_downloaded| { + let _ = app_emit.emit( + "sherpa-onnx-asr-download-progress", + DownloadProgress { + model_id: model_alias_emit.clone(), + file: file_name_emit.clone(), + file_index: 0, + file_count, + bytes_downloaded, + bytes_total: total_bytes, + phase: DownloadPhase::Progress, + error: None, + }, + ); + }); + let result = download_one( + &client, + archive.url, + &archive_path, + total_bytes, + Arc::clone(&cancel), + on_progress, + ) + .await; + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_alias, file_count, total_bytes); + return Ok(()); + } + if let Err(error) = result { + emit_failed(app, model_alias, file_count, total_bytes, &error); + return Err(error); + } + let archive_path_for_extract = archive_path.clone(); + let dir_for_extract = dir.to_path_buf(); + let model_alias_for_extract = model_alias.to_string(); + tauri::async_runtime::spawn_blocking(move || { + extract_release_archive( + &archive_path_for_extract, + &dir_for_extract, + archive, + &model_alias_for_extract, + ) + }) + .await + .map_err(|error| anyhow::anyhow!("extract join failed: {error:#}"))??; + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: file_count, + file_count, + bytes_downloaded: total_bytes, + bytes_total: total_bytes, + phase: DownloadPhase::Finished, + error: None, + }, + ); + Ok(()) +} + +fn extract_release_archive( + archive_path: &Path, + dir: &Path, + archive: sherpa::SherpaReleaseArchive, + model_alias: &str, +) -> Result<()> { + let extract_dir = archive_extract_dir(dir)?; + remove_path_if_exists(&extract_dir)?; + std::fs::create_dir_all(&extract_dir) + .with_context(|| format!("create extract dir failed: {}", extract_dir.display()))?; + let file = std::fs::File::open(archive_path) + .with_context(|| format!("open archive failed: {}", archive_path.display()))?; + let decoder = bzip2::read::BzDecoder::new(file); + let mut tar = tar::Archive::new(decoder); + tar.unpack(&extract_dir) + .with_context(|| format!("unpack archive failed: {}", archive_path.display()))?; + let root = extract_dir.join(archive.root_dir); + if !root.exists() { + anyhow::bail!("archive root missing: {}", root.display()); + } + for required in sherpa::required_files_for_alias(model_alias)? { + let src = root.join(required); + let dest = dir.join(required); + move_path(&src, &dest)?; + } + remove_path_if_exists(&extract_dir)?; + let _ = std::fs::remove_file(archive_path); + Ok(()) +} + +fn archive_extract_dir(dir: &Path) -> Result { + let name = dir + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("invalid model dir: {}", dir.display()))?; + Ok(dir.with_file_name(format!("{name}.extracting"))) +} + +fn move_path(src: &Path, dest: &Path) -> Result<()> { + if !src.exists() { + anyhow::bail!("archive required path missing: {}", src.display()); + } + remove_path_if_exists(dest)?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create parent dir failed: {}", parent.display()))?; + } + match std::fs::rename(src, dest) { + Ok(()) => Ok(()), + Err(_) if src.is_dir() => { + copy_dir_recursive(src, dest)?; + std::fs::remove_dir_all(src) + .with_context(|| format!("remove moved dir failed: {}", src.display()))?; + Ok(()) + } + Err(_) => { + std::fs::copy(src, dest).with_context(|| { + format!("copy file failed: {} -> {}", src.display(), dest.display()) + })?; + std::fs::remove_file(src) + .with_context(|| format!("remove moved file failed: {}", src.display()))?; + Ok(()) + } + } +} + +fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { + std::fs::create_dir_all(dest) + .with_context(|| format!("create dir failed: {}", dest.display()))?; + for entry in + std::fs::read_dir(src).with_context(|| format!("read dir failed: {}", src.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dest_path)?; + } else if file_type.is_file() { + std::fs::copy(&src_path, &dest_path).with_context(|| { + format!( + "copy file failed: {} -> {}", + src_path.display(), + dest_path.display() + ) + })?; + } + } + Ok(()) +} + +fn remove_path_if_exists(path: &Path) -> Result<()> { + match std::fs::metadata(path) { + Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path) + .with_context(|| format!("remove dir failed: {}", path.display())), + Ok(_) => std::fs::remove_file(path) + .with_context(|| format!("remove file failed: {}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("stat failed: {}", path.display())), + } +} + +fn file_is_verified(path: &Path, file: &SherpaRemoteFile) -> bool { + path.exists() && verify_file(path, file).is_ok() +} + +fn verify_file(path: &Path, file: &SherpaRemoteFile) -> Result<()> { + let meta = + std::fs::metadata(path).with_context(|| format!("stat failed: {}", path.display()))?; + if file.size > 0 && meta.len() != file.size { + anyhow::bail!( + "文件大小不匹配: {} actual={} expected={}", + path.display(), + meta.len(), + file.size + ); + } + if let Some(expected) = &file.sha256 { + let actual = sha256_file(path)?; + if !actual.eq_ignore_ascii_case(expected) { + anyhow::bail!( + "SHA-256 不匹配: {} actual={} expected={}", + path.display(), + actual, + expected + ); + } + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .with_context(|| format!("open for sha256 failed: {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = std::io::Read::read(&mut file, &mut buffer) + .with_context(|| format!("read for sha256 failed: {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn emit(app: &AppHandle, payload: DownloadProgress) { + if let Err(error) = app.emit("sherpa-onnx-asr-download-progress", payload) { + log::warn!("[sherpa-asr] 发送下载进度失败: {error}"); + } +} + +fn emit_cancelled(app: &AppHandle, model_alias: &str, file_count: usize, total_bytes: u64) { + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: 0, + file_count, + bytes_downloaded: downloaded_bytes(model_alias), + bytes_total: total_bytes, + phase: DownloadPhase::Cancelled, + error: None, + }, + ); +} + +fn emit_failed( + app: &AppHandle, + model_alias: &str, + file_count: usize, + total_bytes: u64, + error: &anyhow::Error, +) { + emit( + app, + DownloadProgress { + model_id: model_alias.to_string(), + file: String::new(), + file_index: 0, + file_count, + bytes_downloaded: downloaded_bytes(model_alias), + bytes_total: total_bytes, + phase: DownloadPhase::Failed, + error: Some(format!("{error:#}")), + }, + ); +} diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs b/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs new file mode 100644 index 00000000..741d6159 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs @@ -0,0 +1,182 @@ +//! sherpa-onnx 本地 ASR provider(M1 骨架)。 +//! +//! 形状与 `foundry_provider.rs` 对齐: +//! - 作为 `Recorder::AudioConsumer` 持续吃 PCM +//! - 录音结束后 `transcribe(timeout)` 返回 `RawTranscript` +//! - `cancel()` 让任何 in-flight transcription 提前结束(M1 桩,仅清 buffer) +//! +//! M1 阶段: +//! - `transcribe` 调 `SherpaOnnxRuntime::transcribe_pcm`(M1 返回空串) +//! - 让主链路在 Windows + `sherpa-onnx-local` provider 时能跑完 +//! begin_session → 录音 → end_session → polish → insert 的形态 +//! - M1 空 transcript 会走现有 emptyTranscript 护栏;M2 接真实推理后复用同一收尾路径 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use parking_lot::Mutex; + +use crate::asr::RawTranscript; + +use super::sherpa_runtime::SherpaOnnxRuntime; + +pub struct SherpaOnnxAsr { + runtime: Arc, + model_alias: String, + language_hint: Option, + buffer: Mutex>, + cancel_generation: AtomicU64, +} + +impl SherpaOnnxAsr { + pub fn new( + runtime: Arc, + model_alias: String, + language_hint: Option, + ) -> Self { + Self { + runtime, + model_alias, + language_hint: normalize_language_hint(language_hint), + buffer: Mutex::new(Vec::new()), + cancel_generation: AtomicU64::new(0), + } + } + + #[allow(dead_code)] + pub fn model_alias(&self) -> &str { + &self.model_alias + } + + #[allow(dead_code)] + pub fn language_hint(&self) -> Option<&str> { + self.language_hint.as_deref() + } + + pub async fn transcribe(&self, audio_timeout: Duration) -> Result { + let cancel_generation = self.cancel_generation.load(Ordering::SeqCst); + let pcm = self.buffer.lock().clone(); + if pcm.is_empty() { + return Ok(RawTranscript { + text: String::new(), + duration_ms: 0, + }); + } + + let duration_ms = pcm_duration_ms(&pcm); + let result = self + .runtime + .transcribe_pcm(&self.model_alias, &pcm, self.language_hint(), audio_timeout) + .await; + + if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation { + anyhow::bail!("sherpa-onnx transcription cancelled"); + } + + // 与 Foundry 行为对齐:进入推理后清 buffer,避免下一轮重复消费。 + self.buffer.lock().clear(); + + let text = result?; + Ok(RawTranscript { + text: trim_transcript_text(&text), + duration_ms, + }) + } + + pub fn cancel(&self) { + self.cancel_generation.fetch_add(1, Ordering::SeqCst); + self.runtime.request_cancel_prepare(); + self.buffer.lock().clear(); + } +} + +impl crate::recorder::AudioConsumer for SherpaOnnxAsr { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.buffer.lock().extend_from_slice(pcm); + } +} + +fn pcm_duration_ms(pcm: &[u8]) -> u64 { + (pcm.len() as u64 / 2) * 1000 / 16_000 +} + +fn trim_transcript_text(text: &str) -> String { + text.trim().to_string() +} + +fn normalize_language_hint(raw: Option) -> Option { + raw.map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recorder::AudioConsumer; + + fn make_provider() -> SherpaOnnxAsr { + SherpaOnnxAsr::new( + Arc::new(SherpaOnnxRuntime::new()), + "sense-voice-small-zh".into(), + Some(" ZH ".into()), + ) + } + + #[test] + fn normalize_language_hint_trims_and_lowercases() { + let provider = make_provider(); + assert_eq!(provider.language_hint(), Some("zh")); + } + + #[test] + fn empty_language_hint_normalizes_to_none() { + let provider = SherpaOnnxAsr::new( + Arc::new(SherpaOnnxRuntime::new()), + "paraformer-zh".into(), + Some(" ".into()), + ); + assert!(provider.language_hint().is_none()); + } + + #[test] + fn consume_pcm_chunk_extends_buffer() { + let provider = make_provider(); + provider.consume_pcm_chunk(&[1, 2, 3, 4]); + provider.consume_pcm_chunk(&[5, 6]); + assert_eq!(provider.buffer.lock().len(), 6); + } + + #[tokio::test] + async fn empty_buffer_transcribe_returns_empty_transcript() { + let provider = make_provider(); + let result = provider.transcribe(Duration::from_secs(5)).await.unwrap(); + assert!(result.text.is_empty()); + assert_eq!(result.duration_ms, 0); + } + + #[tokio::test] + async fn transcribe_clears_buffer_on_runtime_error() { + let provider = SherpaOnnxAsr::new( + Arc::new(SherpaOnnxRuntime::new()), + "unknown-sherpa-model".into(), + None, + ); + provider.consume_pcm_chunk(&vec![0u8; 32_000]); + let result = provider.transcribe(Duration::from_secs(5)).await; + assert!(result.is_err()); + assert!(provider.buffer.lock().is_empty()); + } + + #[test] + fn cancel_clears_buffer_and_bumps_generation() { + let provider = make_provider(); + provider.consume_pcm_chunk(&[1, 2, 3, 4]); + let before = provider.cancel_generation.load(Ordering::SeqCst); + provider.cancel(); + let after = provider.cancel_generation.load(Ordering::SeqCst); + assert!(after > before); + assert!(provider.buffer.lock().is_empty()); + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs b/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs new file mode 100644 index 00000000..fd98a6cd --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs @@ -0,0 +1,510 @@ +//! sherpa-onnx 本地 ASR runtime(M1 骨架)。 +//! +//! 设计与 `foundry_runtime.rs` 对齐:runtime 是模型/会话/生命周期的单一持有者, +//! 不感知 `Coordinator` / `Recorder` / UI / Tauri 事件。失败统一通过 +//! `anyhow::Error` 上抛,由上层翻译为用户可见文案。 +//! +//! M1 阶段: +//! - 全平台编译通过(避免 macOS / Linux CI 红线) +//! - 不引入 `sherpa-onnx` crate(M2 才加 Windows-only 依赖) +//! - `ensure_loaded` / `transcribe_pcm` / `release_now` 全部桩实现 +//! - 仅维持 active_model / runtime_ready 这种「状态门面」,便于前端联调 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +use crate::asr::local::sherpa::{ + self, SherpaCatalogModel, SherpaFamily, SherpaPreparePhase, SherpaPrepareProgressPayload, + SherpaRuntimeStatus, PROVIDER_ID, +}; + +#[cfg(target_os = "windows")] +use sherpa_onnx::{ + OfflineParaformerModelConfig, OfflineQwen3ASRModelConfig, OfflineRecognizer, + OfflineRecognizerConfig, OfflineSenseVoiceModelConfig, OfflineWhisperModelConfig, +}; + +/// 模型加载状态。M1 阶段不持有任何 native handle; +/// M2 引入 sherpa-onnx crate 后再补 `recognizer: Arc` 之类的字段。 +#[derive(Clone)] +struct LoadedModel { + alias: String, + #[cfg(target_os = "windows")] + recognizer: Arc, +} + +#[derive(Default)] +struct RuntimeState { + loaded: Option, +} + +/// 跨会话单例。生命周期由 `AsyncMutex` 串行化,确保 ensure_loaded / release 不会并发。 +pub struct SherpaOnnxRuntime { + lifecycle: AsyncMutex<()>, + cancel_prepare: AtomicBool, + state: Mutex, +} + +impl Default for SherpaOnnxRuntime { + fn default() -> Self { + Self::new() + } +} + +impl SherpaOnnxRuntime { + pub fn new() -> Self { + Self { + lifecycle: AsyncMutex::new(()), + cancel_prepare: AtomicBool::new(false), + state: Mutex::new(RuntimeState::default()), + } + } + + /// 返回当前 runtime 是否真的具备推理能力。M1 永远是 false; + /// M2 接入 sherpa-onnx 后改为编译期 `#[cfg(target_os = "windows")]` 真值。 + #[allow(dead_code)] + pub fn is_available(&self) -> bool { + cfg!(target_os = "windows") + } + + pub async fn status_snapshot(&self, active_model: &str) -> SherpaRuntimeStatus { + let loaded_model_id = self + .state + .lock() + .loaded + .as_ref() + .map(|loaded| loaded.alias.clone()); + SherpaRuntimeStatus { + provider_id: PROVIDER_ID.into(), + available: self.is_available(), + runtime_ready: loaded_model_id.is_some(), + active_model: active_model.to_string(), + loaded_model_id, + error: None, + } + } + + /// M1:返回静态 catalog。M3 接入下载管理后会合并本地缓存状态。 + #[allow(dead_code)] + pub async fn catalog_snapshot(&self) -> Result> { + let mut catalog = sherpa::static_catalog_models(); + for model in &mut catalog { + let dir = sherpa::model_dir_for_alias(&model.alias)?; + model.cached = sherpa::required_files_for_alias(&model.alias) + .map(|files| files.iter().all(|file| dir.join(file).exists())) + .unwrap_or(false); + model.file_size_mb = model_dir_size_mb(&dir); + } + Ok(catalog) + } + + pub async fn ensure_loaded(&self, alias: &str) -> Result { + self.ensure_loaded_with_progress(alias, |_| {}).await + } + + pub async fn ensure_loaded_with_progress(&self, alias: &str, progress: F) -> Result + where + F: Fn(SherpaPrepareProgressPayload) + Send + Sync + 'static, + { + let _lifecycle = self.lifecycle.lock().await; + self.cancel_prepare.store(false, Ordering::SeqCst); + validate_alias(alias)?; + if let Some(loaded) = self.cached_loaded_model(alias) { + progress(SherpaPrepareProgressPayload::new( + SherpaPreparePhase::Finished, + alias, + "Sherpa-Onnx model already loaded", + Some(100.0), + None, + )); + return Ok(loaded.alias); + } + self.check_prepare_cancelled()?; + let dir = sherpa::model_dir_for_alias(alias)?; + ensure_required_files(alias, &dir)?; + progress(SherpaPrepareProgressPayload::new( + SherpaPreparePhase::Model, + alias, + "Sherpa-Onnx local model files", + Some(100.0), + None, + )); + self.check_prepare_cancelled()?; + progress(SherpaPrepareProgressPayload::new( + SherpaPreparePhase::Load, + alias, + "Load Sherpa-Onnx model", + Some(0.0), + None, + )); + let loaded = load_model(alias, &dir).await?; + self.check_prepare_cancelled()?; + progress(SherpaPrepareProgressPayload::new( + SherpaPreparePhase::Load, + alias, + "Load Sherpa-Onnx model", + Some(100.0), + None, + )); + self.state.lock().loaded = Some(loaded.clone()); + progress(SherpaPrepareProgressPayload::new( + SherpaPreparePhase::Finished, + alias, + "Sherpa-Onnx model ready", + Some(100.0), + None, + )); + Ok(alias.to_string()) + } + + /// M1:永远返回空串,配合 mock pipeline 让用户的话不被「丢失也不被乱写」。 + /// 真实接入见 M2 `OfflineRecognizer::decode`。 + #[allow(dead_code)] + pub async fn transcribe_pcm( + &self, + alias: &str, + pcm: &[u8], + language_hint: Option<&str>, + audio_timeout: std::time::Duration, + ) -> Result { + if pcm.is_empty() { + return Ok(String::new()); + } + let loaded_alias = self.ensure_loaded(alias).await?; + let loaded = self + .state + .lock() + .loaded + .clone() + .filter(|loaded| loaded.alias == loaded_alias) + .context("sherpa-onnx model not loaded")?; + transcribe_loaded_model( + loaded, + pcm.to_vec(), + language_hint.map(str::to_string), + audio_timeout, + ) + .await + } + + pub fn request_cancel_prepare(&self) { + self.cancel_prepare.store(true, Ordering::SeqCst); + } + + #[cfg(test)] + pub(crate) fn cancel_prepare_requested_for_tests(&self) -> bool { + self.cancel_prepare.load(Ordering::SeqCst) + } + + pub async fn release_now(&self) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; + self.state.lock().loaded = None; + Ok(()) + } + + pub fn model_dir_for_alias(alias: &str) -> Result { + sherpa::model_dir_for_alias(alias) + } + + pub async fn delete_model(&self, alias: &str) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; + validate_alias(alias)?; + { + let mut state = self.state.lock(); + if state.loaded.as_ref().map(|loaded| loaded.alias.as_str()) == Some(alias) { + state.loaded = None; + } + } + let dir = sherpa::model_dir_for_alias(alias)?; + if dir.exists() { + std::fs::remove_dir_all(&dir) + .with_context(|| format!("remove sherpa-onnx model dir {}", dir.display()))?; + } + Ok(()) + } + + fn cached_loaded_model(&self, alias: &str) -> Option { + self.state + .lock() + .loaded + .as_ref() + .filter(|loaded| loaded.alias == alias) + .cloned() + } + + fn check_prepare_cancelled(&self) -> Result<()> { + if self.cancel_prepare.load(Ordering::SeqCst) { + anyhow::bail!("sherpa-onnx prepare cancelled"); + } + Ok(()) + } +} + +fn validate_alias(alias: &str) -> Result<()> { + if sherpa::model_alias_is_known(alias) { + Ok(()) + } else { + anyhow::bail!("unknown sherpa-onnx model alias: {alias}"); + } +} + +fn ensure_required_files(alias: &str, dir: &Path) -> Result<()> { + for file in sherpa::required_files_for_alias(alias)? { + let path = dir.join(file); + if !path.exists() { + anyhow::bail!( + "sherpa-onnx model file missing: {}. Place model files under {}", + file, + dir.display() + ); + } + } + Ok(()) +} + +fn model_dir_size_mb(dir: &Path) -> Option { + if !dir.exists() { + return None; + } + let mut bytes = 0u64; + accumulate_dir_size(dir, &mut bytes); + Some(bytes / 1024 / 1024) +} + +fn accumulate_dir_size(dir: &Path, bytes: &mut u64) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + match entry.file_type() { + Ok(file_type) if file_type.is_dir() => accumulate_dir_size(&path, bytes), + Ok(file_type) if file_type.is_file() => { + if let Ok(meta) = entry.metadata() { + *bytes += meta.len(); + } + } + _ => {} + } + } +} + +#[cfg(target_os = "windows")] +async fn load_model(alias: &str, dir: &Path) -> Result { + let alias = alias.to_string(); + let dir = dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + let recognizer = create_offline_recognizer(&alias, &dir)?; + Ok(LoadedModel { + alias, + recognizer: Arc::new(recognizer), + }) + }) + .await + .map_err(|e| anyhow::anyhow!("sherpa-onnx load join failed: {e:#}"))? +} + +#[cfg(not(target_os = "windows"))] +async fn load_model(alias: &str, _dir: &Path) -> Result { + Ok(LoadedModel { + alias: alias.to_string(), + }) +} + +#[cfg(target_os = "windows")] +fn create_offline_recognizer(alias: &str, dir: &Path) -> Result { + let mut config = OfflineRecognizerConfig::default(); + config.model_config.num_threads = std::thread::available_parallelism() + .map(|n| n.get().clamp(1, 4) as i32) + .unwrap_or(2); + config.model_config.provider = Some("cpu".into()); + match model_family(alias)? { + SherpaFamily::SenseVoice => { + config.model_config.tokens = Some(path_to_string(&dir.join("tokens.txt"))?); + config.model_config.sense_voice = OfflineSenseVoiceModelConfig { + model: Some(path_to_string(&dir.join("model.int8.onnx"))?), + language: Some("auto".into()), + use_itn: true, + }; + } + SherpaFamily::Paraformer => { + config.model_config.tokens = Some(path_to_string(&dir.join("tokens.txt"))?); + config.model_config.paraformer = OfflineParaformerModelConfig { + model: Some(path_to_string(&dir.join("model.int8.onnx"))?), + }; + } + SherpaFamily::Whisper => { + config.model_config.tokens = Some(path_to_string(&dir.join("tokens.txt"))?); + config.model_config.whisper = OfflineWhisperModelConfig { + encoder: Some(path_to_string(&dir.join("encoder.int8.onnx"))?), + decoder: Some(path_to_string(&dir.join("decoder.int8.onnx"))?), + language: Some("auto".into()), + task: Some("transcribe".into()), + tail_paddings: 0, + enable_token_timestamps: false, + enable_segment_timestamps: false, + }; + } + SherpaFamily::Qwen3Asr => { + config.model_config.qwen3_asr = OfflineQwen3ASRModelConfig { + conv_frontend: Some(path_to_string(&dir.join("conv_frontend.onnx"))?), + encoder: Some(path_to_string(&dir.join("encoder.int8.onnx"))?), + decoder: Some(path_to_string(&dir.join("decoder.int8.onnx"))?), + tokenizer: Some(path_to_string(&dir.join("tokenizer"))?), + ..Default::default() + }; + config.model_config.num_threads = 3; + } + SherpaFamily::Zipformer => anyhow::bail!("zipformer is not supported by offline batch M2"), + } + OfflineRecognizer::create(&config) + .ok_or_else(|| anyhow::anyhow!("create sherpa-onnx offline recognizer failed")) +} + +fn model_family(alias: &str) -> Result { + sherpa::MODELS + .iter() + .find(|model| model.alias == alias) + .map(|model| model.family) + .context("unknown sherpa-onnx model family") +} + +#[cfg(target_os = "windows")] +fn path_to_string(path: &Path) -> Result { + Ok(path + .to_str() + .ok_or_else(|| anyhow::anyhow!("path is not valid UTF-8: {}", path.display()))? + .to_string()) +} + +#[cfg(target_os = "windows")] +async fn transcribe_loaded_model( + loaded: LoadedModel, + pcm: Vec, + language_hint: Option, + audio_timeout: std::time::Duration, +) -> Result { + tokio::time::timeout(audio_timeout, async move { + tokio::task::spawn_blocking(move || { + let samples = pcm_s16le_to_f32(&pcm)?; + let stream = loaded.recognizer.create_stream(); + if let Some(language) = language_hint.as_deref().filter(|value| !value.is_empty()) { + if stream.has_option("language") { + stream.set_option("language", language); + } + } + stream.accept_waveform(16_000, &samples); + loaded.recognizer.decode(&stream); + let result = stream + .get_result() + .ok_or_else(|| anyhow::anyhow!("sherpa-onnx returned no result"))?; + Ok(result.text) + }) + .await + .map_err(|e| anyhow::anyhow!("sherpa-onnx transcribe join failed: {e:#}"))? + }) + .await + .map_err(|_| anyhow::anyhow!("sherpa-onnx transcribe timeout"))? +} + +#[cfg(not(target_os = "windows"))] +async fn transcribe_loaded_model( + _loaded: LoadedModel, + _pcm: Vec, + _language_hint: Option, + _audio_timeout: std::time::Duration, +) -> Result { + Ok(String::new()) +} + +fn pcm_s16le_to_f32(pcm: &[u8]) -> Result> { + if pcm.len() % 2 != 0 { + anyhow::bail!("PCM buffer length is not aligned to i16 samples"); + } + Ok(pcm + .chunks_exact(2) + .map(|bytes| i16::from_le_bytes([bytes[0], bytes[1]]) as f32 / 32768.0) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn new_runtime_reports_skeleton_shape() { + let runtime = SherpaOnnxRuntime::new(); + let status = runtime.status_snapshot("sense-voice-small-zh").await; + + assert_eq!(status.provider_id, PROVIDER_ID); + assert_eq!(status.available, cfg!(target_os = "windows")); + assert!(!status.runtime_ready); + assert_eq!(status.active_model, "sense-voice-small-zh"); + assert_eq!(status.loaded_model_id, None); + assert_eq!(status.error, None); + } + + #[tokio::test] + async fn ensure_loaded_rejects_unknown_alias() { + let runtime = SherpaOnnxRuntime::new(); + let result = runtime.ensure_loaded("unknown-sherpa-model").await; + assert!(result.is_err()); + } + + #[test] + fn ensure_required_files_reports_missing_model_files() { + let dir = std::env::temp_dir().join(format!( + "openless-sherpa-runtime-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).unwrap(); + let result = ensure_required_files("paraformer-zh", &dir); + std::fs::remove_dir_all(&dir).ok(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn release_now_clears_loaded_model() { + let runtime = SherpaOnnxRuntime::new(); + runtime.release_now().await.unwrap(); + + let status = runtime.status_snapshot("paraformer-zh").await; + assert!(!status.runtime_ready); + assert_eq!(status.loaded_model_id, None); + } + + #[tokio::test] + async fn transcribe_pcm_returns_empty_for_empty_input() { + let runtime = SherpaOnnxRuntime::new(); + let text = runtime + .transcribe_pcm( + "sense-voice-small-zh", + &[], + Some("zh"), + std::time::Duration::from_secs(5), + ) + .await + .unwrap(); + assert!(text.is_empty()); + } + + #[test] + fn pcm_s16le_to_f32_converts_samples() { + let samples = pcm_s16le_to_f32(&[0, 0, 0xff, 0x7f, 0x00, 0x80]).unwrap(); + assert_eq!(samples.len(), 3); + assert_eq!(samples[0], 0.0); + assert!(samples[1] > 0.99); + assert_eq!(samples[2], -1.0); + } + + #[test] + fn pcm_s16le_to_f32_rejects_odd_length() { + assert!(pcm_s16le_to_f32(&[0]).is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index 1e69681b..c0152e99 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -168,6 +168,9 @@ mod tests { modifiers: vec!["cmd".into(), "shift".into()], }; let parsed = parse_binding(&binding).expect("binding parses"); + #[cfg(target_os = "windows")] + assert!(parsed.mods.contains(Modifiers::CONTROL)); + #[cfg(not(target_os = "windows"))] assert!(parsed.mods.contains(Modifiers::SUPER)); assert!(parsed.mods.contains(Modifiers::SHIFT)); assert_eq!(parsed.key, Code::KeyD); diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 395cbf03..6c1670c6 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -11,7 +11,15 @@ use crate::asr::local::foundry::{ model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, }; -use crate::asr::local::FoundryLocalRuntime; +use crate::asr::local::sherpa::{ + model_alias_is_known as sherpa_model_alias_is_known, SherpaCatalogModel, + SherpaPrepareProgressPayload, SherpaRuntimeStatus, + DEFAULT_MODEL_ALIAS as SHERPA_DEFAULT_MODEL_ALIAS, +}; +use crate::asr::local::sherpa_download::{ + fetch_remote_info as fetch_sherpa_remote_info, SherpaDownloadManager, SherpaRemoteInfo, +}; +use crate::asr::local::{FoundryLocalRuntime, SherpaOnnxRuntime}; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{ @@ -67,6 +75,7 @@ pub fn get_default_style_system_prompts() -> StyleSystemPrompts { } trait SettingsWriter { + fn read_settings(&self) -> UserPreferences; fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>; fn refresh_dictation_hotkey(&self); fn refresh_qa_hotkey(&self); @@ -77,6 +86,10 @@ trait SettingsWriter { } impl SettingsWriter for Coordinator { + fn read_settings(&self) -> UserPreferences { + self.prefs().get() + } + fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { self.prefs().set(prefs).map_err(|e| e.to_string()) } @@ -107,6 +120,10 @@ impl SettingsWriter for Coordinator { } impl SettingsWriter for Arc { + fn read_settings(&self) -> UserPreferences { + (**self).read_settings() + } + fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { (**self).write_settings(prefs) } @@ -140,15 +157,35 @@ fn persist_settings( coord: &T, mut prefs: UserPreferences, ) -> Result<(), String> { + let mut previous = coord.read_settings(); + sync_dictation_hotkey_legacy_fields(&mut previous); sync_dictation_hotkey_legacy_fields(&mut prefs); reject_hotkey_collisions(&prefs)?; + let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey; + let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode; + let qa_changed = previous.qa_hotkey != prefs.qa_hotkey; + let translation_changed = previous.translation_hotkey != prefs.translation_hotkey; + let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey; + let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; coord.write_settings(prefs)?; - coord.refresh_dictation_hotkey(); - coord.refresh_qa_hotkey(); - coord.refresh_combo_hotkey(); - coord.refresh_translation_hotkey(); - coord.refresh_switch_style_hotkey(); - coord.refresh_open_app_hotkey(); + if dictation_shortcut_changed || dictation_mode_changed { + coord.refresh_dictation_hotkey(); + } + if dictation_shortcut_changed { + coord.refresh_combo_hotkey(); + } + if qa_changed { + coord.refresh_qa_hotkey(); + } + if translation_changed { + coord.refresh_translation_hotkey(); + } + if switch_style_changed { + coord.refresh_switch_style_hotkey(); + } + if open_app_changed { + coord.refresh_open_app_hotkey(); + } Ok(()) } @@ -603,7 +640,10 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo if provider == "volcengine" { return volcengine_configured(snap); } - if provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider) { + if provider == crate::asr::local::PROVIDER_ID + || active_foundry_asr_is_supported(provider) + || active_sherpa_asr_is_supported(provider) + { // 本地 ASR 不依赖云端凭据。 return true; } @@ -669,12 +709,14 @@ fn configured(field: &Option) -> bool { struct LocalAsrReleasePlan { qwen: bool, foundry: bool, + sherpa: bool, } fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan { LocalAsrReleasePlan { qwen: provider != crate::asr::local::PROVIDER_ID, foundry: provider != FOUNDRY_LOCAL_PROVIDER_ID, + sherpa: provider != crate::asr::local::sherpa::PROVIDER_ID, } } @@ -690,6 +732,18 @@ async fn release_foundry_runtime_if_inactive( } } +async fn release_sherpa_runtime_if_inactive( + runtime: &Arc, + release_sherpa: bool, +) { + if release_sherpa { + runtime.request_cancel_prepare(); + if let Err(error) = runtime.release_now().await { + log::warn!("[sherpa-asr] release inactive runtime failed: {error:#}"); + } + } +} + #[tauri::command] pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { ensure_main_window(&window)?; @@ -705,11 +759,20 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result< pub async fn set_active_asr_provider( coord: CoordinatorState<'_>, runtime: State<'_, Arc>, + sherpa_runtime: State<'_, Arc>, provider: String, ) -> Result<(), String> { if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) { return Err("Foundry Local Whisper is only available on Windows".to_string()); } + if provider == crate::asr::local::sherpa::PROVIDER_ID + && !active_sherpa_asr_is_supported(&provider) + { + return Err("sherpa-onnx local ASR is only available on Windows".to_string()); + } + if CredentialsVault::get_active_asr() == provider { + return Ok(()); + } CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; let release_plan = local_asr_release_plan_for_provider(&provider); if provider == crate::asr::local::PROVIDER_ID { @@ -723,6 +786,7 @@ pub async fn set_active_asr_provider( coord.release_local_asr_engine(); } release_foundry_runtime_if_inactive(runtime.inner(), release_plan.foundry).await; + release_sherpa_runtime_if_inactive(sherpa_runtime.inner(), release_plan.sherpa).await; Ok(()) } @@ -959,7 +1023,9 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { } fn active_asr_is_keyless_for_validation(provider: &str) -> bool { - provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider) + provider == crate::asr::local::PROVIDER_ID + || active_foundry_asr_is_supported(provider) + || active_sherpa_asr_is_supported(provider) } fn active_foundry_asr_is_supported(provider: &str) -> bool { @@ -974,6 +1040,18 @@ fn active_foundry_asr_is_supported(provider: &str) -> bool { } } +fn active_sherpa_asr_is_supported(provider: &str) -> bool { + #[cfg(target_os = "windows")] + { + provider == crate::asr::local::sherpa::PROVIDER_ID + } + #[cfg(not(target_os = "windows"))] + { + let _ = provider; + false + } +} + async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024; let url = asr_transcriptions_url(&config.base_url)?; @@ -2303,6 +2381,9 @@ pub fn foundry_local_asr_set_model( ) -> Result<(), String> { validate_foundry_model_alias(&model_alias)?; let mut prefs = coord.prefs().get(); + if prefs.foundry_local_asr_model == model_alias { + return Ok(()); + } prefs.foundry_local_asr_model = model_alias; coord.prefs().set(prefs).map_err(|e| e.to_string()) } @@ -2314,6 +2395,9 @@ pub fn foundry_local_asr_set_language_hint( ) -> Result<(), String> { let normalized = normalize_foundry_language_hint(&language_hint)?; let mut prefs = coord.prefs().get(); + if prefs.foundry_local_asr_language_hint == normalized { + return Ok(()); + } prefs.foundry_local_asr_language_hint = normalized; coord.prefs().set(prefs).map_err(|e| e.to_string()) } @@ -2324,7 +2408,11 @@ pub fn foundry_local_asr_set_runtime_source( source: String, ) -> Result<(), String> { let mut prefs = coord.prefs().get(); - prefs.foundry_local_runtime_source = normalize_foundry_runtime_source(&source); + let normalized = normalize_foundry_runtime_source(&source); + if prefs.foundry_local_runtime_source == normalized { + return Ok(()); + } + prefs.foundry_local_runtime_source = normalized; coord.prefs().set(prefs).map_err(|e| e.to_string()) } @@ -2382,6 +2470,237 @@ fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgres } } +// ───────────────────── Windows local ASR (sherpa-onnx-local, M1 骨架) ───────────────────── +// +// 命令形态与 Foundry 同形,让前端命令封装可以复用同一种 hook 模式;M1 阶段 +// 不做下载 / 不接 sherpa-onnx crate / 不做实际推理,详见 +// `docs/windows-sherpa-onnx-asr-plan.md`。 + +fn active_sherpa_model_from_prefs(prefs: &UserPreferences) -> String { + if sherpa_model_alias_is_known(&prefs.sherpa_onnx_model) { + prefs.sherpa_onnx_model.clone() + } else { + SHERPA_DEFAULT_MODEL_ALIAS.to_string() + } +} + +fn validate_sherpa_model_alias(model_alias: &str) -> Result<(), String> { + if sherpa_model_alias_is_known(model_alias) { + Ok(()) + } else { + Err(format!("unknown sherpa-onnx model alias: {model_alias}")) + } +} + +fn normalize_sherpa_language_hint(language_hint: &str) -> Result { + let normalized = language_hint.trim().to_lowercase(); + if normalized.is_empty() + || normalized + .chars() + .all(|c| c.is_ascii_lowercase() || c == '-') + { + Ok(normalized) + } else { + Err("language hint must be empty or BCP-47 lowercase code".to_string()) + } +} + +#[tauri::command] +pub async fn sherpa_onnx_asr_status( + coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, +) -> Result { + let prefs = coord.prefs().get(); + let active_model = active_sherpa_model_from_prefs(&prefs); + Ok(runtime.status_snapshot(&active_model).await) +} + +#[tauri::command] +pub async fn sherpa_onnx_asr_catalog( + runtime: State<'_, Arc>, +) -> Result, String> { + runtime + .catalog_snapshot() + .await + .map_err(|e| format!("{e:#}")) +} + +#[tauri::command] +pub async fn sherpa_onnx_asr_fetch_remote_info( + model_alias: String, + mirror: Option, +) -> Result { + validate_sherpa_model_alias(&model_alias)?; + let mirror = mirror.as_deref().map(Mirror::from_str).unwrap_or_default(); + fetch_sherpa_remote_info(&model_alias, mirror) + .await + .map_err(|e| format!("{e:#}")) +} + +#[tauri::command] +pub fn sherpa_onnx_asr_download_model( + app: AppHandle, + manager: State<'_, Arc>, + model_alias: String, + mirror: Option, +) -> Result<(), String> { + validate_sherpa_model_alias(&model_alias)?; + let mirror = mirror.as_deref().map(Mirror::from_str).unwrap_or_default(); + manager.start(app, model_alias, mirror); + Ok(()) +} + +#[tauri::command] +pub fn sherpa_onnx_asr_cancel_download( + manager: State<'_, Arc>, + model_alias: String, +) -> Result<(), String> { + validate_sherpa_model_alias(&model_alias)?; + manager.cancel(&model_alias); + Ok(()) +} + +#[tauri::command] +pub fn sherpa_onnx_asr_set_model( + coord: CoordinatorState<'_>, + model_alias: String, +) -> Result<(), String> { + validate_sherpa_model_alias(&model_alias)?; + let mut prefs = coord.prefs().get(); + if prefs.sherpa_onnx_model == model_alias { + return Ok(()); + } + prefs.sherpa_onnx_model = model_alias; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn sherpa_onnx_asr_set_language_hint( + coord: CoordinatorState<'_>, + language_hint: String, +) -> Result<(), String> { + let normalized = normalize_sherpa_language_hint(&language_hint)?; + let mut prefs = coord.prefs().get(); + if prefs.sherpa_onnx_language_hint == normalized { + return Ok(()); + } + prefs.sherpa_onnx_language_hint = normalized; + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn sherpa_onnx_asr_prepare( + app: AppHandle, + runtime: State<'_, Arc>, + model_alias: String, +) -> Result { + validate_sherpa_model_alias(&model_alias)?; + let progress_app = app.clone(); + let result = runtime + .ensure_loaded_with_progress(&model_alias, move |payload| { + emit_sherpa_prepare_progress(&progress_app, payload); + }) + .await; + match result { + Ok(loaded) => Ok(loaded), + Err(error) => { + let message = format!("{error:#}"); + emit_sherpa_prepare_progress( + &app, + SherpaPrepareProgressPayload::failed( + model_alias, + "sherpa-onnx prepare failed", + message.clone(), + ), + ); + Err(message) + } + } +} + +#[tauri::command] +pub fn sherpa_onnx_asr_cancel_prepare( + runtime: State<'_, Arc>, +) -> Result<(), String> { + runtime.request_cancel_prepare(); + Ok(()) +} + +#[tauri::command] +pub async fn sherpa_onnx_asr_release( + runtime: State<'_, Arc>, +) -> Result<(), String> { + runtime.release_now().await.map_err(|e| format!("{e:#}")) +} + +#[tauri::command] +pub fn sherpa_onnx_asr_model_dir(model_alias: String) -> Result { + validate_sherpa_model_alias(&model_alias)?; + SherpaOnnxRuntime::model_dir_for_alias(&model_alias) + .map(|path| path.display().to_string()) + .map_err(|e| format!("{e:#}")) +} + +#[tauri::command] +pub async fn sherpa_onnx_asr_delete_model( + runtime: State<'_, Arc>, + model_alias: String, +) -> Result<(), String> { + validate_sherpa_model_alias(&model_alias)?; + runtime + .delete_model(&model_alias) + .await + .map_err(|e| format!("{e:#}")) +} + +#[tauri::command] +pub fn sherpa_onnx_asr_reveal_model_dir(model_alias: String) -> Result<(), String> { + validate_sherpa_model_alias(&model_alias)?; + let dir = SherpaOnnxRuntime::model_dir_for_alias(&model_alias).map_err(|e| format!("{e:#}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("create {} failed: {e}", dir.display()))?; + open_path_in_file_manager(&dir) +} + +#[cfg(target_os = "windows")] +fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { + use windows::core::PCWSTR; + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + let operation = wide_null("open"); + let target = wide_null(&path.display().to_string()); + let result = unsafe { + ShellExecuteW( + None, + PCWSTR(operation.as_ptr()), + PCWSTR(target.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + SW_SHOWNORMAL, + ) + }; + if result.0 as isize <= 32 { + Err(format!("ShellExecuteW failed: {}", result.0 as isize)) + } else { + Ok(()) + } +} + +#[cfg(not(target_os = "windows"))] +fn open_path_in_file_manager(_path: &std::path::Path) -> Result<(), String> { + Err("sherpa-onnx model directory is only supported on Windows".to_string()) +} + +fn emit_sherpa_prepare_progress(app: &AppHandle, payload: SherpaPrepareProgressPayload) { + if let Err(error) = app.emit("sherpa-onnx-asr-prepare-progress", payload) { + log::warn!("[sherpa-asr] emit prepare progress failed: {error}"); + } +} + /// 把当前会话的 openless.log 复制到用户选择的位置(前端用 plugin-dialog 拿 target_path)。 /// 路径来自 lib::log_dir_path() —— mac: ~/Library/Logs/OpenLess/openless.log, /// windows: %LOCALAPPDATA%\OpenLess\Logs\openless.log。 @@ -2956,6 +3275,7 @@ mod tests { llm_configured_for_provider, local_asr_release_plan_for_provider, models_url, normalize_foundry_language_hint, parse_gemini_model_ids, parse_latest_beta_from_atom, parse_model_ids, persist_settings, release_foundry_runtime_if_inactive, + release_sherpa_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; @@ -2973,6 +3293,9 @@ mod tests { dictation_refreshes: Mutex, qa_refreshes: Mutex, combo_refreshes: Mutex, + translation_refreshes: Mutex, + switch_style_refreshes: Mutex, + open_app_refreshes: Mutex, } fn snapshot() -> CredentialsSnapshot { @@ -3022,11 +3345,21 @@ mod tests { crate::asr::local::foundry::PROVIDER_ID, &snapshot() )); + #[cfg(target_os = "windows")] + assert!(asr_configured_for_provider( + crate::asr::local::sherpa::PROVIDER_ID, + &snapshot() + )); #[cfg(not(target_os = "windows"))] assert!(!asr_configured_for_provider( crate::asr::local::foundry::PROVIDER_ID, &snapshot() )); + #[cfg(not(target_os = "windows"))] + assert!(!asr_configured_for_provider( + crate::asr::local::sherpa::PROVIDER_ID, + &snapshot() + )); } #[test] @@ -3056,10 +3389,18 @@ mod tests { assert!(active_asr_is_keyless_for_validation( crate::asr::local::foundry::PROVIDER_ID )); + #[cfg(target_os = "windows")] + assert!(active_asr_is_keyless_for_validation( + crate::asr::local::sherpa::PROVIDER_ID + )); #[cfg(not(target_os = "windows"))] assert!(!active_asr_is_keyless_for_validation( crate::asr::local::foundry::PROVIDER_ID )); + #[cfg(not(target_os = "windows"))] + assert!(!active_asr_is_keyless_for_validation( + crate::asr::local::sherpa::PROVIDER_ID + )); assert!(!active_asr_is_keyless_for_validation("volcengine")); assert!(!active_asr_is_keyless_for_validation("whisper")); } @@ -3069,14 +3410,22 @@ mod tests { let qwen = local_asr_release_plan_for_provider(crate::asr::local::PROVIDER_ID); assert!(!qwen.qwen); assert!(qwen.foundry); + assert!(qwen.sherpa); let foundry = local_asr_release_plan_for_provider(crate::asr::local::foundry::PROVIDER_ID); assert!(foundry.qwen); assert!(!foundry.foundry); + assert!(foundry.sherpa); + + let sherpa = local_asr_release_plan_for_provider(crate::asr::local::sherpa::PROVIDER_ID); + assert!(sherpa.qwen); + assert!(sherpa.foundry); + assert!(!sherpa.sherpa); let cloud = local_asr_release_plan_for_provider("volcengine"); assert!(cloud.qwen); assert!(cloud.foundry); + assert!(cloud.sherpa); } #[cfg(target_os = "windows")] @@ -3089,6 +3438,17 @@ mod tests { assert!(runtime.cancel_prepare_requested_for_tests()); } + #[tokio::test] + async fn provider_switch_release_requests_sherpa_prepare_cancel_first() { + let runtime = std::sync::Arc::new(crate::asr::local::SherpaOnnxRuntime::new()); + + release_sherpa_runtime_if_inactive(&runtime, true).await; + + assert!(runtime.cancel_prepare_requested_for_tests()); + let status = runtime.status_snapshot("sense-voice-small-zh").await; + assert!(!status.runtime_ready); + } + #[test] fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() { assert_eq!(normalize_foundry_language_hint("").unwrap(), ""); @@ -3192,6 +3552,10 @@ mod tests { } impl SettingsWriter for FakeSettingsWriter { + fn read_settings(&self) -> UserPreferences { + self.saved.lock().unwrap().clone().unwrap_or_default() + } + fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { *self.saved.lock().unwrap() = Some(prefs); Ok(()) @@ -3209,9 +3573,17 @@ mod tests { *self.combo_refreshes.lock().unwrap() += 1; } - fn refresh_translation_hotkey(&self) {} - fn refresh_switch_style_hotkey(&self) {} - fn refresh_open_app_hotkey(&self) {} + fn refresh_translation_hotkey(&self) { + *self.translation_refreshes.lock().unwrap() += 1; + } + + fn refresh_switch_style_hotkey(&self) { + *self.switch_style_refreshes.lock().unwrap() += 1; + } + + fn refresh_open_app_hotkey(&self) { + *self.open_app_refreshes.lock().unwrap() += 1; + } } #[test] @@ -3320,18 +3692,36 @@ mod tests { } #[test] - fn persist_settings_refreshes_both_hotkey_pipelines() { + fn persist_settings_refreshes_changed_hotkey_pipelines() { let writer = FakeSettingsWriter::default(); + let previous = UserPreferences::default(); + *writer.saved.lock().unwrap() = Some(previous); let prefs = UserPreferences { - hotkey: HotkeyBinding { - trigger: HotkeyTrigger::RightControl, - mode: HotkeyMode::Toggle, - ..Default::default() + dictation_hotkey: ShortcutBinding { + primary: "D".to_string(), + modifiers: vec!["ctrl".to_string()], }, qa_hotkey: Some(ShortcutBinding { - primary: ";".to_string(), - modifiers: vec!["ctrl".to_string(), "shift".to_string()], + primary: "Q".to_string(), + modifiers: vec!["ctrl".to_string(), "alt".to_string()], }), + translation_hotkey: ShortcutBinding { + primary: "T".to_string(), + modifiers: vec!["ctrl".to_string(), "alt".to_string()], + }, + switch_style_hotkey: ShortcutBinding { + primary: "S".to_string(), + modifiers: vec!["ctrl".to_string(), "alt".to_string()], + }, + open_app_hotkey: ShortcutBinding { + primary: "O".to_string(), + modifiers: vec!["ctrl".to_string(), "alt".to_string()], + }, + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::Custom, + mode: HotkeyMode::Hold, + ..Default::default() + }, ..Default::default() }; @@ -3343,15 +3733,54 @@ mod tests { .unwrap() .clone() .expect("settings saved"); - assert_eq!(saved.hotkey.trigger, HotkeyTrigger::RightOption); + assert_eq!(saved.hotkey.trigger, HotkeyTrigger::Custom); assert_eq!(saved.hotkey.mode, prefs.hotkey.mode); assert_eq!( - saved.qa_hotkey.unwrap().primary, - prefs.qa_hotkey.unwrap().primary + saved.dictation_hotkey.primary, + prefs.dictation_hotkey.primary ); + assert_eq!(saved.qa_hotkey.unwrap().primary, "Q"); assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 1); - assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1); assert_eq!(*writer.combo_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.translation_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 1); + } + + #[test] + fn persist_settings_skips_hotkey_refresh_when_shortcuts_unchanged() { + let writer = FakeSettingsWriter::default(); + let previous = UserPreferences::default(); + *writer.saved.lock().unwrap() = Some(previous.clone()); + let prefs = UserPreferences { + active_asr_provider: "whisper".to_string(), + microphone_device_name: "External Mic".to_string(), + hotkey: previous.hotkey, + dictation_hotkey: previous.dictation_hotkey, + qa_hotkey: previous.qa_hotkey, + translation_hotkey: previous.translation_hotkey, + switch_style_hotkey: previous.switch_style_hotkey, + open_app_hotkey: previous.open_app_hotkey, + ..Default::default() + }; + + persist_settings(&writer, prefs.clone()).unwrap(); + + let saved = writer + .saved + .lock() + .unwrap() + .clone() + .expect("settings saved"); + assert_eq!(saved.active_asr_provider, prefs.active_asr_provider); + assert_eq!(saved.microphone_device_name, prefs.microphone_device_name); + assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.qa_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0); } #[test] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 5b5c81d4..a9319f94 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -17,7 +17,9 @@ use tauri::{async_runtime, AppHandle, Emitter, Manager}; use uuid::Uuid; #[cfg(target_os = "windows")] -use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr}; +use crate::asr::local::{ + foundry, sherpa, FoundryLocalRuntime, FoundryLocalWhisperAsr, SherpaOnnxAsr, SherpaOnnxRuntime, +}; use crate::asr::{ BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, @@ -140,6 +142,10 @@ enum ActiveAsr { Bailian(Arc), #[cfg(target_os = "windows")] FoundryLocalWhisper(Arc), + /// Windows sherpa-onnx 本地 ASR(M1 骨架,详见 + /// `docs/windows-sherpa-onnx-asr-plan.md`)。 + #[cfg(target_os = "windows")] + SherpaOnnxLocal(Arc), /// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。 #[cfg(target_os = "macos")] Local(Arc), @@ -149,6 +155,10 @@ fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool { match asr { #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(_) => false, + // sherpa-onnx 首次加载 / 下载 / 推理的耗时类似 Foundry,不走 + // COORDINATOR_GLOBAL_TIMEOUT;各 provider 自己里面控制細粒度超时。 + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(_) => false, _ => true, } } @@ -176,6 +186,11 @@ struct Inner { local_asr_cache: Arc, #[cfg(target_os = "windows")] foundry_local_runtime: Arc, + /// Windows sherpa-onnx 本地 ASR runtime(M1 骨架)。与 Foundry 同处一个 + /// 位置、同一 lifecycle 语义;上层通过 `ActiveAsr::SherpaOnnxLocal` 后只调 + /// runtime,不会跨模块调。 + #[cfg(target_os = "windows")] + sherpa_onnx_runtime: Arc, recorder: Mutex>>, /// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。 /// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取, @@ -243,7 +258,10 @@ impl Coordinator { pub fn new() -> Self { #[cfg(target_os = "windows")] { - Self::new_with_foundry_runtime(Arc::new(FoundryLocalRuntime::new())) + Self::new_with_local_runtimes( + Arc::new(FoundryLocalRuntime::new()), + Arc::new(SherpaOnnxRuntime::new()), + ) } #[cfg(not(target_os = "windows"))] @@ -294,8 +312,19 @@ impl Coordinator { } } + /// 保留旧构造函数:现有调用点(含单元测试)只传 Foundry runtime, + /// sherpa-onnx runtime 采用默认骨架实例。入产后(lib.rs)请走 + /// `new_with_local_runtimes`,确保 Tauri State 共享同一个 Arc。 #[cfg(target_os = "windows")] pub fn new_with_foundry_runtime(foundry_local_runtime: Arc) -> Self { + Self::new_with_local_runtimes(foundry_local_runtime, Arc::new(SherpaOnnxRuntime::new())) + } + + #[cfg(target_os = "windows")] + pub fn new_with_local_runtimes( + foundry_local_runtime: Arc, + sherpa_onnx_runtime: Arc, + ) -> Self { let history = HistoryStore::new().unwrap_or_else(|e| { log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); HistoryStore::new().expect("history store init") @@ -339,6 +368,7 @@ impl Coordinator { qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), foundry_local_runtime, + sherpa_onnx_runtime, shutdown: AtomicBool::new(false), }), } @@ -2208,6 +2238,17 @@ fn ensure_asr_credentials() -> Result<(), String> { } } + if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) { + #[cfg(not(target_os = "windows"))] + { + return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string()); + } + #[cfg(target_os = "windows")] + { + return Ok(()); + } + } + if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() @@ -2235,6 +2276,7 @@ fn is_keyless_local_asr_provider(id: &str) -> bool { #[cfg(target_os = "windows")] { crate::asr::local::foundry::is_foundry_local_whisper(id) + || crate::asr::local::sherpa::is_sherpa_onnx_local(id) } #[cfg(not(target_os = "windows"))] { @@ -2307,6 +2349,32 @@ fn schedule_foundry_local_asr_release(inner: &Arc, session_id: SessionId) }); } +#[cfg(target_os = "windows")] +fn sherpa_onnx_release_keep_secs(inner: &Arc) -> u32 { + inner.prefs.get().sherpa_onnx_keep_loaded_secs +} + +/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放, +/// 避免下一轮 session 重加载同一个模型。M1 阶段 runtime 是骨架,`release_now` +/// 只清 alias state,不会报错。 +#[cfg(target_os = "windows")] +fn schedule_sherpa_onnx_release(inner: &Arc, session_id: SessionId) { + let keep_secs = sherpa_onnx_release_keep_secs(inner); + let runtime = Arc::clone(&inner.sherpa_onnx_runtime); + let inner = Arc::clone(inner); + tauri::async_runtime::spawn(async move { + if keep_secs > 0 { + tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; + } + if !foundry_release_session_is_current(&inner, session_id) { + return; + } + if let Err(error) = runtime.release_now().await { + log::warn!("[sherpa-asr] scheduled release failed: {error:#}"); + } + }); +} + #[cfg(target_os = "macos")] async fn build_local_qwen3( inner: &Arc, @@ -3442,18 +3510,29 @@ mod tests { } #[test] - fn foundry_local_provider_is_keyless_and_not_whisper_compatible() { + fn windows_local_providers_are_keyless_and_not_whisper_compatible() { #[cfg(target_os = "windows")] assert!(is_keyless_local_asr_provider( crate::asr::local::foundry::PROVIDER_ID )); + #[cfg(target_os = "windows")] + assert!(is_keyless_local_asr_provider( + crate::asr::local::sherpa::PROVIDER_ID + )); #[cfg(not(target_os = "windows"))] assert!(!is_keyless_local_asr_provider( crate::asr::local::foundry::PROVIDER_ID )); + #[cfg(not(target_os = "windows"))] + assert!(!is_keyless_local_asr_provider( + crate::asr::local::sherpa::PROVIDER_ID + )); assert!(!is_whisper_compatible_provider( crate::asr::local::foundry::PROVIDER_ID )); + assert!(!is_whisper_compatible_provider( + crate::asr::local::sherpa::PROVIDER_ID + )); } #[cfg(target_os = "windows")] @@ -4060,6 +4139,13 @@ fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration { std::time::Duration::from_secs(secs) } +/// sherpa-onnx M1 阶段超时与 Foundry 同档。M2 接入真实推理后视 CPU 模型 +/// 实际耗时再调(中文 SenseVoice small int8 在 4 核 CPU 上一般 < 3s/30s 音频)。 +#[cfg(target_os = "windows")] +fn sherpa_audio_transcribe_timeout_duration() -> std::time::Duration { + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) +} + /// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 /// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 /// 「准备做下一步副作用前」用。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 65c45b0c..b25ecfca 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -622,6 +622,39 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { return Ok(()); } + // Windows sherpa-onnx-local(M1 骨架):与 Foundry 同形分支,复用 Recorder / + // ActiveAsr / start_recorder_and_enter_listening。runtime/transcribe 都是 + // 桩——M1 这里只验证主链路能跑到 sherpa 这条路径;真实推理见 M2。 + #[cfg(target_os = "windows")] + if sherpa::is_sherpa_onnx_local(&active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) { + prefs.sherpa_onnx_model.clone() + } else { + sherpa::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let local = Arc::new(SherpaOnnxAsr::new( + Arc::clone(&inner.sherpa_onnx_runtime), + model_alias, + language_hint, + )); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + #[cfg(target_os = "macos")] if crate::asr::local::is_local_qwen3(&active_asr) { let local = match build_local_qwen3(inner).await { @@ -1249,6 +1282,47 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } } } + // Windows sherpa-onnx(M1 骨架):transcribe 当前返回空 RawTranscript, + // 上层 empty-transcript guard 会写 emptyTranscript 历史并显示错误胶囊。 + // M2 接入推理后这里的行为就跟 Foundry 完全一致。 + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_sherpa_onnx_release(inner, current_session_id); + r + } + Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] sherpa-onnx transcribe cancelled — discarding transcript" + ); + schedule_sherpa_onnx_release(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + log::error!("[coord] sherpa-onnx transcribe failed: {e:#}"); + schedule_sherpa_onnx_release(inner, current_session_id); + 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()); + } + } + } #[cfg(target_os = "macos")] ActiveAsr::Local(local) => { debug_assert!(uses_global_timeout); diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index ec649d6b..b92299ea 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -70,6 +70,8 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) { ActiveAsr::Bailian(b) => b.cancel(), #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => local.cancel(), + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => local.cancel(), #[cfg(target_os = "macos")] ActiveAsr::Local(local) => local.cancel(), } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index d269738b..1b2c98ec 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -61,9 +61,13 @@ use crate::types::PolishMode; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); + let sherpa_onnx_runtime = Arc::new(asr::local::SherpaOnnxRuntime::new()); + let sherpa_download_manager = + Arc::new(asr::local::sherpa_download::SherpaDownloadManager::new()); #[cfg(target_os = "windows")] - let coordinator = Arc::new(coordinator::Coordinator::new_with_foundry_runtime( + let coordinator = Arc::new(coordinator::Coordinator::new_with_local_runtimes( Arc::clone(&foundry_local_runtime), + Arc::clone(&sherpa_onnx_runtime), )); #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); @@ -116,7 +120,9 @@ pub fn run() { )) .manage(coordinator.clone()) .manage(local_asr_download_manager.clone()) + .manage(sherpa_download_manager.clone()) .manage(foundry_local_runtime.clone()) + .manage(sherpa_onnx_runtime.clone()) .manage(commands::MicrophoneMonitorState::new(None)) .manage(commands::TrayMicrophoneMenuState::new(Vec::new())) .setup(move |app| { @@ -375,6 +381,32 @@ pub fn run() { commands::foundry_local_asr_prepare, commands::foundry_local_asr_cancel_prepare, commands::foundry_local_asr_release, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_status, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_catalog, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_fetch_remote_info, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_download_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_cancel_download, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_set_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_set_language_hint, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_prepare, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_cancel_prepare, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_release, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_model_dir, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_delete_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_reveal_model_dir, commands::export_error_log, restart_app, ]) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index cd0131a4..f7459c68 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -239,6 +239,13 @@ pub fn foundry_native_runtime_root() -> Result { Ok(dir) } +#[cfg(target_os = "windows")] +pub fn sherpa_onnx_models_root() -> Result { + let dir = data_dir()?.join("models").join("sherpa-onnx"); + ensure_dir(&dir)?; + Ok(dir) +} + #[cfg(target_os = "windows")] pub fn foundry_model_cache_root() -> Result { let dir = foundry_local_root()?; diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index 28635587..d5fd3f5e 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -175,6 +175,9 @@ mod tests { }; let parsed = parse_binding(&binding).expect("letter binding parses"); assert_eq!(parsed.key, Code::KeyK); + #[cfg(target_os = "windows")] + assert!(parsed.mods.contains(Modifiers::CONTROL)); + #[cfg(not(target_os = "windows"))] assert!(parsed.mods.contains(Modifiers::SUPER)); assert!(parsed.mods.contains(Modifiers::ALT)); } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 0feb78de..3cd5c3c5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -613,6 +613,17 @@ pub struct UserPreferences { /// Windows Foundry Local Whisper 模型在 runtime 中保持加载多久。 #[serde(default = "default_local_asr_keep_loaded_secs")] pub foundry_local_asr_keep_loaded_secs: u32, + /// Windows sherpa-onnx 本地 ASR(M1 实验 provider,详见 + /// `docs/windows-sherpa-onnx-asr-plan.md`)当前激活的模型 alias。 + #[serde(default = "default_sherpa_onnx_model")] + pub sherpa_onnx_model: String, + /// Windows sherpa-onnx 语言 hint(BCP-47 / ISO 639-1 小写)。空 = 自动。 + #[serde(default)] + pub sherpa_onnx_language_hint: String, + /// Windows sherpa-onnx 模型在 runtime 中保持加载多久(秒),语义与 + /// foundry/qwen3 一致。 + #[serde(default = "default_local_asr_keep_loaded_secs")] + pub sherpa_onnx_keep_loaded_secs: u32, /// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多 /// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 #[serde(default)] @@ -717,6 +728,10 @@ fn default_foundry_local_runtime_source() -> String { "auto".into() } +fn default_sherpa_onnx_model() -> String { + crate::asr::local::sherpa::DEFAULT_MODEL_ALIAS.into() +} + fn default_active_asr_provider() -> String { #[cfg(target_os = "windows")] { @@ -780,6 +795,12 @@ struct UserPreferencesWire { foundry_local_asr_language_hint: String, #[serde(default = "default_local_asr_keep_loaded_secs")] foundry_local_asr_keep_loaded_secs: u32, + #[serde(default = "default_sherpa_onnx_model")] + sherpa_onnx_model: String, + #[serde(default)] + sherpa_onnx_language_hint: String, + #[serde(default = "default_local_asr_keep_loaded_secs")] + sherpa_onnx_keep_loaded_secs: u32, #[serde(default)] update_channel: UpdateChannel, #[serde(default = "default_history_retention_days")] @@ -846,6 +867,9 @@ impl Default for UserPreferencesWire { foundry_local_runtime_source: prefs.foundry_local_runtime_source, foundry_local_asr_language_hint: prefs.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: prefs.foundry_local_asr_keep_loaded_secs, + sherpa_onnx_model: prefs.sherpa_onnx_model, + sherpa_onnx_language_hint: prefs.sherpa_onnx_language_hint, + sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs, update_channel: prefs.update_channel, history_retention_days: prefs.history_retention_days, polish_context_window_minutes: prefs.polish_context_window_minutes, @@ -928,6 +952,9 @@ impl<'de> Deserialize<'de> for UserPreferences { ), foundry_local_asr_language_hint: wire.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: wire.foundry_local_asr_keep_loaded_secs, + sherpa_onnx_model: wire.sherpa_onnx_model, + sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint, + sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs, update_channel: wire.update_channel, history_retention_days: wire.history_retention_days, polish_context_window_minutes: wire.polish_context_window_minutes, @@ -1608,6 +1635,9 @@ impl Default for UserPreferences { foundry_local_runtime_source: default_foundry_local_runtime_source(), foundry_local_asr_language_hint: String::new(), foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), + sherpa_onnx_model: default_sherpa_onnx_model(), + sherpa_onnx_language_hint: String::new(), + sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(), update_channel: UpdateChannel::default(), history_retention_days: default_history_retention_days(), polish_context_window_minutes: default_polish_context_window_minutes(), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e289bd4b..70cad95b 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -601,6 +601,7 @@ export const en: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', + asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)', asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', asrLocalQwen3: 'Local Qwen3-ASR', }, @@ -722,6 +723,7 @@ export const en: typeof zhCN = { localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.', localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.', qwen3Desc: 'Once enabled, the ASR provider will be taken over.', + sherpaDesc: 'Once enabled, the ASR provider will be taken over.', foundryDesc: 'Once enabled, the ASR provider will be taken over.', notSupportedHere: 'Not supported on this platform — no inference module bundled.', enable: 'Enable', @@ -944,6 +946,28 @@ export const en: typeof zhCN = { foundryModelBaseDesc: 'Faster with lower resource use for lightweight daily dictation.', foundryModelTiny: 'Whisper Tiny (fastest / smoke test)', foundryModelTinyDesc: 'Fastest check option for confirming the Foundry path works.', + sherpaTitle: 'Windows sherpa-onnx Local (experimental)', + sherpaDesc: 'Windows uses sherpa-onnx for offline batch recognition on this device with no ASR API key.', + sherpaRuntimeReady: 'Model loaded', + sherpaRuntimeMissing: 'Model not loaded', + sherpaSetDefault: 'Set default / Enable sherpa-onnx', + sherpaPrepare: 'Check local files / Load', + sherpaPreparing: 'Loading…', + sherpaPrepareLocalFiles: 'Check local model files', + sherpaModelDir: 'Model directory', + sherpaRevealDir: 'Open model directory', + sherpaError: 'sherpa-onnx status', + sherpaLanguageJa: 'Japanese ja', + sherpaLanguageKo: 'Korean ko', + sherpaLanguageYue: 'Cantonese yue', + sherpaModelSenseVoice: 'SenseVoice Small (default / Chinese-first)', + sherpaModelSenseVoiceDesc: 'Default experimental model for Chinese and mixed Chinese-English dictation.', + sherpaModelParaformer: 'Paraformer Chinese', + sherpaModelParaformerDesc: 'Chinese-focused experimental model.', + sherpaModelWhisper: 'Whisper Small multilingual', + sherpaModelWhisperDesc: 'Multilingual experimental fallback aligned with Whisper-family behavior.', + sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', + sherpaModelQwen3Desc: 'Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.', 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)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index db4ac6c2..2d357eb1 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -603,6 +603,7 @@ export const ja: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(互換)', + asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)', asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)', asrLocalQwen3: 'ローカル Qwen3-ASR', }, @@ -724,6 +725,7 @@ export const ja: typeof zhCN = { localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。', localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。', qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。', + sherpaDesc: '有効化すると ASR プロバイダーが引き継がれます。', foundryDesc: '有効化すると ASR プロバイダーが引き継がれます。', notSupportedHere: 'このプラットフォームでは未対応(推論モジュール未組込)。', enable: '有効化', @@ -946,6 +948,28 @@ export const ja: typeof zhCN = { foundryModelBaseDesc: 'より高速でリソース消費が少なく、日常の軽量ディクテーションに適しています。', foundryModelTiny: 'Whisper Tiny(最速 / スモークテスト)', foundryModelTinyDesc: 'Foundry 経路が動作するか確認するための最速オプション。', + sherpaTitle: 'Windows sherpa-onnx Local(実験的)', + sherpaDesc: 'Windows では sherpa-onnx によるデバイス上のオフライン一括認識を使用します。ASR API キーは不要です。', + sherpaRuntimeReady: 'モデル読み込み済み', + sherpaRuntimeMissing: 'モデル未読み込み', + sherpaSetDefault: '既定に設定 / sherpa-onnx を有効化', + sherpaPrepare: 'ローカルファイルを確認 / 読み込み', + sherpaPreparing: '読み込み中…', + sherpaPrepareLocalFiles: 'ローカルモデルファイルを確認', + sherpaModelDir: 'モデルディレクトリ', + sherpaRevealDir: 'モデルディレクトリを開く', + sherpaError: 'sherpa-onnx 状態', + sherpaLanguageJa: '日本語 ja', + sherpaLanguageKo: '韓国語 ko', + sherpaLanguageYue: '広東語 yue', + sherpaModelSenseVoice: 'SenseVoice Small(既定 / 中国語優先)', + sherpaModelSenseVoiceDesc: '中国語および中英混在ディクテーション向けの既定実験モデル。', + sherpaModelParaformer: 'Paraformer 中国語', + sherpaModelParaformerDesc: '中国語向けの実験モデル。', + sherpaModelWhisper: 'Whisper Small 多言語', + sherpaModelWhisperDesc: 'Whisper 系列の挙動に合わせた多言語実験フォールバックモデル。', + sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', + sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。', mirrorLabel: 'ダウンロードミラー', mirrorDesc: '公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。', mirrorHuggingface: 'HuggingFace 公式 (huggingface.co)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f6558daf..95a8b637 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -603,6 +603,7 @@ export const ko: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(호환)', + asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)', asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)', asrLocalQwen3: '로컬 Qwen3-ASR', }, @@ -724,6 +725,7 @@ export const ko: typeof zhCN = { localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.', localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.', qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.', + sherpaDesc: '활성화하면 ASR 프로바이더가 인수됩니다.', foundryDesc: '활성화하면 ASR 프로바이더가 인수됩니다.', notSupportedHere: '이 플랫폼에서는 미지원 (추론 모듈 미내장).', enable: '활성화', @@ -946,6 +948,28 @@ export const ko: typeof zhCN = { foundryModelBaseDesc: '더 빠르고 리소스를 적게 사용해 가벼운 일상 받아쓰기에 적합합니다.', foundryModelTiny: 'Whisper Tiny(가장 빠름 / 스모크 테스트)', foundryModelTinyDesc: 'Foundry 경로가 작동하는지 확인하기 위한 가장 빠른 옵션.', + sherpaTitle: 'Windows sherpa-onnx Local(실험적)', + sherpaDesc: 'Windows는 sherpa-onnx로 기기 내 오프라인 일괄 인식을 수행하며 ASR API 키가 필요 없습니다.', + sherpaRuntimeReady: '모델 로드됨', + sherpaRuntimeMissing: '모델 로드되지 않음', + sherpaSetDefault: '기본값으로 설정 / sherpa-onnx 활성화', + sherpaPrepare: '로컬 파일 확인 / 로드', + sherpaPreparing: '로드 중…', + sherpaPrepareLocalFiles: '로컬 모델 파일 확인', + sherpaModelDir: '모델 디렉터리', + sherpaRevealDir: '모델 디렉터리 열기', + sherpaError: 'sherpa-onnx 상태', + sherpaLanguageJa: '일본어 ja', + sherpaLanguageKo: '한국어 ko', + sherpaLanguageYue: '광둥어 yue', + sherpaModelSenseVoice: 'SenseVoice Small(기본 / 중국어 우선)', + sherpaModelSenseVoiceDesc: '중국어 및 중영 혼합 받아쓰기에 적합한 기본 실험 모델.', + sherpaModelParaformer: 'Paraformer 중국어', + sherpaModelParaformerDesc: '중국어 중심 실험 모델.', + sherpaModelWhisper: 'Whisper Small 다국어', + sherpaModelWhisperDesc: 'Whisper 계열 동작에 맞춘 다국어 실험 폴백 모델.', + sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', + sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.', mirrorLabel: '다운로드 미러', mirrorDesc: '공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.', mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f229ae97..9bbfc394 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -599,6 +599,7 @@ export const zhCN = { asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', }, @@ -720,6 +721,7 @@ export const zhCN = { localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。', localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。', qwen3Desc: '启动之后,ASR 提供商将被接管。', + sherpaDesc: '启动之后,ASR 提供商将被接管。', foundryDesc: '启动之后,ASR 提供商将被接管。', notSupportedHere: '本平台暂不支持,未集成推理模块。', enable: '启用', @@ -942,6 +944,28 @@ export const zhCN = { foundryModelBaseDesc: '更快、资源占用更低,适合日常轻量使用。', foundryModelTiny: 'Whisper Tiny(最快 / 冒烟测试)', foundryModelTinyDesc: '最快的检查选项,适合确认 Foundry 路径可用。', + sherpaTitle: 'Windows sherpa-onnx Local(实验性)', + sherpaDesc: 'Windows 使用 sherpa-onnx 在本机离线批量识别,无需 ASR API Key。', + sherpaRuntimeReady: '模型已加载', + sherpaRuntimeMissing: '模型未加载', + sherpaSetDefault: '设为默认 / 启用 sherpa-onnx', + sherpaPrepare: '检查本地文件 / 加载', + sherpaPreparing: '加载中…', + sherpaPrepareLocalFiles: '检查本地模型文件', + sherpaModelDir: '模型目录', + sherpaRevealDir: '打开模型目录', + sherpaError: 'sherpa-onnx 状态', + sherpaLanguageJa: '日语 ja', + sherpaLanguageKo: '韩语 ko', + sherpaLanguageYue: '粤语 yue', + sherpaModelSenseVoice: 'SenseVoice Small(默认 / 中文优先)', + sherpaModelSenseVoiceDesc: '默认实验模型,适合中文与中英混合听写。', + sherpaModelParaformer: 'Paraformer 中文', + sherpaModelParaformerDesc: '面向中文的实验模型。', + sherpaModelWhisper: 'Whisper Small 多语言', + sherpaModelWhisperDesc: '与 Whisper 系列行为一致的多语言实验兜底模型。', + sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', + sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。', mirrorLabel: '下载镜像源', mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 09ba0177..c2be5bad 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -601,6 +601,7 @@ export const zhTW: typeof zhCN = { asrZhipu: '智譜 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', }, @@ -722,6 +723,7 @@ export const zhTW: typeof zhCN = { localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。', localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。', qwen3Desc: '啓動之後,ASR 提供商將被接管。', + sherpaDesc: '啟用後,ASR 提供商將被接管。', foundryDesc: '啓動之後,ASR 提供商將被接管。', notSupportedHere: '本平臺暫不支持,未集成推理模塊。', enable: '啓用', @@ -944,6 +946,28 @@ export const zhTW: typeof zhCN = { foundryModelBaseDesc: '更快、資源佔用更低,適合日常輕量使用。', foundryModelTiny: 'Whisper Tiny(最快 / 冒煙測試)', foundryModelTinyDesc: '最快的檢查選項,適合確認 Foundry 路徑可用。', + sherpaTitle: 'Windows sherpa-onnx Local(實驗性)', + sherpaDesc: 'Windows 使用 sherpa-onnx 在本機離線批次識別,無需 ASR API Key。', + sherpaRuntimeReady: '模型已載入', + sherpaRuntimeMissing: '模型未載入', + sherpaSetDefault: '設為預設 / 啟用 sherpa-onnx', + sherpaPrepare: '檢查本地檔案 / 載入', + sherpaPreparing: '載入中…', + sherpaPrepareLocalFiles: '檢查本地模型檔案', + sherpaModelDir: '模型目錄', + sherpaRevealDir: '開啟模型目錄', + sherpaError: 'sherpa-onnx 狀態', + sherpaLanguageJa: '日語 ja', + sherpaLanguageKo: '韓語 ko', + sherpaLanguageYue: '粵語 yue', + sherpaModelSenseVoice: 'SenseVoice Small(預設 / 中文優先)', + sherpaModelSenseVoiceDesc: '預設實驗模型,適合中文與中英混合聽寫。', + sherpaModelParaformer: 'Paraformer 中文', + sherpaModelParaformerDesc: '面向中文的實驗模型。', + sherpaModelWhisper: 'Whisper Small 多語言', + sherpaModelWhisperDesc: '與 Whisper 系列行為一致的多語言實驗兜底模型。', + sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', + sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。', mirrorLabel: '下載鏡像源', mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 5663f701..eb28575a 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -3,114 +3,129 @@ // the UI is still operable for visual review. import type { - ComboBinding, - CorrectionRule, - CredentialsStatus, - DictationSession, - DictionaryEntry, - HotkeyCapability, - MarketplaceDetail, - MarketplaceListItem, - MarketplaceMyPackItem, - HotkeyStatus, - MicrophoneDevice, - PermissionStatus, - PolishMode, - QaHotkeyBinding, - ShortcutBinding, - StylePack, - StylePackExample, - StylePackKind, - StylePackRuntimeDiagnostics, - StyleSystemPrompts, - UpdateChannel, - UserPreferences, - VocabPresetStore, - WindowsImeStatus, -} from './types'; -export type { UpdateChannel } from './types'; -import { OL_DATA } from './mockData'; -import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel } from './hotkey'; + ComboBinding, + CorrectionRule, + CredentialsStatus, + DictationSession, + DictionaryEntry, + HotkeyCapability, + MarketplaceDetail, + MarketplaceListItem, + MarketplaceMyPackItem, + HotkeyStatus, + MicrophoneDevice, + PermissionStatus, + PolishMode, + QaHotkeyBinding, + ShortcutBinding, + StylePack, + StylePackExample, + StylePackKind, + StylePackRuntimeDiagnostics, + StyleSystemPrompts, + UpdateChannel, + UserPreferences, + VocabPresetStore, + WindowsImeStatus, +} from "./types" +export type { UpdateChannel } from "./types" +import { OL_DATA } from "./mockData" +import { + defaultAppShortcutModifiers, + defaultQaShortcut, + formatComboLabel, +} from "./hotkey" declare global { - interface Window { - __TAURI_INTERNALS__?: unknown; - } + interface Window { + __TAURI_INTERNALS__?: unknown + } } const isTauri = - globalThis.window !== undefined && '__TAURI_INTERNALS__' in globalThis.window; + globalThis.window !== undefined && + "__TAURI_INTERNALS__" in globalThis.window export async function invokeOrMock( - cmd: string, - args: Record | undefined, - mock: () => T, + cmd: string, + args: Record | undefined, + mock: () => T, ): Promise { - if (!isTauri) { - return mock(); - } - const { invoke } = await import('@tauri-apps/api/core'); - return invoke(cmd, args); + if (!isTauri) { + return mock() + } + const { invoke } = await import("@tauri-apps/api/core") + return invoke(cmd, args) } // ── Mock fixtures ────────────────────────────────────────────────────── let mockSettings: UserPreferences = { - hotkey: { trigger: 'rightControl', mode: 'toggle', keys: [{ code: 'ControlRight' }] }, - dictationHotkey: { primary: 'RightControl', modifiers: [] }, - defaultMode: 'structured', - enabledModes: ['raw', 'light', 'structured', 'formal'], - activeStylePackId: 'builtin.structured', - styleSystemPrompts: { - raw: '只做最小化整理:补全标点、必要分句,保留原话顺序、用词和语气。', - light: '把口语转写整理成自然文字,去掉口癖和重复,保留原意与语气。', - structured: '把口述整理成结构清晰的文本,必要时按主题分组输出。', - formal: '输出适合工作沟通与邮件场景的正式表达,不扩写事实。', - }, - customStylePrompts: { raw: '', light: '', structured: '', formal: '' }, - launchAtLogin: false, - showCapsule: true, - muteDuringRecording: false, - microphoneDeviceName: '', - activeAsrProvider: 'foundry-local-whisper', - activeLlmProvider: 'ark', - llmThinkingEnabled: false, - restoreClipboardAfterPaste: true, - pasteShortcut: 'ctrlV', - allowNonTsfInsertionFallback: true, - workingLanguages: ['简体中文'], - translationTargetLanguage: '', - qaHotkey: defaultQaShortcut(), - chineseScriptPreference: 'auto', - outputLanguagePreference: 'auto', - qaSaveHistory: false, - customComboHotkey: null, - translationHotkey: { primary: 'Shift', modifiers: [] }, - switchStyleHotkey: { primary: 'S', modifiers: defaultAppShortcutModifiers() }, - openAppHotkey: { primary: 'O', modifiers: defaultAppShortcutModifiers() }, - localAsrActiveModel: 'qwen3-asr-0.6b', - localAsrMirror: 'huggingface', - localAsrKeepLoadedSecs: 300, - foundryLocalAsrModel: 'whisper-small', - foundryLocalRuntimeSource: 'auto', - foundryLocalAsrLanguageHint: '', - foundryLocalAsrKeepLoadedSecs: 300, - historyRetentionDays: 7, - polishContextWindowMinutes: 5, - startMinimized: false, - updateChannel: 'stable', - streamingInsert: true, - streamingInsertDefaultMigrated: true, - streamingInsertSaveClipboard: true, - autoUpdateCheck: true, - historyMaxEntries: null, - recordAudioForDebug: false, - audioRecordingMaxEntries: null, - marketplaceBaseUrl: 'https://apic.openless.top', - marketplaceDevLogin: '', -}; + hotkey: { + trigger: "rightControl", + mode: "toggle", + keys: [{ code: "ControlRight" }], + }, + dictationHotkey: { primary: "RightControl", modifiers: [] }, + defaultMode: "structured", + enabledModes: ["raw", "light", "structured", "formal"], + activeStylePackId: "builtin.structured", + styleSystemPrompts: { + raw: "只做最小化整理:补全标点、必要分句,保留原话顺序、用词和语气。", + light: "把口语转写整理成自然文字,去掉口癖和重复,保留原意与语气。", + structured: "把口述整理成结构清晰的文本,必要时按主题分组输出。", + formal: "输出适合工作沟通与邮件场景的正式表达,不扩写事实。", + }, + customStylePrompts: { raw: "", light: "", structured: "", formal: "" }, + launchAtLogin: false, + showCapsule: true, + muteDuringRecording: false, + microphoneDeviceName: "", + activeAsrProvider: "foundry-local-whisper", + activeLlmProvider: "ark", + llmThinkingEnabled: false, + restoreClipboardAfterPaste: true, + pasteShortcut: "ctrlV", + allowNonTsfInsertionFallback: true, + workingLanguages: ["简体中文"], + translationTargetLanguage: "", + qaHotkey: defaultQaShortcut(), + chineseScriptPreference: "auto", + outputLanguagePreference: "auto", + qaSaveHistory: false, + customComboHotkey: null, + translationHotkey: { primary: "Shift", modifiers: [] }, + switchStyleHotkey: { + primary: "S", + modifiers: defaultAppShortcutModifiers(), + }, + openAppHotkey: { primary: "O", modifiers: defaultAppShortcutModifiers() }, + localAsrActiveModel: "qwen3-asr-0.6b", + localAsrMirror: "huggingface", + localAsrKeepLoadedSecs: 300, + foundryLocalAsrModel: "whisper-small", + foundryLocalRuntimeSource: "auto", + foundryLocalAsrLanguageHint: "", + foundryLocalAsrKeepLoadedSecs: 300, + sherpaOnnxModel: "sense-voice-small-zh", + sherpaOnnxLanguageHint: "", + sherpaOnnxKeepLoadedSecs: 300, + historyRetentionDays: 7, + polishContextWindowMinutes: 5, + startMinimized: false, + updateChannel: "stable", + streamingInsert: true, + streamingInsertDefaultMigrated: true, + streamingInsertSaveClipboard: true, + autoUpdateCheck: true, + historyMaxEntries: null, + recordAudioForDebug: false, + audioRecordingMaxEntries: null, + marketplaceBaseUrl: "https://apic.openless.top", + marketplaceDevLogin: "", +} const mockFullStylePrompts: StyleSystemPrompts = { - raw: `# 角色 + raw: `# 角色 语音输入整理器。先理解用户意图,再贴近原话做最小整理。 # 任务(原文) @@ -124,7 +139,7 @@ const mockFullStylePrompts: StyleSystemPrompts = { # 输出 直接输出最终正文,不加解释。`, - light: `# 角色 + light: `# 角色 语音输入整理器。把口述整理成自然、顺畅、可直接发送的文字。 # 任务(轻度润色) @@ -137,7 +152,7 @@ const mockFullStylePrompts: StyleSystemPrompts = { # 输出 输出一段可直接发送的自然文字。`, - structured: `# 角色 + structured: `# 角色 语音输入整理器。把 AI 编程协作、技术排障和模型资讯口述整理成结构清楚、术语准确的文本。 # 任务(清晰结构 · AI 编程协作) @@ -148,7 +163,7 @@ Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino # 输出 直接输出最终正文。顶层用 1./2./3.,子项用缩进 3 个空格的 (a)(b)(c)。不加解释。`, - formal: `# 角色 + formal: `# 角色 语音输入整理器。把口述整理成适合邮件、同步和正式沟通的专业表达。 # 任务(正式表达) @@ -161,318 +176,348 @@ Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino # 输出 输出可直接发送的正式文本。`, -}; +} mockSettings = { - ...mockSettings, - styleSystemPrompts: mockFullStylePrompts, - workingLanguages: ['简体中文'], -}; + ...mockSettings, + styleSystemPrompts: mockFullStylePrompts, + workingLanguages: ["简体中文"], +} const mockDefaultStyleSystemPrompts: StyleSystemPrompts = { - ...mockSettings.styleSystemPrompts, -}; + ...mockSettings.styleSystemPrompts, +} const mockBuiltinExamples: Record = { - raw: [ - { - title: '最小整理', - input: '今天下午那个会先别取消我晚点再确认一下然后把下周二也先空出来', - output: '今天下午那个会先别取消,我晚点再确认一下。然后把下周二也先空出来。', - }, - ], - light: [ - { - title: '聊天消息', - input: '你帮我跟设计那边说一下这个首页先别上线我晚上再过一遍', - output: '你帮我跟设计那边说一下,这个首页先别上线,我今晚再过一遍。', - }, - ], - structured: [ - { - title: 'AI 编程任务', - input: '帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里', - output: '帮忙给 Codex 提个任务,主要包含以下内容:\n\n1. 登录页修复\n (a) 修复登录页相关 bug。\n2. 文档与配置\n (a) 补充 README 中的环境变量说明。\n (b) 确认 Secret Key 不被硬编码到代码里。', - }, - ], - formal: [ - { - title: '工作同步', - input: '你帮我发个消息说这个需求今天先不上了等测试和产品都确认完我们再一起推进', - output: '麻烦帮我同步一下:这个需求今天先不上线,待测试和产品都确认完成后,我们再统一推进。', - }, - ], -}; + raw: [ + { + title: "最小整理", + input: "今天下午那个会先别取消我晚点再确认一下然后把下周二也先空出来", + output: "今天下午那个会先别取消,我晚点再确认一下。然后把下周二也先空出来。", + }, + ], + light: [ + { + title: "聊天消息", + input: "你帮我跟设计那边说一下这个首页先别上线我晚上再过一遍", + output: "你帮我跟设计那边说一下,这个首页先别上线,我今晚再过一遍。", + }, + ], + structured: [ + { + title: "AI 编程任务", + input: "帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里", + output: "帮忙给 Codex 提个任务,主要包含以下内容:\n\n1. 登录页修复\n (a) 修复登录页相关 bug。\n2. 文档与配置\n (a) 补充 README 中的环境变量说明。\n (b) 确认 Secret Key 不被硬编码到代码里。", + }, + ], + formal: [ + { + title: "工作同步", + input: "你帮我发个消息说这个需求今天先不上了等测试和产品都确认完我们再一起推进", + output: "麻烦帮我同步一下:这个需求今天先不上线,待测试和产品都确认完成后,我们再统一推进。", + }, + ], +} function makeMockStylePack( - id: string, - kind: StylePackKind, - baseMode: PolishMode, - name: string, - description: string, - prompt: string, - tags: string[], + id: string, + kind: StylePackKind, + baseMode: PolishMode, + name: string, + description: string, + prompt: string, + tags: string[], ): StylePack { - return { - id, - name, - description, - author: 'OpenLess', - version: '1.0.0', - kind, - baseMode, - prompt, - examples: mockBuiltinExamples[baseMode].map(example => ({ ...example })), - tags, - iconPath: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - enabled: true, - active: false, - recommendedModel: null, - compatibleAppVersion: '1.0.0', - }; + return { + id, + name, + description, + author: "OpenLess", + version: "1.0.0", + kind, + baseMode, + prompt, + examples: mockBuiltinExamples[baseMode].map((example) => ({ + ...example, + })), + tags, + iconPath: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + enabled: true, + active: false, + recommendedModel: null, + compatibleAppVersion: "1.0.0", + } } let mockStylePacks: StylePack[] = [ - makeMockStylePack( - 'builtin.raw', - 'builtin', - 'raw', - '原文', - '尽量保留原话顺序和语气,只做必要的断句与标点整理。', - mockSettings.styleSystemPrompts.raw, - ['原文', '最小改写'], - ), - makeMockStylePack( - 'builtin.light', - 'builtin', - 'light', - '轻度润色', - '把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。', - mockSettings.styleSystemPrompts.light, - ['沟通', '自然'], - ), - makeMockStylePack( - 'builtin.structured', - 'builtin', - 'structured', - '清晰结构', - '适合多事项和多主题口述,自动整理为层次清楚的结构化输出。', - mockSettings.styleSystemPrompts.structured, - ['结构化', '条理'], - ), - makeMockStylePack( - 'builtin.formal', - 'builtin', - 'formal', - '正式表达', - '适合邮件、同步和工作沟通场景,语气更完整、专业、克制。', - mockSettings.styleSystemPrompts.formal, - ['正式', '工作沟通'], - ), - { - ...makeMockStylePack( - 'imported.creator-note', - 'imported', - 'light', - '创作者口播', - '给短视频口播和社区帖文使用,句子更紧凑,保留情绪和节奏。', - '你是一个负责整理创作者口播稿的编辑。请把输入整理成适合发帖和口播的自然文本,保留节奏感,不要补充原文没有的信息。', - ['社区', '口播', '节奏感'], + makeMockStylePack( + "builtin.raw", + "builtin", + "raw", + "原文", + "尽量保留原话顺序和语气,只做必要的断句与标点整理。", + mockSettings.styleSystemPrompts.raw, + ["原文", "最小改写"], ), - author: 'Demo Community', - }, -]; + makeMockStylePack( + "builtin.light", + "builtin", + "light", + "轻度润色", + "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", + mockSettings.styleSystemPrompts.light, + ["沟通", "自然"], + ), + makeMockStylePack( + "builtin.structured", + "builtin", + "structured", + "清晰结构", + "适合多事项和多主题口述,自动整理为层次清楚的结构化输出。", + mockSettings.styleSystemPrompts.structured, + ["结构化", "条理"], + ), + makeMockStylePack( + "builtin.formal", + "builtin", + "formal", + "正式表达", + "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", + mockSettings.styleSystemPrompts.formal, + ["正式", "工作沟通"], + ), + { + ...makeMockStylePack( + "imported.creator-note", + "imported", + "light", + "创作者口播", + "给短视频口播和社区帖文使用,句子更紧凑,保留情绪和节奏。", + "你是一个负责整理创作者口播稿的编辑。请把输入整理成适合发帖和口播的自然文本,保留节奏感,不要补充原文没有的信息。", + ["社区", "口播", "节奏感"], + ), + author: "Demo Community", + }, +] function cloneStylePack(stylePack: StylePack): StylePack { - return { - ...stylePack, - tags: [...stylePack.tags], - examples: stylePack.examples.map(example => ({ ...example })), - }; + return { + ...stylePack, + tags: [...stylePack.tags], + examples: stylePack.examples.map((example) => ({ ...example })), + } } function cloneMockStylePacks(): StylePack[] { - return mockStylePacks.map(cloneStylePack); -} - -function composeMockStylePackRuntimeDiagnostics(stylePack: StylePack): StylePackRuntimeDiagnostics { - const trimmedPrompt = stylePack.prompt.trimEnd(); - const contextPremise = mockSettings.workingLanguages.length - ? ['# Context', `Working languages: ${mockSettings.workingLanguages.join(', ')}`].join('\n') - : ''; - const hotwordLines = [`GitHub`, `OpenLess`]; - const hotwordBlock = hotwordLines.length > 0 - ? ['Hotwords (keep the spelling below when they appear in the transcript):', ...hotwordLines.map(word => `- ${word}`)].join('\n') - : ''; - const singleTurnPrompt = [contextPremise, trimmedPrompt, hotwordBlock].filter(Boolean).join('\n\n'); - const historyInstruction = 'When prior turns exist, do not repeat previous assistant outputs. Only polish the current transcript.'; - const multiTurnPrompt = `${singleTurnPrompt}\n\n${historyInstruction}`; - return { - packId: stylePack.id, - packName: stylePack.name, - packPrompt: stylePack.prompt, - packPromptChars: stylePack.prompt.length, - contextPremise, - contextPremiseChars: contextPremise.length, - hotwordBlock, - hotwordBlockChars: hotwordBlock.length, - historyInstruction, - historyInstructionChars: historyInstruction.length, - singleTurnPrompt, - singleTurnPromptChars: singleTurnPrompt.length, - multiTurnPrompt, - multiTurnPromptChars: multiTurnPrompt.length, - workingLanguages: [...mockSettings.workingLanguages], - hotwords: [...hotwordLines], - contextWindowMinutes: mockSettings.polishContextWindowMinutes, - includesContextPremise: Boolean(contextPremise), - includesHotwordBlock: hotwordLines.length > 0, - includesHistoryInstruction: true, - previewOmitsFrontApp: true, - }; + return mockStylePacks.map(cloneStylePack) +} + +function composeMockStylePackRuntimeDiagnostics( + stylePack: StylePack, +): StylePackRuntimeDiagnostics { + const trimmedPrompt = stylePack.prompt.trimEnd() + const contextPremise = mockSettings.workingLanguages.length + ? [ + "# Context", + `Working languages: ${mockSettings.workingLanguages.join(", ")}`, + ].join("\n") + : "" + const hotwordLines = [`GitHub`, `OpenLess`] + const hotwordBlock = + hotwordLines.length > 0 + ? [ + "Hotwords (keep the spelling below when they appear in the transcript):", + ...hotwordLines.map((word) => `- ${word}`), + ].join("\n") + : "" + const singleTurnPrompt = [contextPremise, trimmedPrompt, hotwordBlock] + .filter(Boolean) + .join("\n\n") + const historyInstruction = + "When prior turns exist, do not repeat previous assistant outputs. Only polish the current transcript." + const multiTurnPrompt = `${singleTurnPrompt}\n\n${historyInstruction}` + return { + packId: stylePack.id, + packName: stylePack.name, + packPrompt: stylePack.prompt, + packPromptChars: stylePack.prompt.length, + contextPremise, + contextPremiseChars: contextPremise.length, + hotwordBlock, + hotwordBlockChars: hotwordBlock.length, + historyInstruction, + historyInstructionChars: historyInstruction.length, + singleTurnPrompt, + singleTurnPromptChars: singleTurnPrompt.length, + multiTurnPrompt, + multiTurnPromptChars: multiTurnPrompt.length, + workingLanguages: [...mockSettings.workingLanguages], + hotwords: [...hotwordLines], + contextWindowMinutes: mockSettings.polishContextWindowMinutes, + includesContextPremise: Boolean(contextPremise), + includesHotwordBlock: hotwordLines.length > 0, + includesHistoryInstruction: true, + previewOmitsFrontApp: true, + } } function syncMockSettingsFromStylePacks() { - const enabled = mockStylePacks.filter(pack => pack.enabled); - const active = - mockStylePacks.find(pack => pack.id === mockSettings.activeStylePackId && pack.enabled) ?? - enabled[0] ?? - mockStylePacks[0]; - mockStylePacks = mockStylePacks.map(pack => ({ - ...pack, - active: pack.id === active.id, - })); - mockSettings = { - ...mockSettings, - activeStylePackId: active.id, - defaultMode: active.baseMode, - enabledModes: ['raw', 'light', 'structured', 'formal'].filter(mode => - mockStylePacks.some(pack => pack.enabled && pack.baseMode === mode), - ) as PolishMode[], - styleSystemPrompts: { - raw: mockStylePacks.find(pack => pack.id === 'builtin.raw')?.prompt ?? mockSettings.styleSystemPrompts.raw, - light: - mockStylePacks.find(pack => pack.id === 'builtin.light')?.prompt ?? - mockSettings.styleSystemPrompts.light, - structured: - mockStylePacks.find(pack => pack.id === 'builtin.structured')?.prompt ?? - mockSettings.styleSystemPrompts.structured, - formal: - mockStylePacks.find(pack => pack.id === 'builtin.formal')?.prompt ?? - mockSettings.styleSystemPrompts.formal, - }, - }; + const enabled = mockStylePacks.filter((pack) => pack.enabled) + const active = + mockStylePacks.find( + (pack) => + pack.id === mockSettings.activeStylePackId && pack.enabled, + ) ?? + enabled[0] ?? + mockStylePacks[0] + mockStylePacks = mockStylePacks.map((pack) => ({ + ...pack, + active: pack.id === active.id, + })) + mockSettings = { + ...mockSettings, + activeStylePackId: active.id, + defaultMode: active.baseMode, + enabledModes: ["raw", "light", "structured", "formal"].filter((mode) => + mockStylePacks.some( + (pack) => pack.enabled && pack.baseMode === mode, + ), + ) as PolishMode[], + styleSystemPrompts: { + raw: + mockStylePacks.find((pack) => pack.id === "builtin.raw") + ?.prompt ?? mockSettings.styleSystemPrompts.raw, + light: + mockStylePacks.find((pack) => pack.id === "builtin.light") + ?.prompt ?? mockSettings.styleSystemPrompts.light, + structured: + mockStylePacks.find((pack) => pack.id === "builtin.structured") + ?.prompt ?? mockSettings.styleSystemPrompts.structured, + formal: + mockStylePacks.find((pack) => pack.id === "builtin.formal") + ?.prompt ?? mockSettings.styleSystemPrompts.formal, + }, + } } -syncMockSettingsFromStylePacks(); +syncMockSettingsFromStylePacks() const mockHotkeyCapability: HotkeyCapability = { - adapter: 'windowsLowLevel', - availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand', 'custom'], - requiresAccessibilityPermission: false, - supportsModifierOnlyTrigger: true, - supportsSideSpecificModifiers: true, - explicitFallbackAvailable: false, - statusHint: '默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。', -}; + adapter: "windowsLowLevel", + availableTriggers: [ + "rightControl", + "rightAlt", + "leftControl", + "rightCommand", + "custom", + ], + requiresAccessibilityPermission: false, + supportsModifierOnlyTrigger: true, + supportsSideSpecificModifiers: true, + explicitFallbackAvailable: false, + statusHint: + "默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。", +} const mockCredentialsStatus: CredentialsStatus = { - activeAsrProvider: 'foundry-local-whisper', - activeLlmProvider: 'ark', - asrConfigured: true, - llmConfigured: true, - volcengineConfigured: true, - arkConfigured: true, -}; + activeAsrProvider: "foundry-local-whisper", + activeLlmProvider: "ark", + asrConfigured: true, + llmConfigured: true, + volcengineConfigured: true, + arkConfigured: true, +} export interface ProviderCheckResult { - ok: boolean; + ok: boolean } export interface ProviderModelsResult { - models: string[]; + models: string[] } const mockHotkeyStatus: HotkeyStatus = { - adapter: 'windowsLowLevel', - state: 'installed', - message: 'Windows 低层键盘 hook 已安装', - lastError: null, -}; + adapter: "windowsLowLevel", + state: "installed", + message: "Windows 低层键盘 hook 已安装", + lastError: null, +} const mockWindowsImeStatus: WindowsImeStatus = { - state: 'notWindows', - usingTsfBackend: false, - message: 'Browser dev mock', - dllPath: null, -}; + state: "notWindows", + usingTsfBackend: false, + message: "Browser dev mock", + dllPath: null, +} const mockMicrophoneDevices: MicrophoneDevice[] = [ - { name: 'Built-in Microphone', isDefault: true }, - { name: 'USB Microphone', isDefault: false }, -]; + { name: "Built-in Microphone", isDefault: true }, + { name: "USB Microphone", isDefault: false }, +] const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ - id: `mock-${i}`, - createdAt: new Date().toISOString(), - rawTranscript: h.preview, - finalText: h.preview, - mode: 'structured', - appBundleId: null, - appName: 'VS Code', - insertStatus: 'inserted', - errorCode: null, - durationMs: 600, - dictionaryEntryCount: 28, - hasAudioRecording: null, -})); + id: `mock-${i}`, + createdAt: new Date().toISOString(), + rawTranscript: h.preview, + finalText: h.preview, + mode: "structured", + appBundleId: null, + appName: "VS Code", + insertStatus: "inserted", + errorCode: null, + durationMs: 600, + dictionaryEntryCount: 28, + hasAudioRecording: null, +})) const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ - id: `vocab-${i}`, - phrase: v.word, - note: null, - enabled: true, - hits: v.count, - createdAt: new Date().toISOString(), -})); - -const mockCorrectionRules: CorrectionRule[] = [ - { - id: 'rule-quantity-classifier', - pattern: '{num}粒', - replacement: '{num}例', + id: `vocab-${i}`, + phrase: v.word, + note: null, enabled: true, + hits: v.count, createdAt: new Date().toISOString(), - }, -]; +})) + +const mockCorrectionRules: CorrectionRule[] = [ + { + id: "rule-quantity-classifier", + pattern: "{num}粒", + replacement: "{num}例", + enabled: true, + createdAt: new Date().toISOString(), + }, +] // ── Settings ─────────────────────────────────────────────────────────── export function getSettings(): Promise { - return invokeOrMock('get_settings', undefined, () => ({ ...mockSettings })); + return invokeOrMock("get_settings", undefined, () => ({ ...mockSettings })) } export function getDefaultStyleSystemPrompts(): Promise { - return invokeOrMock('get_default_style_system_prompts', undefined, () => ({ ...mockDefaultStyleSystemPrompts })); + return invokeOrMock("get_default_style_system_prompts", undefined, () => ({ + ...mockDefaultStyleSystemPrompts, + })) } export function setSettings(prefs: UserPreferences): Promise { - return invokeOrMock('set_settings', { prefs }, () => { - mockSettings = { ...prefs }; - mockStylePacks = mockStylePacks.map(pack => { - if (pack.kind === 'builtin') { - return { - ...pack, - enabled: prefs.enabledModes.includes(pack.baseMode), - prompt: prefs.styleSystemPrompts[pack.baseMode], - }; - } - return { ...pack }; - }); - syncMockSettingsFromStylePacks(); - return undefined; - }); + return invokeOrMock("set_settings", { prefs }, () => { + mockSettings = { ...prefs } + mockStylePacks = mockStylePacks.map((pack) => { + if (pack.kind === "builtin") { + return { + ...pack, + enabled: prefs.enabledModes.includes(pack.baseMode), + prompt: prefs.styleSystemPrompts[pack.baseMode], + } + } + return { ...pack } + }) + syncMockSettingsFromStylePacks() + return undefined + }) } // ── Release channel (Beta opt-in) ────────────────────────────────────── @@ -482,33 +527,45 @@ export function setSettings(prefs: UserPreferences): Promise { // 这里 re-export 保持外部模块(SettingsModal 等)import 路径不变。 export interface LatestBetaRelease { - tagName: string; - htmlUrl: string; - publishedAt: string; + tagName: string + htmlUrl: string + publishedAt: string } export function getUpdateChannel(): Promise { - return invokeOrMock('get_update_channel', undefined, () => 'stable' as UpdateChannel); + return invokeOrMock( + "get_update_channel", + undefined, + () => "stable" as UpdateChannel, + ) } export function setUpdateChannel(channel: UpdateChannel): Promise { - return invokeOrMock('set_update_channel', { channel }, () => undefined); + return invokeOrMock("set_update_channel", { channel }, () => undefined) } export function fetchLatestBetaRelease(): Promise { - return invokeOrMock('fetch_latest_beta_release', undefined, () => null); + return invokeOrMock("fetch_latest_beta_release", undefined, () => null) } export function getHotkeyStatus(): Promise { - return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus); + return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) } export function getHotkeyCapability(): Promise { - return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability); + return invokeOrMock( + "get_hotkey_capability", + undefined, + () => mockHotkeyCapability, + ) } export function getWindowsImeStatus(): Promise { - return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); + return invokeOrMock( + "get_windows_ime_status", + undefined, + () => mockWindowsImeStatus, + ) } export interface NetworkCheckResult { @@ -524,518 +581,658 @@ export function checkNetwork(): Promise { } export function listMicrophoneDevices(): Promise { - return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices); + return invokeOrMock( + "list_microphone_devices", + undefined, + () => mockMicrophoneDevices, + ) } export function startMicrophoneLevelMonitor(deviceName: string): Promise { - return invokeOrMock('start_microphone_level_monitor', { deviceName }, () => undefined); + return invokeOrMock( + "start_microphone_level_monitor", + { deviceName }, + () => undefined, + ) } export function stopMicrophoneLevelMonitor(): Promise { - return invokeOrMock('stop_microphone_level_monitor', undefined, () => undefined); + return invokeOrMock( + "stop_microphone_level_monitor", + undefined, + () => undefined, + ) +} + +export function isWaylandCliMode(): Promise { + return invokeOrMock("is_wayland_cli_mode", undefined, () => false) } // ── Credentials ──────────────────────────────────────────────────────── export function getCredentials(): Promise { - return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); + return invokeOrMock( + "get_credentials", + undefined, + () => mockCredentialsStatus, + ) } export function setCredential(account: string, value: string): Promise { - return invokeOrMock('set_credential', { account, value }, () => undefined); + return invokeOrMock("set_credential", { account, value }, () => undefined) } export function setActiveAsrProvider(provider: string): Promise { - return invokeOrMock('set_active_asr_provider', { provider }, () => undefined); + return invokeOrMock( + "set_active_asr_provider", + { provider }, + () => undefined, + ) } export function setActiveLlmProvider(provider: string): Promise { - return invokeOrMock('set_active_llm_provider', { provider }, () => undefined); + return invokeOrMock( + "set_active_llm_provider", + { provider }, + () => undefined, + ) } export function readCredential(account: string): Promise { - return invokeOrMock('read_credential', { account }, () => null); + return invokeOrMock( + "read_credential", + { account }, + () => null, + ) } -export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise { - return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true })); +export function validateProviderCredentials( + kind: "llm" | "asr", +): Promise { + return invokeOrMock("validate_provider_credentials", { kind }, () => ({ + ok: true, + })) } -export function listProviderModels(kind: 'llm' | 'asr'): Promise { - return invokeOrMock('list_provider_models', { kind }, () => ({ models: kind === 'llm' ? ['gpt-4o', 'deepseek-v4-flash', 'deepseek-v4-pro'] : ['whisper-1'] })); +export function listProviderModels( + kind: "llm" | "asr", +): Promise { + return invokeOrMock("list_provider_models", { kind }, () => ({ + models: + kind === "llm" + ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] + : ["whisper-1"], + })) } // ── History ──────────────────────────────────────────────────────────── export function listHistory(): Promise { - return invokeOrMock('list_history', undefined, () => mockHistory); + return invokeOrMock("list_history", undefined, () => mockHistory) } export function deleteHistoryEntry(id: string): Promise { - return invokeOrMock('delete_history_entry', { id }, () => undefined); + return invokeOrMock("delete_history_entry", { id }, () => undefined) } export function clearHistory(): Promise { - return invokeOrMock('clear_history', undefined, () => undefined); + return invokeOrMock("clear_history", undefined, () => undefined) } /** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开 * 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。 * 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */ export function readAudioRecording(sessionId: string): Promise { - return invokeOrMock( - 'read_audio_recording', - { sessionId }, - () => new Uint8Array(), - ).then(value => { - // Tauri 默认把 Vec 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。 - if (value instanceof Uint8Array) return value; - if (Array.isArray(value)) return new Uint8Array(value as number[]); - return new Uint8Array(value as ArrayBuffer); - }); + return invokeOrMock( + "read_audio_recording", + { sessionId }, + () => new Uint8Array(), + ).then((value) => { + // Tauri 默认把 Vec 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。 + if (value instanceof Uint8Array) return value + if (Array.isArray(value)) return new Uint8Array(value as number[]) + return new Uint8Array(value as ArrayBuffer) + }) } // ── Vocab ────────────────────────────────────────────────────────────── export function listVocab(): Promise { - return invokeOrMock('list_vocab', undefined, () => mockVocab); + return invokeOrMock("list_vocab", undefined, () => mockVocab) } -export function addVocab(phrase: string, note?: string): Promise { - return invokeOrMock('add_vocab', { phrase, note }, () => ({ - id: `vocab-new-${Date.now()}`, - phrase, - note: note ?? null, - enabled: true, - hits: 0, - createdAt: new Date().toISOString(), - })); +export function addVocab( + phrase: string, + note?: string, +): Promise { + return invokeOrMock("add_vocab", { phrase, note }, () => ({ + id: `vocab-new-${Date.now()}`, + phrase, + note: note ?? null, + enabled: true, + hits: 0, + createdAt: new Date().toISOString(), + })) } export function removeVocab(id: string): Promise { - return invokeOrMock('remove_vocab', { id }, () => undefined); + return invokeOrMock("remove_vocab", { id }, () => undefined) } export function setVocabEnabled(id: string, enabled: boolean): Promise { - return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined); + return invokeOrMock("set_vocab_enabled", { id, enabled }, () => undefined) } export function listCorrectionRules(): Promise { - return invokeOrMock('list_correction_rules', undefined, () => mockCorrectionRules); -} - -export function addCorrectionRule(pattern: string, replacement: string): Promise { - return invokeOrMock('add_correction_rule', { pattern, replacement }, () => ({ - id: `rule-new-${Date.now()}`, - pattern, - replacement, - enabled: true, - createdAt: new Date().toISOString(), - })); + return invokeOrMock( + "list_correction_rules", + undefined, + () => mockCorrectionRules, + ) +} + +export function addCorrectionRule( + pattern: string, + replacement: string, +): Promise { + return invokeOrMock( + "add_correction_rule", + { pattern, replacement }, + () => ({ + id: `rule-new-${Date.now()}`, + pattern, + replacement, + enabled: true, + createdAt: new Date().toISOString(), + }), + ) } export function removeCorrectionRule(id: string): Promise { - return invokeOrMock('remove_correction_rule', { id }, () => undefined); + return invokeOrMock("remove_correction_rule", { id }, () => undefined) } -export function setCorrectionRuleEnabled(id: string, enabled: boolean): Promise { - return invokeOrMock('set_correction_rule_enabled', { id, enabled }, () => undefined); +export function setCorrectionRuleEnabled( + id: string, + enabled: boolean, +): Promise { + return invokeOrMock( + "set_correction_rule_enabled", + { id, enabled }, + () => undefined, + ) } export function listVocabPresets(): Promise { - return invokeOrMock('list_vocab_presets', undefined, () => ({ - custom: [], - overrides: [], - disabledBuiltinPresetIds: [], - })); + return invokeOrMock("list_vocab_presets", undefined, () => ({ + custom: [], + overrides: [], + disabledBuiltinPresetIds: [], + })) } export function saveVocabPresets(store: VocabPresetStore): Promise { - return invokeOrMock('save_vocab_presets', { store }, () => undefined); + return invokeOrMock("save_vocab_presets", { store }, () => undefined) } // ── Dictation lifecycle ──────────────────────────────────────────────── export function startDictation(): Promise { - return invokeOrMock('start_dictation', undefined, () => undefined); + return invokeOrMock("start_dictation", undefined, () => undefined) } export function stopDictation(): Promise { - return invokeOrMock('stop_dictation', undefined, () => undefined); + return invokeOrMock("stop_dictation", undefined, () => undefined) } export function cancelDictation(): Promise { - return invokeOrMock('cancel_dictation', undefined, () => undefined); + return invokeOrMock("cancel_dictation", undefined, () => undefined) } export function handleWindowHotkeyEvent( - eventType: 'keydown' | 'keyup', - key: string, - code: string, - repeat: boolean, + eventType: "keydown" | "keyup", + key: string, + code: string, + repeat: boolean, ): Promise { - return invokeOrMock( - 'handle_window_hotkey_event', - { event_type: eventType, key, code, repeat }, - () => undefined, - ); + return invokeOrMock( + "handle_window_hotkey_event", + { event_type: eventType, key, code, repeat }, + () => undefined, + ) } // ── Polish ───────────────────────────────────────────────────────────── export function repolish(rawText: string, mode: PolishMode): Promise { - return invokeOrMock('repolish', { rawText, mode }, () => rawText); + return invokeOrMock("repolish", { rawText, mode }, () => rawText) } export function setDefaultPolishMode(mode: PolishMode): Promise { - return invokeOrMock('set_default_polish_mode', { mode }, () => { - const packId = `builtin.${mode}`; - mockStylePacks = mockStylePacks.map(pack => ({ - ...pack, - enabled: pack.id === packId ? true : pack.enabled, - active: pack.id === packId, - })); - mockSettings = { ...mockSettings, activeStylePackId: packId }; - syncMockSettingsFromStylePacks(); - return undefined; - }); -} - -export function setStyleEnabled(mode: PolishMode, enabled: boolean): Promise { - return invokeOrMock('set_style_enabled', { mode, enabled }, () => { - const packId = `builtin.${mode}`; - mockStylePacks = mockStylePacks.map(pack => - pack.id === packId ? { ...pack, enabled } : { ...pack }, - ); - syncMockSettingsFromStylePacks(); - return undefined; - }); + return invokeOrMock("set_default_polish_mode", { mode }, () => { + const packId = `builtin.${mode}` + mockStylePacks = mockStylePacks.map((pack) => ({ + ...pack, + enabled: pack.id === packId ? true : pack.enabled, + active: pack.id === packId, + })) + mockSettings = { ...mockSettings, activeStylePackId: packId } + syncMockSettingsFromStylePacks() + return undefined + }) +} + +export function setStyleEnabled( + mode: PolishMode, + enabled: boolean, +): Promise { + return invokeOrMock("set_style_enabled", { mode, enabled }, () => { + const packId = `builtin.${mode}` + mockStylePacks = mockStylePacks.map((pack) => + pack.id === packId ? { ...pack, enabled } : { ...pack }, + ) + syncMockSettingsFromStylePacks() + return undefined + }) } export function listStylePacks(): Promise { - return invokeOrMock('list_style_packs', undefined, () => cloneMockStylePacks()); + return invokeOrMock("list_style_packs", undefined, () => + cloneMockStylePacks(), + ) } export function saveStylePack(stylePack: StylePack): Promise { - return invokeOrMock('save_style_pack', { stylePack }, () => { - mockStylePacks = mockStylePacks.map(pack => (pack.id === stylePack.id ? cloneStylePack(stylePack) : pack)); - syncMockSettingsFromStylePacks(); - return cloneStylePack(mockStylePacks.find(pack => pack.id === stylePack.id) ?? stylePack); - }); -} - -export function createStylePackFromTemplate(template: StylePack): Promise { - return invokeOrMock('create_style_pack_from_template', { template }, () => { - const created: StylePack = { - ...cloneStylePack(template), - id: `imported-mock-${Date.now()}`, - kind: 'imported', - active: false, - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - mockStylePacks = [...mockStylePacks, created]; - return cloneStylePack(created); - }); -} - -export function previewStylePackRuntime(stylePack: StylePack): Promise { - return invokeOrMock('preview_style_pack_runtime', { stylePack }, () => composeMockStylePackRuntimeDiagnostics(stylePack)); + return invokeOrMock("save_style_pack", { stylePack }, () => { + mockStylePacks = mockStylePacks.map((pack) => + pack.id === stylePack.id ? cloneStylePack(stylePack) : pack, + ) + syncMockSettingsFromStylePacks() + return cloneStylePack( + mockStylePacks.find((pack) => pack.id === stylePack.id) ?? + stylePack, + ) + }) +} + +export function createStylePackFromTemplate( + template: StylePack, +): Promise { + return invokeOrMock("create_style_pack_from_template", { template }, () => { + const created: StylePack = { + ...cloneStylePack(template), + id: `imported-mock-${Date.now()}`, + kind: "imported", + active: false, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + mockStylePacks = [...mockStylePacks, created] + return cloneStylePack(created) + }) +} + +export function previewStylePackRuntime( + stylePack: StylePack, +): Promise { + return invokeOrMock("preview_style_pack_runtime", { stylePack }, () => + composeMockStylePackRuntimeDiagnostics(stylePack), + ) } export function setActiveStylePack(id: string): Promise { - return invokeOrMock('set_active_style_pack', { id }, () => { - mockStylePacks = mockStylePacks.map(pack => ({ - ...pack, - enabled: pack.id === id ? true : pack.enabled, - active: pack.id === id, - })); - mockSettings = { ...mockSettings, activeStylePackId: id }; - syncMockSettingsFromStylePacks(); - return cloneStylePack(mockStylePacks.find(pack => pack.id === id)!); - }); -} - -export function setStylePackEnabled(id: string, enabled: boolean): Promise { - return invokeOrMock('set_style_pack_enabled', { id, enabled }, () => { - mockStylePacks = mockStylePacks.map(pack => - pack.id === id ? { ...pack, enabled } : { ...pack }, - ); - syncMockSettingsFromStylePacks(); - return cloneMockStylePacks(); - }); + return invokeOrMock("set_active_style_pack", { id }, () => { + mockStylePacks = mockStylePacks.map((pack) => ({ + ...pack, + enabled: pack.id === id ? true : pack.enabled, + active: pack.id === id, + })) + mockSettings = { ...mockSettings, activeStylePackId: id } + syncMockSettingsFromStylePacks() + return cloneStylePack(mockStylePacks.find((pack) => pack.id === id)!) + }) +} + +export function setStylePackEnabled( + id: string, + enabled: boolean, +): Promise { + return invokeOrMock("set_style_pack_enabled", { id, enabled }, () => { + mockStylePacks = mockStylePacks.map((pack) => + pack.id === id ? { ...pack, enabled } : { ...pack }, + ) + syncMockSettingsFromStylePacks() + return cloneMockStylePacks() + }) } export function resetBuiltinStylePack(id: string): Promise { - return invokeOrMock('reset_builtin_style_pack', { id }, () => { - const builtinDefaults: Record = { - 'builtin.raw': makeMockStylePack( - 'builtin.raw', - 'builtin', - 'raw', - '原文', - '尽量保留原话顺序和语气,只做必要的断句与标点整理。', - mockDefaultStyleSystemPrompts.raw, - ['原文', '最小改写'], - ), - 'builtin.light': makeMockStylePack( - 'builtin.light', - 'builtin', - 'light', - '轻度润色', - '把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。', - '把口述整理成自然、顺畅、可直接发送的文字,去掉口头禅和重复,保留原意与语气。', - ['沟通', '自然'], - ), - 'builtin.structured': makeMockStylePack( - 'builtin.structured', - 'builtin', - 'structured', - '清晰结构', - '面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。', - mockDefaultStyleSystemPrompts.structured, - ['AI 编程', '技术结构化'], - ), - 'builtin.formal': makeMockStylePack( - 'builtin.formal', - 'builtin', - 'formal', - '正式表达', - '适合邮件、同步和工作沟通场景,语气更完整、专业、克制。', - '输出适合工作沟通、邮件和汇报场景的正式表达,不扩写事实。', - ['正式', '工作沟通'], - ), - }; - const current = mockStylePacks.find(pack => pack.id === id); - const reset = builtinDefaults[id]; - if (!current || !reset) { - throw new Error(`style pack not found: ${id}`); - } - mockStylePacks = mockStylePacks.map(pack => - pack.id === id - ? { - ...reset, - enabled: current.enabled, - active: current.active, - } - : pack, - ); - syncMockSettingsFromStylePacks(); - return cloneStylePack(mockStylePacks.find(pack => pack.id === id)!); - }); + return invokeOrMock("reset_builtin_style_pack", { id }, () => { + const builtinDefaults: Record = { + "builtin.raw": makeMockStylePack( + "builtin.raw", + "builtin", + "raw", + "原文", + "尽量保留原话顺序和语气,只做必要的断句与标点整理。", + mockDefaultStyleSystemPrompts.raw, + ["原文", "最小改写"], + ), + "builtin.light": makeMockStylePack( + "builtin.light", + "builtin", + "light", + "轻度润色", + "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", + "把口述整理成自然、顺畅、可直接发送的文字,去掉口头禅和重复,保留原意与语气。", + ["沟通", "自然"], + ), + "builtin.structured": makeMockStylePack( + "builtin.structured", + "builtin", + "structured", + "清晰结构", + "面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。", + mockDefaultStyleSystemPrompts.structured, + ["AI 编程", "技术结构化"], + ), + "builtin.formal": makeMockStylePack( + "builtin.formal", + "builtin", + "formal", + "正式表达", + "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", + "输出适合工作沟通、邮件和汇报场景的正式表达,不扩写事实。", + ["正式", "工作沟通"], + ), + } + const current = mockStylePacks.find((pack) => pack.id === id) + const reset = builtinDefaults[id] + if (!current || !reset) { + throw new Error(`style pack not found: ${id}`) + } + mockStylePacks = mockStylePacks.map((pack) => + pack.id === id + ? { + ...reset, + enabled: current.enabled, + active: current.active, + } + : pack, + ) + syncMockSettingsFromStylePacks() + return cloneStylePack(mockStylePacks.find((pack) => pack.id === id)!) + }) } export function deleteStylePack(id: string): Promise { - return invokeOrMock('delete_style_pack', { id }, () => { - mockStylePacks = mockStylePacks.filter(pack => pack.id !== id); - syncMockSettingsFromStylePacks(); - return undefined; - }); + return invokeOrMock("delete_style_pack", { id }, () => { + mockStylePacks = mockStylePacks.filter((pack) => pack.id !== id) + syncMockSettingsFromStylePacks() + return undefined + }) } export function importStylePackFromZip(zipPath: string): Promise { - return invokeOrMock('import_style_pack_from_zip', { zipPath }, () => { - const seed = Date.now(); - const pack = { - ...makeMockStylePack( - `imported.mock-${seed}`, - 'imported', - 'light', - '导入风格包', - `从 ${zipPath.split(/[\\\\/]/).pop() || 'ZIP'} 导入的风格包`, - '你是一个负责把口述整理成清晰、利落、适合社区分享文本的编辑,请完整保留事实,不要补充原文没有的信息。', - ['导入', 'ZIP'], - ), - author: 'Imported ZIP', - }; - mockStylePacks = [pack, ...mockStylePacks]; - syncMockSettingsFromStylePacks(); - return cloneStylePack(pack); - }); -} - -export function exportStylePackToZip(id: string, targetPath: string): Promise { - return invokeOrMock('export_style_pack_to_zip', { id, targetPath }, () => targetPath); + return invokeOrMock("import_style_pack_from_zip", { zipPath }, () => { + const seed = Date.now() + const pack = { + ...makeMockStylePack( + `imported.mock-${seed}`, + "imported", + "light", + "导入风格包", + `从 ${zipPath.split(/[\\\\/]/).pop() || "ZIP"} 导入的风格包`, + "你是一个负责把口述整理成清晰、利落、适合社区分享文本的编辑,请完整保留事实,不要补充原文没有的信息。", + ["导入", "ZIP"], + ), + author: "Imported ZIP", + } + mockStylePacks = [pack, ...mockStylePacks] + syncMockSettingsFromStylePacks() + return cloneStylePack(pack) + }) +} + +export function exportStylePackToZip( + id: string, + targetPath: string, +): Promise { + return invokeOrMock( + "export_style_pack_to_zip", + { id, targetPath }, + () => targetPath, + ) } // ── Permissions ──────────────────────────────────────────────────────── export function checkAccessibilityPermission(): Promise { - return invokeOrMock('check_accessibility_permission', undefined, () => 'granted' as const); + return invokeOrMock( + "check_accessibility_permission", + undefined, + () => "granted" as const, + ) } export function requestAccessibilityPermission(): Promise { - return invokeOrMock('request_accessibility_permission', undefined, () => 'granted' as const); + return invokeOrMock( + "request_accessibility_permission", + undefined, + () => "granted" as const, + ) } export function checkMicrophonePermission(): Promise { - return invokeOrMock('check_microphone_permission', undefined, () => 'granted' as const); + return invokeOrMock( + "check_microphone_permission", + undefined, + () => "granted" as const, + ) } export function requestMicrophonePermission(): Promise { - return invokeOrMock('request_microphone_permission', undefined, () => 'granted' as const); + return invokeOrMock( + "request_microphone_permission", + undefined, + () => "granted" as const, + ) } -export function openSystemSettings(pane: 'accessibility' | 'microphone'): Promise { - return invokeOrMock('open_system_settings', { pane }, () => undefined); +export function openSystemSettings( + pane: "accessibility" | "microphone", +): Promise { + return invokeOrMock("open_system_settings", { pane }, () => undefined) } export function triggerMicrophonePrompt(): Promise { - return invokeOrMock('trigger_microphone_prompt', undefined, () => undefined); + return invokeOrMock("trigger_microphone_prompt", undefined, () => undefined) } export function restartApp(): Promise { - return invokeOrMock('restart_app', undefined, () => undefined); + return invokeOrMock("restart_app", undefined, () => undefined) } // ── QA (划词语音问答) ─────────────────────────────────────────────────── // 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个 // 命令查询与控制 QA 浮窗。 export function getQaHotkeyLabel(): Promise { - return invokeOrMock('get_qa_hotkey_label', undefined, () => formatComboLabel(defaultQaShortcut())); + return invokeOrMock("get_qa_hotkey_label", undefined, () => + formatComboLabel(defaultQaShortcut()), + ) } export function setQaHotkey(binding: QaHotkeyBinding | null): Promise { - return invokeOrMock('set_qa_hotkey', { binding }, () => undefined); + return invokeOrMock("set_qa_hotkey", { binding }, () => undefined) } export function qaWindowDismiss(): Promise { - return invokeOrMock('qa_window_dismiss', undefined, () => undefined); + return invokeOrMock("qa_window_dismiss", undefined, () => undefined) } export function qaWindowPin(pinned: boolean): Promise { - return invokeOrMock('qa_window_pin', { pinned }, () => undefined); + return invokeOrMock("qa_window_pin", { pinned }, () => undefined) } // ── Combo Hotkey (自定义录音组合键) ─────────────────────────────────── export function validateComboHotkey(binding: ComboBinding): Promise { - return invokeOrMock('validate_combo_hotkey', { binding }, () => undefined); + return invokeOrMock("validate_combo_hotkey", { binding }, () => undefined) } export function setComboHotkey(binding: ComboBinding): Promise { - return invokeOrMock('set_combo_hotkey', { binding }, () => undefined); + return invokeOrMock("set_combo_hotkey", { binding }, () => undefined) } -export function validateShortcutBinding(binding: ShortcutBinding): Promise { - return invokeOrMock('validate_shortcut_binding', { binding }, () => undefined); +export function validateShortcutBinding( + binding: ShortcutBinding, +): Promise { + return invokeOrMock( + "validate_shortcut_binding", + { binding }, + () => undefined, + ) } export function setDictationHotkey(binding: ShortcutBinding): Promise { - return invokeOrMock('set_dictation_hotkey', { binding }, () => undefined); + return invokeOrMock("set_dictation_hotkey", { binding }, () => undefined) } export function setTranslationHotkey(binding: ShortcutBinding): Promise { - return invokeOrMock('set_translation_hotkey', { binding }, () => undefined); + return invokeOrMock("set_translation_hotkey", { binding }, () => undefined) } export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise { - return invokeOrMock('set_switch_style_hotkey', { binding }, () => undefined); + return invokeOrMock("set_switch_style_hotkey", { binding }, () => undefined) } export function setOpenAppHotkey(binding: ShortcutBinding): Promise { - return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined); + return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined) } export function setShortcutRecordingActive(active: boolean): Promise { - return invokeOrMock('set_shortcut_recording_active', { active }, () => undefined); + return invokeOrMock( + "set_shortcut_recording_active", + { active }, + () => undefined, + ) } export async function openExternal(url: string): Promise { - if (!isTauri) { - window.open(url, '_blank', 'noopener,noreferrer'); - return; - } - const { open } = await import('@tauri-apps/plugin-shell'); - await open(url); + if (!isTauri) { + window.open(url, "_blank", "noopener,noreferrer") + return + } + const { open } = await import("@tauri-apps/plugin-shell") + await open(url) } /** * 让用户选 save 路径并把当前会话日志(openless.log)复制过去。 * 浏览器开发模式下走 mock 不实际写盘。返回最终 save 的绝对路径,取消选择则返回 null。 */ -export async function exportErrorLog(suggestedFileName: string): Promise { - if (!isTauri) { - return `~/Downloads/${suggestedFileName}`; - } - const { save } = await import('@tauri-apps/plugin-dialog'); - const target = await save({ - defaultPath: suggestedFileName, - filters: [{ name: 'Log', extensions: ['log', 'txt'] }], - }); - if (!target) return null; - await invokeOrMock('export_error_log', { targetPath: target }, () => undefined); - return target; -} - -export { isTauri }; +export async function exportErrorLog( + suggestedFileName: string, +): Promise { + if (!isTauri) { + return `~/Downloads/${suggestedFileName}` + } + const { save } = await import("@tauri-apps/plugin-dialog") + const target = await save({ + defaultPath: suggestedFileName, + filters: [{ name: "Log", extensions: ["log", "txt"] }], + }) + if (!target) return null + await invokeOrMock( + "export_error_log", + { targetPath: target }, + () => undefined, + ) + return target +} + +export { isTauri } // ── Marketplace (Phase A) ───────────────────────────────────────────── // 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback // 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。 const MOCK_MARKETPLACE: MarketplaceListItem[] = [ - { - id: '00000000-0000-0000-0000-000000000001', - slug: 'demo-pack', - name: '示范风格包', - description: 'Mock 数据 - vite dev 模式下显示', - authorLogin: 'demo', - version: '1.0.0', - baseMode: 'structured', - tags: ['demo'], - likeCount: 12, - downloadCount: 50, - publishedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, -]; + { + id: "00000000-0000-0000-0000-000000000001", + slug: "demo-pack", + name: "示范风格包", + description: "Mock 数据 - vite dev 模式下显示", + authorLogin: "demo", + version: "1.0.0", + baseMode: "structured", + tags: ["demo"], + likeCount: 12, + downloadCount: 50, + publishedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, +] export function listMarketplace( - options: { query?: string; sort?: 'new' | 'popular'; limit?: number } = {}, + options: { query?: string; sort?: "new" | "popular"; limit?: number } = {}, ): Promise { - return invokeOrMock('marketplace_list', options, () => MOCK_MARKETPLACE); + return invokeOrMock("marketplace_list", options, () => MOCK_MARKETPLACE) } -export function fetchMarketplaceDetail(packId: string): Promise { - return invokeOrMock('marketplace_detail', { packId }, () => ({ - ...MOCK_MARKETPLACE[0], - prompt: '# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。', - state: 'approved' as const, - })); +export function fetchMarketplaceDetail( + packId: string, +): Promise { + return invokeOrMock("marketplace_detail", { packId }, () => ({ + ...MOCK_MARKETPLACE[0], + prompt: "# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。", + state: "approved" as const, + })) } export function installMarketplacePack(packId: string): Promise { - return invokeOrMock('marketplace_install', { packId }, () => mockStylePacks[0]); + return invokeOrMock( + "marketplace_install", + { packId }, + () => mockStylePacks[0], + ) } export function uploadMarketplacePack( - packId: string, - originPackId?: string | null, + packId: string, + originPackId?: string | null, ): Promise<{ id: string; state: string; message: string }> { - return invokeOrMock('marketplace_upload', { packId, originPackId: originPackId ?? null }, () => ({ - id: 'mock-uploaded', - state: 'pending', - message: 'Mock 上传成功(vite dev)', - })); + return invokeOrMock( + "marketplace_upload", + { packId, originPackId: originPackId ?? null }, + () => ({ + id: "mock-uploaded", + state: "pending", + message: "Mock 上传成功(vite dev)", + }), + ) } export function likeMarketplacePack( - packId: string, + packId: string, ): Promise<{ likeCount: number; alreadyLiked: boolean }> { - return invokeOrMock('marketplace_like', { packId }, () => ({ - likeCount: 13, - alreadyLiked: false, - })); + return invokeOrMock("marketplace_like", { packId }, () => ({ + likeCount: 13, + alreadyLiked: false, + })) } /** 拉当前登录用户赞过的所有 pack id(用于红心 + 「我赞过的」过滤)。 */ export function marketplaceMyLikes(): Promise { - return invokeOrMock('marketplace_my_likes', undefined, () => []); + return invokeOrMock("marketplace_my_likes", undefined, () => []) } /** 拉当前登录用户发布过的所有 pack(含审核中/已撤回),用于「我的发布」。 */ export function marketplaceMyPacks(): Promise { - return invokeOrMock('marketplace_my_packs', undefined, () => []); + return invokeOrMock( + "marketplace_my_packs", + undefined, + () => [], + ) } /** 撤回自己发布的 pack(后端软删 state='withdrawn')。仅允许原作者。 */ export function marketplaceDelete(packId: string): Promise { - return invokeOrMock('marketplace_delete', { packId }, () => undefined); + return invokeOrMock("marketplace_delete", { packId }, () => undefined) } // ─────────────────────── GitHub OAuth Device Flow (Phase 1) ─────────────── @@ -1046,34 +1243,44 @@ export function marketplaceDelete(packId: string): Promise { // 需要预先配置 GITHUB_OAUTH_CLIENT_ID(OAuth App client_id,非敏感,可硬编码)。 export interface GithubDeviceStartResponse { - deviceCode: string; - userCode: string; - verificationUri: string; - interval: number; - expiresIn: number; + deviceCode: string + userCode: string + verificationUri: string + interval: number + expiresIn: number } export type GithubDevicePollResult = - | { kind: 'authorized'; login: string } - | { kind: 'pending' } - | { kind: 'slowDown' } - | { kind: 'error'; message: string }; + | { kind: "authorized"; login: string } + | { kind: "pending" } + | { kind: "slowDown" } + | { kind: "error"; message: string } export function githubDeviceFlowStart(): Promise { - return invokeOrMock('github_device_flow_start', undefined, () => ({ - deviceCode: 'mock-device-code-xxxxxxxx', - userCode: 'MOCK-CODE', - verificationUri: 'https://github.com/login/device', - interval: 5, - expiresIn: 900, - })); -} - -export function githubDeviceFlowPoll(deviceCode: string): Promise { - return invokeOrMock('github_device_flow_poll', { deviceCode }, () => ({ - kind: 'authorized' as const, - login: 'mock-user', - })); + return invokeOrMock( + "github_device_flow_start", + undefined, + () => ({ + deviceCode: "mock-device-code-xxxxxxxx", + userCode: "MOCK-CODE", + verificationUri: "https://github.com/login/device", + interval: 5, + expiresIn: 900, + }), + ) +} + +export function githubDeviceFlowPoll( + deviceCode: string, +): Promise { + return invokeOrMock( + "github_device_flow_poll", + { deviceCode }, + () => ({ + kind: "authorized" as const, + login: "mock-user", + }), + ) } // ─────────────────────── Marketplace 差量缓存(localStorage) ──────────────── @@ -1093,123 +1300,152 @@ export function githubDeviceFlowPoll(deviceCode: string): Promise MARKETPLACE_LIST_TTL_MS) return null; - return parsed.items.filter(it => it && isValidMarketplacePackId(it.id)); - } catch { - return null; - } + try { + const raw = localStorage.getItem(MARKETPLACE_LIST_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as { + items: MarketplaceListItem[] + ts: number + } + if (!parsed || !Array.isArray(parsed.items)) return null + if (Date.now() - parsed.ts > MARKETPLACE_LIST_TTL_MS) return null + return parsed.items.filter( + (it) => it && isValidMarketplacePackId(it.id), + ) + } catch { + return null + } } export function writeMarketplaceListCache(items: MarketplaceListItem[]): void { - try { - const sanitized = items.filter(it => it && isValidMarketplacePackId(it.id)); - localStorage.setItem( - MARKETPLACE_LIST_CACHE_KEY, - JSON.stringify({ items: sanitized, ts: Date.now() }), - ); - // 服务端最新视图里没有的 (id, version, updatedAt) 一律驱逐 —— - // 这是「云端哈希被移除时本机也移除」的执行点。 - const keepKeys = new Set( - sanitized.map(it => detailCacheKey(it.id, it.version ?? '', it.updatedAt ?? '')), - ); - pruneMarketplaceDetailCache(keepKeys); - } catch { - // quota exceeded / disabled — silent - } + try { + const sanitized = items.filter( + (it) => it && isValidMarketplacePackId(it.id), + ) + localStorage.setItem( + MARKETPLACE_LIST_CACHE_KEY, + JSON.stringify({ items: sanitized, ts: Date.now() }), + ) + // 服务端最新视图里没有的 (id, version, updatedAt) 一律驱逐 —— + // 这是「云端哈希被移除时本机也移除」的执行点。 + const keepKeys = new Set( + sanitized.map((it) => + detailCacheKey(it.id, it.version ?? "", it.updatedAt ?? ""), + ), + ) + pruneMarketplaceDetailCache(keepKeys) + } catch { + // quota exceeded / disabled — silent + } } type MarketplaceDetailCacheEntry = { - key: string; - detail: MarketplaceDetail; - ts: number; -}; - -function readMarketplaceDetailStore(): Record { - try { - const raw = localStorage.getItem(MARKETPLACE_DETAIL_CACHE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record | null; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - -function writeMarketplaceDetailStore(store: Record): void { - try { - localStorage.setItem(MARKETPLACE_DETAIL_CACHE_KEY, JSON.stringify(store)); - } catch { - // 配额耗尽 — 下次 read 时按 entries 数清理,命中失败会重新走网络。 - } + key: string + detail: MarketplaceDetail + ts: number +} + +function readMarketplaceDetailStore(): Record< + string, + MarketplaceDetailCacheEntry +> { + try { + const raw = localStorage.getItem(MARKETPLACE_DETAIL_CACHE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record< + string, + MarketplaceDetailCacheEntry + > | null + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +function writeMarketplaceDetailStore( + store: Record, +): void { + try { + localStorage.setItem( + MARKETPLACE_DETAIL_CACHE_KEY, + JSON.stringify(store), + ) + } catch { + // 配额耗尽 — 下次 read 时按 entries 数清理,命中失败会重新走网络。 + } } export function readMarketplaceDetailCache( - packId: string, - version: string, - updatedAt: string, + packId: string, + version: string, + updatedAt: string, ): MarketplaceDetail | null { - if (!isValidMarketplacePackId(packId)) return null; - const store = readMarketplaceDetailStore(); - const entry = store[detailCacheKey(packId, version, updatedAt)]; - if (!entry) return null; - if (Date.now() - entry.ts > MARKETPLACE_DETAIL_TTL_MS) return null; - if (!entry.detail || entry.detail.id !== packId) return null; - return entry.detail; + if (!isValidMarketplacePackId(packId)) return null + const store = readMarketplaceDetailStore() + const entry = store[detailCacheKey(packId, version, updatedAt)] + if (!entry) return null + if (Date.now() - entry.ts > MARKETPLACE_DETAIL_TTL_MS) return null + if (!entry.detail || entry.detail.id !== packId) return null + return entry.detail } export function writeMarketplaceDetailCache(detail: MarketplaceDetail): void { - if (!isValidMarketplacePackId(detail.id)) return; - if ( - typeof detail.prompt === 'string' - && detail.prompt.length > MARKETPLACE_DETAIL_MAX_PROMPT_CHARS - ) { - // 巨型 prompt 拒收 —— 防 OOM / 防服务端被攻陷后用大 payload 拖慢客户端。 - return; - } - const store = readMarketplaceDetailStore(); - const key = detailCacheKey(detail.id, detail.version ?? '', detail.updatedAt ?? ''); - store[key] = { key, detail, ts: Date.now() }; - // LRU: 旧的优先丢 - const entries = Object.values(store).sort((a, b) => a.ts - b.ts); - while (entries.length > MARKETPLACE_DETAIL_MAX_ENTRIES) { - const oldest = entries.shift(); - if (oldest) delete store[oldest.key]; - } - writeMarketplaceDetailStore(store); + if (!isValidMarketplacePackId(detail.id)) return + if ( + typeof detail.prompt === "string" && + detail.prompt.length > MARKETPLACE_DETAIL_MAX_PROMPT_CHARS + ) { + // 巨型 prompt 拒收 —— 防 OOM / 防服务端被攻陷后用大 payload 拖慢客户端。 + return + } + const store = readMarketplaceDetailStore() + const key = detailCacheKey( + detail.id, + detail.version ?? "", + detail.updatedAt ?? "", + ) + store[key] = { key, detail, ts: Date.now() } + // LRU: 旧的优先丢 + const entries = Object.values(store).sort((a, b) => a.ts - b.ts) + while (entries.length > MARKETPLACE_DETAIL_MAX_ENTRIES) { + const oldest = entries.shift() + if (oldest) delete store[oldest.key] + } + writeMarketplaceDetailStore(store) } function pruneMarketplaceDetailCache(keepKeys: Set): void { - const store = readMarketplaceDetailStore(); - let changed = false; - for (const key of Object.keys(store)) { - if (!keepKeys.has(key)) { - delete store[key]; - changed = true; + const store = readMarketplaceDetailStore() + let changed = false + for (const key of Object.keys(store)) { + if (!keepKeys.has(key)) { + delete store[key] + changed = true + } } - } - if (changed) writeMarketplaceDetailStore(store); + if (changed) writeMarketplaceDetailStore(store) } diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index e1aac87d..9b0b5a59 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -6,333 +6,600 @@ // 注意:模型文件清单与尺寸不在此处硬编码 —— 通过 // `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。 -import { invokeOrMock } from './ipc'; +import { invokeOrMock } from "./ipc" -export type LocalAsrMirror = 'huggingface' | 'hf-mirror'; +export type LocalAsrMirror = "huggingface" | "hf-mirror" export interface LocalAsrSettings { - providerId: string; - activeModel: string; - mirror: string; - /** macOS 才编入 vendored Open-Less/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */ - engineAvailable: boolean; + providerId: string + activeModel: string + mirror: string + /** macOS 才编入 vendored Open-Less/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */ + engineAvailable: boolean } export interface LocalAsrModelStatus { - id: string; - hfRepo: string; - downloadedBytes: number; - isDownloaded: boolean; + id: string + hfRepo: string + downloadedBytes: number + isDownloaded: boolean } export interface LocalAsrRemoteFile { - path: string; - size: number; + path: string + size: number } export interface LocalAsrRemoteInfo { - modelId: string; - mirror: string; - files: LocalAsrRemoteFile[]; - totalBytes: number; + modelId: string + mirror: string + files: LocalAsrRemoteFile[] + totalBytes: number } export type LocalAsrDownloadPhase = - | 'started' - | 'progress' - | 'finished' - | 'cancelled' - | 'failed'; + | "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; + modelId: string + file: string + fileIndex: number + fileCount: number + bytesDownloaded: number + bytesTotal: number + phase: LocalAsrDownloadPhase + error: string | null } export interface FoundryLocalAsrStatus { - providerId: string; - available: boolean; - runtimeReady: boolean; - runtimeSource: FoundryRuntimeSource; - activeModel: string; - loadedModelId: string | null; - endpoint: string | null; - error: string | null; + providerId: string + available: boolean + runtimeReady: boolean + runtimeSource: FoundryRuntimeSource + activeModel: string + loadedModelId: string | null + endpoint: string | null + error: string | null } export const FOUNDRY_LOCAL_ASR_MODEL_ALIASES = [ - 'whisper-small', - 'whisper-medium', - 'whisper-large-v3-turbo', - 'whisper-base', - 'whisper-tiny', -] as const; - -export type FoundryLocalAsrModelAlias = typeof FOUNDRY_LOCAL_ASR_MODEL_ALIASES[number]; -export type FoundryLocalAsrLanguageHint = '' | 'zh' | 'en'; -export type FoundryRuntimeSource = 'auto' | 'nuget' | 'ort-nightly'; + "whisper-small", + "whisper-medium", + "whisper-large-v3-turbo", + "whisper-base", + "whisper-tiny", +] as const + +export type FoundryLocalAsrModelAlias = + (typeof FOUNDRY_LOCAL_ASR_MODEL_ALIASES)[number] +export type FoundryLocalAsrLanguageHint = "" | "zh" | "en" +export type FoundryRuntimeSource = "auto" | "nuget" | "ort-nightly" export interface FoundryLocalAsrCatalogModel { - alias: FoundryLocalAsrModelAlias; - displayName: string; - cached: boolean; - fileSizeMb: number | null; + alias: FoundryLocalAsrModelAlias + displayName: string + cached: boolean + fileSizeMb: number | null } export type FoundryPreparePhase = - | 'runtime' - | 'model' - | 'load' - | 'finished' - | 'failed'; + | "runtime" + | "model" + | "load" + | "finished" + | "failed" export interface FoundryPrepareProgress { - phase: FoundryPreparePhase; - modelAlias: string; - label: string; - percent: number | null; - error: string | null; + phase: FoundryPreparePhase + modelAlias: string + label: string + percent: number | null + error: string | null } export interface FoundryLocalAsrModelOption { - alias: FoundryLocalAsrModelAlias; - labelKey: `localAsr.foundryModel${'Small' | 'Medium' | 'Large' | 'Base' | 'Tiny'}`; - descKey: `localAsr.foundryModel${'Small' | 'Medium' | 'Large' | 'Base' | 'Tiny'}Desc`; + alias: FoundryLocalAsrModelAlias + labelKey: `localAsr.foundryModel${"Small" | "Medium" | "Large" | "Base" | "Tiny"}` + descKey: `localAsr.foundryModel${"Small" | "Medium" | "Large" | "Base" | "Tiny"}Desc` } export const FOUNDRY_LOCAL_ASR_MODELS: FoundryLocalAsrModelOption[] = [ - { - alias: 'whisper-small', - labelKey: 'localAsr.foundryModelSmall', - descKey: 'localAsr.foundryModelSmallDesc', - }, - { - alias: 'whisper-medium', - labelKey: 'localAsr.foundryModelMedium', - descKey: 'localAsr.foundryModelMediumDesc', - }, - { - alias: 'whisper-large-v3-turbo', - labelKey: 'localAsr.foundryModelLarge', - descKey: 'localAsr.foundryModelLargeDesc', - }, - { - alias: 'whisper-base', - labelKey: 'localAsr.foundryModelBase', - descKey: 'localAsr.foundryModelBaseDesc', - }, - { - alias: 'whisper-tiny', - labelKey: 'localAsr.foundryModelTiny', - descKey: 'localAsr.foundryModelTinyDesc', - }, -]; + { + alias: "whisper-small", + labelKey: "localAsr.foundryModelSmall", + descKey: "localAsr.foundryModelSmallDesc", + }, + { + alias: "whisper-medium", + labelKey: "localAsr.foundryModelMedium", + descKey: "localAsr.foundryModelMediumDesc", + }, + { + alias: "whisper-large-v3-turbo", + labelKey: "localAsr.foundryModelLarge", + descKey: "localAsr.foundryModelLargeDesc", + }, + { + alias: "whisper-base", + labelKey: "localAsr.foundryModelBase", + descKey: "localAsr.foundryModelBaseDesc", + }, + { + alias: "whisper-tiny", + labelKey: "localAsr.foundryModelTiny", + descKey: "localAsr.foundryModelTinyDesc", + }, +] const MOCK_FOUNDRY_CATALOG: FoundryLocalAsrCatalogModel[] = [ - { - alias: 'whisper-small', - displayName: 'Whisper Small', - cached: false, - fileSizeMb: 967, - }, - { - alias: 'whisper-medium', - displayName: 'Whisper Medium', - cached: false, - fileSizeMb: 937, - }, - { - alias: 'whisper-large-v3-turbo', - displayName: 'Whisper Large V3 Turbo', - cached: false, - fileSizeMb: 1285, - }, - { - alias: 'whisper-base', - displayName: 'Whisper Base', - cached: true, - fileSizeMb: 291, - }, - { - alias: 'whisper-tiny', - displayName: 'Whisper Tiny', - cached: false, - fileSizeMb: 151, - }, -]; + { + alias: "whisper-small", + displayName: "Whisper Small", + cached: false, + fileSizeMb: 967, + }, + { + alias: "whisper-medium", + displayName: "Whisper Medium", + cached: false, + fileSizeMb: 937, + }, + { + alias: "whisper-large-v3-turbo", + displayName: "Whisper Large V3 Turbo", + cached: false, + fileSizeMb: 1285, + }, + { + alias: "whisper-base", + displayName: "Whisper Base", + cached: true, + fileSizeMb: 291, + }, + { + alias: "whisper-tiny", + displayName: "Whisper Tiny", + cached: false, + fileSizeMb: 151, + }, +] const MOCK_SETTINGS: LocalAsrSettings = { - providerId: 'local-qwen3', - activeModel: 'qwen3-asr-0.6b', - mirror: 'huggingface', - engineAvailable: false, -}; + 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, - }, -]; + { + id: "qwen3-asr-0.6b", + hfRepo: "Qwen/Qwen3-ASR-0.6B", + downloadedBytes: 0, + isDownloaded: false, + }, + { + id: "qwen3-asr-1.7b", + hfRepo: "Qwen/Qwen3-ASR-1.7B", + downloadedBytes: 0, + isDownloaded: false, + }, +] export function getLocalAsrSettings(): Promise { - return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS); + return invokeOrMock( + "local_asr_get_settings", + undefined, + () => MOCK_SETTINGS, + ) } export function setLocalAsrActiveModel(modelId: string): Promise { - return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined); + return invokeOrMock( + "local_asr_set_active_model", + { modelId }, + () => undefined, + ) } export function setLocalAsrMirror(mirror: string): Promise { - return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined); + return invokeOrMock("local_asr_set_mirror", { mirror }, () => undefined) } export function listLocalAsrModels(): Promise { - return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS); + return invokeOrMock("local_asr_list_models", undefined, () => MOCK_MODELS) } export function fetchLocalAsrRemoteInfo( - modelId: string, - mirror?: string, + modelId: string, + mirror?: string, ): Promise { - return invokeOrMock( - 'local_asr_fetch_remote_info', - { modelId, mirror }, - () => ({ - modelId, - mirror: mirror ?? 'huggingface', - files: [], - totalBytes: 0, - }), - ); + return invokeOrMock( + "local_asr_fetch_remote_info", + { modelId, mirror }, + () => ({ + modelId, + mirror: mirror ?? "huggingface", + files: [], + totalBytes: 0, + }), + ) } export function downloadLocalAsrModel( - modelId: string, - mirror?: string, + modelId: string, + mirror?: string, ): Promise { - return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined); + return invokeOrMock( + "local_asr_download_model", + { modelId, mirror }, + () => undefined, + ) } export function cancelLocalAsrDownload(modelId: string): Promise { - return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined); + return invokeOrMock( + "local_asr_cancel_download", + { modelId }, + () => undefined, + ) } export function deleteLocalAsrModel(modelId: string): Promise { - return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined); + return invokeOrMock("local_asr_delete_model", { modelId }, () => undefined) } export interface LocalAsrTestResult { - backend: string; - modelId: string; - expectedText: string; - transcribedText: string; - audioMs: number; - loadMs: number; - transcribeMs: number; -} - -export function testLocalAsrModel(modelId: string): Promise { - return invokeOrMock( - 'local_asr_test_model', - { modelId }, - () => ({ - backend: 'mock', - modelId, - expectedText: 'Hello. This is a test of the Voxtrail speech-to-text system.', - transcribedText: '(浏览器 dev mock,实际推理需要在 Tauri 应用内)', - audioMs: 3000, - loadMs: 0, - transcribeMs: 0, - }), - ); + backend: string + modelId: string + expectedText: string + transcribedText: string + audioMs: number + loadMs: number + transcribeMs: number +} + +export function testLocalAsrModel( + modelId: string, +): Promise { + return invokeOrMock("local_asr_test_model", { modelId }, () => ({ + backend: "mock", + modelId, + expectedText: + "Hello. This is a test of the Voxtrail speech-to-text system.", + transcribedText: "(浏览器 dev mock,实际推理需要在 Tauri 应用内)", + audioMs: 3000, + loadMs: 0, + transcribeMs: 0, + })) } export interface LocalAsrEngineStatus { - loaded: boolean; - modelId: string | null; - keepLoadedSecs: number; + loaded: boolean + modelId: string | null + keepLoadedSecs: number } export function getLocalAsrEngineStatus(): Promise { - return invokeOrMock('local_asr_engine_status', undefined, () => ({ - loaded: false, - modelId: null, - keepLoadedSecs: 300, - })); + return invokeOrMock("local_asr_engine_status", undefined, () => ({ + loaded: false, + modelId: null, + keepLoadedSecs: 300, + })) } export function releaseLocalAsrEngine(): Promise { - return invokeOrMock('local_asr_release_engine', undefined, () => undefined); + return invokeOrMock("local_asr_release_engine", undefined, () => undefined) } export function preloadLocalAsr(): Promise { - return invokeOrMock('local_asr_preload', undefined, () => undefined); + return invokeOrMock("local_asr_preload", undefined, () => undefined) } export function setLocalAsrKeepLoadedSecs(seconds: number): Promise { - return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined); + return invokeOrMock( + "local_asr_set_keep_loaded_secs", + { seconds }, + () => undefined, + ) } export function getFoundryLocalAsrStatus(): Promise { - return invokeOrMock('foundry_local_asr_status', undefined, () => ({ - providerId: 'foundry-local-whisper', - available: true, - runtimeReady: false, - runtimeSource: 'auto', - activeModel: 'whisper-small', - loadedModelId: null, - endpoint: null, - error: null, - })); -} - -export function getFoundryLocalAsrCatalog(): Promise { - return invokeOrMock('foundry_local_asr_catalog', undefined, () => MOCK_FOUNDRY_CATALOG); + return invokeOrMock("foundry_local_asr_status", undefined, () => ({ + providerId: "foundry-local-whisper", + available: true, + runtimeReady: false, + runtimeSource: "auto", + activeModel: "whisper-small", + loadedModelId: null, + endpoint: null, + error: null, + })) +} + +export function getFoundryLocalAsrCatalog(): Promise< + FoundryLocalAsrCatalogModel[] +> { + return invokeOrMock( + "foundry_local_asr_catalog", + undefined, + () => MOCK_FOUNDRY_CATALOG, + ) } export function setFoundryLocalAsrModel(modelAlias: string): Promise { - return invokeOrMock('foundry_local_asr_set_model', { modelAlias }, () => undefined); + return invokeOrMock( + "foundry_local_asr_set_model", + { modelAlias }, + () => undefined, + ) } -export function setFoundryLocalAsrLanguageHint(languageHint: string): Promise { - return invokeOrMock( - 'foundry_local_asr_set_language_hint', - { languageHint }, - () => undefined, - ); +export function setFoundryLocalAsrLanguageHint( + languageHint: string, +): Promise { + return invokeOrMock( + "foundry_local_asr_set_language_hint", + { languageHint }, + () => undefined, + ) } export function setFoundryLocalRuntimeSource(source: string): Promise { - return invokeOrMock( - 'foundry_local_asr_set_runtime_source', - { source }, - () => undefined, - ); + return invokeOrMock( + "foundry_local_asr_set_runtime_source", + { source }, + () => undefined, + ) } export function prepareFoundryLocalAsr(modelAlias: string): Promise { - return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`); + return invokeOrMock( + "foundry_local_asr_prepare", + { modelAlias }, + () => `mock-${modelAlias}`, + ) } export function cancelFoundryLocalAsrPrepare(): Promise { - return invokeOrMock('foundry_local_asr_cancel_prepare', undefined, () => undefined); + return invokeOrMock( + "foundry_local_asr_cancel_prepare", + undefined, + () => undefined, + ) } export function releaseFoundryLocalAsr(): Promise { - return invokeOrMock('foundry_local_asr_release', undefined, () => undefined); + return invokeOrMock("foundry_local_asr_release", undefined, () => undefined) +} + +// ─── Sherpa-Onnx Local ASR ─────────────────────────────────────────── + +export type SherpaOnnxModelAlias = + | "sense-voice-small-zh" + | "paraformer-zh" + | "whisper-small-multi" + | "qwen3-asr-0.6b-int8" + +export type SherpaOnnxMirror = "huggingface" | "hf-mirror" | "github-release" + +export interface SherpaOnnxAsrStatus { + providerId: string + available: boolean + runtimeReady: boolean + activeModel: string + loadedModelId: string | null + error: string | null +} + +export interface SherpaOnnxCatalogModel { + alias: SherpaOnnxModelAlias + displayName: string + cached: boolean + fileSizeMb: number | null +} + +export interface SherpaOnnxModelOption { + alias: SherpaOnnxModelAlias + labelKey: string + descKey: string +} + +export const SHERPA_ONNX_ASR_MODELS: SherpaOnnxModelOption[] = [ + { + alias: "sense-voice-small-zh", + labelKey: "localAsr.sherpaModelSenseVoice", + descKey: "localAsr.sherpaModelSenseVoiceDesc", + }, + { + alias: "paraformer-zh", + labelKey: "localAsr.sherpaModelParaformer", + descKey: "localAsr.sherpaModelParaformerDesc", + }, + { + alias: "whisper-small-multi", + labelKey: "localAsr.sherpaModelWhisper", + descKey: "localAsr.sherpaModelWhisperDesc", + }, + { + alias: "qwen3-asr-0.6b-int8", + labelKey: "localAsr.sherpaModelQwen3", + descKey: "localAsr.sherpaModelQwen3Desc", + }, +] + +export function getSherpaOnnxAsrStatus(): Promise { + return invokeOrMock("sherpa_onnx_asr_status", undefined, () => ({ + providerId: "sherpa-onnx-local", + available: true, + runtimeReady: false, + activeModel: "sense-voice-small-zh", + loadedModelId: null, + error: null, + })) +} + +export function getSherpaOnnxAsrCatalog(): Promise { + return invokeOrMock("sherpa_onnx_asr_catalog", undefined, () => [ + { + alias: "sense-voice-small-zh" as const, + displayName: "SenseVoice Small", + cached: false, + fileSizeMb: 230, + }, + { + alias: "paraformer-zh" as const, + displayName: "Paraformer ZH", + cached: false, + fileSizeMb: 220, + }, + { + alias: "whisper-small-multi" as const, + displayName: "Whisper Small", + cached: false, + fileSizeMb: 480, + }, + { + alias: "qwen3-asr-0.6b-int8" as const, + displayName: "Qwen3-ASR 0.6B INT8", + cached: false, + fileSizeMb: 700, + }, + ]) +} + +export function setSherpaOnnxAsrModel(modelAlias: string): Promise { + return invokeOrMock( + "sherpa_onnx_asr_set_model", + { modelAlias }, + () => undefined, + ) +} + +export function setSherpaOnnxAsrLanguageHint( + languageHint: string, +): Promise { + return invokeOrMock( + "sherpa_onnx_asr_set_language_hint", + { languageHint }, + () => undefined, + ) +} + +export function prepareSherpaOnnxAsr(modelAlias?: string): Promise { + return invokeOrMock( + "sherpa_onnx_asr_prepare", + modelAlias ? { modelAlias } : undefined, + () => undefined, + ) +} + +export function cancelSherpaOnnxAsrPrepare(): Promise { + return invokeOrMock( + "sherpa_onnx_asr_cancel_prepare", + undefined, + () => undefined, + ) +} + +export function releaseSherpaOnnxAsr(): Promise { + return invokeOrMock("sherpa_onnx_asr_release", undefined, () => undefined) +} + +export function getSherpaOnnxAsrModelDir(modelAlias?: string): Promise { + return invokeOrMock( + "sherpa_onnx_asr_model_dir", + modelAlias ? { modelAlias } : undefined, + () => "", + ) +} + +export function revealSherpaOnnxAsrModelDir( + modelAlias?: string, +): Promise { + return invokeOrMock( + "sherpa_onnx_asr_reveal_model_dir", + modelAlias ? { modelAlias } : undefined, + () => undefined, + ) +} + +export function deleteSherpaOnnxAsrModel(modelAlias: string): Promise { + return invokeOrMock( + "sherpa_onnx_asr_delete_model", + { modelAlias }, + () => undefined, + ) +} + +export interface SherpaOnnxRemoteInfo { + modelAlias: string + mirror: string + files: { path: string; sizeBytes: number }[] + totalBytes: number +} + +export function fetchSherpaOnnxAsrRemoteInfo( + modelAlias: string, + mirror?: string, +): Promise { + return invokeOrMock( + "sherpa_onnx_asr_fetch_remote_info", + { modelAlias, mirror }, + () => ({ + modelAlias, + mirror: mirror ?? "huggingface", + files: [], + totalBytes: 0, + }), + ) +} + +export function downloadSherpaOnnxAsrModel( + modelAlias: string, + mirror?: string, +): Promise { + return invokeOrMock( + "sherpa_onnx_asr_download_model", + { modelAlias, mirror }, + () => undefined, + ) +} + +export function cancelSherpaOnnxAsrDownload( + modelAlias?: string, +): Promise { + return invokeOrMock( + "sherpa_onnx_asr_cancel_download", + modelAlias ? { modelAlias } : undefined, + () => undefined, + ) +} + +export type SherpaOnnxLanguageHint = + | "" + | "auto" + | "zh" + | "en" + | "ja" + | "ko" + | "yue" + +export type SherpaPreparePhase = + | "runtime" + | "model" + | "load" + | "finished" + | "failed" + +export interface SherpaPrepareProgress { + phase: SherpaPreparePhase + modelAlias: string + label: string + percent: number | null + error: string | null } diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 3f93ca38..52154120 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -55,6 +55,9 @@ const previousPrefs: UserPreferences = { foundryLocalRuntimeSource: 'auto', foundryLocalAsrLanguageHint: '', foundryLocalAsrKeepLoadedSecs: 300, + sherpaOnnxModel: 'sense-voice-small-zh', + sherpaOnnxLanguageHint: '', + sherpaOnnxKeepLoadedSecs: 300, historyRetentionDays: 7, polishContextWindowMinutes: 5, startMinimized: false, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 21a6a722..245e7895 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -267,6 +267,12 @@ export interface UserPreferences { foundryLocalAsrLanguageHint: string; /** Windows Foundry Local Whisper 模型在 runtime 中保持加载的秒数。 */ foundryLocalAsrKeepLoadedSecs: number; + /** Windows sherpa-onnx 本地 ASR 当前激活的模型 alias。 */ + sherpaOnnxModel: string; + /** Windows sherpa-onnx 语言 hint。空字符串表示自动检测。 */ + sherpaOnnxLanguageHint: string; + /** Windows sherpa-onnx 模型在 runtime 中保持加载的秒数。 */ + sherpaOnnxKeepLoadedSecs: number; /** 历史记录保留天数。0 = 不按时间清理(仍受 200 条上限)。默认 7。 */ historyRetentionDays: number; /** 对话感知 polish 上下文窗口(分钟)。0 = 关闭。默认 5。详见 PR-A。 */ diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index bd7d6bdf..c2fdc919 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -7,48 +7,74 @@ // - 监听 `local-asr-download-progress` 事件实时刷新进度 // - Win 端引擎不可用时禁用下载按钮,提示见 issue #256 -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import { isTauri, setActiveAsrProvider } from '../lib/ipc'; import { - FOUNDRY_LOCAL_ASR_MODELS, - cancelFoundryLocalAsrPrepare, - cancelLocalAsrDownload, - deleteLocalAsrModel, - downloadLocalAsrModel, - fetchLocalAsrRemoteInfo, - getFoundryLocalAsrCatalog, - getFoundryLocalAsrStatus, - getLocalAsrEngineStatus, - getLocalAsrSettings, - listLocalAsrModels, - prepareFoundryLocalAsr, - preloadLocalAsr, - releaseFoundryLocalAsr, - releaseLocalAsrEngine, - setFoundryLocalAsrLanguageHint, - setFoundryLocalAsrModel, - setFoundryLocalRuntimeSource, - setLocalAsrActiveModel, - setLocalAsrKeepLoadedSecs, - setLocalAsrMirror, - testLocalAsrModel, - type FoundryLocalAsrCatalogModel, - type FoundryLocalAsrLanguageHint, - type FoundryLocalAsrModelAlias, - type FoundryLocalAsrStatus, - type FoundryRuntimeSource, - type FoundryPrepareProgress, - type LocalAsrDownloadProgress, - type LocalAsrEngineStatus, - type LocalAsrModelStatus, - type LocalAsrSettings, - type LocalAsrTestResult, -} from '../lib/localAsr'; -import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { detectOS } from '../components/WindowChrome'; -import { SelectLite } from '../components/ui/SelectLite'; -import { Btn, Card, PageHeader, Pill } from './_atoms'; + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" +import { useTranslation } from "react-i18next" +import { isTauri, setActiveAsrProvider } from "../lib/ipc" +import { + FOUNDRY_LOCAL_ASR_MODELS, + SHERPA_ONNX_ASR_MODELS, + cancelFoundryLocalAsrPrepare, + cancelSherpaOnnxAsrDownload, + cancelSherpaOnnxAsrPrepare, + cancelLocalAsrDownload, + deleteSherpaOnnxAsrModel, + deleteLocalAsrModel, + downloadLocalAsrModel, + downloadSherpaOnnxAsrModel, + fetchLocalAsrRemoteInfo, + fetchSherpaOnnxAsrRemoteInfo, + getFoundryLocalAsrCatalog, + getFoundryLocalAsrStatus, + getLocalAsrEngineStatus, + getLocalAsrSettings, + getSherpaOnnxAsrCatalog, + getSherpaOnnxAsrModelDir, + getSherpaOnnxAsrStatus, + listLocalAsrModels, + prepareFoundryLocalAsr, + prepareSherpaOnnxAsr, + preloadLocalAsr, + releaseFoundryLocalAsr, + releaseLocalAsrEngine, + releaseSherpaOnnxAsr, + revealSherpaOnnxAsrModelDir, + setFoundryLocalAsrLanguageHint, + setFoundryLocalAsrModel, + setFoundryLocalRuntimeSource, + setLocalAsrActiveModel, + setLocalAsrKeepLoadedSecs, + setLocalAsrMirror, + setSherpaOnnxAsrLanguageHint, + setSherpaOnnxAsrModel, + testLocalAsrModel, + type FoundryLocalAsrCatalogModel, + type FoundryLocalAsrLanguageHint, + type FoundryLocalAsrModelAlias, + type FoundryLocalAsrStatus, + type FoundryRuntimeSource, + type FoundryPrepareProgress, + type LocalAsrDownloadProgress, + type LocalAsrEngineStatus, + type LocalAsrModelStatus, + type LocalAsrSettings, + type LocalAsrTestResult, + type SherpaOnnxAsrStatus, + type SherpaOnnxCatalogModel, + type SherpaOnnxLanguageHint, + type SherpaOnnxModelAlias, + type SherpaPrepareProgress, +} from "../lib/localAsr" +import { useHotkeySettings } from "../state/HotkeySettingsContext" +import { detectOS } from "../components/WindowChrome" +import { SelectLite } from "../components/ui/SelectLite" +import { Btn, Card, PageHeader, Pill } from "./_atoms" // Foundry Local Whisper 后端只在 Windows 编译实体(foundry_local_sdk 仅 Windows), // 非 Windows 平台 runtime 是 stub 永远 unavailable。前端这一页对应的卡片、状态拉取、 @@ -58,1177 +84,2911 @@ import { Btn, Card, PageHeader, Pill } from './_atoms'; // `#[cfg(target_os = "macos")]`),Qwen3 模型管理 UI 也按 IS_MAC 守严——之前用 // `!IS_WINDOWS` 会让假设的 Linux 渲染路径暴露死 UI(pr_agent #403 'Linux regression' // 修法)。 -const IS_WINDOWS = detectOS() === 'win'; -const IS_MAC = detectOS() === 'mac'; +const IS_WINDOWS = detectOS() === "win" +const IS_MAC = detectOS() === "mac" interface RemoteSize { - totalBytes: number; - fileCount: number; - loading: boolean; - error: string | null; + totalBytes: number + fileCount: number + loading: boolean + error: string | null } interface LocalAsrProps { - /// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced); - /// 此时跳过外层 page padding/height、PageHeader 与独立警告 Card —— 这些由 - /// 宿主 AdvancedSection 决定(包括把警告统一到页面顶部的浮层 popup 上)。 - /// `embedded=false`(默认)保留原全屏页样式,供 v 旧版本的独立「模型设置」 - /// 页面入口使用——但当前代码里该入口已删,本分支会一并移除。 - embedded?: boolean; + /// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced); + /// 此时跳过外层 page padding/height、PageHeader 与独立警告 Card —— 这些由 + /// 宿主 AdvancedSection 决定(包括把警告统一到页面顶部的浮层 popup 上)。 + /// `embedded=false`(默认)保留原全屏页样式,供 v 旧版本的独立「模型设置」 + /// 页面入口使用——但当前代码里该入口已删,本分支会一并移除。 + embedded?: boolean } export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { - const { t } = useTranslation(); - const { prefs, updatePrefs } = useHotkeySettings(); - const [settings, setSettings] = useState(null); - const [models, setModels] = useState([]); - const [progress, setProgress] = useState>({}); - const [remoteSizes, setRemoteSizes] = useState>({}); - const [error, setError] = useState(null); - const [busyModelId, setBusyModelId] = useState(null); - const [foundryStatus, setFoundryStatus] = useState(null); - const [foundryCatalog, setFoundryCatalog] = useState([]); - const [selectedFoundryAlias, setSelectedFoundryAlias] = useState('whisper-small'); - const [foundryBusy, setFoundryBusy] = useState<'enable' | 'prepare' | 'release' | null>(null); - const [foundryProgress, setFoundryProgress] = useState(null); - const [foundryCancelRequested, setFoundryCancelRequested] = useState(false); - const [testingModelId, setTestingModelId] = useState(null); - const [testResults, setTestResults] = useState>({}); - const [engineStatus, setEngineStatus] = useState(null); - const refreshTimer = useRef(null); - const foundryRefreshTimer = useRef(null); - const engineStatusTimer = useRef(null); - const foundrySelectionDirty = useRef(false); - - const refreshEngineStatus = async () => { - try { - const status = await getLocalAsrEngineStatus(); - setEngineStatus(status); - } catch (err) { - console.warn('[localAsr] engine status query failed', err); + const { t } = useTranslation() + const { prefs, updatePrefs } = useHotkeySettings() + const [settings, setSettings] = useState(null) + const [models, setModels] = useState([]) + const [progress, setProgress] = useState< + Record + >({}) + const [remoteSizes, setRemoteSizes] = useState>( + {}, + ) + const [error, setError] = useState(null) + const [busyModelId, setBusyModelId] = useState(null) + const [foundryStatus, setFoundryStatus] = + useState(null) + const [foundryCatalog, setFoundryCatalog] = useState< + FoundryLocalAsrCatalogModel[] + >([]) + const [selectedFoundryAlias, setSelectedFoundryAlias] = + useState("whisper-small") + const [foundryBusy, setFoundryBusy] = useState< + "enable" | "prepare" | "release" | null + >(null) + const [foundryProgress, setFoundryProgress] = + useState(null) + const [foundryCancelRequested, setFoundryCancelRequested] = useState(false) + const [sherpaStatus, setSherpaStatus] = + useState(null) + const [sherpaCatalog, setSherpaCatalog] = useState< + SherpaOnnxCatalogModel[] + >([]) + const [selectedSherpaAlias, setSelectedSherpaAlias] = + useState("sense-voice-small-zh") + const [sherpaBusy, setSherpaBusy] = useState< + | "enable" + | "prepare" + | "download" + | "release" + | "delete" + | "reveal" + | null + >(null) + const [sherpaProgress, setSherpaProgress] = + useState(null) + const [sherpaDownloadProgress, setSherpaDownloadProgress] = useState< + Record + >({}) + const [sherpaRemoteSizes, setSherpaRemoteSizes] = useState< + Record + >({}) + const [sherpaCancelRequested, setSherpaCancelRequested] = useState(false) + const [sherpaDownloadCancelRequested, setSherpaDownloadCancelRequested] = + useState(false) + const [sherpaModelDir, setSherpaModelDir] = useState("") + const [testingModelId, setTestingModelId] = useState(null) + const [testResults, setTestResults] = useState< + Record + >({}) + const [engineStatus, setEngineStatus] = + useState(null) + const refreshTimer = useRef(null) + const foundryRefreshTimer = useRef(null) + const sherpaRefreshTimer = useRef(null) + const sherpaDownloadRefreshTimer = useRef(null) + const engineStatusTimer = useRef(null) + const foundrySelectionDirty = useRef(false) + const sherpaSelectionDirty = useRef(false) + const sherpaAnchorRef = useRef(null) + const scrollGuard = useRef<{ scroller: HTMLElement; top: number } | null>( + null, + ) + const scrollGuardTimer = useRef(null) + const scrollGuardCleanup = useRef<(() => void) | null>(null) + + const restoreScrollGuard = () => { + const guard = scrollGuard.current + if (!guard) return + if (guard.scroller.scrollTop !== guard.top) { + guard.scroller.scrollTop = guard.top + } + } + + const scheduleScrollGuardRestore = () => { + window.setTimeout(restoreScrollGuard, 0) + window.setTimeout(restoreScrollGuard, 80) + window.setTimeout(restoreScrollGuard, 200) + window.requestAnimationFrame(() => { + restoreScrollGuard() + window.requestAnimationFrame(restoreScrollGuard) + }) + } + + const activateScrollGuard = () => { + if (scrollGuardCleanup.current) scrollGuardCleanup.current() + const scroller = sherpaAnchorRef.current?.closest( + ".ol-thinscroll", + ) as HTMLElement | null + if (!scroller) return + scrollGuard.current = { scroller, top: scroller.scrollTop } + scheduleScrollGuardRestore() + + const deactivate = () => { + scrollGuard.current = null + scroller.removeEventListener("wheel", deactivate) + scroller.removeEventListener("pointerdown", deactivate) + if (scrollGuardTimer.current) { + window.clearTimeout(scrollGuardTimer.current) + scrollGuardTimer.current = null + } + scrollGuardCleanup.current = null + } + scrollGuardCleanup.current = deactivate + scroller.addEventListener("wheel", deactivate, { + once: true, + passive: true, + }) + scroller.addEventListener("pointerdown", deactivate, { once: true }) + if (scrollGuardTimer.current) + window.clearTimeout(scrollGuardTimer.current) + scrollGuardTimer.current = window.setTimeout(deactivate, 10_000) + } + + useLayoutEffect(() => { + restoreScrollGuard() + }) + + const preserveEmbeddedScroll = (element: Element | null) => { + const scroller = element?.closest( + ".ol-thinscroll", + ) as HTMLElement | null + if (!scroller) return () => undefined + const top = scroller.scrollTop + return () => { + window.requestAnimationFrame(() => { + scroller.scrollTop = top + }) + } + } + + const refreshEngineStatus = async () => { + try { + const status = await getLocalAsrEngineStatus() + setEngineStatus(status) + } catch (err) { + console.warn("[localAsr] engine status query failed", err) + } + } + + const refreshFoundryStatus = async () => { + try { + const status = await getFoundryLocalAsrStatus() + setFoundryStatus(status) + if ( + !foundrySelectionDirty.current && + isFoundryAlias(status.activeModel) + ) { + setSelectedFoundryAlias(status.activeModel) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setFoundryStatus({ + providerId: "foundry-local-whisper", + available: false, + runtimeReady: false, + runtimeSource: selectedFoundryRuntimeSource, + activeModel: selectedFoundryAlias, + loadedModelId: null, + endpoint: null, + error: message, + }) + } + } + + const refreshFoundryCatalog = async () => { + try { + const catalog = await getFoundryLocalAsrCatalog() + setFoundryCatalog(catalog) + } catch (err) { + console.warn("[localAsr] Foundry catalog query failed", err) + } + } + + const refreshSherpaStatus = async () => { + try { + const status = await getSherpaOnnxAsrStatus() + setSherpaStatus(status) + if ( + !sherpaSelectionDirty.current && + isSherpaAlias(status.activeModel) + ) { + setSelectedSherpaAlias(status.activeModel) + void refreshSherpaModelDir(status.activeModel) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setSherpaStatus({ + providerId: "sherpa-onnx-local", + available: false, + runtimeReady: false, + activeModel: selectedSherpaAlias, + loadedModelId: null, + error: message, + }) + } + } + + const refreshSherpaCatalog = async () => { + try { + const catalog = await getSherpaOnnxAsrCatalog() + setSherpaCatalog(catalog) + } catch (err) { + console.warn("[localAsr] Sherpa catalog query failed", err) + } + } + + const refreshSherpaModelDir = async (modelAlias: string) => { + try { + const dir = await getSherpaOnnxAsrModelDir(modelAlias) + setSherpaModelDir((current) => (current === dir ? current : dir)) + } catch (err) { + console.warn("[localAsr] Sherpa model dir query failed", err) + } + } + + const refresh = async () => { + try { + setError(null) + const [s, list] = await Promise.all([ + getLocalAsrSettings(), + listLocalAsrModels(), + ]) + setSettings(s) + setModels(list) + void refreshEngineStatus() + if (IS_WINDOWS) { + void refreshFoundryStatus() + void refreshFoundryCatalog() + void refreshSherpaStatus() + void refreshSherpaCatalog() + void refreshSherpaModelDir(selectedSherpaAlias) + void Promise.all( + SHERPA_ONNX_ASR_MODELS.map((m) => + ensureSherpaRemoteSize(m.alias, s.mirror), + ), + ) + } + // 拉远端真实尺寸(每个模型一次,结果留缓存) + 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), + }, + })) + } + } + + const ensureSherpaRemoteSize = async ( + modelAlias: string, + mirror: string, + ) => { + setSherpaRemoteSizes((prev) => { + if (prev[modelAlias] && !prev[modelAlias].error) return prev + return { + ...prev, + [modelAlias]: { + totalBytes: 0, + fileCount: 0, + loading: true, + error: null, + }, + } + }) + try { + const info = await fetchSherpaOnnxAsrRemoteInfo(modelAlias, mirror) + setSherpaRemoteSizes((prev) => ({ + ...prev, + [modelAlias]: { + totalBytes: info.totalBytes, + fileCount: info.files.length, + loading: false, + error: null, + }, + })) + } catch (e) { + setSherpaRemoteSizes((prev) => ({ + ...prev, + [modelAlias]: { + 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) + } + if (scrollGuardCleanup.current) scrollGuardCleanup.current() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 镜像变更后重拉一次远端尺寸(不同镜像 API 返回的 size 数值是一致的, + // 但请求路径不同——切镜像时强制刷新一次让用户看到新源能否访通)。 + useEffect(() => { + if (!settings) return + setRemoteSizes({}) + setSherpaRemoteSizes({}) + void Promise.all( + models.map((m) => ensureRemoteSize(m.id, settings.mirror)), + ) + if (IS_WINDOWS) { + void Promise.all( + SHERPA_ONNX_ASR_MODELS.map((m) => + ensureSherpaRemoteSize(m.alias, settings.mirror), + ), + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings?.mirror]) + + // 订阅下载进度事件 — 仅 Tauri 环境(浏览器 dev mock 无事件)。 + useEffect(() => { + if (!isTauri) return + let unlisten: undefined | (() => void) + let cancelled = false + ;(async () => { + const { listen } = await import("@tauri-apps/api/event") + const off = await listen( + "local-asr-download-progress", + (e) => { + const payload = e.payload + if (payload.phase === "cancelled") { + // 取消时清条目,bar 是否还显示交给 hasPartial 判断 + setProgress((prev) => { + const next = { ...prev } + delete next[payload.modelId] + return next + }) + } else { + 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 + }, []) + + useEffect(() => { + if (!isTauri || !IS_WINDOWS) return + let unlisten: undefined | (() => void) + let cancelled = false + ;(async () => { + const { listen } = await import("@tauri-apps/api/event") + const off = await listen( + "foundry-local-asr-prepare-progress", + (e) => { + const payload = e.payload + setFoundryProgress(payload) + if ( + payload.phase === "finished" || + payload.phase === "failed" + ) { + if (foundryRefreshTimer.current) + window.clearTimeout(foundryRefreshTimer.current) + foundryRefreshTimer.current = window.setTimeout(() => { + void refreshFoundryStatus() + void refreshFoundryCatalog() + }, 200) + } + }, + ) + if (cancelled) { + off() + } else { + unlisten = off + } + })().catch((err) => + console.warn("[localAsr] Foundry prepare subscribe failed", err), + ) + return () => { + cancelled = true + if (unlisten) unlisten() + if (foundryRefreshTimer.current) + window.clearTimeout(foundryRefreshTimer.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!isTauri || !IS_WINDOWS) return + let unlisten: undefined | (() => void) + let cancelled = false + ;(async () => { + const { listen } = await import("@tauri-apps/api/event") + const off = await listen( + "sherpa-onnx-asr-prepare-progress", + (e) => { + const payload = e.payload + setSherpaProgress(payload) + if ( + payload.phase === "finished" || + payload.phase === "failed" + ) { + if (sherpaRefreshTimer.current) + window.clearTimeout(sherpaRefreshTimer.current) + sherpaRefreshTimer.current = window.setTimeout(() => { + void refreshSherpaStatus() + void refreshSherpaCatalog() + }, 200) + } + }, + ) + if (cancelled) { + off() + } else { + unlisten = off + } + })().catch((err) => + console.warn("[localAsr] Sherpa prepare subscribe failed", err), + ) + return () => { + cancelled = true + if (unlisten) unlisten() + if (sherpaRefreshTimer.current) + window.clearTimeout(sherpaRefreshTimer.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!isTauri || !IS_WINDOWS) return + let unlisten: undefined | (() => void) + let cancelled = false + ;(async () => { + const { listen } = await import("@tauri-apps/api/event") + const off = await listen( + "sherpa-onnx-asr-download-progress", + (e) => { + const payload = e.payload + setSherpaDownloadProgress((prev) => ({ + ...prev, + [payload.modelId]: payload, + })) + if ( + payload.phase === "finished" || + payload.phase === "cancelled" || + payload.phase === "failed" + ) { + setSherpaBusy((current) => + current === "download" ? null : current, + ) + setSherpaDownloadCancelRequested(false) + if (sherpaDownloadRefreshTimer.current) { + window.clearTimeout( + sherpaDownloadRefreshTimer.current, + ) + } + sherpaDownloadRefreshTimer.current = window.setTimeout( + () => { + void refreshSherpaStatus() + void refreshSherpaCatalog() + void refreshSherpaModelDir(payload.modelId) + }, + 200, + ) + } + }, + ) + if (cancelled) { + off() + } else { + unlisten = off + } + })().catch((err) => + console.warn("[localAsr] Sherpa download subscribe failed", err), + ) + return () => { + cancelled = true + if (unlisten) unlisten() + if (sherpaDownloadRefreshTimer.current) + window.clearTimeout(sherpaDownloadRefreshTimer.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 refreshFoundryStatus = async () => { - try { - const status = await getFoundryLocalAsrStatus(); - setFoundryStatus(status); - if (!foundrySelectionDirty.current && isFoundryAlias(status.activeModel)) { - setSelectedFoundryAlias(status.activeModel); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setFoundryStatus({ - providerId: 'foundry-local-whisper', - available: false, - runtimeReady: false, - runtimeSource: selectedFoundryRuntimeSource, - activeModel: selectedFoundryAlias, - loadedModelId: null, - endpoint: null, - error: message, - }); + + const syncFoundryPrefs = async ( + modelAlias: FoundryLocalAsrModelAlias, + enableProvider: boolean, + ) => { + await updatePrefs((current) => { + const nextProvider = enableProvider + ? "foundry-local-whisper" + : current.activeAsrProvider + if ( + current.activeAsrProvider === nextProvider && + current.foundryLocalAsrModel === modelAlias + ) { + return current + } + return { + ...current, + activeAsrProvider: nextProvider, + foundryLocalAsrModel: modelAlias, + } + }) } - }; - - const refreshFoundryCatalog = async () => { - try { - const catalog = await getFoundryLocalAsrCatalog(); - setFoundryCatalog(catalog); - } catch (err) { - console.warn('[localAsr] Foundry catalog query failed', err); + + const handleFoundryLanguageChange = async ( + languageHint: FoundryLocalAsrLanguageHint, + restoreScroll?: () => void, + ) => { + try { + setError(null) + await setFoundryLocalAsrLanguageHint(languageHint) + await updatePrefs((current) => + current.foundryLocalAsrLanguageHint === languageHint + ? current + : { + ...current, + foundryLocalAsrLanguageHint: languageHint, + }, + ) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + restoreScroll?.() + } } - }; - - const refresh = async () => { - try { - setError(null); - const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); - setSettings(s); - setModels(list); - void refreshEngineStatus(); - if (IS_WINDOWS) { - void refreshFoundryStatus(); - void refreshFoundryCatalog(); - } - // 拉远端真实尺寸(每个模型一次,结果留缓存) - void Promise.all( - list.map(async m => { - await ensureRemoteSize(m.id, s.mirror); - }), - ); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const handleFoundryRuntimeSourceChange = async ( + runtimeSource: FoundryRuntimeSource, + restoreScroll?: () => void, + ) => { + try { + setError(null) + await setFoundryLocalRuntimeSource(runtimeSource) + await updatePrefs((current) => + current.foundryLocalRuntimeSource === runtimeSource + ? current + : { + ...current, + foundryLocalRuntimeSource: runtimeSource, + }, + ) + await refreshFoundryStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + restoreScroll?.() + } } - }; - - 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), - }, - })); + + const handleEnableFoundry = async () => { + if (!foundryAvailable) return + setFoundryBusy("enable") + try { + setError(null) + await setFoundryLocalAsrModel(selectedFoundryAlias) + await setActiveAsrProvider("foundry-local-whisper") + await syncFoundryPrefs(selectedFoundryAlias, true) + foundrySelectionDirty.current = false + await refreshFoundryStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setFoundryBusy(null) + } } - }; - - 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('local-asr-download-progress', e => { - const payload = e.payload; - if (payload.phase === 'cancelled') { - // 取消时清条目,bar 是否还显示交给 hasPartial 判断 - setProgress(prev => { - const next = { ...prev }; - delete next[payload.modelId]; - return next; - }); - } else { - setProgress(prev => ({ ...prev, [payload.modelId]: payload })); + + const handlePrepareFoundry = async () => { + if (!foundryAvailable) return + setFoundryBusy("prepare") + setFoundryCancelRequested(false) + setFoundryProgress({ + phase: "runtime", + modelAlias: selectedFoundryAlias, + label: t("localAsr.foundryPrepareRuntime"), + percent: 0, + error: null, + }) + try { + setError(null) + await setFoundryLocalAsrModel(selectedFoundryAlias) + await syncFoundryPrefs(selectedFoundryAlias, false) + await prepareFoundryLocalAsr(selectedFoundryAlias) + foundrySelectionDirty.current = false + await refreshFoundryStatus() + await refreshFoundryCatalog() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + await refreshFoundryStatus() + await refreshFoundryCatalog() + } finally { + setFoundryBusy(null) + setFoundryCancelRequested(false) } - if ( - payload.phase === 'finished' || - payload.phase === 'cancelled' || - payload.phase === 'failed' - ) { - if (refreshTimer.current) window.clearTimeout(refreshTimer.current); - refreshTimer.current = window.setTimeout(() => { - void refresh(); - }, 200); + } + + const handleCancelFoundryPrepare = async () => { + if (foundryBusy !== "prepare") return + setFoundryCancelRequested(true) + try { + await cancelFoundryLocalAsrPrepare() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) } - }); - 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 - }, []); - - useEffect(() => { - if (!isTauri || !IS_WINDOWS) return; - let unlisten: undefined | (() => void); - let cancelled = false; - (async () => { - const { listen } = await import('@tauri-apps/api/event'); - const off = await listen('foundry-local-asr-prepare-progress', e => { - const payload = e.payload; - setFoundryProgress(payload); - if (payload.phase === 'finished' || payload.phase === 'failed') { - if (foundryRefreshTimer.current) window.clearTimeout(foundryRefreshTimer.current); - foundryRefreshTimer.current = window.setTimeout(() => { - void refreshFoundryStatus(); - void refreshFoundryCatalog(); - }, 200); + } + + const handleReleaseFoundry = async () => { + setFoundryBusy("release") + try { + setError(null) + await releaseFoundryLocalAsr() + await refreshFoundryStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setFoundryBusy(null) } - }); - if (cancelled) { - off(); - } else { - unlisten = off; - } - })().catch(err => console.warn('[localAsr] Foundry prepare subscribe failed', err)); - return () => { - cancelled = true; - if (unlisten) unlisten(); - if (foundryRefreshTimer.current) window.clearTimeout(foundryRefreshTimer.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 syncFoundryPrefs = async (modelAlias: FoundryLocalAsrModelAlias, enableProvider: boolean) => { - await updatePrefs(current => ({ - ...current, - activeAsrProvider: enableProvider ? 'foundry-local-whisper' : current.activeAsrProvider, - foundryLocalAsrModel: modelAlias, - })); - }; - - const handleFoundryLanguageChange = async (languageHint: FoundryLocalAsrLanguageHint) => { - try { - setError(null); - await setFoundryLocalAsrLanguageHint(languageHint); - await updatePrefs(current => ({ - ...current, - foundryLocalAsrLanguageHint: languageHint, - })); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const syncSherpaPrefs = async ( + modelAlias: SherpaOnnxModelAlias, + enableProvider: boolean, + ) => { + await updatePrefs((current) => { + const nextProvider = enableProvider + ? "sherpa-onnx-local" + : current.activeAsrProvider + if ( + current.activeAsrProvider === nextProvider && + current.sherpaOnnxModel === modelAlias + ) { + return current + } + return { + ...current, + activeAsrProvider: nextProvider, + sherpaOnnxModel: modelAlias, + } + }) } - }; - - const handleFoundryRuntimeSourceChange = async (runtimeSource: FoundryRuntimeSource) => { - try { - setError(null); - await setFoundryLocalRuntimeSource(runtimeSource); - await updatePrefs(current => ({ - ...current, - foundryLocalRuntimeSource: runtimeSource, - })); - await refreshFoundryStatus(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const activateSherpaProvider = async (modelAlias: SherpaOnnxModelAlias) => { + await setSherpaOnnxAsrModel(modelAlias) + await setActiveAsrProvider("sherpa-onnx-local") + await syncSherpaPrefs(modelAlias, true) + sherpaSelectionDirty.current = false } - }; - - const handleEnableFoundry = async () => { - if (!foundryAvailable) return; - setFoundryBusy('enable'); - try { - setError(null); - await setFoundryLocalAsrModel(selectedFoundryAlias); - await setActiveAsrProvider('foundry-local-whisper'); - await syncFoundryPrefs(selectedFoundryAlias, true); - foundrySelectionDirty.current = false; - await refreshFoundryStatus(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setFoundryBusy(null); + + const handleSherpaModelChange = async (alias: SherpaOnnxModelAlias) => { + activateScrollGuard() + sherpaSelectionDirty.current = true + setSelectedSherpaAlias(alias) + void refreshSherpaModelDir(alias) + try { + setError(null) + await activateSherpaProvider(alias) + await refreshSherpaStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } } - }; - - const handlePrepareFoundry = async () => { - if (!foundryAvailable) return; - setFoundryBusy('prepare'); - setFoundryCancelRequested(false); - setFoundryProgress({ - phase: 'runtime', - modelAlias: selectedFoundryAlias, - label: t('localAsr.foundryPrepareRuntime'), - percent: 0, - error: null, - }); - try { - setError(null); - await setFoundryLocalAsrModel(selectedFoundryAlias); - await syncFoundryPrefs(selectedFoundryAlias, false); - await prepareFoundryLocalAsr(selectedFoundryAlias); - foundrySelectionDirty.current = false; - await refreshFoundryStatus(); - await refreshFoundryCatalog(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - await refreshFoundryStatus(); - await refreshFoundryCatalog(); - } finally { - setFoundryBusy(null); - setFoundryCancelRequested(false); + + const handleSherpaLanguageChange = async ( + languageHint: SherpaOnnxLanguageHint, + restoreScroll?: () => void, + ) => { + try { + setError(null) + await setSherpaOnnxAsrLanguageHint(languageHint) + await updatePrefs((current) => + current.sherpaOnnxLanguageHint === languageHint + ? current + : { + ...current, + sherpaOnnxLanguageHint: languageHint, + }, + ) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + restoreScroll?.() + } + } + + const handleEnableSherpa = async () => { + if (!sherpaAvailable) return + setSherpaBusy("enable") + try { + setError(null) + await activateSherpaProvider(selectedSherpaAlias) + await refreshSherpaStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSherpaBusy(null) + } + } + + const handlePrepareSherpa = async () => { + if (!sherpaAvailable) return + setSherpaBusy("prepare") + setSherpaCancelRequested(false) + setSherpaProgress({ + phase: "model", + modelAlias: selectedSherpaAlias, + label: t("localAsr.sherpaPrepareLocalFiles"), + percent: 0, + error: null, + }) + try { + setError(null) + await activateSherpaProvider(selectedSherpaAlias) + await prepareSherpaOnnxAsr(selectedSherpaAlias) + sherpaSelectionDirty.current = false + await refreshSherpaStatus() + await refreshSherpaCatalog() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + await refreshSherpaStatus() + await refreshSherpaCatalog() + } finally { + setSherpaBusy(null) + setSherpaCancelRequested(false) + } + } + + const handleCancelSherpaPrepare = async () => { + if (sherpaBusy !== "prepare") return + setSherpaCancelRequested(true) + try { + await cancelSherpaOnnxAsrPrepare() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } } - }; - - const handleCancelFoundryPrepare = async () => { - if (foundryBusy !== 'prepare') return; - setFoundryCancelRequested(true); - try { - await cancelFoundryLocalAsrPrepare(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const handleReleaseSherpa = async () => { + setSherpaBusy("release") + try { + setError(null) + await releaseSherpaOnnxAsr() + await refreshSherpaStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSherpaBusy(null) + } } - }; - - const handleReleaseFoundry = async () => { - setFoundryBusy('release'); - try { - setError(null); - await releaseFoundryLocalAsr(); - await refreshFoundryStatus(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setFoundryBusy(null); + + const handleRevealSherpaDir = async () => { + setSherpaBusy("reveal") + try { + setError(null) + await revealSherpaOnnxAsrModelDir(selectedSherpaAlias) + await refreshSherpaModelDir(selectedSherpaAlias) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSherpaBusy(null) + } } - }; - - const handleDownload = async (modelId: string) => { - setBusyModelId(modelId); - // 重下载时,第一个后端事件到达前先用本地已知值占位,避免进度条从 0% 跳到真实位置。 - // 优先级:上一次 progress(取消后已删,通常没有)→ models 里的 downloadedBytes(cancel 时乐观写入) - const model = models.find(m => m.id === modelId); - const initialDownloaded = - progress[modelId]?.bytesDownloaded ?? model?.downloadedBytes ?? 0; - setProgress(prev => ({ - ...prev, - [modelId]: { - modelId, - file: '', - fileIndex: 0, - fileCount: remoteSizes[modelId]?.fileCount ?? 0, - bytesDownloaded: initialDownloaded, - bytesTotal: remoteSizes[modelId]?.totalBytes ?? 0, - phase: 'started', - error: null, - }, - })); - try { - await downloadLocalAsrModel(modelId, settings?.mirror); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setProgress(prev => { - const cur = prev[modelId]; - if (cur?.phase === 'started') { - return { ...prev, [modelId]: { ...cur, phase: 'failed', error: e instanceof Error ? e.message : String(e) } }; + + const handleDeleteSherpa = async () => { + setSherpaBusy("delete") + try { + setError(null) + await deleteSherpaOnnxAsrModel(selectedSherpaAlias) + setSherpaDownloadProgress((prev) => { + const next = { ...prev } + delete next[selectedSherpaAlias] + return next + }) + await refreshSherpaStatus() + await refreshSherpaCatalog() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSherpaBusy(null) } - return prev; - }); - } finally { - setBusyModelId(null); } - }; - - const handleCancel = async (modelId: string) => { - // Progress 事件里的 bytesDownloaded 是后端 in_flight + already_done,是真实字节 - const lastBytes = progress[modelId]?.bytesDownloaded ?? 0; - try { - await cancelLocalAsrDownload(modelId); - setProgress(prev => { - const next = { ...prev }; - delete next[modelId]; - return next; - }); - // 乐观更新:让 hasPartial 立刻翻 true,不等 listener 200ms 后的 refresh - if (lastBytes > 0) { - setModels(prev => - prev.map(m => (m.id === modelId ? { ...m, downloadedBytes: lastBytes } : m)), - ); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const handleDownloadSherpa = async () => { + if (!sherpaAvailable) return + const modelAlias = selectedSherpaAlias + const remoteSize = sherpaRemoteSizes[modelAlias] + const initialDownloaded = + sherpaDownloadProgress[modelAlias]?.bytesDownloaded ?? 0 + setSherpaBusy("download") + setSherpaDownloadCancelRequested(false) + setSherpaDownloadProgress((prev) => ({ + ...prev, + [modelAlias]: { + modelId: modelAlias, + file: "", + fileIndex: 0, + fileCount: remoteSize?.fileCount ?? 0, + bytesDownloaded: initialDownloaded, + bytesTotal: remoteSize?.totalBytes ?? 0, + phase: "started", + error: null, + }, + })) + try { + setError(null) + await activateSherpaProvider(modelAlias) + await downloadSherpaOnnxAsrModel(modelAlias, settings?.mirror) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setError(message) + setSherpaDownloadProgress((prev) => { + const cur = prev[modelAlias] + return { + ...prev, + [modelAlias]: { + modelId: modelAlias, + file: cur?.file ?? "", + fileIndex: cur?.fileIndex ?? 0, + fileCount: cur?.fileCount ?? remoteSize?.fileCount ?? 0, + bytesDownloaded: cur?.bytesDownloaded ?? 0, + bytesTotal: + cur?.bytesTotal ?? remoteSize?.totalBytes ?? 0, + phase: "failed", + error: message, + }, + } + }) + setSherpaBusy(null) + } } - }; - - 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 handleCancelSherpaDownload = async () => { + if (sherpaBusy !== "download") return + setSherpaDownloadCancelRequested(true) + try { + await cancelSherpaOnnxAsrDownload(selectedSherpaAlias) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setSherpaDownloadCancelRequested(false) + } } - }; - - const handleKeepLoadedChange = async (seconds: number) => { - try { - await setLocalAsrKeepLoadedSecs(seconds); - await refresh(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const handleDownload = async (modelId: string) => { + setBusyModelId(modelId) + // 重下载时,第一个后端事件到达前先用本地已知值占位,避免进度条从 0% 跳到真实位置。 + // 优先级:上一次 progress(取消后已删,通常没有)→ models 里的 downloadedBytes(cancel 时乐观写入) + const model = models.find((m) => m.id === modelId) + const initialDownloaded = + progress[modelId]?.bytesDownloaded ?? model?.downloadedBytes ?? 0 + setProgress((prev) => ({ + ...prev, + [modelId]: { + modelId, + file: "", + fileIndex: 0, + fileCount: remoteSizes[modelId]?.fileCount ?? 0, + bytesDownloaded: initialDownloaded, + bytesTotal: remoteSizes[modelId]?.totalBytes ?? 0, + phase: "started", + error: null, + }, + })) + try { + await downloadLocalAsrModel(modelId, settings?.mirror) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setProgress((prev) => { + const cur = prev[modelId] + if (cur?.phase === "started") { + return { + ...prev, + [modelId]: { + ...cur, + phase: "failed", + error: e instanceof Error ? e.message : String(e), + }, + } + } + return prev + }) + } finally { + setBusyModelId(null) + } } - }; - - const handleReleaseEngine = async () => { - try { - await releaseLocalAsrEngine(); - await refreshEngineStatus(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + + const handleCancel = async (modelId: string) => { + // Progress 事件里的 bytesDownloaded 是后端 in_flight + already_done,是真实字节 + const lastBytes = progress[modelId]?.bytesDownloaded ?? 0 + try { + await cancelLocalAsrDownload(modelId) + setProgress((prev) => { + const next = { ...prev } + delete next[modelId] + return next + }) + // 乐观更新:让 hasPartial 立刻翻 true,不等 listener 200ms 后的 refresh + if (lastBytes > 0) { + setModels((prev) => + prev.map((m) => + m.id === modelId + ? { ...m, downloadedBytes: lastBytes } + : m, + ), + ) + } + } 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 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 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 handleKeepLoadedChange = async (seconds: number) => { + try { + await setLocalAsrKeepLoadedSecs(seconds) + await refresh() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } } - }; - - const handleMirrorChange = async (mirror: string) => { - try { - await setLocalAsrMirror(mirror); - 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 engineAvailable = settings?.engineAvailable ?? false; - const foundryPlatformAvailable = isWindowsLikePlatform(); - const foundryAvailable = foundryStatus?.available === true || (foundryPlatformAvailable && foundryStatus?.available !== false); - const foundryDefault = prefs?.activeAsrProvider === 'foundry-local-whisper'; - const selectedFoundryModel = FOUNDRY_LOCAL_ASR_MODELS.find( - model => model.alias === selectedFoundryAlias, - ) ?? FOUNDRY_LOCAL_ASR_MODELS[0]; - const selectedFoundryCatalog = foundryCatalog.find(model => model.alias === selectedFoundryAlias); - const selectedFoundryDisplayName = selectedFoundryCatalog?.displayName ?? t(selectedFoundryModel.labelKey); - const selectedFoundrySizeMb = formatFoundrySizeMb(selectedFoundryCatalog?.fileSizeMb); - const selectedFoundrySizeLabel = selectedFoundrySizeMb - ? t('localAsr.foundryApproxSizeMb', { mb: selectedFoundrySizeMb }) - : t('localAsr.sizeUnknown'); - const selectedFoundryDownloadLabel = selectedFoundryCatalog?.cached - ? t('localAsr.downloadedBadge') - : t('localAsr.notDownloadedBadge'); - const selectedFoundryLanguageHint = normalizeFoundryLanguageHintForUi( - prefs?.foundryLocalAsrLanguageHint ?? '', - ); - const selectedFoundryRuntimeSource = normalizeFoundryRuntimeSourceForUi( - prefs?.foundryLocalRuntimeSource ?? foundryStatus?.runtimeSource ?? 'auto', - ); - const foundryPrepareLabel = - foundryBusy === 'prepare' - ? foundryCancelRequested - ? t('localAsr.foundryCancelling') - : t('localAsr.foundryPreparing') - : foundryProgress?.phase === 'failed' - ? t('localAsr.foundryRetryPrepare') - : t('localAsr.foundryPrepare'); - - // embedded=true 嵌入「高级」设置:跳过外层 page padding/height、PageHeader, - // 与独立警告 Card——AdvancedSection 自己负责标题与短警告 + 启用时的浮层 popup, - // LocalAsr 只输出实际功能 Cards(Foundry / Qwen3 模型状态 / 模型列表)。 - const Wrapper = embedded - ? (props: { children: ReactNode }) => <>{props.children} - : (props: { children: ReactNode }) => ( -
- {props.children} -
- ); - - return ( - - {!embedded && ( - - )} - - {!embedded && ( - /* 性能/质量预期警告 —— embedded 模式下由 AdvancedSection 自己渲染,避免重复。 */ - -
- ⚠️ {t('localAsr.performanceWarning')} -
-
- )} - - {IS_WINDOWS && ( - -
-
-
-
-
- {t('localAsr.foundryTitle')} -
- {foundryDefault && {t('localAsr.activeBadge')}} - - {foundryStatus?.available - ? t('localAsr.foundryAvailable') - : t('localAsr.foundryUnavailable')} - - - {foundryStatus?.runtimeReady - ? t('localAsr.foundryRuntimeReady') - : t('localAsr.foundryRuntimeMissing')} - -
-
- {t('localAsr.foundryDesc')} -
-
-
-
- -
-
- {t('localAsr.foundrySelectedModel')}: - {selectedFoundryDisplayName} - · {selectedFoundrySizeLabel} · {selectedFoundryDownloadLabel} - · {t(selectedFoundryModel.descKey)} -
-
- {t('localAsr.foundryRuntimeSourceLabel')}: - {t(`localAsr.foundryRuntimeSource${selectedFoundryRuntimeSource === 'ort-nightly' ? 'OrtNightly' : selectedFoundryRuntimeSource === 'nuget' ? 'Nuget' : 'Auto'}`)} - · {t('localAsr.foundryRuntimeSourceDesc')} -
-
- {t('localAsr.foundryLanguageLabel')}: - {selectedFoundryLanguageHint - ? t(`localAsr.foundryLanguage${selectedFoundryLanguageHint === 'zh' ? 'Zh' : 'En'}`) - : t('localAsr.foundryLanguageAuto')} - · {t('localAsr.foundryLanguageDesc')} -
-
- {t('localAsr.foundryActiveModel')}: - {foundryStatus?.activeModel ?? 'whisper-small'} -
-
- {t('localAsr.foundryLoadedModel')}: - {foundryStatus?.loadedModelId ?? t('localAsr.foundryNotLoaded')} -
- {foundryStatus?.error && ( -
- {t('localAsr.foundryError')}: - {foundryStatus.error} + > + {props.children}
+ ) + + return ( + + {!embedded && ( + )} -
- - {(foundryBusy === 'prepare' || foundryProgress) && ( - - )} - -
- void handleEnableFoundry()}> - {foundryBusy === 'enable' ? t('localAsr.foundryEnabling') : t('localAsr.foundrySetDefault')} - - void handlePrepareFoundry()}> - {foundryPrepareLabel} - - {foundryBusy === 'prepare' && ( - void handleCancelFoundryPrepare()}> - {foundryCancelRequested - ? t('localAsr.foundryCancelRequested') - : t('localAsr.foundryCancelPrepare')} - + + {!embedded && ( + /* 性能/质量预期警告 —— embedded 模式下由 AdvancedSection 自己渲染,避免重复。 */ + +
+ ⚠️ {t("localAsr.performanceWarning")} +
+
)} - void handleReleaseFoundry()}> - {foundryBusy === 'release' ? t('localAsr.foundryReleasing') : t('localAsr.releaseNow')} - -
-
- - )} - - {/* Qwen3 模型管理区——macOS 是实体后端(#[cfg(target_os = "macos")]),Windows - 上 backend 永远 unavailable,但 PO 要求 Windows 用户仍能看见这块,整体 - 灰显 + 禁用交互 + 顶部横幅说明不可用,引导他们使用上方 Foundry Local Whisper。 - Linux 当前没有任何本地 ASR 提供,沿用 IS_MAC 隐藏(dead UI 仍然要藏)。 */} - {(IS_MAC || IS_WINDOWS) && (<> - {/* v1.3.1-6 用户反馈:Windows 顶部"暂不支持"banner 白底太显眼。已经整段 opacity/grayscale - + inert 灰显 + 不可交互了,banner 是 noise,直接删。AT 仍可通过区域 aria-disabled - + 灰显视觉判断不可用。 */} -
- {IS_MAC && !engineAvailable && ( - -
- {t('localAsr.engineUnavailable')} -
-
- )} - -
- {/* v1.3.1-6 用户拍板:千问3 ASR 改为「实验性」分组,独立于 Foundry/云端 ASR。 - 浅 amber badge 跟 thinking 扫光暖色调一致。 */} - {t('localAsr.qwenExperimentalBadge')} -
- {t('localAsr.qwenTitle')} -
-
- -
-
-
- {t('localAsr.mirrorLabel')} -
-
- {t('localAsr.mirrorDesc')} -
-
- void handleMirrorChange(next)} - options={[ - { value: 'huggingface', label: t('localAsr.mirrorHuggingface') }, - { value: 'hf-mirror', label: t('localAsr.mirrorHfMirror') }, - ]} - ariaLabel={t('localAsr.mirrorLabel')} - style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }} - /> -
-
- - {/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */} - {engineAvailable && ( - -
-
-
-
- {t('localAsr.engineStatusLabel')} -
-
- {engineStatus?.loaded - ? t('localAsr.engineLoaded', { model: engineStatus.modelId ?? '' }) - : t('localAsr.engineUnloaded')} + {IS_WINDOWS && ( + +
+
+
+
+
+ {t("localAsr.foundryTitle")} +
+ {foundryDefault && ( + + {t("localAsr.activeBadge")} + + )} + + {foundryStatus?.available + ? t("localAsr.foundryAvailable") + : t("localAsr.foundryUnavailable")} + + + {foundryStatus?.runtimeReady + ? t("localAsr.foundryRuntimeReady") + : t( + "localAsr.foundryRuntimeMissing", + )} + +
+
+ {t("localAsr.foundryDesc")} +
+
+
+ + + +
+
+ +
+
+ + {t("localAsr.foundrySelectedModel")}:{" "} + + {selectedFoundryDisplayName} + + {" "} + · {selectedFoundrySizeLabel} ·{" "} + {selectedFoundryDownloadLabel} + + + {" "} + · {t(selectedFoundryModel.descKey)} + +
+
+ + {t("localAsr.foundryRuntimeSourceLabel")} + :{" "} + + {t( + `localAsr.foundryRuntimeSource${selectedFoundryRuntimeSource === "ort-nightly" ? "OrtNightly" : selectedFoundryRuntimeSource === "nuget" ? "Nuget" : "Auto"}`, + )} + + {" "} + · {t("localAsr.foundryRuntimeSourceDesc")} + +
+
+ + {t("localAsr.foundryLanguageLabel")}:{" "} + + {selectedFoundryLanguageHint + ? t( + `localAsr.foundryLanguage${selectedFoundryLanguageHint === "zh" ? "Zh" : "En"}`, + ) + : t("localAsr.foundryLanguageAuto")} + + {" "} + · {t("localAsr.foundryLanguageDesc")} + +
+
+ + {t("localAsr.foundryActiveModel")}:{" "} + + {foundryStatus?.activeModel ?? "whisper-small"} +
+
+ + {t("localAsr.foundryLoadedModel")}:{" "} + + {foundryStatus?.loadedModelId ?? + t("localAsr.foundryNotLoaded")} +
+ {foundryStatus?.error && ( +
+ {t("localAsr.foundryError")}: + {foundryStatus.error} +
+ )} +
+ + {(foundryBusy === "prepare" || foundryProgress) && ( + + )} + +
+ void handleEnableFoundry()} + > + {foundryBusy === "enable" + ? t("localAsr.foundryEnabling") + : t("localAsr.foundrySetDefault")} + + void handlePrepareFoundry()} + > + {foundryPrepareLabel} + + {foundryBusy === "prepare" && ( + + void handleCancelFoundryPrepare() + } + > + {foundryCancelRequested + ? t("localAsr.foundryCancelRequested") + : t("localAsr.foundryCancelPrepare")} + + )} + void handleReleaseFoundry()} + > + {foundryBusy === "release" + ? t("localAsr.foundryReleasing") + : t("localAsr.releaseNow")} + +
+
+
+ )} + + {IS_WINDOWS && ( + +
{ + if (event.key === "Enter" || event.key === " ") { + activateScrollGuard() + } + }} + style={{ + display: "flex", + flexDirection: "column", + gap: 14, + }} + > +
+
+
+
+ {t("localAsr.sherpaTitle")} +
+ {sherpaDefault && ( + + {t("localAsr.activeBadge")} + + )} + + {sherpaStatus?.available + ? t("localAsr.foundryAvailable") + : t("localAsr.foundryUnavailable")} + + + {sherpaStatus?.runtimeReady + ? t("localAsr.sherpaRuntimeReady") + : t( + "localAsr.sherpaRuntimeMissing", + )} + +
+
+ {t("localAsr.sherpaDesc")} +
+
+
+ + + +
+
+ +
+
+ + {t("localAsr.foundrySelectedModel")}:{" "} + + {selectedSherpaDisplayName} + + {" "} + · {selectedSherpaSizeLabel} ·{" "} + {selectedSherpaDownloadLabel} + + · {t(selectedSherpaModel.descKey)} +
+
+ + {t("localAsr.sherpaModelDir")}:{" "} + + {sherpaModelDir || "—"} +
+
+ + {t("localAsr.foundryLoadedModel")}:{" "} + + {sherpaStatus?.loadedModelId ?? + t("localAsr.foundryNotLoaded")} +
+ {sherpaStatus?.error && ( +
+ {t("localAsr.sherpaError")}: + {sherpaStatus.error} +
+ )} +
+ + {(sherpaBusy === "prepare" || sherpaProgress) && ( + + )} + + {showSherpaDownloadProgress && ( + + )} + +
+ void handleEnableSherpa()} + > + {sherpaBusy === "enable" + ? t("localAsr.foundryEnabling") + : t("localAsr.sherpaSetDefault")} + + void handlePrepareSherpa()} + > + {sherpaPrepareLabel} + + {selectedSherpaCatalog?.cached !== true && + !isSherpaDownloading && ( + + void handleDownloadSherpa() + } + > + {hasSherpaPartial + ? t("localAsr.resume") + : t("localAsr.download")} + + )} + {isSherpaDownloading && ( + + void handleCancelSherpaDownload() + } + > + {sherpaDownloadCancelRequested + ? t("localAsr.foundryCancelRequested") + : t("localAsr.cancel")} + + )} + {sherpaBusy === "prepare" && ( + + void handleCancelSherpaPrepare() + } + > + {sherpaCancelRequested + ? t("localAsr.foundryCancelRequested") + : t("localAsr.foundryCancelPrepare")} + + )} + void handleReleaseSherpa()} + > + {sherpaBusy === "release" + ? t("localAsr.foundryReleasing") + : t("localAsr.releaseNow")} + + void handleRevealSherpaDir()} + > + {sherpaBusy === "reveal" + ? t("common.loading") + : t("localAsr.sherpaRevealDir")} + + void handleDeleteSherpa()} + > + {sherpaBusy === "delete" + ? t("common.loading") + : t("localAsr.delete")} + +
+
+
+ )} + + {/* Qwen3 模型管理区——只在 macOS 渲染(后端 #[cfg(target_os = "macos")] 独占)。 + Windows / Linux 看见镜像源 / 下载 / 模型列表都是 dead UI。Foundry 块自身已经 + 被上方 IS_WINDOWS 守卫,错误 Card(共享 setError,被 Foundry handler 也写) + 保持无条件露出。 */} + {IS_MAC && ( + <> + {!engineAvailable && ( + +
+ {t("localAsr.engineUnavailable")} +
+
+ )} + +
+ {t("localAsr.qwenTitle")} +
+ + +
+
+
+ {t("localAsr.mirrorLabel")} +
+
+ {t("localAsr.mirrorDesc")} +
+
+ +
+
+ + {/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */} + {engineAvailable && ( + +
+
+
+
+ {t("localAsr.engineStatusLabel")} +
+
+ {engineStatus?.loaded + ? t("localAsr.engineLoaded", { + model: + engineStatus.modelId ?? + "", + }) + : t("localAsr.engineUnloaded")} +
+
+
+ {engineStatus?.loaded ? ( + + void handleReleaseEngine() + } + > + {t("localAsr.releaseNow")} + + ) : ( + + void handlePreload() + } + > + {t("localAsr.loadNow")} + + )} +
+
+
+
+
+ {t("localAsr.keepLoadedLabel")} +
+
+ {t("localAsr.keepLoadedDesc")} +
+
+ +
+
+
+ )} + + )} + + {error && ( + +
+ {error} +
+
+ )} + + {IS_MAC && ( +
+ {models.map((model) => ( + void handleDownload(model.id)} + onCancel={() => void handleCancel(model.id)} + onDelete={() => void handleDelete(model.id)} + onSetActive={() => + void handleSetActiveModel(model.id) + } + onTest={() => void handleTest(model.id)} + /> + ))}
-
-
- {engineStatus?.loaded ? ( - void handleReleaseEngine()}> - {t('localAsr.releaseNow')} - - ) : ( - void handlePreload()}> - {t('localAsr.loadNow')} - - )} -
-
-
-
-
- {t('localAsr.keepLoadedLabel')} + )} + + ) +} + +function FoundryPrepareProgressBlock({ + progress, + modelCached, + cancelRequested, +}: { + progress: FoundryPrepareProgress | null + modelCached: boolean + cancelRequested: boolean +}) { + const { t } = useTranslation() + const stages = [ + { phase: "runtime", label: t("localAsr.foundryPrepareRuntime") }, + { phase: "model", label: t("localAsr.foundryPrepareModel") }, + { phase: "load", label: t("localAsr.foundryPrepareLoad") }, + ] as const + const currentIndex = progress + ? stages.findIndex((stage) => stage.phase === progress.phase) + : -1 + + return ( +
+ {stages.map((stage, index) => { + const finished = + progress?.phase === "finished" || currentIndex > index + const skippedCachedModel = + stage.phase === "model" && + modelCached && + (progress?.phase === "load" || + progress?.phase === "finished") + const active = progress?.phase === stage.phase + const failed = progress?.phase === "failed" + const percent = + finished || skippedCachedModel + ? 100 + : active + ? Math.max(0, Math.min(100, progress?.percent ?? 0)) + : 0 + const detail = skippedCachedModel + ? t("localAsr.foundryPrepareModelSkipped") + : active + ? progress?.label + : finished + ? t("localAsr.foundryPrepareDone") + : t("localAsr.foundryPrepareWaiting") + return ( +
+
+ + {stage.label} + + + {failed + ? t("localAsr.failed") + : `${Math.round(percent)}%`} + +
+
+
+
+
+ {detail} +
+
+ ) + })} + {cancelRequested && ( +
+ {t("localAsr.foundryCancelBestEffort")}
-
- {t('localAsr.keepLoadedDesc')} + )} + {progress?.phase === "failed" && progress.error && ( +
+ {progress.error}
-
- void handleKeepLoadedChange(Number(next))} - options={[ - { value: '0', label: t('localAsr.keepImmediate') }, - { value: '60', label: t('localAsr.keep1min') }, - { value: '300', label: t('localAsr.keep5min') }, - { value: '1800', label: t('localAsr.keep30min') }, - { value: '86400', label: t('localAsr.keepForever') }, - ]} - ariaLabel={t('localAsr.keepLoadedLabel')} - style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }} - /> -
-
- - )} -
- )} - - {error && ( - -
{error}
-
- )} - - {(IS_MAC || IS_WINDOWS) && ( -
- {IS_MAC ? ( -
- {models.map(model => ( - void handleDownload(model.id)} - onCancel={() => void handleCancel(model.id)} - onDelete={() => void handleDelete(model.id)} - onSetActive={() => void handleSetActiveModel(model.id)} - onTest={() => void handleTest(model.id)} - /> - ))} -
- ) : ( - /* v1.3.1-6: Windows 列表为空时不再画白底 banner Card(用户反馈"白色显眼"), - 留个低调的灰色 placeholder 维持容器高度。整段已 inert + 灰显,AT 用户感知到的也是"不可用"。 */ -
- )} -
- )} - - ); + )} +
+ ) } -function FoundryPrepareProgressBlock({ - progress, - modelCached, - cancelRequested, +function DownloadProgressBlock({ + progress, + remoteSize, + cancelRequested, }: { - progress: FoundryPrepareProgress | null; - modelCached: boolean; - cancelRequested: boolean; + progress?: LocalAsrDownloadProgress + remoteSize?: RemoteSize + cancelRequested: boolean }) { - const { t } = useTranslation(); - const stages = [ - { phase: 'runtime', label: t('localAsr.foundryPrepareRuntime') }, - { phase: 'model', label: t('localAsr.foundryPrepareModel') }, - { phase: 'load', label: t('localAsr.foundryPrepareLoad') }, - ] as const; - const currentIndex = progress ? stages.findIndex(stage => stage.phase === progress.phase) : -1; - - return ( -
- {stages.map((stage, index) => { - const finished = progress?.phase === 'finished' || currentIndex > index; - const skippedCachedModel = - stage.phase === 'model' && - modelCached && - (progress?.phase === 'load' || progress?.phase === 'finished'); - const active = progress?.phase === stage.phase; - const failed = progress?.phase === 'failed'; - const percent = finished || skippedCachedModel - ? 100 - : active - ? Math.max(0, Math.min(100, progress?.percent ?? 0)) - : 0; - const detail = skippedCachedModel - ? t('localAsr.foundryPrepareModelSkipped') - : active - ? progress?.label - : finished - ? t('localAsr.foundryPrepareDone') - : t('localAsr.foundryPrepareWaiting'); - return ( -
-
- - {stage.label} - - - {failed ? t('localAsr.failed') : `${Math.round(percent)}%`} - + const { t } = useTranslation() + const downloadedBytes = progress?.bytesDownloaded ?? 0 + const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0 + const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0 + const failed = progress?.phase === "failed" + return ( +
+
+ + {t("localAsr.foundryPrepareModel")} + + + {failed + ? t("localAsr.failed") + : `${Math.round(ratio * 100)}%`} +
-
-
+ > +
-
- {detail} +
+ {failed + ? `${t("localAsr.failed")}: ${progress?.error ?? ""}` + : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + + (progress?.file ? ` · ${progress.file}` : "")}
-
- ); - })} - {cancelRequested && ( -
- {t('localAsr.foundryCancelBestEffort')} -
- )} - {progress?.phase === 'failed' && progress.error && ( -
- {progress.error} + {cancelRequested && ( +
+ {t("localAsr.foundryCancelRequested")} +
+ )}
- )} -
- ); + ) } 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; + 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, + 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' || 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 ( - -
-
-
-
{model.id}
- {isActive && {t('localAsr.activeBadge')}} - {model.isDownloaded && {t('localAsr.downloadedBadge')}} -
-
- {model.hfRepo} · {sizeLabel} -
- {showProgress && ( -
-
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" || 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 ( + +
+ display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 16, + }} + > +
+
+
+ {model.id} +
+ {isActive && ( + + {t("localAsr.activeBadge")} + + )} + {model.isDownloaded && ( + + {t("localAsr.downloadedBadge")} + + )} +
+
+ {model.hfRepo} · {sizeLabel} +
+ {showProgress && ( +
+
+
+
+
+ {progress?.phase === "failed" + ? `${t("localAsr.failed")}: ${progress.error ?? ""}` + : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + + (progress?.file + ? ` · ${progress.file}` + : "")} +
+
+ )} +
-
-
- {progress?.phase === 'failed' - ? `${t('localAsr.failed')}: ${progress.error ?? ''}` - : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + - (progress?.file ? ` · ${progress.file}` : '')} -
+ style={{ + display: "flex", + gap: 8, + flexShrink: 0, + flexWrap: "wrap", + justifyContent: "flex-end", + maxWidth: 360, + }} + > + {model.isDownloaded ? ( + <> + {!isActive && ( + + {t("localAsr.setActive")} + + )} + + {testing + ? t("localAsr.testRunning") + : t("localAsr.test")} + + + {t("localAsr.delete")} + + + ) : isDownloading ? ( + + {t("localAsr.cancel")} + + ) : ( + <> + + {hasPartial + ? t("localAsr.resume") + : t("localAsr.download")} + + {hasPartial && ( + + {t("localAsr.delete")} + + )} + + )} +
- )} -
-
- {model.isDownloaded ? ( - <> - {!isActive && ( - - {t('localAsr.setActive')} - - )} - - {testing ? t('localAsr.testRunning') : t('localAsr.test')} - - - {t('localAsr.delete')} - - - ) : isDownloading ? ( - - {t('localAsr.cancel')} - - ) : ( - <> - - {hasPartial ? t('localAsr.resume') : t('localAsr.download')} - - {hasPartial && ( - - {t('localAsr.delete')} - - )} - - )} -
-
- {testResult && } - - ); + {testResult && } + + ) } -function TestResultBlock({ result }: { result: LocalAsrTestResult | { error: string } }) { - const { t } = useTranslation(); - const hasError = 'error' in result; - return ( -
- {hasError ? ( -
- {t('localAsr.testFailed')}: {result.error} -
- ) : ( -
-
- {t('localAsr.testHeading')} -
-
- {t('localAsr.testExpected')}: - {result.expectedText} -
-
- {t('localAsr.testActual')}: - {result.transcribedText || '(空)'} -
-
- {t('localAsr.testStats', { - audio: (result.audioMs / 1000).toFixed(1), - load: (result.loadMs / 1000).toFixed(1), - transcribe: (result.transcribeMs / 1000).toFixed(1), - backend: result.backend, - })} -
+function TestResultBlock({ + result, +}: { + result: LocalAsrTestResult | { error: string } +}) { + const { t } = useTranslation() + const hasError = "error" in result + return ( +
+ {hasError ? ( +
+ {t("localAsr.testFailed")}: + {result.error} +
+ ) : ( +
+
+ {t("localAsr.testHeading")} +
+
+ + {t("localAsr.testExpected")}:{" "} + + {result.expectedText} +
+
+ + {t("localAsr.testActual")}:{" "} + + {result.transcribedText || "(空)"} +
+
+ {t("localAsr.testStats", { + audio: (result.audioMs / 1000).toFixed(1), + load: (result.loadMs / 1000).toFixed(1), + transcribe: (result.transcribeMs / 1000).toFixed(1), + backend: result.backend, + })} +
+
+ )}
- )} -
- ); + ) } function isFoundryAlias(value: string): value is FoundryLocalAsrModelAlias { - return FOUNDRY_LOCAL_ASR_MODELS.some(model => model.alias === value); + return FOUNDRY_LOCAL_ASR_MODELS.some((model) => model.alias === value) +} + +function isSherpaAlias(value: string): value is SherpaOnnxModelAlias { + return SHERPA_ONNX_ASR_MODELS.some((model) => model.alias === value) } -function normalizeFoundryLanguageHintForUi(value: string): FoundryLocalAsrLanguageHint { - return value === 'zh' || value === 'en' ? value : ''; +function normalizeFoundryLanguageHintForUi( + value: string, +): FoundryLocalAsrLanguageHint { + return value === "zh" || value === "en" ? value : "" } -function normalizeFoundryRuntimeSourceForUi(value: string): FoundryRuntimeSource { - return value === 'nuget' || value === 'ort-nightly' ? value : 'auto'; +function normalizeSherpaLanguageHintForUi( + value: string, +): SherpaOnnxLanguageHint { + return value === "zh" || + value === "en" || + value === "ja" || + value === "ko" || + value === "yue" + ? value + : "" +} + +function normalizeFoundryRuntimeSourceForUi( + value: string, +): FoundryRuntimeSource { + return value === "nuget" || value === "ort-nightly" ? value : "auto" } function isWindowsLikePlatform(): boolean { - const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; - const platform = nav.userAgentData?.platform || navigator.platform || navigator.userAgent; - return /win/i.test(platform); + const nav = navigator as Navigator & { + userAgentData?: { platform?: string } + } + const platform = + nav.userAgentData?.platform || navigator.platform || navigator.userAgent + return /win/i.test(platform) } -function formatFoundrySizeMb(fileSizeMb: number | null | undefined): string | null { - if (typeof fileSizeMb !== 'number' || fileSizeMb <= 0) return null; - return Math.round(fileSizeMb).toLocaleString(); +function formatFoundrySizeMb( + fileSizeMb: number | null | undefined, +): string | null { + if (typeof fileSizeMb !== "number" || fileSizeMb <= 0) return null + return Math.round(fileSizeMb).toLocaleString() } 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`; + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB` + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB` } diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx index 096aeb27..0ffeb6ae 100644 --- a/openless-all/app/src/pages/settings/AdvancedSection.tsx +++ b/openless-all/app/src/pages/settings/AdvancedSection.tsx @@ -1,115 +1,154 @@ // 高级设置:流式输入开关 / 同步剪贴板 / 本地 ASR 模型启用与禁用。 // 拆出自 Settings.tsx,逻辑零改动;i18n key 全部保持 `settings.advanced.*`。 -import { useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { LocalAsr } from '../LocalAsr'; -import { detectOS } from '../../components/WindowChrome'; -import { setActiveAsrProvider } from '../../lib/ipc'; -import { useHotkeySettings } from '../../state/HotkeySettingsContext'; -import { Btn, Card } from '../_atoms'; -import { SettingRow, Toggle, type AsrPresetId } from './shared'; +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { LocalAsr } from "../LocalAsr" +import { detectOS } from "../../components/WindowChrome" +import { setActiveAsrProvider } from "../../lib/ipc" +import { useHotkeySettings } from "../../state/HotkeySettingsContext" +import { Btn, Card } from "../_atoms" +import { SettingRow, Toggle, type AsrPresetId } from "./shared" export function AdvancedSection() { - const { t } = useTranslation(); - const { prefs, updatePrefs } = useHotkeySettings(); - const os = detectOS(); - const isMac = os === 'mac'; - const isWin = os === 'win'; - const isLinux = os === 'linux'; - const platformSupported = isMac || isWin; - const switchSeqRef = useRef(0); - const [busy, setBusy] = useState(false); - // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; - // 点取消 → 回到 null。一次只允许一个 modal。 - const [pendingTarget, setPendingTarget] = useState(null); + const { t } = useTranslation() + const { prefs, updatePrefs } = useHotkeySettings() + const os = detectOS() + const isMac = os === "mac" + const isWin = os === "win" + const isLinux = os === "linux" + const platformSupported = isMac || isWin + const switchSeqRef = useRef(0) + const [busy, setBusy] = useState(false) + // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; + // 点取消 → 回到 null。一次只允许一个 modal。 + const [pendingTarget, setPendingTarget] = useState(null) - const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId; - const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3'; - const isOnFoundry = activeAsrProvider === 'foundry-local-whisper'; - const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry; + const activeAsrProvider = (prefs?.activeAsrProvider ?? + "volcengine") as AsrPresetId + const isOnLocalQwen3 = activeAsrProvider === "local-qwen3" + const isOnFoundry = activeAsrProvider === "foundry-local-whisper" + const isOnSherpaOnnx = activeAsrProvider === "sherpa-onnx-local" + const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry || isOnSherpaOnnx - const requestEnable = (target: AsrPresetId) => { - setPendingTarget(target); - }; + const requestEnable = (target: AsrPresetId) => { + setPendingTarget(target) + } - const performSwitch = async (target: AsrPresetId) => { - setBusy(true); - const seq = ++switchSeqRef.current; - try { - await setActiveAsrProvider(target); - if (seq !== switchSeqRef.current) return; - if (prefs) { - await updatePrefs({ ...prefs, activeAsrProvider: target }); - } - } finally { - if (seq === switchSeqRef.current) { - setBusy(false); - setPendingTarget(null); - } + const performSwitch = async (target: AsrPresetId) => { + setBusy(true) + const seq = ++switchSeqRef.current + try { + await setActiveAsrProvider(target) + if (seq !== switchSeqRef.current) return + if (prefs) { + await updatePrefs({ ...prefs, activeAsrProvider: target }) + } + } finally { + if (seq === switchSeqRef.current) { + setBusy(false) + setPendingTarget(null) + } + } } - }; - const pendingNameKey = - pendingTarget === 'local-qwen3' ? 'asrLocalQwen3' - : pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper' - : null; + const pendingNameKey = + pendingTarget === "local-qwen3" + ? "asrLocalQwen3" + : pendingTarget === "foundry-local-whisper" + ? "asrFoundryLocalWhisper" + : pendingTarget === "sherpa-onnx-local" + ? "asrSherpaOnnxLocal" + : null - return ( - <> - {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── + return ( + <> + {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── 点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} - {pendingTarget && pendingNameKey && ( -
{ - if (e.target === e.currentTarget && !busy) setPendingTarget(null); - }}> - -
- ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} -
-
- {t('settings.advanced.confirmEnableLocalBody', { - target: t(`settings.providers.presets.${pendingNameKey}`), - })} -
-
- setPendingTarget(null)}> - {t('common.cancel')} - - void performSwitch(pendingTarget)}> - {t('settings.advanced.confirm')} - -
-
-
- )} + {pendingTarget && pendingNameKey && ( +
{ + if (e.target === e.currentTarget && !busy) + setPendingTarget(null) + }} + > + +
+ ⚠️ {t("settings.advanced.confirmEnableLocalTitle")} +
+
+ {t("settings.advanced.confirmEnableLocalBody", { + target: t( + `settings.providers.presets.${pendingNameKey}`, + ), + })} +
+
+ setPendingTarget(null)} + > + {t("common.cancel")} + + + void performSwitch(pendingTarget) + } + > + {t("settings.advanced.confirm")} + +
+
+
+ )} - {/* ─── 流式输入(全平台 opt-in) ─────────────────────────────────── + {/* ─── 流式输入(全平台 opt-in) ─────────────────────────────────── 润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) @@ -118,144 +157,285 @@ export function AdvancedSection() { - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} - -
- {t(isLinux - ? 'settings.advanced.streamingInsertTitleLinux' - : 'settings.advanced.streamingInsertTitle')} -
-
- {t('settings.advanced.streamingInsertDesc')} -
- - { - if (prefs) void updatePrefs({ ...prefs, streamingInsert: next }); - }} - /> - - - { - if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next }); - }} - /> - -
+ +
+ {t( + isLinux + ? "settings.advanced.streamingInsertTitleLinux" + : "settings.advanced.streamingInsertTitle", + )} +
+
+ {t("settings.advanced.streamingInsertDesc")} +
+ + { + if (prefs) + void updatePrefs({ + ...prefs, + streamingInsert: next, + }) + }} + /> + + + { + if (prefs) + void updatePrefs({ + ...prefs, + streamingInsertSaveClipboard: next, + }) + }} + /> + +
- - {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 + + {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 Windows:标题区整体灰显 —— "本地 ASR 模型(实验性)" 在 Win 上几乎只有 Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于"实验性" 框架。灰显视觉让用户知道这条"实验性"主线在 Win 不可用,关注点转到下方 Foundry 行。 */} -
-
-
{t('settings.advanced.localAsrTitle')}
-
- {t('settings.advanced.localAsrDesc')} -
-
-
- ⚠️ {t('settings.advanced.localAsrWarningShort')} -
-
+
+
+
+ {t("settings.advanced.localAsrTitle")} +
+
+ {t("settings.advanced.localAsrDesc")} +
+
+
+ ⚠️ {t("settings.advanced.localAsrWarningShort")} +
+
- {!platformSupported ? ( -
- {t('settings.advanced.platformNotSupported')} -
- ) : ( - <> - {/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off + {!platformSupported ? ( +
+ {t("settings.advanced.platformNotSupported")} +
+ ) : ( + <> + {/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off + 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台 异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立 "禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感 (pr_agent #403 'Stale Windows state' 修法)。 Windows 整行灰显,跟"本地 ASR 实验性"标题区视觉对齐 —— 用户一眼看出 这条线在 Win 上不能用,关注点落到下方 Foundry 行。 */} -
- -
- { - if (next) requestEnable('local-qwen3'); - else void performSwitch('volcengine'); - } : undefined} - /> -
-
-
+
+ +
+ { + if (next) + requestEnable( + "local-qwen3", + ) + else + void performSwitch( + "volcengine", + ) + } + : undefined + } + /> +
+
+
- {/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} - {isWin && ( - -
- { - if (next) requestEnable('foundry-local-whisper'); - else void performSwitch('volcengine'); - } : undefined} - /> -
-
- )} - - )} + {/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} + {isWin && ( + <> + +
+ { + if (next) + requestEnable( + "foundry-local-whisper", + ) + else + void performSwitch( + "volcengine", + ) + } + : undefined + } + /> +
+
+ +
+ { + if (next) + requestEnable( + "sherpa-onnx-local", + ) + else + void performSwitch( + "volcengine", + ) + } + : undefined + } + /> +
+
+ + )} + + )} - {/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出: + {/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出: - Linux / 不支持平台:根本没有任何引擎行 - 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3): 本机引擎 Toggle 是 off,关不动异常 active 的对方引擎 否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */} - {isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && ( - -
- void performSwitch('volcengine')}> - {t('settings.advanced.disable')} - -
-
- )} -
+ {isOnAnyLocal && + !( + (isMac && isOnLocalQwen3) || + (isWin && (isOnFoundry || isOnSherpaOnnx)) + ) && ( + +
+ + void performSwitch("volcengine") + } + > + {t("settings.advanced.disable")} + +
+
+ )} +
- {/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local) + {/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local) inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */} - {platformSupported && } - - ); + {platformSupported && } + + ) } diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 4d555e54..f5ce6ed6 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -1,76 +1,184 @@ // 共享在 Settings 各 section 间的原子(SettingRow / Toggle / inputStyle)。 // AsrPresetId 也放在这里,让 AdvancedSection 与 Settings.tsx 都从一处来源拿。 -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from "react" -export function SectionTitle({ children, style }: { children: ReactNode; style?: CSSProperties }) { - return
{children}
; +export function SectionTitle({ + children, + style, +}: { + children: ReactNode + style?: CSSProperties +}) { + return ( +
+ {children} +
+ ) } -export function SectionDesc({ children, style }: { children: ReactNode; style?: CSSProperties }) { - return
{children}
; +export function SectionDesc({ + children, + style, +}: { + children: ReactNode + style?: CSSProperties +}) { + return ( +
+ {children} +
+ ) } interface SettingRowProps { - label: string; - desc?: string; - children: ReactNode; - controlWidth?: number | string; + label: string + desc?: string + children: ReactNode + controlWidth?: number | string } -export function SettingRow({ label, desc, children, controlWidth }: SettingRowProps) { - return ( -
-
-
{label}
- {desc &&
{desc}
} -
-
{children}
-
- ); +export function SettingRow({ + label, + desc, + children, + controlWidth, +}: SettingRowProps) { + return ( +
+
+
+ {label} +
+ {desc && ( +
+ {desc} +
+ )} +
+
+ {children} +
+
+ ) } -export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { - return ( - - ); +export function Toggle({ + on, + onToggle, +}: { + on: boolean + onToggle?: (next: boolean) => void +}) { + return ( + + ) } export const inputStyle: CSSProperties = { - flex: 1, height: 32, padding: '0 10px', - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, fontSize: 12.5, - fontFamily: 'inherit', outline: 'none', - background: 'var(--ol-surface-2)', - width: '100%', maxWidth: 360, - transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', -}; + flex: 1, + height: 32, + padding: "0 10px", + border: "0.5px solid var(--ol-line-strong)", + borderRadius: 8, + fontSize: 12.5, + fontFamily: "inherit", + outline: "none", + background: "var(--ol-surface-2)", + width: "100%", + maxWidth: 360, + transition: + "background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)", +} // ASR provider id 集合,跟 Settings.tsx::ASR_PRESETS 一一对应。 // 拆成独立类型让 AdvancedSection / ProvidersSection 都能用同一份不互相依赖。 export type AsrPresetId = - | 'volcengine' - | 'bailian' - | 'siliconflow' - | 'zhipu' - | 'groq' - | 'whisper' - | 'foundry-local-whisper' - | 'local-qwen3'; + | "volcengine" + | "bailian" + | "siliconflow" + | "zhipu" + | "groq" + | "whisper" + | "foundry-local-whisper" + | "sherpa-onnx-local" + | "local-qwen3" diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 3a2653ee..7282482f 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -1,184 +1,235 @@ import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; -import { getHotkeyCapability, getSettings, isTauri, setSettings } from '../lib/ipc'; -import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types'; -import i18n, { outputPrefsForLocale, type SupportedLocale } from '../i18n'; + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" +import { + getHotkeyCapability, + getSettings, + isTauri, + setSettings, +} from "../lib/ipc" +import type { + HotkeyBinding, + HotkeyCapability, + UserPreferences, +} from "../lib/types" +import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n" interface HotkeySettingsContextValue { - prefs: UserPreferences | null; - hotkey: HotkeyBinding | null; - capability: HotkeyCapability | null; - loading: boolean; - error: string | null; - refresh: () => Promise; - updatePrefs: ( - next: UserPreferences | ((current: UserPreferences) => UserPreferences), - ) => Promise; + prefs: UserPreferences | null + hotkey: HotkeyBinding | null + capability: HotkeyCapability | null + loading: boolean + error: string | null + refresh: () => Promise + updatePrefs: ( + next: UserPreferences | ((current: UserPreferences) => UserPreferences), + ) => Promise } -const HotkeySettingsContext = createContext(null); +const HotkeySettingsContext = createContext( + null, +) const errorMessage = (error: unknown) => - String(error instanceof Error ? error.message : error); + String(error instanceof Error ? error.message : error) export function HotkeySettingsProvider({ children }: { children: ReactNode }) { - const [prefs, setPrefs] = useState(null); - const [capability, setCapability] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const persistQueueRef = useRef>(Promise.resolve()); - const latestPrefsRef = useRef(null); + const [prefs, setPrefs] = useState(null) + const [capability, setCapability] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const persistQueueRef = useRef>(Promise.resolve()) + const latestPrefsRef = useRef(null) - const refresh = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [prefsResult, capabilityResult] = await Promise.allSettled([ - getSettings(), - getHotkeyCapability(), - ]); - let nextError: string | null = null; - if (prefsResult.status === 'fulfilled') { - setPrefs(prefsResult.value); - } else { - console.error('[hotkey-settings] failed to load preferences', prefsResult.reason); - nextError = errorMessage(prefsResult.reason); - } - if (capabilityResult.status === 'fulfilled') { - setCapability(capabilityResult.value); - } else { - console.error('[hotkey-settings] failed to load hotkey capability', capabilityResult.reason); - nextError = errorMessage(capabilityResult.reason); - } - setError(nextError); - } catch (error) { - console.error('[hotkey-settings] failed to refresh hotkey settings', error); - setError(errorMessage(error)); - } finally { - setLoading(false); - } - }, []); + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [prefsResult, capabilityResult] = await Promise.allSettled([ + getSettings(), + getHotkeyCapability(), + ]) + let nextError: string | null = null + if (prefsResult.status === "fulfilled") { + setPrefs(prefsResult.value) + } else { + console.error( + "[hotkey-settings] failed to load preferences", + prefsResult.reason, + ) + nextError = errorMessage(prefsResult.reason) + } + if (capabilityResult.status === "fulfilled") { + setCapability(capabilityResult.value) + } else { + console.error( + "[hotkey-settings] failed to load hotkey capability", + capabilityResult.reason, + ) + nextError = errorMessage(capabilityResult.reason) + } + setError(nextError) + } catch (error) { + console.error( + "[hotkey-settings] failed to refresh hotkey settings", + error, + ) + setError(errorMessage(error)) + } finally { + setLoading(false) + } + }, []) - const queueSetSettings = useCallback((resolveNext: (current: UserPreferences) => UserPreferences) => { - const task = persistQueueRef.current - .catch(() => undefined) - .then(async () => { - const current = latestPrefsRef.current; - if (!current) return; - const next = resolveNext(current); - await setSettings(next); - }); - persistQueueRef.current = task; - return task; - }, []); + const queueSetSettings = useCallback( + (resolveNext: (current: UserPreferences) => UserPreferences) => { + const task = persistQueueRef.current + .catch(() => undefined) + .then(async () => { + const current = latestPrefsRef.current + if (!current) return + const next = resolveNext(current) + if (next === current) return + await setSettings(next) + }) + persistQueueRef.current = task + return task + }, + [], + ) - useEffect(() => { - void refresh(); - }, [refresh]); + useEffect(() => { + void refresh() + }, [refresh]) - useEffect(() => { - if (!isTauri) return; - let cancelled = false; - let unlisten: (() => void) | undefined; - void (async () => { - try { - const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('prefs:changed', event => { - const nextPrefs = event.payload; - if (!nextPrefs) return; - latestPrefsRef.current = nextPrefs; - setPrefs(nextPrefs); - }); - if (cancelled) { - handle(); - } else { - unlisten = handle; + useEffect(() => { + if (!isTauri) return + let cancelled = false + let unlisten: (() => void) | undefined + void (async () => { + try { + const { listen } = await import("@tauri-apps/api/event") + const handle = await listen( + "prefs:changed", + (event) => { + const nextPrefs = event.payload + if (!nextPrefs) return + latestPrefsRef.current = nextPrefs + setPrefs(nextPrefs) + }, + ) + if (cancelled) { + handle() + } else { + unlisten = handle + } + } catch (error) { + console.warn( + "[settings] prefs:changed listener setup failed", + error, + ) + } + })() + return () => { + cancelled = true + unlisten?.() } - } catch (error) { - console.warn('[settings] prefs:changed listener setup failed', error); - } - })(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); + }, []) - useEffect(() => { - latestPrefsRef.current = prefs; - }, [prefs]); + useEffect(() => { + latestPrefsRef.current = prefs + }, [prefs]) - useEffect(() => { - const currentPrefs = latestPrefsRef.current; - if (!currentPrefs) return; - const lang = (i18n.resolvedLanguage || i18n.language || '').toLowerCase(); - const resolvedLocale: SupportedLocale = - lang.startsWith('zh-tw') || lang.includes('hant') - ? 'zh-TW' - : lang.startsWith('zh-cn') || lang.startsWith('zh') - ? 'zh-CN' - : lang.startsWith('ja') - ? 'ja' - : lang.startsWith('ko') - ? 'ko' - : 'en'; - const nextLocalePrefs = outputPrefsForLocale(resolvedLocale); - if ( - currentPrefs.chineseScriptPreference === nextLocalePrefs.chineseScriptPreference && - currentPrefs.outputLanguagePreference === nextLocalePrefs.outputLanguagePreference - ) { - return; - } - const merged = { ...currentPrefs, ...nextLocalePrefs }; - latestPrefsRef.current = merged; - setPrefs(merged); - void queueSetSettings(current => ({ ...current, ...nextLocalePrefs })).catch( - error => { - console.warn('[settings] sync locale output preferences failed', error); - }, - ); - }, [prefs, queueSetSettings]); + useEffect(() => { + const currentPrefs = latestPrefsRef.current + if (!currentPrefs) return + const lang = ( + i18n.resolvedLanguage || + i18n.language || + "" + ).toLowerCase() + const resolvedLocale: SupportedLocale = + lang.startsWith("zh-tw") || lang.includes("hant") + ? "zh-TW" + : lang.startsWith("zh-cn") || lang.startsWith("zh") + ? "zh-CN" + : lang.startsWith("ja") + ? "ja" + : lang.startsWith("ko") + ? "ko" + : "en" + const nextLocalePrefs = outputPrefsForLocale(resolvedLocale) + if ( + currentPrefs.chineseScriptPreference === + nextLocalePrefs.chineseScriptPreference && + currentPrefs.outputLanguagePreference === + nextLocalePrefs.outputLanguagePreference + ) { + return + } + const merged = { ...currentPrefs, ...nextLocalePrefs } + latestPrefsRef.current = merged + setPrefs(merged) + void queueSetSettings((current) => ({ + ...current, + ...nextLocalePrefs, + })).catch((error) => { + console.warn( + "[settings] sync locale output preferences failed", + error, + ) + }) + }, [prefs, queueSetSettings]) - const updatePrefs = useCallback( - async (next: UserPreferences | ((current: UserPreferences) => UserPreferences)) => { - const current = latestPrefsRef.current; - if (!current) return; - const resolved = typeof next === 'function' ? next(current) : next; - setPrefs(resolved); - latestPrefsRef.current = resolved; - await queueSetSettings(() => resolved); - }, - [queueSetSettings], - ); + const updatePrefs = useCallback( + async ( + next: + | UserPreferences + | ((current: UserPreferences) => UserPreferences), + ) => { + const current = latestPrefsRef.current + if (!current) return + const resolved = typeof next === "function" ? next(current) : next + if (resolved === current) return + setPrefs(resolved) + latestPrefsRef.current = resolved + await queueSetSettings(() => resolved) + }, + [queueSetSettings], + ) - const value = useMemo( - () => ({ - prefs, - hotkey: prefs?.hotkey ?? null, - capability, - loading, - error, - refresh, - updatePrefs, - }), - [capability, error, loading, prefs, refresh, updatePrefs], - ); + const value = useMemo( + () => ({ + prefs, + hotkey: prefs?.hotkey ?? null, + capability, + loading, + error, + refresh, + updatePrefs, + }), + [capability, error, loading, prefs, refresh, updatePrefs], + ) - return {children}; + return ( + + {children} + + ) } export function useHotkeySettings() { - const value = useContext(HotkeySettingsContext); - if (!value) { - throw new Error('useHotkeySettings must be used within HotkeySettingsProvider'); - } - return value; + const value = useContext(HotkeySettingsContext) + if (!value) { + throw new Error( + "useHotkeySettings must be used within HotkeySettingsProvider", + ) + } + return value }