From 3bfd61cf1c8e5ba279368cd3fe75e1224e921cc7 Mon Sep 17 00:00:00 2001 From: weikeyi Date: Wed, 20 May 2026 12:48:50 +0800 Subject: [PATCH 1/3] feat(windows): add sherpa-onnx local ASR --- docs/windows-sherpa-onnx-asr-plan.md | 421 ++ openless-all/app/src-tauri/Cargo.lock | 75 +- openless-all/app/src-tauri/Cargo.toml | 4 + openless-all/app/src-tauri/build.rs | 22 + .../app/src-tauri/src/asr/local/download.rs | 7 +- .../src-tauri/src/asr/local/foundry_native.rs | 4 +- .../app/src-tauri/src/asr/local/mod.rs | 14 +- .../app/src-tauri/src/asr/local/sherpa.rs | 351 ++ .../src/asr/local/sherpa_download.rs | 751 +++ .../src/asr/local/sherpa_provider.rs | 182 + .../src-tauri/src/asr/local/sherpa_runtime.rs | 510 +++ .../app/src-tauri/src/combo_hotkey.rs | 3 + openless-all/app/src-tauri/src/commands.rs | 477 +- openless-all/app/src-tauri/src/coordinator.rs | 92 +- .../src-tauri/src/coordinator/dictation.rs | 74 + .../src-tauri/src/coordinator/resources.rs | 2 + openless-all/app/src-tauri/src/lib.rs | 34 +- openless-all/app/src-tauri/src/persistence.rs | 7 + openless-all/app/src-tauri/src/qa_hotkey.rs | 3 + openless-all/app/src-tauri/src/types.rs | 30 + openless-all/app/src/i18n/en.ts | 2084 +++++---- openless-all/app/src/i18n/ja.ts | 2086 +++++---- openless-all/app/src/i18n/ko.ts | 2074 +++++---- openless-all/app/src/i18n/zh-CN.ts | 2016 +++++---- openless-all/app/src/i18n/zh-TW.ts | 2027 +++++---- openless-all/app/src/lib/ipc.ts | 1802 ++++---- openless-all/app/src/lib/localAsr.ts | 715 ++- openless-all/app/src/lib/stylePrefs.test.ts | 3 + openless-all/app/src/lib/types.ts | 6 + openless-all/app/src/pages/LocalAsr.tsx | 4032 ++++++++++++----- .../src/pages/settings/AdvancedSection.tsx | 618 ++- .../app/src/pages/settings/shared.tsx | 220 +- .../app/src/state/HotkeySettingsContext.tsx | 373 +- 33 files changed, 13706 insertions(+), 7413 deletions(-) create mode 100644 docs/windows-sherpa-onnx-asr-plan.md create mode 100644 openless-all/app/src-tauri/src/asr/local/sherpa.rs create mode 100644 openless-all/app/src-tauri/src/asr/local/sherpa_download.rs create mode 100644 openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs create mode 100644 openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs 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/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..056e1d9f --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs @@ -0,0 +1,751 @@ +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) { + let dest = dir.join(archive.file_name); + if let Ok(meta) = std::fs::metadata(&dest) { + return meta.len(); + } + return partial_actual_size(&dest.with_extension("partial")); + } + 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() +} + +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 6715f03f..262cf007 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(()) } @@ -571,7 +608,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; } @@ -637,12 +677,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, } } @@ -658,6 +700,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)?; @@ -673,11 +727,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 { @@ -691,6 +754,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(()) } @@ -927,7 +991,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 { @@ -942,6 +1008,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)?; @@ -2271,6 +2349,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()) } @@ -2282,6 +2363,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()) } @@ -2292,7 +2376,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()) } @@ -2350,6 +2438,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。 @@ -2918,6 +3237,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; @@ -2935,6 +3255,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 { @@ -2984,11 +3307,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] @@ -3018,10 +3351,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")); } @@ -3031,14 +3372,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")] @@ -3051,6 +3400,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(), ""); @@ -3154,6 +3514,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(()) @@ -3171,9 +3535,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] @@ -3282,18 +3654,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() }; @@ -3305,15 +3695,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 b347239a..8715990b 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| { @@ -374,6 +380,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 4d3fff50..d538e26c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1,983 +1,1151 @@ // English resources — translated from zh-CN.ts. Keep keys in sync. -import type { zhCN } from './zh-CN'; +import type { zhCN } from "./zh-CN" // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { - app: { - name: 'OpenLess', - tagline: 'Speak naturally, write perfectly', - }, - common: { - loading: 'Loading…', - retry: 'Retry', - settingsLoadFailed: 'Settings load failed', - refresh: 'Refresh', - clear: 'Clear', - copy: 'Copy', - delete: 'Delete', - later: 'Later', - cancel: 'Cancel', - close: 'Close', - show: 'Show', - hide: 'Hide', - saved: 'Saved', - saving: 'Saving…', - copied: 'Copied', - operationFailed: 'Operation failed', - add: 'Add', - durationSeconds: '{{value}}s', - durationMinutes: '{{value}}m', - }, - capsule: { - thinking: 'thinking', - cancelled: 'Cancelled', - error: 'Something went wrong', - inserted: 'Inserted {{count}}', - translating: 'Translating', - }, - qa: { - thinking: 'Thinking…', - error: 'Something went wrong. Please try again.', - errorRetry: 'Retry', - errorRetryHint: 'Press {{recordHotkey}} again to retry.', - pinTooltip: 'Pin (stay open)', - unpinTooltip: 'Unpin', - closeTooltip: 'Close', - selectionPreview: 'From selected text:', - emptyTitle: 'Press {{recordHotkey}} to ask', - emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.', - recordingHint: 'Recording… press {{recordHotkey}} again to submit', - statusIdle: 'Press {{recordHotkey}} to ask', - statusRecording: 'Recording', - statusThinking: 'Thinking', - statusError: 'Error', - }, - nav: { - overview: 'Overview', - history: 'History', - vocab: 'Vocabulary', - style: 'Style', - marketplace: 'Marketplace', - translation: 'Translation', - selectionAsk: 'Ask', - localAsr: 'Models', - }, - marketplace: { - kicker: 'MARKETPLACE', - title: 'Style Pack Marketplace', - desc: 'Browse, install, and share community style packs.', - searchPlaceholder: 'Search name / description / tags…', - sortPopular: 'Popular', - sortNew: 'Newest', - uploadBtn: 'Upload', - uploadDisabledHint: 'Set your GitHub login in Settings → Marketplace first', - refreshBtn: 'Refresh', - empty: 'No style packs yet', - emptyHint: 'Try a different keyword, or upload your own', - loadFailed: 'Load failed: {{err}}', - noDescription: '(no description)', - installBtn: 'Install', - likeBtn: 'Like', - installed: 'Installed "{{name}}" locally', - uploaded: 'Uploaded — waiting for review', - uploadTitle: 'Pick a style pack to upload', - uploadHint: 'Uploading as {{login}}. Content goes to the cloud review queue.', - uploadNoLocal: 'No local style packs to upload', - errors: { - detail: 'Detail load failed: {{err}}', - install: 'Install failed: {{err}}', - like: 'Like failed: {{err}}', - upload: 'Upload failed: {{err}}', - loadLocal: 'Load local packs failed: {{err}}', + app: { + name: "OpenLess", + tagline: "Speak naturally, write perfectly", }, - sortLiked: 'Liked', - likedEmpty: 'You have not liked any style packs yet', - likedEmptyHint: 'Open any pack and tap the star — liked packs appear here', - derivativeBadge: 'Derived from @{{login}}', - detail: { - withdrawBtn: 'Withdraw', - withdrawConfirm: 'Withdraw "{{name}}" from the marketplace? Your local copy is kept.', - withdrawSuccess: 'Withdrawn from marketplace', - withdrawFailed: 'Withdraw failed: {{err}}', + common: { + loading: "Loading…", + retry: "Retry", + settingsLoadFailed: "Settings load failed", + refresh: "Refresh", + clear: "Clear", + copy: "Copy", + delete: "Delete", + later: "Later", + cancel: "Cancel", + close: "Close", + show: "Show", + hide: "Hide", + saved: "Saved", + saving: "Saving…", + copied: "Copied", + operationFailed: "Operation failed", + add: "Add", + durationSeconds: "{{value}}s", + durationMinutes: "{{value}}m", }, - myPacks: { - buttonLabel: 'My Packs', - buttonTitle: 'View {{login}}\'s publications', - buttonTitleEmpty: 'Set publisher identity in Settings → Marketplace first', - searchPlaceholder: 'Search name or tags', - notLoggedIn: 'Set publisher identity in Settings → Marketplace first', - emptyTitle: 'You have not published any style packs yet', - emptyHint: 'Edit a pack in the Style page and click "Publish to Marketplace", or upload a local pack from the top-right.', - noMatch: 'No matching style packs', - summary: '{{count}} published', - summaryPending: '{{count}} published · {{pending}} pending review', - versionDate: 'v{{version}} · {{date}}', - stats: '★ {{likes}} · ↓ {{downloads}}', - actions: { - update: 'Update', - withdraw: 'Withdraw', - }, - loadFailed: 'Failed to load my packs: {{err}}', - loadingTitle: 'Loading…', - loadingHint: 'Fetching your latest publications from the marketplace.', - loadErrorTitle: 'Load failed', - loadErrorRetry: 'Retry', + capsule: { + thinking: "thinking", + cancelled: "Cancelled", + error: "Something went wrong", + inserted: "Inserted {{count}}", + translating: "Translating", }, - upload: { - confirmBtn: 'Confirm upload', - updateTitle: 'Update "{{name}}"', - updateHint: 'Pick the local newer version, then click "Confirm upload". A same-name pack is pre-selected.', - recommendedBadge: 'Recommended', + qa: { + thinking: "Thinking…", + error: "Something went wrong. Please try again.", + errorRetry: "Retry", + errorRetryHint: "Press {{recordHotkey}} again to retry.", + pinTooltip: "Pin (stay open)", + unpinTooltip: "Unpin", + closeTooltip: "Close", + selectionPreview: "From selected text:", + emptyTitle: "Press {{recordHotkey}} to ask", + emptyDesc: + "Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.", + recordingHint: "Recording… press {{recordHotkey}} again to submit", + statusIdle: "Press {{recordHotkey}} to ask", + statusRecording: "Recording", + statusThinking: "Thinking", + statusError: "Error", }, - state: { - pending: 'Pending', - approved: 'Published', - rejected: 'Rejected', - withdrawn: 'Withdrawn', - superseded: 'Superseded', - unknown: 'Unknown', + nav: { + overview: "Overview", + history: "History", + vocab: "Vocabulary", + style: "Style", + marketplace: "Marketplace", + translation: "Translation", + selectionAsk: "Ask", + localAsr: "Models", }, - oauth: { - title: 'Sign in with GitHub', - generating: 'Generating device code…', - browserHint: 'Open {{uri}} in your browser and enter this code:', - copyBtn: 'Copy', - copied: 'Device code copied', - copyFailed: 'Copy failed: {{err}}', - openBrowserBtn: 'Open browser', - cancelBtn: 'Cancel', - waiting: 'Waiting for browser authorization…', - successAs: 'Signed in as @{{login}}', - retryBtn: 'Retry', - closeBtn: 'Close', - loginBtn: 'Sign in', - loginTooltip: 'Sign in with GitHub', - reloginTooltip: 'Click to re-sign-in / switch account (current @{{login}})', - }, - modal: { - loggedIn: 'Current sign-in identity — change in Settings → Recording → Marketplace', - notLoggedIn: 'Not signed in — go to Settings → Recording → Marketplace to set publisher name', - notLoggedInLabel: 'Not signed in', - }, - }, - shell: { - shortcutLabel: 'Recording shortcut', - shortcutHint: 'Start / Stop', - betaTag: 'BETA', - betaNote: 'All data stays on this device.', - footer: { - account: 'Account', - feedback: 'Feedback', - settings: 'Settings', - help: 'Help', - version: 'Version {{version}}', - helpPopover: { - tagline: 'Local-first voice input layer', - releaseNotes: 'Release notes ↗', - docs: 'Help center ↗', - }, - }, - providerPrompt: { - title: 'Set up speech providers', - body: 'No ASR or LLM provider is configured yet. Voice input and polishing will not work until you add credentials.', - later: 'Later', - openSettings: 'Open Settings', - }, - hotkeyModePrompt: { - title: 'Review your recording mode', - body: 'Default is now Toggle. If you changed the trigger mode before, please confirm it in Recording settings.', - later: 'Remind me later', - openSettings: 'Open Recording', - }, - }, - onboarding: { - welcome: 'Welcome to OpenLess', - intro: 'Speak locally, type locally. Two system permissions are needed before you start.', - accessibilityTitle: 'Accessibility', - hotkeyTitle: 'Global hotkey', - accessibilityDesc: 'Used to listen to the global hotkey (default {{trigger}}) and write transcripts at the cursor.', - hotkeyDesc: 'Used to confirm that the global hotkey listener is available.', - micTitle: 'Microphone', - micDesc: 'Used to capture your voice input.', - actionNotApplicable: 'Not required', - actionGranted: 'Granted', - actionOpenSystem: 'Open System Settings', - actionGrant: 'Grant', - actionRequestMic: 'Request access', - accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', - footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.', - }, - overview: { - kicker: 'DASHBOARD', - title: "Today's overview", - desc: 'Today\'s dictation stats and system status.', - pressPrefix: 'Press', - pressSuffix: 'to start', - asrKind: 'ASR', - llmKind: 'LLM', - asrName: 'Volcengine', - asrSubname: 'bigmodel', - llmName: 'OpenAI-compatible', - llmConfigured: 'Active LLM configured', - llmNotConfigured: 'Not configured', - statusConfigured: 'Configured', - statusNotConfigured: 'Not configured', - statusUnknown: 'Unavailable', - credentialsLoadError: 'Could not read credential status', - metricChars: 'Characters today', - metricSegments: '{{count}} segments', - metricDuration: 'Total duration today', - metricAvg: 'Avg per segment', - metricAvgTrend: "Today's average", - metricNoData: 'No data', - historyLoadError: 'History load failed', - metricTotal: 'Total records', - metricTotalTrend: 'Local archive (max 200)', - weekTitle: 'Last 7 days', - weekUnit: 'count / day', - recentTitle: 'Recent transcripts', - recentAll: 'View all →', - recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.', - recentLoadFailed: 'Could not load recent transcripts. Please retry.', - historyRetry: 'Retry', - weekDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], - }, - history: { - kicker: 'HISTORY', - title: 'History', - desc: 'Locally stored transcripts.', - filterAll: 'All', - summary: '{{total}} total · showing {{shown}}', - empty: 'No history yet. Press {{trigger}} to record one.', - loadFailed: 'Failed to load history: {{err}}', - retry: 'Retry', - clearFailed: 'Failed to clear history: {{err}}', - deleteFailed: 'Failed to delete entry: {{err}}', - copyFailed: 'Failed to copy: {{err}}', - playRecording: 'Play recording', - audioLoading: 'Loading…', - exportRecording: 'Export recording', - exportFailed: 'Failed to export: {{err}}', - rawLabel: 'Raw', - rawEmpty: '(empty)', - selectHint: 'Select an entry on the left to see details.', - insertedTo: 'Inserted into', - chars: '{{count}} chars', - vocabHits: '{{count}} vocab hits', - inserted: 'Inserted', - pasteSent: 'Paste sent', - copiedFallback: 'Copied (use {{shortcut}})', - insertFailed: 'Insert failed', - confirmClear: 'Delete all {{count}} history entries? This cannot be undone.', - }, - vocab: { - kicker: 'VOCABULARY', - title: 'Vocabulary', - desc: 'Add terms or jargon to improve recognition accuracy.', - sectionTitle: 'Entries', - placeholder: 'Type a word, press Enter or click Add…', - tip: 'Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically', - loadFailed: 'Load failed: {{err}}', - empty: 'No entries yet. Add a new term or piece of jargon above so the model can prioritize it.', - tipDisabled: 'Click to disable this entry', - tipEnabled: 'Click to enable this entry', - removeAria: 'Remove', - corrections: { - title: 'Correction rules', - tip: 'Fix common ASR mistakes. Supports {num} number wildcard.', - patternPlaceholder: 'Mistaken text, e.g. {num}粒', - replacementPlaceholder: 'Target text, e.g. {num}例', - empty: 'No correction rules yet.', - invalid: 'Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.', - tipDisabled: 'Click to disable this rule', - tipEnabled: 'Click to enable this rule', - removeAria: 'Remove correction rule', - }, - presets: { - title: 'Scenario presets', - tip: 'Multi-select to apply in batch. Supports edit and create.', - create: 'New preset', - apply: 'Apply selected', - save: 'Save preset', - edit: 'Edit {{name}}', - newPreset: 'New preset', - namePlaceholder: 'Preset name', - wordsPlaceholder: 'Terms (comma or newline separated)', - }, - }, - style: { - kicker: 'STYLE', - title: 'Output style', - desc: 'Choose the default output style for recording.', - masterToggle: 'Master switch', - currentDefault: 'Current default', - ariaSetDefault: 'Set as default', - saveFailed: 'Save failed: {{error}}', - customPromptTitle: 'Custom prompt', - customPromptPlaceholder: 'Optional. Appended to this style’s built-in system prompt.', - customPromptHint: 'Leave empty to preserve current behavior. After saving, it applies to both this style’s live polish path and repolish. Press Ctrl/Cmd+Enter to save as well.', - customPromptSave: 'Save prompt', - customPromptDirty: 'Unsaved', - systemPromptMovedHint: 'Full system prompt editing has moved to Settings -> Providers. This page now only controls which styles are enabled and which one is the default.', - modes: { - raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." }, - light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." }, - structured: { name: 'Structured', desc: 'Auto-organizes into a numbered outline when you cover several topics or steps.', sample: '1. Topic one\na. Point\nb. Point\n2. Topic two\na. Point\nb. Point' }, - formal: { name: 'Formal', desc: 'Email and workplace tone — more complete, more professional.', sample: 'Detects greetings/sign-offs in email contexts; avoids empty pleasantries.' }, - }, - pack: { - kicker: 'STYLE PACKS', - title: 'Style Packs', - desc: 'Manage local style packs.', - marketplaceBtn: 'Marketplace', - loadFailed: 'Failed to load style packs: {{err}}', - importZip: 'Import ZIP', - exportZip: 'Export ZIP', - exportShort: 'Export', - publishMarketplace: 'Publish to Marketplace', - updateMarketplace: 'Update Marketplace version', - publishDisabledHint: 'Configure your GitHub login in Settings → Marketplace first', - publishSuccess: 'Published — pending review on marketplace', - publishFailed: 'Publish failed: {{err}}', - publishBuiltinRejected: 'Built-in packs cannot be published. Clone first via edit.', - builtin: 'Built-in', - imported: 'Imported', - active: 'Active', - activate: 'Activate', - edit: 'Edit', - closeEditor: 'Close', - unsaved: 'Unsaved', - listTitle: 'Local Packs', - listDesc: 'Browse and switch packs.', - listCount: '{{count}} packs', - addPackTileTitle: 'New Pack', - addPackTileHint: 'Start from a blank template.', - createSuccess: 'New pack created.', - createFailed: 'Failed to create pack: {{err}}', - save: 'Save', - revert: 'Revert', - saveSuccess: 'Style pack saved.', - saveFailed: 'Failed to save style pack: {{err}}', - activateSuccess: 'Set "{{name}}" as current.', - activateFailed: 'Failed to set current style pack: {{err}}', - importSuccess: 'Imported "{{name}}".', - importFailed: 'Failed to import ZIP: {{err}}', - exportSuccess: 'Exported to {{path}}', - exportFailed: 'Failed to export ZIP: {{err}}', - exportDirtyFirst: 'Save this pack before exporting ZIP.', - resetBuiltin: 'Reset', - resetSuccess: 'Reset "{{name}}".', - resetFailed: 'Failed to reset pack: {{err}}', - deleteImported: 'Delete', - deleteConfirm: 'Delete "{{name}}"? This cannot be undone.', - deleteSuccess: 'Deleted "{{name}}".', - deleteFailed: 'Failed to delete pack: {{err}}', - summaryCurrentEmpty: 'No pack selected yet', - editorTitle: 'Edit Pack', - editorDesc: 'Edit this pack.', - metaTitle: 'Installation Info', - metaSource: 'Source', - metaBaseMode: 'Base Mode', - metaUpdatedAt: 'Updated', - fieldName: 'Name', - fieldAuthor: 'Author', - fieldAuthorPlaceholder: 'Optional source label', - fieldVersion: 'Version', - fieldTags: 'Tags', - fieldTagsPlaceholder: 'Comma-separated tags, e.g. community, voiceover, formal', - fieldDescription: 'Description', - fieldModel: 'Recommended Model (Metadata)', - fieldModelPlaceholder: 'Optional, e.g. gpt-4.1 / deepseek-v3', - fieldModelHint: 'Metadata only. Does not switch model.', - fieldCompatibility: 'Compatible App Version', - fieldCompatibilityPlaceholder: 'Optional, e.g. >=1.3.0', - fullPromptTitle: 'System Prompt', - fullPromptHint: 'The prompt owned by this pack.', - promptChars: '{{count}} chars', - runtimeTitle: 'OpenLess Runtime Directives', - runtimeDesc: 'Read-only runtime helpers.', - runtimeContextTitle: 'Context premise', - runtimeContextDesc: 'From language and app context', - runtimeContextEmpty: 'Not added in the current preview.', - runtimeHotwordTitle: 'Hotword block', - runtimeHotwordDesc: 'From enabled hotwords', - runtimeHotwordEmpty: 'Not added in the current preview.', - runtimeHistoryTitle: 'Multi-turn history guardrail', - runtimeHistoryDesc: 'Only for live multi-turn polish', - runtimeHistoryEmpty: 'Only added when prior turns exist.', - runtimeActive: 'Active', - runtimeInactive: 'Inactive', - runtimePreviewFailed: 'Failed to build runtime preview: {{err}}', - runtimePreviewOmittedFrontApp: 'Preview omits the front-app label.', - examplesTitle: 'Effect Examples', - examplesDesc: 'Exported with the pack.', - addExample: 'Add Example', - examplesEmpty: 'No examples yet.', - exampleTitlePlaceholder: 'Example {{index}} title', - exampleInput: 'Input', - exampleOutput: 'Output', - examplesCount: '{{count}} examples', - discardCloseConfirm: 'Discard unsaved changes and close the editor?', - discardSwitchConfirm: 'Discard unsaved changes and switch to "{{name}}"?', - derivativeBadge: 'Derived from @{{login}}', - }, - }, - translation: { - kicker: 'TRANSLATION', - title: 'Translation', - desc: 'Auto-translate recordings into a target language before insertion.', - statusEnabled: 'Enabled', - statusDisabled: 'Disabled', - working: { - title: 'Working languages', - desc: 'Select languages you use regularly to improve polish and translation.', - }, - target: { - title: 'Translation target language', - desc: 'Press Shift during recording to trigger translation. "Disabled" makes Shift a no-op.', - disabled: 'Disabled (Shift does nothing)', - }, - save: { - workingFailed: 'Failed to save working languages. Please try again.', - targetFailed: 'Failed to save translation target. Please try again.', - hotkeyRegisterFailed: 'Failed to register the translation shortcut. The preference was not saved.', - hotkeySaveFailed: 'Failed to save the translation shortcut. Please try again.', + marketplace: { + kicker: "MARKETPLACE", + title: "Style Pack Marketplace", + desc: "Browse, install, and share community style packs.", + searchPlaceholder: "Search name / description / tags…", + sortPopular: "Popular", + sortNew: "Newest", + uploadBtn: "Upload", + uploadDisabledHint: + "Set your GitHub login in Settings → Marketplace first", + refreshBtn: "Refresh", + empty: "No style packs yet", + emptyHint: "Try a different keyword, or upload your own", + loadFailed: "Load failed: {{err}}", + noDescription: "(no description)", + installBtn: "Install", + likeBtn: "Like", + installed: 'Installed "{{name}}" locally', + uploaded: "Uploaded — waiting for review", + uploadTitle: "Pick a style pack to upload", + uploadHint: + "Uploading as {{login}}. Content goes to the cloud review queue.", + uploadNoLocal: "No local style packs to upload", + errors: { + detail: "Detail load failed: {{err}}", + install: "Install failed: {{err}}", + like: "Like failed: {{err}}", + upload: "Upload failed: {{err}}", + loadLocal: "Load local packs failed: {{err}}", + }, + sortLiked: "Liked", + likedEmpty: "You have not liked any style packs yet", + likedEmptyHint: + "Open any pack and tap the star — liked packs appear here", + derivativeBadge: "Derived from @{{login}}", + detail: { + withdrawBtn: "Withdraw", + withdrawConfirm: + 'Withdraw "{{name}}" from the marketplace? Your local copy is kept.', + withdrawSuccess: "Withdrawn from marketplace", + withdrawFailed: "Withdraw failed: {{err}}", + }, + myPacks: { + buttonLabel: "My Packs", + buttonTitle: "View {{login}}'s publications", + buttonTitleEmpty: + "Set publisher identity in Settings → Marketplace first", + searchPlaceholder: "Search name or tags", + notLoggedIn: + "Set publisher identity in Settings → Marketplace first", + emptyTitle: "You have not published any style packs yet", + emptyHint: + 'Edit a pack in the Style page and click "Publish to Marketplace", or upload a local pack from the top-right.', + noMatch: "No matching style packs", + summary: "{{count}} published", + summaryPending: "{{count}} published · {{pending}} pending review", + versionDate: "v{{version}} · {{date}}", + stats: "★ {{likes}} · ↓ {{downloads}}", + actions: { + update: "Update", + withdraw: "Withdraw", + }, + loadFailed: "Failed to load my packs: {{err}}", + loadingTitle: "Loading…", + loadingHint: + "Fetching your latest publications from the marketplace.", + loadErrorTitle: "Load failed", + loadErrorRetry: "Retry", + }, + upload: { + confirmBtn: "Confirm upload", + updateTitle: 'Update "{{name}}"', + updateHint: + 'Pick the local newer version, then click "Confirm upload". A same-name pack is pre-selected.', + recommendedBadge: "Recommended", + }, + state: { + pending: "Pending", + approved: "Published", + rejected: "Rejected", + withdrawn: "Withdrawn", + superseded: "Superseded", + unknown: "Unknown", + }, + oauth: { + title: "Sign in with GitHub", + generating: "Generating device code…", + browserHint: "Open {{uri}} in your browser and enter this code:", + copyBtn: "Copy", + copied: "Device code copied", + copyFailed: "Copy failed: {{err}}", + openBrowserBtn: "Open browser", + cancelBtn: "Cancel", + waiting: "Waiting for browser authorization…", + successAs: "Signed in as @{{login}}", + retryBtn: "Retry", + closeBtn: "Close", + loginBtn: "Sign in", + loginTooltip: "Sign in with GitHub", + reloginTooltip: + "Click to re-sign-in / switch account (current @{{login}})", + }, + modal: { + loggedIn: + "Current sign-in identity — change in Settings → Recording → Marketplace", + notLoggedIn: + "Not signed in — go to Settings → Recording → Marketplace to set publisher name", + notLoggedInLabel: "Not signed in", + }, }, - howto: { - title: 'How to use', - step1: 'Place cursor in any text field.', - step2: 'Press {{trigger}} to start recording.', - step3: 'Press Shift once during recording to activate translation.', - step4: 'Press {{trigger}} again to stop.', - step5: 'Translated text is inserted at the cursor.', - indicatorTitle: 'How to confirm translation mode is on', - indicatorDesc: 'A blue "Translating" indicator appears at the bottom of the screen after pressing Shift.', - fallbackTitle: 'Safety fallbacks', - fallbackDesc: 'If translation fails, the raw transcript is inserted instead.', + shell: { + shortcutLabel: "Recording shortcut", + shortcutHint: "Start / Stop", + betaTag: "BETA", + betaNote: "All data stays on this device.", + footer: { + account: "Account", + feedback: "Feedback", + settings: "Settings", + help: "Help", + version: "Version {{version}}", + helpPopover: { + tagline: "Local-first voice input layer", + releaseNotes: "Release notes ↗", + docs: "Help center ↗", + }, + }, + providerPrompt: { + title: "Set up speech providers", + body: "No ASR or LLM provider is configured yet. Voice input and polishing will not work until you add credentials.", + later: "Later", + openSettings: "Open Settings", + }, + hotkeyModePrompt: { + title: "Review your recording mode", + body: "Default is now Toggle. If you changed the trigger mode before, please confirm it in Recording settings.", + later: "Remind me later", + openSettings: "Open Recording", + }, }, - }, - selectionAsk: { - kicker: 'SELECTION ASK', - title: 'Selection Ask', - desc: 'Select text and ask questions by voice, with multi-turn follow-ups.', - statusEnabled: 'Enabled', - statusDisabled: 'Disabled', - hotkey: { - title: 'Hotkey to open the panel', - desc: 'Opens/closes the panel. Recording uses {{recordHotkey}}.', - optionDisabled: 'Disabled', - chordWarning: '', + onboarding: { + welcome: "Welcome to OpenLess", + intro: "Speak locally, type locally. Two system permissions are needed before you start.", + accessibilityTitle: "Accessibility", + hotkeyTitle: "Global hotkey", + accessibilityDesc: + "Used to listen to the global hotkey (default {{trigger}}) and write transcripts at the cursor.", + hotkeyDesc: + "Used to confirm that the global hotkey listener is available.", + micTitle: "Microphone", + micDesc: "Used to capture your voice input.", + actionNotApplicable: "Not required", + actionGranted: "Granted", + actionOpenSystem: "Open System Settings", + actionGrant: "Grant", + actionRequestMic: "Request access", + accessibilityHint: + "After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).", + footerHint: + "This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.", }, - save: { - hotkeyRegisterFailed: 'Failed to register the Selection Ask shortcut. The preference was not saved.', - hotkeySaveFailed: 'Failed to save the Selection Ask shortcut. Please try again.', - historySaveFailed: 'Failed to save the Q&A history setting. Please try again.', + overview: { + kicker: "DASHBOARD", + title: "Today's overview", + desc: "Today's dictation stats and system status.", + pressPrefix: "Press", + pressSuffix: "to start", + asrKind: "ASR", + llmKind: "LLM", + asrName: "Volcengine", + asrSubname: "bigmodel", + llmName: "OpenAI-compatible", + llmConfigured: "Active LLM configured", + llmNotConfigured: "Not configured", + statusConfigured: "Configured", + statusNotConfigured: "Not configured", + statusUnknown: "Unavailable", + credentialsLoadError: "Could not read credential status", + metricChars: "Characters today", + metricSegments: "{{count}} segments", + metricDuration: "Total duration today", + metricAvg: "Avg per segment", + metricAvgTrend: "Today's average", + metricNoData: "No data", + historyLoadError: "History load failed", + metricTotal: "Total records", + metricTotalTrend: "Local archive (max 200)", + weekTitle: "Last 7 days", + weekUnit: "count / day", + recentTitle: "Recent transcripts", + recentAll: "View all →", + recentEmpty: + "No records yet. Press {{trigger}} to start your first recording.", + recentLoadFailed: "Could not load recent transcripts. Please retry.", + historyRetry: "Retry", + weekDays: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], }, history: { - title: 'Save history', - desc: 'Save Q&A records locally when enabled. Off by default.', - }, - howto: { - title: 'How to use', - step1: 'Press {{hotkey}} to open the panel.', - step2: 'Select text in any app.', - step3: 'Press {{recordHotkey}} to record, press again to submit.', - step4: 'Continue pressing {{recordHotkey}} for follow-up questions.', - step5: 'Press Esc to close and clear history.', - windowTitle: 'Position, drag, and pin', - windowDesc: 'The panel is draggable and remembers its position. Pin to keep it open.', - privacyTitle: 'Privacy contract', - privacyDesc: 'Selected text lives only in memory until the panel closes. Text over 4000 chars is truncated.', - }, - }, - settings: { - kicker: 'SETTINGS', - title: 'Settings', - desc: 'Recording, providers, shortcuts, and permissions.', - sections: { - recording: 'Recording', - providers: 'Providers', - shortcuts: 'Shortcuts', - permissions: 'Permissions', - language: 'Language', - advanced: 'Advanced', - about: 'About', - }, - recording: { - title: 'Recording', - desc: 'Global recording hotkey and trigger mode.', - hotkeyLabel: 'Recording hotkey', - hotkeyDescAcc: 'Press to capture voice globally (requires Accessibility permission).', - hotkeyDescNoAcc: 'Press to capture voice globally.', - modeLabel: 'Trigger mode', - modeDesc: 'Toggle = tap once to start, again to stop. Push-to-talk = hold to record.', - modeToggle: 'Toggle', - modeHold: 'Push-to-talk', - migrationNoticeTitle: 'Default recording mode is now Toggle', - migrationNoticeDesc: 'This update changes the default; if you prefer push-to-talk, switch it back here.', - microphoneLabel: 'Preferred microphone', - microphoneDesc: 'Choose the preferred input device; falls back to system default when unavailable.', - microphoneDefault: 'System default microphone', - microphoneDefaultDesc: 'Use the system default input device', - microphoneSystemDefault: 'system default', - microphoneUnavailable: 'unavailable', - microphoneLoadError: 'Failed to load microphones: {{message}}', - microphoneDialogTitle: 'Microphone', - microphoneDialogDesc: 'Choose a microphone that can pick up your voice.', - microphoneMonitorError: 'Failed to monitor input level: {{message}}', - capsuleLabel: 'Recording capsule', - capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording.', - muteDuringRecordingLabel: 'Mute while recording', - muteDuringRecordingDesc: 'Temporarily mute system output during voice input to avoid speaker echo.', - insertGroupTitle: 'Insertion & clipboard', - restoreClipboardLabel: 'Restore clipboard after insert', - restoreClipboardDesc: 'Restore your original clipboard after a successful paste (Windows / Linux only).', - pasteShortcutLabel: 'Simulated paste shortcut', - pasteShortcutDesc: 'Which paste combo to simulate when inserting; some terminals need Ctrl+Shift+V (Windows / Linux only).', - pasteShortcutCtrlV: 'Ctrl+V (default / most apps)', - pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)', - pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)', - comboRecordLabel: 'Record shortcut', - comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.', - comboRecordBtn: 'Record shortcut', - comboRecordHint: 'Press your shortcut combination\u2026', - comboRecorded: 'Recorded', - comboClear: 'Clear', - comboConflict: 'This shortcut combination is not available', - allowNonTsfFallbackLabel: 'Allow non-TSF fallback', - allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', - historyGroupTitle: 'History & context', - historyRetentionLabel: 'History retention (days)', - historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', - historyMaxEntriesLabel: 'Max history entries', - historyMaxEntriesDesc: 'Max sessions retained locally. Blank = 200. Range 5–200.', - polishContextWindowLabel: 'Polish context window (minutes)', - polishContextWindowDesc: 'Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.', - recordAudioForDebugLabel: 'Keep raw recording (debug)', - recordAudioForDebugDesc: 'Save raw microphone audio as wav for diagnosing recognition issues.', - audioRecordingMaxEntriesLabel: 'Max raw recordings', - audioRecordingMaxEntriesDesc: 'Max wav files retained locally. Blank = 200.', - startupGroupTitle: 'Startup', - startMinimizedLabel: 'Start minimized (no main window)', - startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.', - autoUpdateCheckLabel: 'Auto-check for updates', - autoUpdateCheckDesc: 'Check for updates on launch and every 60 minutes.', - marketplaceGroupTitle: 'Style Pack Marketplace', - marketplaceBaseUrlLabel: 'Backend URL', - marketplaceBaseUrlDesc: 'Marketplace backend URL. Blank uses the default.', - marketplaceDevLoginLabel: 'GitHub login (upload identity)', - marketplaceDevLoginDesc: 'Identifies the uploader. Blank disables upload and likes.', - startupAtBoot: 'Launch at login', - startupAtBootDesc: 'Start OpenLess automatically when you sign in.', - startupAtBootError: 'Failed to toggle launch at login: {{message}}', - }, - providers: { - llmTitle: 'LLM (polishing)', - llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', - providerLabel: 'Provider', - llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', - credentialStorageNotice: 'Credentials are stored in the OS credential vault.', - codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.', - asrProviderDesc: 'Switching providers automatically loads the matching credentials.', - asrTitle: 'ASR (transcription)', - asrDesc: 'Used to turn speech into text in real time.', - presets: { - ark: 'ARK (Volcengine Ark)', - deepseek: 'DeepSeek', - siliconflow: 'SiliconFlow', - openai: 'OpenAI', - gemini: 'Google Gemini', - codexOAuth: 'Codex OAuth', - mimo: 'Xiaomi MiMo', - cometapi: 'CometAPI', - openrouterFree: 'OpenRouter (free models)', - alibabaCoding: 'Alibaba Cloud Coding Plan', - codingPlanX: 'CodingPlanX', - custom: 'Custom', - asrVolcengine: 'Volcengine bigasr', - asrBailian: 'Alibaba Bailian realtime ASR', - asrSiliconflow: 'SiliconFlow SenseVoice', - asrZhipu: 'Zhipu GLM-ASR', - asrGroq: 'Groq Whisper-large-v3', - asrWhisper: 'OpenAI Whisper (compatible)', - asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', - asrLocalQwen3: 'Local Qwen3-ASR', - }, - volcengineAppKeyLabel: 'APP ID', - volcengineAccessKeyLabel: 'Access Token', - volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', - localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', - localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.', - asrProviderTakenOver: 'ASR provider taken over', - localAsrHint: 'Runs on this machine, no API key needed. Download the model from HuggingFace.', - foundryLocalAsrHint: 'Runs on this device, no ASR API key needed. First use downloads runtime components and model.', - localAsrPerformanceWarning: 'Local inference is slower than cloud ASR with potentially lower Chinese accuracy. Best for offline or privacy-sensitive use.', - localAsrReady: '{{model}} downloaded', - localAsrNotReady: '{{model}} not downloaded', - localAsrGoDownload: 'Open Models page to download', - localAsrManage: 'Open Models page', - localAsrDownloadedTitle: 'Downloaded models', - localAsrDelete: 'Delete', - fillDefault: 'Fill default value', - readFailed: 'Read failed', - apiKeyLabel: 'API Key', - baseUrlLabel: 'Base URL', - modelLabel: 'Model', - thinkingModeLabel: 'Thinking', - thinkingModeOn: 'On', - thinkingModeOff: 'Off', - thinkingModeHint: 'Off disables or minimizes thinking with provider-level official parameters. On enables thinking by channel defaults. No prompt injection or per-model adapters.', - bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)', - bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.', - appIdLabel: 'App ID', - accessKeyLabel: 'Access Key', - resourceIdLabel: 'Resource ID', - toolsLabel: 'Connection check', - toolsDesc: 'Save the fields above, then validate the selected model or fetch models. Manual model input remains available if fetching fails.', - validate: 'Validate', - validating: 'Validating…', - fetchModels: 'Fetch models', - loadingModels: 'Fetching models…', - modelMissing: 'No model is configured. Please enter a model ID first.', - modelsEmpty: 'Credentials are valid, but no models were returned.', - modelsLoaded: 'Fetched {{count}} models.', - selectModel: 'Select a model to fill the field above', - modelSaved: 'Saved model {{model}}.', - validateSuccess: 'Connection check passed.', - providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.', - endpointMustUseHttps: 'Endpoint must use HTTPS (localhost/127.0.0.1 are allowed for local testing).', - endpointInvalid: 'Endpoint format is invalid.', - responseTooLarge: 'Provider response is too large to validate safely.', - asrInvalidJson: 'ASR response is not valid JSON.', - asrMissingTextField: 'ASR response is missing the text field.', - apiKeyMissing: 'API Key is empty.', - endpointMissing: 'Endpoint is empty.', - requestTimeout: 'Request timed out. Try again later.', - }, - shortcuts: { - title: 'Shortcut reference', - descAcc: 'All shortcuts apply globally. Accessibility permission must be granted in Permissions.', - descNoAcc: 'All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.', - startStop: 'Start / Stop recording', - cancel: 'Cancel current recording', - confirm: 'Confirm capsule insertion', - switchStyle: 'Switch to previous style', - openApp: 'Open OpenLess', - confirmHint: 'Click ✓ on the capsule', - notSupported: 'Not yet supported', - }, - permissions: { - title: 'Permissions', - descAcc: 'OpenLess needs the following system permissions to work. After granting, fully quit and relaunch the app for changes to take effect.', - descNoAcc: 'OpenLess needs microphone access and uses the global hotkey listener state to verify the native hook is running.', - micLabel: 'Microphone', - micDesc: 'Used to capture your voice input.', - accLabel: 'Accessibility', - accDesc: 'Used to listen to the global hotkey and write transcripts at the cursor.', - hotkeyLabel: 'Global hotkey', - hotkeyDescWithAdapter: 'Active adapter: {{adapter}}. Used to confirm the hotkey listener is installed.', - hotkeyDescPlain: 'Used to confirm the hotkey listener is installed.', - networkLabel: 'Network', - networkDesc: 'Required for cloud ASR / LLM calls. Disable for local-only mode.', - networkOk: 'Available', - checking: 'Checking…', - granted: 'Granted', - notApplicable: 'Not required', - denied: 'Not granted', - indeterminate: 'Undetermined', - openSystem: 'Open System Settings', - grant: 'Grant', - hotkeyInstalled: 'Installed', - hotkeyStarting: 'Installing…', - hotkeyFailed: 'Listener failed', - windowsImeLabel: 'Windows input method backend', - windowsImeDesc: 'Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.', - windowsImeInstalled: 'Installed', - windowsImeUnavailable: 'Unavailable', - windowsIme: { - installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', - notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', - registrationBroken: 'Registration is broken. Reinstall the OpenLess IME.', - notWindows: 'Only available on Windows.', - }, + kicker: "HISTORY", + title: "History", + desc: "Locally stored transcripts.", + filterAll: "All", + summary: "{{total}} total · showing {{shown}}", + empty: "No history yet. Press {{trigger}} to record one.", + loadFailed: "Failed to load history: {{err}}", + retry: "Retry", + clearFailed: "Failed to clear history: {{err}}", + deleteFailed: "Failed to delete entry: {{err}}", + copyFailed: "Failed to copy: {{err}}", + playRecording: "Play recording", + audioLoading: "Loading…", + exportRecording: "Export recording", + exportFailed: "Failed to export: {{err}}", + rawLabel: "Raw", + rawEmpty: "(empty)", + selectHint: "Select an entry on the left to see details.", + insertedTo: "Inserted into", + chars: "{{count}} chars", + vocabHits: "{{count}} vocab hits", + inserted: "Inserted", + pasteSent: "Paste sent", + copiedFallback: "Copied (use {{shortcut}})", + insertFailed: "Insert failed", + confirmClear: + "Delete all {{count}} history entries? This cannot be undone.", }, - advanced: { - streamingInsertTitle: 'Streaming insertion', - streamingInsertTitleLinux: 'Streaming insertion (experimental)', - streamingInsertDesc: - 'Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.', - streamingInsertLabel: 'Streaming insertion', - streamingInsertHintMac: - 'Temporarily switches the input source to ABC so CJK IMEs cannot intercept keystrokes; restored on session end.', - streamingInsertHintWindows: - 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', - streamingInsertHintLinux: - 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', - streamingInsertSaveClipboardLabel: 'Copy to clipboard', - streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.', - localAsrTitle: 'Local ASR models (experimental)', - 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.', - foundryDesc: 'Once enabled, the ASR provider will be taken over.', - notSupportedHere: 'Not supported on this platform — no inference module bundled.', - enable: 'Enable', - alreadyActive: 'Active', - disableLocalLabel: 'Disable local ASR', - disableLocalDesc: 'Switch back to cloud ASR (defaults to Volcengine bigasr).', - disable: 'Disable', - platformNotSupported: 'Local ASR model integration is not supported on this platform.', - confirmEnableLocalTitle: 'Enable local ASR?', - confirmEnableLocalBody: 'Transcription will be slower than cloud and potentially less accurate.', - confirm: 'Enable', + vocab: { + kicker: "VOCABULARY", + title: "Vocabulary", + desc: "Add terms or jargon to improve recognition accuracy.", + sectionTitle: "Entries", + placeholder: "Type a word, press Enter or click Add…", + tip: "Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically", + loadFailed: "Load failed: {{err}}", + empty: "No entries yet. Add a new term or piece of jargon above so the model can prioritize it.", + tipDisabled: "Click to disable this entry", + tipEnabled: "Click to enable this entry", + removeAria: "Remove", + corrections: { + title: "Correction rules", + tip: "Fix common ASR mistakes. Supports {num} number wildcard.", + patternPlaceholder: "Mistaken text, e.g. {num}粒", + replacementPlaceholder: "Target text, e.g. {num}例", + empty: "No correction rules yet.", + invalid: + "Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.", + tipDisabled: "Click to disable this rule", + tipEnabled: "Click to enable this rule", + removeAria: "Remove correction rule", + }, + presets: { + title: "Scenario presets", + tip: "Multi-select to apply in batch. Supports edit and create.", + create: "New preset", + apply: "Apply selected", + save: "Save preset", + edit: "Edit {{name}}", + newPreset: "New preset", + namePlaceholder: "Preset name", + wordsPlaceholder: "Terms (comma or newline separated)", + }, }, - language: { - title: 'Interface language', - desc: 'Switch the UI language. Applies to the current session immediately and persists across launches.', - label: 'Language', - labelDesc: 'Choose "Follow system" to match the OS language at launch.', - followSystem: 'Follow system', - zh: '简体中文', - zhTW: '繁體中文', - en: 'English', - ja: '日本語 (Beta)', - ko: '한국어 (Beta)', - restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.', + style: { + kicker: "STYLE", + title: "Output style", + desc: "Choose the default output style for recording.", + masterToggle: "Master switch", + currentDefault: "Current default", + ariaSetDefault: "Set as default", + saveFailed: "Save failed: {{error}}", + customPromptTitle: "Custom prompt", + customPromptPlaceholder: + "Optional. Appended to this style’s built-in system prompt.", + customPromptHint: + "Leave empty to preserve current behavior. After saving, it applies to both this style’s live polish path and repolish. Press Ctrl/Cmd+Enter to save as well.", + customPromptSave: "Save prompt", + customPromptDirty: "Unsaved", + systemPromptMovedHint: + "Full system prompt editing has moved to Settings -> Providers. This page now only controls which styles are enabled and which one is the default.", + modes: { + raw: { + name: "Raw", + desc: "Only adds punctuation and natural breaks — no rewriting or expansion.", + sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact.", + }, + light: { + name: "Light polish", + desc: "Drops fillers, adds punctuation, and produces sendable natural prose.", + sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain.", + }, + structured: { + name: "Structured", + desc: "Auto-organizes into a numbered outline when you cover several topics or steps.", + sample: "1. Topic one\na. Point\nb. Point\n2. Topic two\na. Point\nb. Point", + }, + formal: { + name: "Formal", + desc: "Email and workplace tone — more complete, more professional.", + sample: "Detects greetings/sign-offs in email contexts; avoids empty pleasantries.", + }, + }, + pack: { + kicker: "STYLE PACKS", + title: "Style Packs", + desc: "Manage local style packs.", + marketplaceBtn: "Marketplace", + loadFailed: "Failed to load style packs: {{err}}", + importZip: "Import ZIP", + exportZip: "Export ZIP", + exportShort: "Export", + publishMarketplace: "Publish to Marketplace", + updateMarketplace: "Update Marketplace version", + publishDisabledHint: + "Configure your GitHub login in Settings → Marketplace first", + publishSuccess: "Published — pending review on marketplace", + publishFailed: "Publish failed: {{err}}", + publishBuiltinRejected: + "Built-in packs cannot be published. Clone first via edit.", + builtin: "Built-in", + imported: "Imported", + active: "Active", + activate: "Activate", + edit: "Edit", + closeEditor: "Close", + unsaved: "Unsaved", + listTitle: "Local Packs", + listDesc: "Browse and switch packs.", + listCount: "{{count}} packs", + addPackTileTitle: "New Pack", + addPackTileHint: "Start from a blank template.", + createSuccess: "New pack created.", + createFailed: "Failed to create pack: {{err}}", + save: "Save", + revert: "Revert", + saveSuccess: "Style pack saved.", + saveFailed: "Failed to save style pack: {{err}}", + activateSuccess: 'Set "{{name}}" as current.', + activateFailed: "Failed to set current style pack: {{err}}", + importSuccess: 'Imported "{{name}}".', + importFailed: "Failed to import ZIP: {{err}}", + exportSuccess: "Exported to {{path}}", + exportFailed: "Failed to export ZIP: {{err}}", + exportDirtyFirst: "Save this pack before exporting ZIP.", + resetBuiltin: "Reset", + resetSuccess: 'Reset "{{name}}".', + resetFailed: "Failed to reset pack: {{err}}", + deleteImported: "Delete", + deleteConfirm: 'Delete "{{name}}"? This cannot be undone.', + deleteSuccess: 'Deleted "{{name}}".', + deleteFailed: "Failed to delete pack: {{err}}", + summaryCurrentEmpty: "No pack selected yet", + editorTitle: "Edit Pack", + editorDesc: "Edit this pack.", + metaTitle: "Installation Info", + metaSource: "Source", + metaBaseMode: "Base Mode", + metaUpdatedAt: "Updated", + fieldName: "Name", + fieldAuthor: "Author", + fieldAuthorPlaceholder: "Optional source label", + fieldVersion: "Version", + fieldTags: "Tags", + fieldTagsPlaceholder: + "Comma-separated tags, e.g. community, voiceover, formal", + fieldDescription: "Description", + fieldModel: "Recommended Model (Metadata)", + fieldModelPlaceholder: "Optional, e.g. gpt-4.1 / deepseek-v3", + fieldModelHint: "Metadata only. Does not switch model.", + fieldCompatibility: "Compatible App Version", + fieldCompatibilityPlaceholder: "Optional, e.g. >=1.3.0", + fullPromptTitle: "System Prompt", + fullPromptHint: "The prompt owned by this pack.", + promptChars: "{{count}} chars", + runtimeTitle: "OpenLess Runtime Directives", + runtimeDesc: "Read-only runtime helpers.", + runtimeContextTitle: "Context premise", + runtimeContextDesc: "From language and app context", + runtimeContextEmpty: "Not added in the current preview.", + runtimeHotwordTitle: "Hotword block", + runtimeHotwordDesc: "From enabled hotwords", + runtimeHotwordEmpty: "Not added in the current preview.", + runtimeHistoryTitle: "Multi-turn history guardrail", + runtimeHistoryDesc: "Only for live multi-turn polish", + runtimeHistoryEmpty: "Only added when prior turns exist.", + runtimeActive: "Active", + runtimeInactive: "Inactive", + runtimePreviewFailed: "Failed to build runtime preview: {{err}}", + runtimePreviewOmittedFrontApp: "Preview omits the front-app label.", + examplesTitle: "Effect Examples", + examplesDesc: "Exported with the pack.", + addExample: "Add Example", + examplesEmpty: "No examples yet.", + exampleTitlePlaceholder: "Example {{index}} title", + exampleInput: "Input", + exampleOutput: "Output", + examplesCount: "{{count}} examples", + discardCloseConfirm: + "Discard unsaved changes and close the editor?", + discardSwitchConfirm: + 'Discard unsaved changes and switch to "{{name}}"?', + derivativeBadge: "Derived from @{{login}}", + }, }, - about: { - tagline: 'Speak naturally, write perfectly', - checkUpdate: 'Check for updates', - checkUpdateBtn: 'Check', - checkingUpdate: 'Checking…', - upToDate: 'You are already on the latest version.', - updateError: 'Update check or install failed. Please try again later.', - openReleases: 'Open Releases', - source: 'Source', - docs: 'Docs', - feedback: 'Feedback', - qq: 'QQ community group', - qqDesc: 'Search the group number in QQ to join, or scan the QR code.', - copyQq: 'Copy group number', - privacy: 'Privacy', - privacyDesc: 'All data stays on this device. Cloud APIs do not retain recordings.', - localFirst: 'Local-first', - betaChannelLabel: 'Join Beta channel', - betaChannelDesc: 'Receive Beta updates when enabled. May be unstable — recommended only for early adopters.', - betaChannelFetching: 'Fetching the latest Beta…', - betaChannelFetchBtn: 'Look up latest Beta', - betaChannelLatestPrefix: 'Latest Beta:', - betaChannelDownloadBtn: 'Open download page', - betaChannelRefresh: 'Refresh', - betaChannelNoBeta: 'No Beta release has been published yet.', - betaChannelFetchError: 'Failed to fetch Beta release info. Please try again later.', - betaChannelUpToDate: 'Up to date', - betaChannelUpdateNow: 'Update now', - betaChannelUpdateNowTitle: 'Check and download the latest Beta, then show the update dialog', - betaChannelChecking: 'Checking…', - updateDialog: { - available: { - title: 'Update available', - desc: 'OpenLess {{version}} is available. Update now?', - }, - downloading: { - title: 'Downloading update', - desc: 'Downloading OpenLess {{version}}. Keep the app open.', - }, - downloaded: { - title: 'Update ready', - desc: 'OpenLess {{version}} has been installed. Restart automatically now to apply it?', - }, - installing: { - title: 'Installing update', - desc: 'Installing OpenLess {{version}}. Keep the app open.', - }, - install: 'Update now', - downloadingLabel: 'Downloading…', - installingLabel: 'Installing…', - later: 'Restart manually later', - restartNow: 'Restart now', - progress: '{{progress}}% · {{downloaded}} / {{total}}', - progressUnknown: '{{downloaded}} downloaded', - }, + translation: { + kicker: "TRANSLATION", + title: "Translation", + desc: "Auto-translate recordings into a target language before insertion.", + statusEnabled: "Enabled", + statusDisabled: "Disabled", + working: { + title: "Working languages", + desc: "Select languages you use regularly to improve polish and translation.", + }, + target: { + title: "Translation target language", + desc: 'Press Shift during recording to trigger translation. "Disabled" makes Shift a no-op.', + disabled: "Disabled (Shift does nothing)", + }, + save: { + workingFailed: + "Failed to save working languages. Please try again.", + targetFailed: + "Failed to save translation target. Please try again.", + hotkeyRegisterFailed: + "Failed to register the translation shortcut. The preference was not saved.", + hotkeySaveFailed: + "Failed to save the translation shortcut. Please try again.", + }, + howto: { + title: "How to use", + step1: "Place cursor in any text field.", + step2: "Press {{trigger}} to start recording.", + step3: "Press Shift once during recording to activate translation.", + step4: "Press {{trigger}} again to stop.", + step5: "Translated text is inserted at the cursor.", + indicatorTitle: "How to confirm translation mode is on", + indicatorDesc: + 'A blue "Translating" indicator appears at the bottom of the screen after pressing Shift.', + fallbackTitle: "Safety fallbacks", + fallbackDesc: + "If translation fails, the raw transcript is inserted instead.", + }, }, - }, - modal: { - sections: { - account: 'Account', - settings: 'Settings', - personalize: 'Personalize', - about: 'About', - helpCenter: 'Help center', - releaseNotes: 'Release notes', + selectionAsk: { + kicker: "SELECTION ASK", + title: "Selection Ask", + desc: "Select text and ask questions by voice, with multi-turn follow-ups.", + statusEnabled: "Enabled", + statusDisabled: "Disabled", + hotkey: { + title: "Hotkey to open the panel", + desc: "Opens/closes the panel. Recording uses {{recordHotkey}}.", + optionDisabled: "Disabled", + chordWarning: "", + }, + save: { + hotkeyRegisterFailed: + "Failed to register the Selection Ask shortcut. The preference was not saved.", + hotkeySaveFailed: + "Failed to save the Selection Ask shortcut. Please try again.", + historySaveFailed: + "Failed to save the Q&A history setting. Please try again.", + }, + history: { + title: "Save history", + desc: "Save Q&A records locally when enabled. Off by default.", + }, + howto: { + title: "How to use", + step1: "Press {{hotkey}} to open the panel.", + step2: "Select text in any app.", + step3: "Press {{recordHotkey}} to record, press again to submit.", + step4: "Continue pressing {{recordHotkey}} for follow-up questions.", + step5: "Press Esc to close and clear history.", + windowTitle: "Position, drag, and pin", + windowDesc: + "The panel is draggable and remembers its position. Pin to keep it open.", + privacyTitle: "Privacy contract", + privacyDesc: + "Selected text lives only in memory until the panel closes. Text over 4000 chars is truncated.", + }, }, - account: { - localUser: 'Local user', - localUserDesc: 'Not signed in · all data stays local', - loginSync: 'Sign in / Sync', - footer: 'Runs fully locally by default. Sign in to sync vocabulary and style presets across devices.', + settings: { + kicker: "SETTINGS", + title: "Settings", + desc: "Recording, providers, shortcuts, and permissions.", + sections: { + recording: "Recording", + providers: "Providers", + shortcuts: "Shortcuts", + permissions: "Permissions", + language: "Language", + advanced: "Advanced", + about: "About", + }, + recording: { + title: "Recording", + desc: "Global recording hotkey and trigger mode.", + hotkeyLabel: "Recording hotkey", + hotkeyDescAcc: + "Press to capture voice globally (requires Accessibility permission).", + hotkeyDescNoAcc: "Press to capture voice globally.", + modeLabel: "Trigger mode", + modeDesc: + "Toggle = tap once to start, again to stop. Push-to-talk = hold to record.", + modeToggle: "Toggle", + modeHold: "Push-to-talk", + migrationNoticeTitle: "Default recording mode is now Toggle", + migrationNoticeDesc: + "This update changes the default; if you prefer push-to-talk, switch it back here.", + microphoneLabel: "Preferred microphone", + microphoneDesc: + "Choose the preferred input device; falls back to system default when unavailable.", + microphoneDefault: "System default microphone", + microphoneDefaultDesc: "Use the system default input device", + microphoneSystemDefault: "system default", + microphoneUnavailable: "unavailable", + microphoneLoadError: "Failed to load microphones: {{message}}", + microphoneDialogTitle: "Microphone", + microphoneDialogDesc: + "Choose a microphone that can pick up your voice.", + microphoneMonitorError: + "Failed to monitor input level: {{message}}", + capsuleLabel: "Recording capsule", + capsuleDesc: + "Show a translucent capsule at the bottom of the screen while recording.", + muteDuringRecordingLabel: "Mute while recording", + muteDuringRecordingDesc: + "Temporarily mute system output during voice input to avoid speaker echo.", + insertGroupTitle: "Insertion & clipboard", + restoreClipboardLabel: "Restore clipboard after insert", + restoreClipboardDesc: + "Restore your original clipboard after a successful paste (Windows / Linux only).", + pasteShortcutLabel: "Simulated paste shortcut", + pasteShortcutDesc: + "Which paste combo to simulate when inserting; some terminals need Ctrl+Shift+V (Windows / Linux only).", + pasteShortcutCtrlV: "Ctrl+V (default / most apps)", + pasteShortcutCtrlShiftV: + "Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)", + pasteShortcutShiftInsert: "Shift+Insert (xterm / urxvt)", + comboRecordLabel: "Record shortcut", + comboRecordDesc: + "Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.", + comboRecordBtn: "Record shortcut", + comboRecordHint: "Press your shortcut combination\u2026", + comboRecorded: "Recorded", + comboClear: "Clear", + comboConflict: "This shortcut combination is not available", + allowNonTsfFallbackLabel: "Allow non-TSF fallback", + allowNonTsfFallbackDesc: + "Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.", + historyGroupTitle: "History & context", + historyRetentionLabel: "History retention (days)", + historyRetentionDesc: + "Entries older than this are pruned on new writes; 0 = no time-based pruning.", + historyMaxEntriesLabel: "Max history entries", + historyMaxEntriesDesc: + "Max sessions retained locally. Blank = 200. Range 5–200.", + polishContextWindowLabel: "Polish context window (minutes)", + polishContextWindowDesc: + "Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.", + recordAudioForDebugLabel: "Keep raw recording (debug)", + recordAudioForDebugDesc: + "Save raw microphone audio as wav for diagnosing recognition issues.", + audioRecordingMaxEntriesLabel: "Max raw recordings", + audioRecordingMaxEntriesDesc: + "Max wav files retained locally. Blank = 200.", + startupGroupTitle: "Startup", + startMinimizedLabel: "Start minimized (no main window)", + startMinimizedDesc: + "No main window on any launch path — menu bar / tray only.", + autoUpdateCheckLabel: "Auto-check for updates", + autoUpdateCheckDesc: + "Check for updates on launch and every 60 minutes.", + marketplaceGroupTitle: "Style Pack Marketplace", + marketplaceBaseUrlLabel: "Backend URL", + marketplaceBaseUrlDesc: + "Marketplace backend URL. Blank uses the default.", + marketplaceDevLoginLabel: "GitHub login (upload identity)", + marketplaceDevLoginDesc: + "Identifies the uploader. Blank disables upload and likes.", + startupAtBoot: "Launch at login", + startupAtBootDesc: "Start OpenLess automatically when you sign in.", + startupAtBootError: "Failed to toggle launch at login: {{message}}", + }, + providers: { + llmTitle: "LLM (polishing)", + llmDesc: "OpenAI-compatible protocol. Multiple vendors supported.", + providerLabel: "Provider", + llmProviderDesc: + "Selecting a preset auto-fills the default Base URL.", + credentialStorageNotice: + "Credentials are stored in the OS credential vault.", + codexOAuthNotice: + "Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.", + asrProviderDesc: + "Switching providers automatically loads the matching credentials.", + asrTitle: "ASR (transcription)", + asrDesc: "Used to turn speech into text in real time.", + presets: { + ark: "ARK (Volcengine Ark)", + deepseek: "DeepSeek", + siliconflow: "SiliconFlow", + openai: "OpenAI", + gemini: "Google Gemini", + codexOAuth: "Codex OAuth", + mimo: "Xiaomi MiMo", + cometapi: "CometAPI", + openrouterFree: "OpenRouter (free models)", + alibabaCoding: "Alibaba Cloud Coding Plan", + codingPlanX: "CodingPlanX", + custom: "Custom", + asrVolcengine: "Volcengine bigasr", + asrBailian: "Alibaba Bailian realtime ASR", + asrSiliconflow: "SiliconFlow SenseVoice", + asrZhipu: "Zhipu GLM-ASR", + asrGroq: "Groq Whisper-large-v3", + asrWhisper: "OpenAI Whisper (compatible)", + asrFoundryLocalWhisper: "Local Whisper (Foundry Local)", + asrSherpaOnnxLocal: "Local sherpa-onnx (experimental)", + asrLocalQwen3: "Local Qwen3-ASR", + }, + volcengineAppKeyLabel: "APP ID", + volcengineAccessKeyLabel: "Access Token", + volcengineResourceIdLabel: "Resource ID", + volcengineMappingNote: + "Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.", + localAsrActiveNotice: + "Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.", + localAsrTakeoverHint: + 'Once "{{name}}" is enabled, the ASR provider will be taken over.', + asrProviderTakenOver: "ASR provider taken over", + localAsrHint: + "Runs on this machine, no API key needed. Download the model from HuggingFace.", + foundryLocalAsrHint: + "Runs on this device, no ASR API key needed. First use downloads runtime components and model.", + localAsrPerformanceWarning: + "Local inference is slower than cloud ASR with potentially lower Chinese accuracy. Best for offline or privacy-sensitive use.", + localAsrReady: "{{model}} downloaded", + localAsrNotReady: "{{model}} not downloaded", + localAsrGoDownload: "Open Models page to download", + localAsrManage: "Open Models page", + localAsrDownloadedTitle: "Downloaded models", + localAsrDelete: "Delete", + fillDefault: "Fill default value", + readFailed: "Read failed", + apiKeyLabel: "API Key", + baseUrlLabel: "Base URL", + modelLabel: "Model", + thinkingModeLabel: "Thinking", + thinkingModeOn: "On", + thinkingModeOff: "Off", + thinkingModeHint: + "Off disables or minimizes thinking with provider-level official parameters. On enables thinking by channel defaults. No prompt injection or per-model adapters.", + bailianVocabularyIdLabel: "Hotword Vocabulary ID (optional)", + bailianVocabularyIdNote: + "If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.", + appIdLabel: "App ID", + accessKeyLabel: "Access Key", + resourceIdLabel: "Resource ID", + toolsLabel: "Connection check", + toolsDesc: + "Save the fields above, then validate the selected model or fetch models. Manual model input remains available if fetching fails.", + validate: "Validate", + validating: "Validating…", + fetchModels: "Fetch models", + loadingModels: "Fetching models…", + modelMissing: + "No model is configured. Please enter a model ID first.", + modelsEmpty: "Credentials are valid, but no models were returned.", + modelsLoaded: "Fetched {{count}} models.", + selectModel: "Select a model to fill the field above", + modelSaved: "Saved model {{model}}.", + validateSuccess: "Connection check passed.", + providerHttpStatus: + "Provider returned HTTP {{status}}. Check the API key permissions or endpoint.", + endpointMustUseHttps: + "Endpoint must use HTTPS (localhost/127.0.0.1 are allowed for local testing).", + endpointInvalid: "Endpoint format is invalid.", + responseTooLarge: + "Provider response is too large to validate safely.", + asrInvalidJson: "ASR response is not valid JSON.", + asrMissingTextField: "ASR response is missing the text field.", + apiKeyMissing: "API Key is empty.", + endpointMissing: "Endpoint is empty.", + requestTimeout: "Request timed out. Try again later.", + }, + shortcuts: { + title: "Shortcut reference", + descAcc: + "All shortcuts apply globally. Accessibility permission must be granted in Permissions.", + descNoAcc: + "All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.", + startStop: "Start / Stop recording", + cancel: "Cancel current recording", + confirm: "Confirm capsule insertion", + switchStyle: "Switch to previous style", + openApp: "Open OpenLess", + confirmHint: "Click ✓ on the capsule", + notSupported: "Not yet supported", + }, + permissions: { + title: "Permissions", + descAcc: + "OpenLess needs the following system permissions to work. After granting, fully quit and relaunch the app for changes to take effect.", + descNoAcc: + "OpenLess needs microphone access and uses the global hotkey listener state to verify the native hook is running.", + micLabel: "Microphone", + micDesc: "Used to capture your voice input.", + accLabel: "Accessibility", + accDesc: + "Used to listen to the global hotkey and write transcripts at the cursor.", + hotkeyLabel: "Global hotkey", + hotkeyDescWithAdapter: + "Active adapter: {{adapter}}. Used to confirm the hotkey listener is installed.", + hotkeyDescPlain: + "Used to confirm the hotkey listener is installed.", + networkLabel: "Network", + networkDesc: + "Required for cloud ASR / LLM calls. Disable for local-only mode.", + networkOk: "Available", + checking: "Checking…", + granted: "Granted", + notApplicable: "Not required", + denied: "Not granted", + indeterminate: "Undetermined", + openSystem: "Open System Settings", + grant: "Grant", + hotkeyInstalled: "Installed", + hotkeyStarting: "Installing…", + hotkeyFailed: "Listener failed", + windowsImeLabel: "Windows input method backend", + windowsImeDesc: + "Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.", + windowsImeInstalled: "Installed", + windowsImeUnavailable: "Unavailable", + windowsIme: { + installed: + "Installed. Voice input temporarily switches to the OpenLess IME.", + notInstalled: + "Not installed. OpenLess is using the clipboard/WM_PASTE fallback.", + registrationBroken: + "Registration is broken. Reinstall the OpenLess IME.", + notWindows: "Only available on Windows.", + }, + }, + advanced: { + streamingInsertTitle: "Streaming insertion", + streamingInsertTitleLinux: "Streaming insertion (experimental)", + streamingInsertDesc: + "Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.", + streamingInsertLabel: "Streaming insertion", + streamingInsertHintMac: + "Temporarily switches the input source to ABC so CJK IMEs cannot intercept keystrokes; restored on session end.", + streamingInsertHintWindows: + "SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.", + streamingInsertHintLinux: + "Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.", + streamingInsertSaveClipboardLabel: "Copy to clipboard", + streamingInsertSaveClipboardHint: + "After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.", + localAsrTitle: "Local ASR models (experimental)", + 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.", + foundryDesc: "Once enabled, the ASR provider will be taken over.", + sherpaDesc: "Once enabled, the ASR provider will be taken over.", + notSupportedHere: + "Not supported on this platform — no inference module bundled.", + enable: "Enable", + alreadyActive: "Active", + disableLocalLabel: "Disable local ASR", + disableLocalDesc: + "Switch back to cloud ASR (defaults to Volcengine bigasr).", + disable: "Disable", + platformNotSupported: + "Local ASR model integration is not supported on this platform.", + confirmEnableLocalTitle: "Enable local ASR?", + confirmEnableLocalBody: + "Transcription will be slower than cloud and potentially less accurate.", + confirm: "Enable", + }, + language: { + title: "Interface language", + desc: "Switch the UI language. Applies to the current session immediately and persists across launches.", + label: "Language", + labelDesc: + 'Choose "Follow system" to match the OS language at launch.', + followSystem: "Follow system", + zh: "简体中文", + zhTW: "繁體中文", + en: "English", + ja: "日本語 (Beta)", + ko: "한국어 (Beta)", + restartHint: + "Some native menus (system tray, etc.) may require an app restart to fully switch.", + }, + about: { + tagline: "Speak naturally, write perfectly", + checkUpdate: "Check for updates", + checkUpdateBtn: "Check", + checkingUpdate: "Checking…", + upToDate: "You are already on the latest version.", + updateError: + "Update check or install failed. Please try again later.", + openReleases: "Open Releases", + source: "Source", + docs: "Docs", + feedback: "Feedback", + qq: "QQ community group", + qqDesc: "Search the group number in QQ to join, or scan the QR code.", + copyQq: "Copy group number", + privacy: "Privacy", + privacyDesc: + "All data stays on this device. Cloud APIs do not retain recordings.", + localFirst: "Local-first", + betaChannelLabel: "Join Beta channel", + betaChannelDesc: + "Receive Beta updates when enabled. May be unstable — recommended only for early adopters.", + betaChannelFetching: "Fetching the latest Beta…", + betaChannelFetchBtn: "Look up latest Beta", + betaChannelLatestPrefix: "Latest Beta:", + betaChannelDownloadBtn: "Open download page", + betaChannelRefresh: "Refresh", + betaChannelNoBeta: "No Beta release has been published yet.", + betaChannelFetchError: + "Failed to fetch Beta release info. Please try again later.", + betaChannelUpToDate: "Up to date", + betaChannelUpdateNow: "Update now", + betaChannelUpdateNowTitle: + "Check and download the latest Beta, then show the update dialog", + betaChannelChecking: "Checking…", + updateDialog: { + available: { + title: "Update available", + desc: "OpenLess {{version}} is available. Update now?", + }, + downloading: { + title: "Downloading update", + desc: "Downloading OpenLess {{version}}. Keep the app open.", + }, + downloaded: { + title: "Update ready", + desc: "OpenLess {{version}} has been installed. Restart automatically now to apply it?", + }, + installing: { + title: "Installing update", + desc: "Installing OpenLess {{version}}. Keep the app open.", + }, + install: "Update now", + downloadingLabel: "Downloading…", + installingLabel: "Installing…", + later: "Restart manually later", + restartNow: "Restart now", + progress: "{{progress}}% · {{downloaded}} / {{total}}", + progressUnknown: "{{downloaded}} downloaded", + }, + }, }, - personalize: { - appearance: 'Appearance', - appearanceDesc: 'Follow system / Light / Dark', - appearanceSystem: 'Follow system', - appearanceLight: 'Light', - appearanceDark: 'Dark', - font: 'Font size', - fontDesc: 'Scale the entire UI font size — applies instantly.', - fontSmall: 'Small', - fontMedium: 'Medium', - fontLarge: 'Large', - blur: 'Glass blur intensity', - blurDesc: 'Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).', - startupOpen: 'On launch', - startupOverview: 'Overview', - startupLast: 'Last position', - startupAtBoot: 'Launch at login', + modal: { + sections: { + account: "Account", + settings: "Settings", + personalize: "Personalize", + about: "About", + helpCenter: "Help center", + releaseNotes: "Release notes", + }, + account: { + localUser: "Local user", + localUserDesc: "Not signed in · all data stays local", + loginSync: "Sign in / Sync", + footer: "Runs fully locally by default. Sign in to sync vocabulary and style presets across devices.", + }, + personalize: { + appearance: "Appearance", + appearanceDesc: "Follow system / Light / Dark", + appearanceSystem: "Follow system", + appearanceLight: "Light", + appearanceDark: "Dark", + font: "Font size", + fontDesc: "Scale the entire UI font size — applies instantly.", + fontSmall: "Small", + fontMedium: "Medium", + fontLarge: "Large", + blur: "Glass blur intensity", + blurDesc: + "Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).", + startupOpen: "On launch", + startupOverview: "Overview", + startupLast: "Last position", + startupAtBoot: "Launch at login", + }, + about: { + tagline: "Speak naturally, write perfectly", + checkUpdate: "Check for updates", + checkUpdateBtn: "Check", + docs: "Docs", + docsBtn: "openless.app/docs ↗", + feedback: "Feedback channel", + feedbackBtn: "GitHub Issues ↗", + source: "Source", + qq: "Community QQ Group", + qqDesc: "Search the group number on QQ to join, or scan the QR code.", + copyQq: "Copy group number", + exportErrorLog: "Export error log", + exportErrorLogDesc: + "Save the current session log to disk for debugging or sending us feedback.", + exportErrorLogBtn: "Export", + exporting: "Exporting…", + exportSuccess: "Saved", + exportFailed: "Export failed", + privacy: "Privacy", + privacyDesc: + "All transcripts stay on this device. Cloud APIs are used only for real-time calls.", + localFirst: "Local-first", + }, }, - about: { - tagline: 'Speak naturally, write perfectly', - checkUpdate: 'Check for updates', - checkUpdateBtn: 'Check', - docs: 'Docs', - docsBtn: 'openless.app/docs ↗', - feedback: 'Feedback channel', - feedbackBtn: 'GitHub Issues ↗', - source: 'Source', - qq: 'Community QQ Group', - qqDesc: 'Search the group number on QQ to join, or scan the QR code.', - copyQq: 'Copy group number', - exportErrorLog: 'Export error log', - exportErrorLogDesc: 'Save the current session log to disk for debugging or sending us feedback.', - exportErrorLogBtn: 'Export', - exporting: 'Exporting…', - exportSuccess: 'Saved', - exportFailed: 'Export failed', - privacy: 'Privacy', - privacyDesc: 'All transcripts stay on this device. Cloud APIs are used only for real-time calls.', - localFirst: 'Local-first', + windowChrome: { + minimize: "Minimize", + maximize: "Maximize", + close: "Close", }, - }, - windowChrome: { - minimize: 'Minimize', - maximize: 'Maximize', - close: 'Close', - }, - hotkey: { - triggers: { - rightOption: 'Right Option', - leftOption: 'Left Option', - rightControl: 'Right Control', - leftControl: 'Left Control', - rightCommand: 'Right Command', - fn: 'Fn (Globe key)', - rightAlt: 'Right Alt', - custom: 'Custom combination\u2026', + hotkey: { + triggers: { + rightOption: "Right Option", + leftOption: "Left Option", + rightControl: "Right Control", + leftControl: "Left Control", + rightCommand: "Right Command", + fn: "Fn (Globe key)", + rightAlt: "Right Alt", + custom: "Custom combination\u2026", + }, + fallback: "Global hotkey", + modeHoldSuffix: " (push-to-talk)", + modeToggleSuffix: " (start / stop)", + usageHold: "Hold {{trigger}} to talk, release to stop.", + usageToggle: "Press {{trigger}} to start, press again to stop.", + adapter: { + macEventTap: "macOS Event Tap", + windowsLowLevel: "Windows low-level keyboard hook", + fcitx5: "fcitx5 input method plugin", + }, }, - fallback: 'Global hotkey', - modeHoldSuffix: ' (push-to-talk)', - modeToggleSuffix: ' (start / stop)', - usageHold: 'Hold {{trigger}} to talk, release to stop.', - usageToggle: 'Press {{trigger}} to start, press again to stop.', - adapter: { - macEventTap: 'macOS Event Tap', - windowsLowLevel: 'Windows low-level keyboard hook', - fcitx5: 'fcitx5 input method plugin', + localAsr: { + kicker: "LOCAL ASR", + title: "Models", + desc: "Manage on-device speech recognition models.", + qwenTitle: "Qwen3-ASR model manager", + qwenExperimentalBadge: "Experimental", + engineUnavailable: + "The Qwen3-ASR inference engine is not bundled on this platform. You can still download models, but Qwen3-ASR cannot be activated here yet.", + qwenUnavailableOnWindows: + "Qwen3-ASR is not supported on Windows yet. Please use Foundry Local Whisper above instead.", + foundryTitle: "Windows Foundry Local Whisper", + foundryDesc: + "On-device speech recognition, no ASR API key needed. First use requires downloading runtime and model.", + foundryAvailable: "Available on Windows", + foundryUnavailable: "Windows only", + foundryRuntimeReady: "Runtime components downloaded", + foundryRuntimeMissing: "Runtime components not downloaded", + foundryRuntimeSourceLabel: "Runtime component source", + foundryRuntimeSourceAuto: "Auto (NuGet first)", + foundryRuntimeSourceNuget: "NuGet official feed", + foundryRuntimeSourceOrtNightly: "Microsoft ORT-Nightly feed", + foundryRuntimeSourceDesc: + "Runtime components are downloaded before first use.", + foundrySelectedModel: "Selected model", + foundryActiveModel: "Current default alias", + foundryLoadedModel: "Loaded model", + foundryNotLoaded: "Not loaded", + foundryError: "Foundry status", + foundrySetDefault: "Set default / Enable Windows local ASR", + foundryEnabling: "Enabling…", + foundryPrepare: "Prepare / Download / Load", + foundryPreparing: "Preparing…", + foundryReleasing: "Releasing…", + foundryRetryPrepare: "Continue / Retry prepare", + foundryCancelPrepare: "Cancel prepare", + foundryCancelRequested: "Cancel requested", + foundryCancelling: "Cancelling…", + foundryCancelBestEffort: + "Cancellation requested. Will stop after the current step completes. Retry later.", + foundryPrepareRuntime: "Prepare runtime components", + foundryPrepareModel: "Download model", + foundryPrepareLoad: "Load model", + foundryPrepareModelSkipped: + "Model already downloaded; download skipped", + foundryPrepareDone: "Done", + foundryPrepareWaiting: "Waiting", + foundryApproxSizeMb: "about {{mb}} MB", + foundryLanguageLabel: "Recognition language", + foundryLanguageAuto: "Auto", + foundryLanguageZh: "Chinese zh", + foundryLanguageEn: "English en", + foundryLanguageDesc: + "Choose Chinese for Chinese dictation, Auto for mixed use.", + foundryModelSmall: "Whisper Small (default / balanced)", + foundryModelSmallDesc: + "Default balanced option for quality and resource use.", + foundryModelMedium: "Whisper Medium (higher quality)", + foundryModelMediumDesc: + "Higher accuracy for stronger devices that can handle larger downloads and slower inference.", + foundryModelLarge: "Whisper Large V3 Turbo (best quality)", + foundryModelLargeDesc: + "Large-model option for high-end devices and quality-first use.", + foundryModelBase: "Whisper Base (faster / lower resource)", + 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)", + mirrorHfMirror: "Mainland mirror (hf-mirror.com)", + mirrorGithubRelease: "GitHub release archive", + activeBadge: "In use", + downloadedBadge: "Downloaded", + notDownloadedBadge: "Not downloaded", + download: "Download", + resume: "Resume", + cancel: "Cancel", + delete: "Delete", + setActive: "Set as default", + failed: "Failed", + cancelled: "Cancelled", + files: "files", + sizeLoading: "Fetching size…", + sizeUnknown: "Size unknown", + performanceWarning: + "Local ASR is best for offline or privacy-sensitive use. First use requires model download.", + test: "Load & Test", + testRunning: "Testing…", + testHeading: "Built-in audio test", + testExpected: "Expected", + testActual: "Got", + testStats: + "Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}", + testFailed: "Test failed", + engineStatusLabel: "Engine in memory", + engineLoaded: "Loaded: {{model}}", + engineUnloaded: "Not loaded (first transcription must load the model)", + loadNow: "Load now", + releaseNow: "Release now", + keepLoadedLabel: "Keep loaded for", + keepLoadedDesc: + "How long Qwen3-ASR stays in memory after the last use, before being freed.", + keepImmediate: "Release immediately", + keep1min: "1 minute after last use", + keep5min: "5 minutes after last use (default)", + keep30min: "30 minutes after last use", + keepForever: "Never release (always loaded)", }, - }, - localAsr: { - kicker: 'LOCAL ASR', - title: 'Models', - desc: 'Manage on-device speech recognition models.', - qwenTitle: 'Qwen3-ASR model manager', - qwenExperimentalBadge: 'Experimental', - engineUnavailable: 'The Qwen3-ASR inference engine is not bundled on this platform. You can still download models, but Qwen3-ASR cannot be activated here yet.', - qwenUnavailableOnWindows: 'Qwen3-ASR is not supported on Windows yet. Please use Foundry Local Whisper above instead.', - foundryTitle: 'Windows Foundry Local Whisper', - foundryDesc: 'On-device speech recognition, no ASR API key needed. First use requires downloading runtime and model.', - foundryAvailable: 'Available on Windows', - foundryUnavailable: 'Windows only', - foundryRuntimeReady: 'Runtime components downloaded', - foundryRuntimeMissing: 'Runtime components not downloaded', - foundryRuntimeSourceLabel: 'Runtime component source', - foundryRuntimeSourceAuto: 'Auto (NuGet first)', - foundryRuntimeSourceNuget: 'NuGet official feed', - foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly feed', - foundryRuntimeSourceDesc: 'Runtime components are downloaded before first use.', - foundrySelectedModel: 'Selected model', - foundryActiveModel: 'Current default alias', - foundryLoadedModel: 'Loaded model', - foundryNotLoaded: 'Not loaded', - foundryError: 'Foundry status', - foundrySetDefault: 'Set default / Enable Windows local ASR', - foundryEnabling: 'Enabling…', - foundryPrepare: 'Prepare / Download / Load', - foundryPreparing: 'Preparing…', - foundryReleasing: 'Releasing…', - foundryRetryPrepare: 'Continue / Retry prepare', - foundryCancelPrepare: 'Cancel prepare', - foundryCancelRequested: 'Cancel requested', - foundryCancelling: 'Cancelling…', - foundryCancelBestEffort: 'Cancellation requested. Will stop after the current step completes. Retry later.', - foundryPrepareRuntime: 'Prepare runtime components', - foundryPrepareModel: 'Download model', - foundryPrepareLoad: 'Load model', - foundryPrepareModelSkipped: 'Model already downloaded; download skipped', - foundryPrepareDone: 'Done', - foundryPrepareWaiting: 'Waiting', - foundryApproxSizeMb: 'about {{mb}} MB', - foundryLanguageLabel: 'Recognition language', - foundryLanguageAuto: 'Auto', - foundryLanguageZh: 'Chinese zh', - foundryLanguageEn: 'English en', - foundryLanguageDesc: 'Choose Chinese for Chinese dictation, Auto for mixed use.', - foundryModelSmall: 'Whisper Small (default / balanced)', - foundryModelSmallDesc: 'Default balanced option for quality and resource use.', - foundryModelMedium: 'Whisper Medium (higher quality)', - foundryModelMediumDesc: 'Higher accuracy for stronger devices that can handle larger downloads and slower inference.', - foundryModelLarge: 'Whisper Large V3 Turbo (best quality)', - foundryModelLargeDesc: 'Large-model option for high-end devices and quality-first use.', - foundryModelBase: 'Whisper Base (faster / lower resource)', - 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.', - mirrorLabel: 'Download mirror', - mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.', - mirrorHuggingface: 'HuggingFace official (huggingface.co)', - mirrorHfMirror: 'Mainland mirror (hf-mirror.com)', - activeBadge: 'In use', - downloadedBadge: 'Downloaded', - notDownloadedBadge: 'Not downloaded', - download: 'Download', - resume: 'Resume', - cancel: 'Cancel', - delete: 'Delete', - setActive: 'Set as default', - failed: 'Failed', - cancelled: 'Cancelled', - files: 'files', - sizeLoading: 'Fetching size…', - sizeUnknown: 'Size unknown', - performanceWarning: 'Local ASR is best for offline or privacy-sensitive use. First use requires model download.', - test: 'Load & Test', - testRunning: 'Testing…', - testHeading: 'Built-in audio test', - testExpected: 'Expected', - testActual: 'Got', - testStats: 'Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}', - testFailed: 'Test failed', - engineStatusLabel: 'Engine in memory', - engineLoaded: 'Loaded: {{model}}', - engineUnloaded: 'Not loaded (first transcription must load the model)', - loadNow: 'Load now', - releaseNow: 'Release now', - keepLoadedLabel: 'Keep loaded for', - keepLoadedDesc: 'How long Qwen3-ASR stays in memory after the last use, before being freed.', - keepImmediate: 'Release immediately', - keep1min: '1 minute after last use', - keep5min: '5 minutes after last use (default)', - keep30min: '30 minutes after last use', - keepForever: 'Never release (always loaded)', - }, -}; +} diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index e52459a5..8f06f69f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1,985 +1,1151 @@ -import type { zhCN } from './zh-CN'; -import { en } from './en'; +import type { zhCN } from "./zh-CN" +import { en } from "./en" // 日本語 (Beta) — AI 機械翻訳ベース。ネイティブ話者によるレビューを推奨。 // 構造は zh-CN.ts に追従。新しい key を追加する場合は ja.ts / ko.ts も同時に // 更新してください(更新されない key は ...en で英語にフォールバックします)。 export const ja: typeof zhCN = { - ...en, - app: { - name: 'OpenLess', - tagline: '自然に話し、きれいに書く', - }, - common: { - loading: '読み込み中…', - retry: '再試行', - settingsLoadFailed: '設定の読み込みに失敗しました', - refresh: '更新', - clear: 'クリア', - copy: 'コピー', - delete: '削除', - later: '後で', - cancel: 'キャンセル', - close: '閉じる', - show: '表示', - hide: '非表示', - saved: '保存しました', - saving: '保存中', - copied: 'コピーしました', - operationFailed: '操作に失敗しました', - add: '追加', - durationSeconds: '{{value}} 秒', - durationMinutes: '{{value}} 分', - }, - capsule: { - thinking: 'thinking', - cancelled: 'キャンセルしました', - error: 'エラーが発生しました', - inserted: '{{count}} 文字を入力しました', - translating: '翻訳中', - }, - qa: { - thinking: '思考中…', - error: 'エラーが発生しました。後でもう一度お試しください。', - errorRetry: '再試行', - errorRetryHint: '再度 {{recordHotkey}} を押して質問し直してください。', - pinTooltip: 'ピン留め(自動で閉じない)', - unpinTooltip: 'ピン留めを解除', - closeTooltip: '閉じる', - selectionPreview: '選択テキスト:', - emptyTitle: '{{recordHotkey}} を押して質問を開始', - emptyDesc: '任意のアプリでテキストを選択した後、{{recordHotkey}} を 1 回押して録音を開始し、もう 1 回押して送信します。回答はここに表示され、続けて追加質問が可能です。', - recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します', - statusIdle: '{{recordHotkey}} で質問', - statusRecording: '録音中', - statusThinking: '思考中', - statusError: 'エラー', - }, - nav: { - overview: '概要', - history: '履歴', - vocab: '語彙', - style: 'スタイル', - marketplace: 'マーケット', - translation: '翻訳', - selectionAsk: '選択追問', - localAsr: 'モデル設定', - }, - marketplace: { - kicker: 'MARKETPLACE', - title: 'スタイルパック マーケット', - desc: 'コミュニティのスタイルパックを閲覧・インストール・共有。', - searchPlaceholder: '名前 / 説明 / タグを検索…', - sortPopular: '人気順', - sortNew: '新着', - uploadBtn: 'アップロード', - uploadDisabledHint: '先に 設定 → マーケット で GitHub ユーザー名を設定してください', - refreshBtn: '更新', - empty: 'まだスタイルパックがありません', - emptyHint: '別のキーワードを試すか、自分のパックを共有してみましょう', - loadFailed: '読み込み失敗:{{err}}', - noDescription: '(説明なし)', - installBtn: 'インストール', - likeBtn: 'いいね', - installed: '「{{name}}」をローカルにインストールしました', - uploaded: 'アップロード完了、審査中', - uploadTitle: 'アップロードするパックを選択', - uploadHint: '{{login}} としてアップロードします。内容はクラウド審査キューに送信されます。', - uploadNoLocal: 'アップロード可能なローカルパックがありません', - errors: { - detail: '詳細の読み込み失敗:{{err}}', - install: 'インストール失敗:{{err}}', - like: 'いいね失敗:{{err}}', - upload: 'アップロード失敗:{{err}}', - loadLocal: 'ローカルパック読み込み失敗:{{err}}', + ...en, + app: { + name: "OpenLess", + tagline: "自然に話し、きれいに書く", }, - sortLiked: 'いいね済み', - likedEmpty: 'まだいいねしたパックがありません', - likedEmptyHint: 'パックを開いて星をタップするとここに表示されます', - derivativeBadge: '@{{login}} から派生', - detail: { - withdrawBtn: '公開を取り下げる', - withdrawConfirm: '「{{name}}」をマーケットから取り下げますか?ローカルコピーは保持されます。', - withdrawSuccess: 'マーケットから取り下げました', - withdrawFailed: '取り下げ失敗:{{err}}', + common: { + loading: "読み込み中…", + retry: "再試行", + settingsLoadFailed: "設定の読み込みに失敗しました", + refresh: "更新", + clear: "クリア", + copy: "コピー", + delete: "削除", + later: "後で", + cancel: "キャンセル", + close: "閉じる", + show: "表示", + hide: "非表示", + saved: "保存しました", + saving: "保存中", + copied: "コピーしました", + operationFailed: "操作に失敗しました", + add: "追加", + durationSeconds: "{{value}} 秒", + durationMinutes: "{{value}} 分", }, - myPacks: { - buttonLabel: '自分の公開', - buttonTitle: '{{login}} の公開を見る', - buttonTitleEmpty: '先に 設定 → マーケット で公開者名を設定してください', - searchPlaceholder: '名前・タグを検索', - notLoggedIn: '先に 設定 → マーケット で公開者名を設定してください', - emptyTitle: 'まだ公開したパックはありません', - emptyHint: '「スタイル」ページで編集して「マーケットに公開」をクリックするか、右上からローカルパックをアップロードしてください。', - noMatch: '一致するパックがありません', - summary: '公開済み {{count}} 個', - summaryPending: '公開済み {{count}} 個 · 審査中 {{pending}} 個', - versionDate: 'v{{version}} · {{date}}', - stats: '★ {{likes}} · ↓ {{downloads}}', - actions: { - update: '更新', - withdraw: '取り下げ', - }, - loadFailed: '自分の公開の読み込みに失敗:{{err}}', - loadingTitle: '読み込み中…', - loadingHint: 'マーケットからあなたの最新公開を取得しています。', - loadErrorTitle: '読み込み失敗', - loadErrorRetry: '再試行', + capsule: { + thinking: "thinking", + cancelled: "キャンセルしました", + error: "エラーが発生しました", + inserted: "{{count}} 文字を入力しました", + translating: "翻訳中", }, - upload: { - confirmBtn: 'アップロード確定', - updateTitle: '「{{name}}」を更新', - updateHint: 'アップロードするローカルの新版を選んで「アップロード確定」を押してください。同名パックは自動選択されます。', - recommendedBadge: '推奨', + qa: { + thinking: "思考中…", + error: "エラーが発生しました。後でもう一度お試しください。", + errorRetry: "再試行", + errorRetryHint: "再度 {{recordHotkey}} を押して質問し直してください。", + pinTooltip: "ピン留め(自動で閉じない)", + unpinTooltip: "ピン留めを解除", + closeTooltip: "閉じる", + selectionPreview: "選択テキスト:", + emptyTitle: "{{recordHotkey}} を押して質問を開始", + emptyDesc: + "任意のアプリでテキストを選択した後、{{recordHotkey}} を 1 回押して録音を開始し、もう 1 回押して送信します。回答はここに表示され、続けて追加質問が可能です。", + recordingHint: + "録音中… {{recordHotkey}} をもう一度押して終了し、質問します", + statusIdle: "{{recordHotkey}} で質問", + statusRecording: "録音中", + statusThinking: "思考中", + statusError: "エラー", }, - state: { - pending: '審査中', - approved: '公開済み', - rejected: '却下', - withdrawn: '取り下げ', - superseded: '新版に置換済み', - unknown: '不明', + nav: { + overview: "概要", + history: "履歴", + vocab: "語彙", + style: "スタイル", + marketplace: "マーケット", + translation: "翻訳", + selectionAsk: "選択追問", + localAsr: "モデル設定", }, - oauth: { - title: 'GitHub でサインイン', - generating: 'デバイスコードを生成中…', - browserHint: 'ブラウザで {{uri}} を開き、このコードを入力してください:', - copyBtn: 'コピー', - copied: 'デバイスコードをコピー', - copyFailed: 'コピー失敗:{{err}}', - openBrowserBtn: 'ブラウザを開く', - cancelBtn: 'キャンセル', - waiting: 'ブラウザでの認可を待っています…', - successAs: '@{{login}} としてサインイン', - retryBtn: '再試行', - closeBtn: '閉じる', - loginBtn: 'サインイン', - loginTooltip: 'GitHub でサインイン', - reloginTooltip: '再サインイン / アカウント切替(現在 @{{login}})', - }, - modal: { - loggedIn: '現在のサインイン ID —— 設定 → 録音 → マーケット で変更', - notLoggedIn: '未サインイン —— 設定 → 録音 → マーケット で公開者名を設定', - notLoggedInLabel: '未サインイン', - }, - }, - shell: { - shortcutLabel: '録音ショートカット', - shortcutHint: '開始 / 停止', - betaTag: 'BETA', - betaNote: 'すべてのデータはローカルにのみ保存されます。', - footer: { - account: 'アカウント', - feedback: 'フィードバック', - settings: '設定', - help: 'ヘルプ', - version: 'バージョン {{version}}', - helpPopover: { - tagline: 'ローカル駆動の音声入力レイヤー', - releaseNotes: 'リリースノートを見る ↗', - docs: 'ヘルプセンター ↗', - }, - }, - providerPrompt: { - title: '音声プロバイダーを設定', - body: 'ASR または LLM プロバイダーが未設定のため、音声入力と整文が一時的に利用できません。', - later: '後で', - openSettings: '設定を開く', - }, - hotkeyModePrompt: { - title: '録音方式を確認', - body: 'デフォルトがトグルに変更されました。以前トリガーモードを変更した場合は、録音設定で確認してください。', - later: '後で通知', - openSettings: '録音設定を開く', - }, - }, - onboarding: { - welcome: 'OpenLess へようこそ', - intro: 'ローカルで話し、ローカルで文字に。開始前にシステム権限が 2 つ必要です。', - accessibilityTitle: 'アクセシビリティ', - hotkeyTitle: 'グローバルショートカット', - accessibilityDesc: 'グローバルショートカット(既定 {{trigger}})の検知と、認識結果のカーソル位置への入力に使用します。', - hotkeyDesc: 'グローバルショートカット監視が利用可能か確認するために使用します。', - micTitle: 'マイク', - micDesc: '音声入力の取得に使用します。', - actionNotApplicable: '権限不要', - actionGranted: '許可済み', - actionOpenSystem: 'システム設定を開く', - actionGrant: '許可する', - actionRequestMic: '許可ダイアログを表示', - accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。', - footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。', - }, - overview: { - kicker: 'DASHBOARD', - title: '本日の概要', - desc: '本日のディクテーション統計とシステム状態。', - pressPrefix: '押す', - pressSuffix: 'で録音開始', - asrKind: 'ASR 音声', - llmKind: 'LLM モデル', - asrName: 'Volcengine', - asrSubname: 'bigmodel', - llmName: 'OpenAI 互換', - llmConfigured: 'アクティブ LLM を設定済み', - llmNotConfigured: '未設定', - statusConfigured: '設定済み', - statusNotConfigured: '未設定', - statusUnknown: '読み取れません', - credentialsLoadError: '認証情報の状態を読み取れません', - metricChars: '本日の文字数', - metricSegments: '{{count}} セグメント', - metricDuration: '本日の合計時間', - metricAvg: '平均セグメント', - metricAvgTrend: '本日の平均', - metricNoData: 'データなし', - historyLoadError: '履歴の読み込みに失敗', - metricTotal: '累計記録', - metricTotalTrend: 'ローカル保存(上限 200)', - weekTitle: '直近 7 日', - weekUnit: '件 / 日', - recentTitle: '最近の認識', - recentAll: 'すべて表示 →', - recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。', - recentLoadFailed: '最近の認識を読み込めません。再試行してください。', - historyRetry: '再試行', - weekDays: ['日', '月', '火', '水', '木', '金', '土'], - }, - history: { - kicker: 'HISTORY', - title: '履歴', - desc: 'ローカルに保存された認識記録。', - filterAll: 'すべて', - summary: '合計 {{total}} 件 · 表示 {{shown}}', - empty: '履歴がありません。{{trigger}} を押して録音してみましょう。', - loadFailed: '履歴の読み込みに失敗:{{err}}', - retry: '再試行', - clearFailed: '履歴の消去に失敗:{{err}}', - deleteFailed: '記録の削除に失敗:{{err}}', - copyFailed: 'コピーに失敗:{{err}}', - playRecording: '録音を再生', - audioLoading: '読み込み中…', - exportRecording: '録音をエクスポート', - exportFailed: 'エクスポート失敗:{{err}}', - rawLabel: '原文', - rawEmpty: '(空)', - selectHint: '左側から 1 件選択して詳細を表示。', - insertedTo: '入力先', - chars: '{{count}} 文字', - vocabHits: '{{count}} ホットワード', - inserted: '入力済み', - pasteSent: '貼り付けを試行', - copiedFallback: 'コピー済み(要 {{shortcut}})', - insertFailed: '入力失敗', - confirmClear: '全 {{count}} 件の記録を削除しますか?この操作は取り消せません。', - }, - vocab: { - kicker: 'VOCABULARY', - title: '語彙', - desc: '新語や専門用語を追加して認識精度を向上。', - sectionTitle: '項目', - placeholder: '単語を入力し、Enter または追加をクリック…', - tip: '日本語と英数の混在対応 · 数字始まりは字面通り認識 · ヒット回数を自動カウント', - loadFailed: '読み込み失敗:{{err}}', - empty: '語彙がありません。新語や専門用語を上に入力すると、ディクテーション時に優先的にマッチします。', - tipDisabled: 'クリックで無効化', - tipEnabled: 'クリックで有効化', - removeAria: '削除', - corrections: { - title: '補正ルール', - tip: 'ASR の誤認識を修正。{num} 数字ワイルドカード対応。', - patternPlaceholder: '誤認識された表記(例:{num}粒)', - replacementPlaceholder: '修正後の表記(例:{num}例)', - empty: '補正ルールはまだありません。', - invalid: '文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。', - tipDisabled: 'クリックしてこのルールを無効化', - tipEnabled: 'クリックしてこのルールを有効化', - removeAria: '補正ルールを削除', - }, - presets: { - title: 'シーンプリセット', - tip: '複数選択で一括適用。編集・新規作成対応。', - create: 'プリセット新規作成', - apply: '選択中を有効化', - save: 'プリセットを保存', - edit: '{{name}} を編集', - newPreset: '新しいプリセット', - namePlaceholder: 'プリセット名', - wordsPlaceholder: '語彙(カンマまたは改行区切り)', - }, - }, - style: { - kicker: 'STYLE', - title: '出力スタイル', - desc: '録音のデフォルト出力スタイルを選択。', - masterToggle: '全体有効化', - currentDefault: '現在のデフォルト', - ariaSetDefault: 'デフォルトに設定', - saveFailed: '保存に失敗しました: {{error}}', - customPromptTitle: 'カスタムプロンプト', - customPromptPlaceholder: '任意。このスタイルの組み込み system prompt の末尾に追加されます。', - customPromptHint: '空のままなら現在の挙動を維持します。保存後、このスタイルの整文と repolish の両方に適用されます。Ctrl/Cmd+Enter でも保存できます。', - customPromptSave: 'プロンプトを保存', - customPromptDirty: '未保存', - systemPromptMovedHint: 'フルの system prompt 編集は Settings -> Providers に移動しました。このページではスタイルの有効化とデフォルト設定だけを扱います。', - modes: { - raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' }, - light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, - structured: { name: '明確な構造', desc: '複数のトピックや手順がある場合は、自動的に箇条書きに整理します。', sample: '1. トピック 1\na. ポイント\nb. ポイント\n2. トピック 2\na. ポイント\nb. ポイント' }, - formal: { name: '正式な表現', desc: '業務コミュニケーションやメール用途向け。よりプロフェッショナルで完成度の高い文体。', sample: 'メール用途では挨拶 / 結びを自動認識します。空疎な定型句は持ち込みません。' }, - }, - pack: { - kicker: 'STYLE PACKS', - title: 'スタイルパック', - desc: 'ローカルスタイルパックを管理。', - marketplaceBtn: 'マーケット', - loadFailed: 'スタイルパックの読み込みに失敗:{{err}}', - importZip: 'ZIP をインポート', - exportZip: 'ZIP をエクスポート', - exportShort: 'エクスポート', - publishMarketplace: 'マーケットに公開', - updateMarketplace: 'マーケットの新版に更新', - publishDisabledHint: '先に 設定 → マーケット で GitHub ユーザー名を設定してください', - publishSuccess: '公開完了、マーケット審査待ち', - publishFailed: '公開失敗:{{err}}', - publishBuiltinRejected: 'ビルトインパックは直接公開できません。先に編集してインポート版を作成してください。', - builtin: 'ビルトイン', - imported: 'インポート', - active: '使用中', - activate: '有効化', - edit: '編集', - closeEditor: '閉じる', - unsaved: '未保存', - listTitle: 'ローカルパック', - listDesc: 'パックを閲覧・切替。', - listCount: '{{count}} 個', - addPackTileTitle: '新規パック', - addPackTileHint: '空のテンプレートから開始。', - createSuccess: '新規パックを作成しました', - createFailed: 'パック作成失敗:{{err}}', - save: '保存', - revert: '元に戻す', - saveSuccess: 'スタイルパックを保存しました', - saveFailed: 'スタイルパック保存失敗:{{err}}', - activateSuccess: '"{{name}}" を使用中に設定しました', - activateFailed: '使用中の設定に失敗:{{err}}', - importSuccess: '"{{name}}" をインポートしました', - importFailed: 'ZIP インポート失敗:{{err}}', - exportSuccess: '{{path}} にエクスポートしました', - exportFailed: 'ZIP エクスポート失敗:{{err}}', - exportDirtyFirst: 'ZIP をエクスポートする前に現在のパックを保存してください。', - resetBuiltin: 'リセット', - resetSuccess: '"{{name}}" をリセットしました', - resetFailed: 'パックのリセット失敗:{{err}}', - deleteImported: '削除', - deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。', - deleteSuccess: '"{{name}}" を削除しました', - deleteFailed: 'パック削除失敗:{{err}}', - summaryCurrentEmpty: 'まだパックが選択されていません', - editorTitle: 'パック編集', - editorDesc: 'このパックを編集します。', - metaTitle: 'インストール情報', - metaSource: 'ソース', - metaBaseMode: 'ベースモード', - metaUpdatedAt: '更新日時', - fieldName: '名前', - fieldAuthor: '作者', - fieldAuthorPlaceholder: '任意。ソース表示用', - fieldVersion: 'バージョン', - fieldTags: 'タグ', - fieldTagsPlaceholder: 'カンマ区切り、例: community, voiceover, formal', - fieldDescription: '説明', - fieldModel: '推奨モデル(メタデータのみ)', - fieldModelPlaceholder: '任意。例: gpt-4.1 / deepseek-v3', - fieldModelHint: 'メタデータのみ。実際のモデルは切り替わりません。', - fieldCompatibility: '互換アプリバージョン', - fieldCompatibilityPlaceholder: '任意。例: >=1.3.0', - fullPromptTitle: 'System Prompt', - fullPromptHint: 'このパック固有の Prompt です。', - promptChars: '{{count}} 文字', - runtimeTitle: 'OpenLess 実行時付加指令', - runtimeDesc: '読み取り専用の実行時ヘルパー。', - runtimeContextTitle: 'コンテキスト前提', - runtimeContextDesc: '言語とアプリのコンテキストから', - runtimeContextEmpty: '現在のプレビューでは付加されません。', - runtimeHotwordTitle: 'ホットワードブロック', - runtimeHotwordDesc: '有効なホットワードから', - runtimeHotwordEmpty: '現在のプレビューでは付加されません。', - runtimeHistoryTitle: 'マルチターン履歴ガード', - runtimeHistoryDesc: 'ライブのマルチターン polish のみで使用', - runtimeHistoryEmpty: '前のターンが存在する場合のみ付加。', - runtimeActive: '有効', - runtimeInactive: '無効', - runtimePreviewFailed: '実行時プレビュー生成失敗:{{err}}', - runtimePreviewOmittedFrontApp: 'プレビューはフロントアプリのラベルを省略しています。', - examplesTitle: '効果例', - examplesDesc: 'パックと一緒にエクスポートされます。', - addExample: '例を追加', - examplesEmpty: 'まだ例がありません。', - exampleTitlePlaceholder: '例 {{index}} のタイトル', - exampleInput: '入力', - exampleOutput: '出力', - examplesCount: '{{count}} 個の例', - discardCloseConfirm: '未保存の変更を破棄してエディタを閉じますか?', - discardSwitchConfirm: '未保存の変更を破棄して "{{name}}" に切り替えますか?', - derivativeBadge: '@{{login}} から派生', - }, - }, - translation: { - kicker: 'TRANSLATION', - title: '翻訳', - desc: '録音後に自動翻訳してから入力。', - statusEnabled: '有効', - statusDisabled: '無効', - working: { - title: '作業言語', - desc: '日常使用する言語を選択し、整文と翻訳に反映。', - }, - target: { - title: '翻訳ターゲット言語', - desc: '録音中に Shift で翻訳を起動。「無効」で Shift 無効化。', - disabled: '無効(Shift で翻訳を発動しない)', - }, - save: { - workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。', - targetFailed: '翻訳ターゲット言語の保存に失敗しました。もう一度お試しください。', - hotkeyRegisterFailed: '翻訳ショートカットの登録に失敗しました。設定は保存されていません。', - hotkeySaveFailed: '翻訳ショートカットの保存に失敗しました。もう一度お試しください。', + marketplace: { + kicker: "MARKETPLACE", + title: "スタイルパック マーケット", + desc: "コミュニティのスタイルパックを閲覧・インストール・共有。", + searchPlaceholder: "名前 / 説明 / タグを検索…", + sortPopular: "人気順", + sortNew: "新着", + uploadBtn: "アップロード", + uploadDisabledHint: + "先に 設定 → マーケット で GitHub ユーザー名を設定してください", + refreshBtn: "更新", + empty: "まだスタイルパックがありません", + emptyHint: "別のキーワードを試すか、自分のパックを共有してみましょう", + loadFailed: "読み込み失敗:{{err}}", + noDescription: "(説明なし)", + installBtn: "インストール", + likeBtn: "いいね", + installed: "「{{name}}」をローカルにインストールしました", + uploaded: "アップロード完了、審査中", + uploadTitle: "アップロードするパックを選択", + uploadHint: + "{{login}} としてアップロードします。内容はクラウド審査キューに送信されます。", + uploadNoLocal: "アップロード可能なローカルパックがありません", + errors: { + detail: "詳細の読み込み失敗:{{err}}", + install: "インストール失敗:{{err}}", + like: "いいね失敗:{{err}}", + upload: "アップロード失敗:{{err}}", + loadLocal: "ローカルパック読み込み失敗:{{err}}", + }, + sortLiked: "いいね済み", + likedEmpty: "まだいいねしたパックがありません", + likedEmptyHint: "パックを開いて星をタップするとここに表示されます", + derivativeBadge: "@{{login}} から派生", + detail: { + withdrawBtn: "公開を取り下げる", + withdrawConfirm: + "「{{name}}」をマーケットから取り下げますか?ローカルコピーは保持されます。", + withdrawSuccess: "マーケットから取り下げました", + withdrawFailed: "取り下げ失敗:{{err}}", + }, + myPacks: { + buttonLabel: "自分の公開", + buttonTitle: "{{login}} の公開を見る", + buttonTitleEmpty: + "先に 設定 → マーケット で公開者名を設定してください", + searchPlaceholder: "名前・タグを検索", + notLoggedIn: "先に 設定 → マーケット で公開者名を設定してください", + emptyTitle: "まだ公開したパックはありません", + emptyHint: + "「スタイル」ページで編集して「マーケットに公開」をクリックするか、右上からローカルパックをアップロードしてください。", + noMatch: "一致するパックがありません", + summary: "公開済み {{count}} 個", + summaryPending: "公開済み {{count}} 個 · 審査中 {{pending}} 個", + versionDate: "v{{version}} · {{date}}", + stats: "★ {{likes}} · ↓ {{downloads}}", + actions: { + update: "更新", + withdraw: "取り下げ", + }, + loadFailed: "自分の公開の読み込みに失敗:{{err}}", + loadingTitle: "読み込み中…", + loadingHint: "マーケットからあなたの最新公開を取得しています。", + loadErrorTitle: "読み込み失敗", + loadErrorRetry: "再試行", + }, + upload: { + confirmBtn: "アップロード確定", + updateTitle: "「{{name}}」を更新", + updateHint: + "アップロードするローカルの新版を選んで「アップロード確定」を押してください。同名パックは自動選択されます。", + recommendedBadge: "推奨", + }, + state: { + pending: "審査中", + approved: "公開済み", + rejected: "却下", + withdrawn: "取り下げ", + superseded: "新版に置換済み", + unknown: "不明", + }, + oauth: { + title: "GitHub でサインイン", + generating: "デバイスコードを生成中…", + browserHint: + "ブラウザで {{uri}} を開き、このコードを入力してください:", + copyBtn: "コピー", + copied: "デバイスコードをコピー", + copyFailed: "コピー失敗:{{err}}", + openBrowserBtn: "ブラウザを開く", + cancelBtn: "キャンセル", + waiting: "ブラウザでの認可を待っています…", + successAs: "@{{login}} としてサインイン", + retryBtn: "再試行", + closeBtn: "閉じる", + loginBtn: "サインイン", + loginTooltip: "GitHub でサインイン", + reloginTooltip: "再サインイン / アカウント切替(現在 @{{login}})", + }, + modal: { + loggedIn: "現在のサインイン ID —— 設定 → 録音 → マーケット で変更", + notLoggedIn: + "未サインイン —— 設定 → 録音 → マーケット で公開者名を設定", + notLoggedInLabel: "未サインイン", + }, }, - howto: { - title: '使い方', - step1: '任意の入力欄にカーソルを置く。', - step2: '{{trigger}} を押して録音開始。', - step3: '録音中に Shift を一度押して翻訳を起動。', - step4: '再度 {{trigger}} を押して停止。', - step5: '翻訳結果がカーソル位置に挿入されます。', - indicatorTitle: '翻訳モードの確認方法', - indicatorDesc: 'Shift を押すと画面下部に青い「翻訳中」表示が出ます。', - fallbackTitle: 'セーフティフォールバック', - fallbackDesc: '翻訳失敗時は原文がそのまま挿入されます。', + shell: { + shortcutLabel: "録音ショートカット", + shortcutHint: "開始 / 停止", + betaTag: "BETA", + betaNote: "すべてのデータはローカルにのみ保存されます。", + footer: { + account: "アカウント", + feedback: "フィードバック", + settings: "設定", + help: "ヘルプ", + version: "バージョン {{version}}", + helpPopover: { + tagline: "ローカル駆動の音声入力レイヤー", + releaseNotes: "リリースノートを見る ↗", + docs: "ヘルプセンター ↗", + }, + }, + providerPrompt: { + title: "音声プロバイダーを設定", + body: "ASR または LLM プロバイダーが未設定のため、音声入力と整文が一時的に利用できません。", + later: "後で", + openSettings: "設定を開く", + }, + hotkeyModePrompt: { + title: "録音方式を確認", + body: "デフォルトがトグルに変更されました。以前トリガーモードを変更した場合は、録音設定で確認してください。", + later: "後で通知", + openSettings: "録音設定を開く", + }, }, - }, - selectionAsk: { - kicker: 'SELECTION ASK', - title: '選択追問', - desc: 'テキストを選択して音声で質問。複数ターンの追問対応。', - statusEnabled: '有効', - statusDisabled: '無効', - hotkey: { - title: 'フロートウィンドウのショートカット', - desc: 'パネルの開閉を制御。パネル内の録音は {{recordHotkey}}。', - optionDisabled: '無効', - chordWarning: '', + onboarding: { + welcome: "OpenLess へようこそ", + intro: "ローカルで話し、ローカルで文字に。開始前にシステム権限が 2 つ必要です。", + accessibilityTitle: "アクセシビリティ", + hotkeyTitle: "グローバルショートカット", + accessibilityDesc: + "グローバルショートカット(既定 {{trigger}})の検知と、認識結果のカーソル位置への入力に使用します。", + hotkeyDesc: + "グローバルショートカット監視が利用可能か確認するために使用します。", + micTitle: "マイク", + micDesc: "音声入力の取得に使用します。", + actionNotApplicable: "権限不要", + actionGranted: "許可済み", + actionOpenSystem: "システム設定を開く", + actionGrant: "許可する", + actionRequestMic: "許可ダイアログを表示", + accessibilityHint: + "許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。", + footerHint: + "すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。", }, - save: { - hotkeyRegisterFailed: '選択追問ショートカットの登録に失敗しました。設定は保存されていません。', - hotkeySaveFailed: '選択追問ショートカットの保存に失敗しました。もう一度お試しください。', - historySaveFailed: 'Q&A 履歴設定の保存に失敗しました。もう一度お試しください。', + overview: { + kicker: "DASHBOARD", + title: "本日の概要", + desc: "本日のディクテーション統計とシステム状態。", + pressPrefix: "押す", + pressSuffix: "で録音開始", + asrKind: "ASR 音声", + llmKind: "LLM モデル", + asrName: "Volcengine", + asrSubname: "bigmodel", + llmName: "OpenAI 互換", + llmConfigured: "アクティブ LLM を設定済み", + llmNotConfigured: "未設定", + statusConfigured: "設定済み", + statusNotConfigured: "未設定", + statusUnknown: "読み取れません", + credentialsLoadError: "認証情報の状態を読み取れません", + metricChars: "本日の文字数", + metricSegments: "{{count}} セグメント", + metricDuration: "本日の合計時間", + metricAvg: "平均セグメント", + metricAvgTrend: "本日の平均", + metricNoData: "データなし", + historyLoadError: "履歴の読み込みに失敗", + metricTotal: "累計記録", + metricTotalTrend: "ローカル保存(上限 200)", + weekTitle: "直近 7 日", + weekUnit: "件 / 日", + recentTitle: "最近の認識", + recentAll: "すべて表示 →", + recentEmpty: + "記録がありません。{{trigger}} を押して最初の録音を始めましょう。", + recentLoadFailed: "最近の認識を読み込めません。再試行してください。", + historyRetry: "再試行", + weekDays: ["日", "月", "火", "水", "木", "金", "土"], }, history: { - title: '履歴を保存', - desc: '有効時、Q&A 記録をローカルに保存。デフォルト OFF。', - }, - howto: { - title: '使い方', - step1: '{{hotkey}} でパネルを開く。', - step2: '任意のアプリでテキストを選択。', - step3: '{{recordHotkey}} で録音、再度押して送信。', - step4: '{{recordHotkey}} で続けて追問可能。', - step5: 'Esc でパネルを閉じ、履歴をクリア。', - windowTitle: 'ウィンドウの位置 + ドラッグ + ピン留め', - windowDesc: 'パネルはドラッグ可能で位置を記憶。ピン留めで開いたままに。', - privacyTitle: 'プライバシー契約', - privacyDesc: '選択テキストはメモリ上のみ。パネルを閉じると破棄。4000 字超は自動切り詰め。', - }, - }, - settings: { - kicker: 'SETTINGS', - title: '設定', - desc: '録音、プロバイダー、ショートカット、権限の設定。', - sections: { - recording: '録音', - providers: 'プロバイダー', - shortcuts: 'ショートカット', - permissions: '権限', - language: '言語', - advanced: '詳細設定', - about: '情報', - }, - recording: { - title: '録音', - desc: 'グローバル録音のショートカットとトリガー方式を定義します。', - hotkeyLabel: '録音ショートカット', - hotkeyDescAcc: '押すと音声キャプチャを開始(グローバル)。アクセシビリティ権限が必要です。', - hotkeyDescNoAcc: '押すと音声キャプチャを開始(グローバル)。追加の権限は不要。', - modeLabel: '録音方式', - modeDesc: 'トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。', - modeToggle: 'トグル式', - modeHold: '押し続けて話す', - migrationNoticeTitle: 'デフォルトがトグル式に変更されました', - migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', - microphoneLabel: '優先マイク', - microphoneDesc: '優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。', - microphoneDefault: 'システムのデフォルトマイク', - microphoneDefaultDesc: 'システムのデフォルト入力デバイスを使用', - microphoneSystemDefault: 'システムデフォルト', - microphoneUnavailable: '利用不可', - microphoneLoadError: 'マイクの読み込みに失敗:{{message}}', - microphoneDialogTitle: 'マイク', - microphoneDialogDesc: '声を拾えるマイクを選択してください。メーターが動かない場合は別のマイクを試してください。', - microphoneMonitorError: '入力レベルの監視に失敗:{{message}}', - capsuleLabel: '録音カプセル', - capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。', - muteDuringRecordingLabel: '録音中はミュート', - muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。', - insertGroupTitle: '挿入とクリップボード', - restoreClipboardLabel: '入力後にクリップボードを復元', - restoreClipboardDesc: 'ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。', - pasteShortcutLabel: '貼り付けショートカット', - pasteShortcutDesc: '挿入時に模擬するペーストショートカット。一部のターミナルでは Ctrl+Shift+V が必要(Windows / Linux のみ)。', - pasteShortcutCtrlV: 'Ctrl+V(既定 / ほとんどのアプリ)', - pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)', - pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', - comboRecordLabel: 'ショートカットを記録', - comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。', - comboRecordBtn: 'ショートカットを記録', - comboRecordHint: 'ショートカットの組み合わせを押してください…', - comboRecorded: '記録済み', - comboClear: 'クリア', - comboConflict: 'このショートカットの組み合わせは使用できません', - allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', - allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', - historyGroupTitle: '履歴とコンテキスト', - historyRetentionLabel: '履歴保持期間(日)', - historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', - historyMaxEntriesLabel: '履歴件数の上限', - historyMaxEntriesDesc: 'ローカル保持セッション上限。空欄 = 200。範囲 5–200。', - polishContextWindowLabel: '会話コンテキスト窓(分)', - polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。', - recordAudioForDebugLabel: '元の録音を保持(デバッグ)', - recordAudioForDebugDesc: '生のマイク音声を wav で保存し、認識問題の診断に利用。', - audioRecordingMaxEntriesLabel: '元音声の保持件数', - audioRecordingMaxEntriesDesc: 'ローカル保持 wav ファイル上限。空欄 = 200。', - startupGroupTitle: '起動', - startMinimizedLabel: '起動時にメインウィンドウを表示しない', - startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。', - autoUpdateCheckLabel: 'アップデートを自動チェック', - autoUpdateCheckDesc: '起動時および 60 分ごとに自動チェック。', - marketplaceGroupTitle: 'スタイルパックマーケット', - marketplaceBaseUrlLabel: 'バックエンド URL', - marketplaceBaseUrlDesc: 'マーケットプレイスの URL。空欄でデフォルト値。', - marketplaceDevLoginLabel: 'GitHub ログイン名(アップロード ID)', - marketplaceDevLoginDesc: 'アップロード者を識別。空欄でアップロード・いいね無効。', - startupAtBoot: '起動時に自動起動', - startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', - startupAtBootError: '自動起動の切り替えに失敗:{{message}}', - }, - providers: { - llmTitle: 'LLM モデル(整文)', - llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。', - providerLabel: 'サプライヤー', - llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', - credentialStorageNotice: '資格情報は OS の資格情報ストアに保存されます。', - codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。', - asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', - asrTitle: 'ASR 音声(転写)', - asrDesc: '口述をリアルタイムでテキストに転写。', - presets: { - ark: 'ARK(Volcengine Ark)', - deepseek: 'DeepSeek', - siliconflow: 'SiliconFlow', - openai: 'OpenAI', - gemini: 'Google Gemini', - codexOAuth: 'Codex OAuth', - mimo: 'Xiaomi MiMo', - cometapi: 'CometAPI', - openrouterFree: 'OpenRouter(無料モデル)', - alibabaCoding: 'Alibaba Cloud Coding Plan', - codingPlanX: 'CodingPlanX', - custom: 'カスタム', - asrVolcengine: 'Volcengine bigasr', - asrBailian: 'Alibaba Bailian リアルタイム ASR', - asrSiliconflow: 'SiliconFlow SenseVoice', - asrZhipu: 'Zhipu GLM-ASR', - asrGroq: 'Groq Whisper-large-v3', - asrWhisper: 'OpenAI Whisper(互換)', - asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)', - asrLocalQwen3: 'ローカル Qwen3-ASR', - }, - volcengineAppKeyLabel: 'APP ID', - volcengineAccessKeyLabel: 'Access Token', - volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key は現在不要。Resource ID のデフォルトは volc.bigasr.sauc.duration。', - localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', - localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。', - asrProviderTakenOver: 'ASR プロバイダーは引き継ぎ済み', - localAsrHint: 'デバイス上で動作、API キー不要。HuggingFace からモデルをダウンロード。', - foundryLocalAsrHint: 'デバイス上で動作、ASR API キー不要。初回はランタイムとモデルをダウンロード。', - localAsrPerformanceWarning: 'ローカル推論はクラウドより遅く、中国語の精度が低くなる場合があります。オフラインまたはプライバシー重視の場合に。', - localAsrReady: '{{model}} ダウンロード済み', - localAsrNotReady: '{{model}} 未ダウンロード', - localAsrGoDownload: 'モデル設定でダウンロード', - localAsrManage: 'モデル設定を開く', - localAsrDownloadedTitle: 'ダウンロード済みモデル', - localAsrDelete: '削除', - fillDefault: 'デフォルト値を入力', - readFailed: '読み込み失敗', - apiKeyLabel: 'API キー', - baseUrlLabel: 'エンドポイント', - modelLabel: 'モデル', - thinkingModeLabel: '思考', - thinkingModeOn: 'オン', - thinkingModeOff: 'オフ', - thinkingModeHint: 'オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。', - bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)', - bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。', - appIdLabel: 'App ID(アプリケーション ID)', - accessKeyLabel: 'Access Key', - resourceIdLabel: 'Resource ID', - toolsLabel: '接続チェック', - toolsDesc: '上記の設定を保存してから、現在のモデル接続性を検証またはモデル一覧を取得します。失敗してもモデル ID を手動入力できます。', - validate: '検証', - validating: '検証中…', - fetchModels: 'モデル一覧', - loadingModels: 'モデル取得中…', - modelMissing: 'モデルが未設定です。先にモデル ID を入力してください。', - modelsEmpty: '認証成功ですが、利用可能なモデルが返されませんでした。', - modelsLoaded: '{{count}} 個のモデルを取得しました。', - selectModel: 'モデルを選んで上記欄に入力', - modelSaved: 'モデル {{model}} を保存しました。', - validateSuccess: '接続チェックに合格しました。', - providerHttpStatus: 'サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。', - endpointMustUseHttps: 'Endpoint は HTTPS を使用する必要があります(localhost/127.0.0.1 を除く)。', - endpointInvalid: 'Endpoint の形式が無効です。', - responseTooLarge: 'サプライヤーの応答が大きすぎるため、安全のため検証を停止しました。', - asrInvalidJson: 'ASR の応答が有効な JSON ではありません。', - asrMissingTextField: 'ASR の応答に text フィールドがありません。', - apiKeyMissing: 'API Key が空です。', - endpointMissing: 'Endpoint が空です。', - requestTimeout: 'リクエストがタイムアウトしました。後で再試行してください。', - }, - shortcuts: { - title: 'ショートカット一覧', - descAcc: 'すべてのショートカットはグローバルで有効。権限設定でアクセシビリティを許可する必要があります。', - descNoAcc: 'すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。', - startStop: '録音開始 / 停止', - cancel: '本回の録音をキャンセル', - confirm: 'カプセル入力を確定', - switchStyle: '前のスタイルに切り替え', - openApp: 'OpenLess を開く', - confirmHint: '右側の ✓ をクリック', - notSupported: '未対応', - }, - permissions: { - title: '権限', - descAcc: 'OpenLess は正常動作のため以下のシステム権限が必要です。許可後は通常、App を完全に終了して再起動する必要があります。', - descNoAcc: 'OpenLess はマイクへのアクセスと、グローバルショートカット監視状態を通じてネイティブフックの正常動作を判定する必要があります。', - micLabel: 'マイク', - micDesc: '音声入力の取得に使用します。', - accLabel: 'アクセシビリティ', - accDesc: 'グローバルショートカットの監視と認識結果のカーソル位置への入力に使用。', - hotkeyLabel: 'グローバルショートカット', - hotkeyDescWithAdapter: '現在のアダプタ:{{adapter}}。ショートカット監視がインストール済みかを判定します。', - hotkeyDescPlain: 'ショートカット監視がインストール済みかを判定します。', - networkLabel: 'ネットワーク', - networkDesc: 'クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。', - networkOk: '利用可能', - checking: '確認中…', - granted: '許可済み', - notApplicable: '権限不要', - denied: '未許可', - indeterminate: '未確定', - openSystem: 'システム設定を開く', - grant: '許可する', - hotkeyInstalled: 'インストール済み', - hotkeyStarting: 'インストール中…', - hotkeyFailed: '監視失敗', - windowsImeLabel: 'Windows 入力メソッドバックエンド', - windowsImeDesc: '音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。', - windowsImeInstalled: 'インストール済み', - windowsImeUnavailable: '利用不可', - windowsIme: { - installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', - notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', - registrationBroken: '登録が破損しています。OpenLess IME を再インストールしてください。', - notWindows: 'Windows のみ利用可能。', - }, + kicker: "HISTORY", + title: "履歴", + desc: "ローカルに保存された認識記録。", + filterAll: "すべて", + summary: "合計 {{total}} 件 · 表示 {{shown}}", + empty: "履歴がありません。{{trigger}} を押して録音してみましょう。", + loadFailed: "履歴の読み込みに失敗:{{err}}", + retry: "再試行", + clearFailed: "履歴の消去に失敗:{{err}}", + deleteFailed: "記録の削除に失敗:{{err}}", + copyFailed: "コピーに失敗:{{err}}", + playRecording: "録音を再生", + audioLoading: "読み込み中…", + exportRecording: "録音をエクスポート", + exportFailed: "エクスポート失敗:{{err}}", + rawLabel: "原文", + rawEmpty: "(空)", + selectHint: "左側から 1 件選択して詳細を表示。", + insertedTo: "入力先", + chars: "{{count}} 文字", + vocabHits: "{{count}} ホットワード", + inserted: "入力済み", + pasteSent: "貼り付けを試行", + copiedFallback: "コピー済み(要 {{shortcut}})", + insertFailed: "入力失敗", + confirmClear: + "全 {{count}} 件の記録を削除しますか?この操作は取り消せません。", }, - advanced: { - streamingInsertTitle: 'ストリーミング入力', - streamingInsertTitleLinux: 'ストリーミング入力(実験的)', - streamingInsertDesc: - '逐字リアルタイム挿入で体感遅延を低減。条件不一致時はワンショット貼り付けにフォールバック。', - streamingInsertLabel: 'ストリーミング入力', - streamingInsertHintMac: - 'ストリーミング中は一時的に ABC 入力ソースへ切替(CJK IME による傍受を回避)。セッション終了時に自動で元へ戻ります。', - streamingInsertHintWindows: - 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', - streamingInsertHintLinux: - 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', - streamingInsertSaveClipboardLabel: 'クリップボードに保存', - streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。', - localAsrTitle: 'ローカル ASR モデル(実験的)', - localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。', - localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。', - qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。', - foundryDesc: '有効化すると ASR プロバイダーが引き継がれます。', - notSupportedHere: 'このプラットフォームでは未対応(推論モジュール未組込)。', - enable: '有効化', - alreadyActive: '有効', - disableLocalLabel: 'ローカル ASR を無効化', - disableLocalDesc: 'クラウド ASR(既定は Volcengine bigasr)に戻します。', - disable: '無効化', - platformNotSupported: 'このプラットフォームではローカル ASR モデル統合に対応していません。', - confirmEnableLocalTitle: 'ローカル ASR を有効化しますか?', - confirmEnableLocalBody: '有効にすると転写はクラウドより遅くなり、精度が低くなる場合があります。', - confirm: '有効化する', + vocab: { + kicker: "VOCABULARY", + title: "語彙", + desc: "新語や専門用語を追加して認識精度を向上。", + sectionTitle: "項目", + placeholder: "単語を入力し、Enter または追加をクリック…", + tip: "日本語と英数の混在対応 · 数字始まりは字面通り認識 · ヒット回数を自動カウント", + loadFailed: "読み込み失敗:{{err}}", + empty: "語彙がありません。新語や専門用語を上に入力すると、ディクテーション時に優先的にマッチします。", + tipDisabled: "クリックで無効化", + tipEnabled: "クリックで有効化", + removeAria: "削除", + corrections: { + title: "補正ルール", + tip: "ASR の誤認識を修正。{num} 数字ワイルドカード対応。", + patternPlaceholder: "誤認識された表記(例:{num}粒)", + replacementPlaceholder: "修正後の表記(例:{num}例)", + empty: "補正ルールはまだありません。", + invalid: + "文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。", + tipDisabled: "クリックしてこのルールを無効化", + tipEnabled: "クリックしてこのルールを有効化", + removeAria: "補正ルールを削除", + }, + presets: { + title: "シーンプリセット", + tip: "複数選択で一括適用。編集・新規作成対応。", + create: "プリセット新規作成", + apply: "選択中を有効化", + save: "プリセットを保存", + edit: "{{name}} を編集", + newPreset: "新しいプリセット", + namePlaceholder: "プリセット名", + wordsPlaceholder: "語彙(カンマまたは改行区切り)", + }, }, - language: { - title: '表示言語', - desc: 'UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。', - label: '言語', - labelDesc: '「システムに従う」を選ぶと OS の言語に合わせます。', - followSystem: 'システムに従う', - zh: '简体中文', - zhTW: '繁體中文', - en: 'English', - ja: '日本語 (Beta)', - ko: '한국어 (Beta)', - restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。', + style: { + kicker: "STYLE", + title: "出力スタイル", + desc: "録音のデフォルト出力スタイルを選択。", + masterToggle: "全体有効化", + currentDefault: "現在のデフォルト", + ariaSetDefault: "デフォルトに設定", + saveFailed: "保存に失敗しました: {{error}}", + customPromptTitle: "カスタムプロンプト", + customPromptPlaceholder: + "任意。このスタイルの組み込み system prompt の末尾に追加されます。", + customPromptHint: + "空のままなら現在の挙動を維持します。保存後、このスタイルの整文と repolish の両方に適用されます。Ctrl/Cmd+Enter でも保存できます。", + customPromptSave: "プロンプトを保存", + customPromptDirty: "未保存", + systemPromptMovedHint: + "フルの system prompt 編集は Settings -> Providers に移動しました。このページではスタイルの有効化とデフォルト設定だけを扱います。", + modes: { + raw: { + name: "原文", + desc: "句読点と必要な区切りのみ補い、書き換えや拡張はしません。", + sample: "元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。", + }, + light: { + name: "軽い整文", + desc: "口癖の除去、句読点の補完、自然な送信可能テキストへの整理。", + sample: "原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。", + }, + structured: { + name: "明確な構造", + desc: "複数のトピックや手順がある場合は、自動的に箇条書きに整理します。", + sample: "1. トピック 1\na. ポイント\nb. ポイント\n2. トピック 2\na. ポイント\nb. ポイント", + }, + formal: { + name: "正式な表現", + desc: "業務コミュニケーションやメール用途向け。よりプロフェッショナルで完成度の高い文体。", + sample: "メール用途では挨拶 / 結びを自動認識します。空疎な定型句は持ち込みません。", + }, + }, + pack: { + kicker: "STYLE PACKS", + title: "スタイルパック", + desc: "ローカルスタイルパックを管理。", + marketplaceBtn: "マーケット", + loadFailed: "スタイルパックの読み込みに失敗:{{err}}", + importZip: "ZIP をインポート", + exportZip: "ZIP をエクスポート", + exportShort: "エクスポート", + publishMarketplace: "マーケットに公開", + updateMarketplace: "マーケットの新版に更新", + publishDisabledHint: + "先に 設定 → マーケット で GitHub ユーザー名を設定してください", + publishSuccess: "公開完了、マーケット審査待ち", + publishFailed: "公開失敗:{{err}}", + publishBuiltinRejected: + "ビルトインパックは直接公開できません。先に編集してインポート版を作成してください。", + builtin: "ビルトイン", + imported: "インポート", + active: "使用中", + activate: "有効化", + edit: "編集", + closeEditor: "閉じる", + unsaved: "未保存", + listTitle: "ローカルパック", + listDesc: "パックを閲覧・切替。", + listCount: "{{count}} 個", + addPackTileTitle: "新規パック", + addPackTileHint: "空のテンプレートから開始。", + createSuccess: "新規パックを作成しました", + createFailed: "パック作成失敗:{{err}}", + save: "保存", + revert: "元に戻す", + saveSuccess: "スタイルパックを保存しました", + saveFailed: "スタイルパック保存失敗:{{err}}", + activateSuccess: '"{{name}}" を使用中に設定しました', + activateFailed: "使用中の設定に失敗:{{err}}", + importSuccess: '"{{name}}" をインポートしました', + importFailed: "ZIP インポート失敗:{{err}}", + exportSuccess: "{{path}} にエクスポートしました", + exportFailed: "ZIP エクスポート失敗:{{err}}", + exportDirtyFirst: + "ZIP をエクスポートする前に現在のパックを保存してください。", + resetBuiltin: "リセット", + resetSuccess: '"{{name}}" をリセットしました', + resetFailed: "パックのリセット失敗:{{err}}", + deleteImported: "削除", + deleteConfirm: + '"{{name}}" を削除しますか?この操作は取り消せません。', + deleteSuccess: '"{{name}}" を削除しました', + deleteFailed: "パック削除失敗:{{err}}", + summaryCurrentEmpty: "まだパックが選択されていません", + editorTitle: "パック編集", + editorDesc: "このパックを編集します。", + metaTitle: "インストール情報", + metaSource: "ソース", + metaBaseMode: "ベースモード", + metaUpdatedAt: "更新日時", + fieldName: "名前", + fieldAuthor: "作者", + fieldAuthorPlaceholder: "任意。ソース表示用", + fieldVersion: "バージョン", + fieldTags: "タグ", + fieldTagsPlaceholder: + "カンマ区切り、例: community, voiceover, formal", + fieldDescription: "説明", + fieldModel: "推奨モデル(メタデータのみ)", + fieldModelPlaceholder: "任意。例: gpt-4.1 / deepseek-v3", + fieldModelHint: "メタデータのみ。実際のモデルは切り替わりません。", + fieldCompatibility: "互換アプリバージョン", + fieldCompatibilityPlaceholder: "任意。例: >=1.3.0", + fullPromptTitle: "System Prompt", + fullPromptHint: "このパック固有の Prompt です。", + promptChars: "{{count}} 文字", + runtimeTitle: "OpenLess 実行時付加指令", + runtimeDesc: "読み取り専用の実行時ヘルパー。", + runtimeContextTitle: "コンテキスト前提", + runtimeContextDesc: "言語とアプリのコンテキストから", + runtimeContextEmpty: "現在のプレビューでは付加されません。", + runtimeHotwordTitle: "ホットワードブロック", + runtimeHotwordDesc: "有効なホットワードから", + runtimeHotwordEmpty: "現在のプレビューでは付加されません。", + runtimeHistoryTitle: "マルチターン履歴ガード", + runtimeHistoryDesc: "ライブのマルチターン polish のみで使用", + runtimeHistoryEmpty: "前のターンが存在する場合のみ付加。", + runtimeActive: "有効", + runtimeInactive: "無効", + runtimePreviewFailed: "実行時プレビュー生成失敗:{{err}}", + runtimePreviewOmittedFrontApp: + "プレビューはフロントアプリのラベルを省略しています。", + examplesTitle: "効果例", + examplesDesc: "パックと一緒にエクスポートされます。", + addExample: "例を追加", + examplesEmpty: "まだ例がありません。", + exampleTitlePlaceholder: "例 {{index}} のタイトル", + exampleInput: "入力", + exampleOutput: "出力", + examplesCount: "{{count}} 個の例", + discardCloseConfirm: "未保存の変更を破棄してエディタを閉じますか?", + discardSwitchConfirm: + '未保存の変更を破棄して "{{name}}" に切り替えますか?', + derivativeBadge: "@{{login}} から派生", + }, }, - about: { - tagline: '自然に話し、きれいに書く', - checkUpdate: 'アップデート確認', - checkUpdateBtn: '確認', - checkingUpdate: '確認中…', - upToDate: '現在最新バージョンです。', - updateError: '確認またはアップデートに失敗しました。後で再試行してください。', - openReleases: 'Releases を開く', - source: 'ソース', - docs: 'ドキュメント', - feedback: 'フィードバック', - qq: 'コミュニティ QQ グループ', - qqDesc: 'QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。', - copyQq: 'グループ番号をコピー', - privacy: 'プライバシー', - privacyDesc: 'すべてのデータはローカルに保存。クラウド API は録音を保持しません。', - localFirst: 'ローカル優先', - betaChannelLabel: 'Beta チャンネルに参加', - betaChannelDesc: '有効にすると Beta 更新を受信。不安定な場合があります。', - betaChannelFetching: '最新 Beta 版を取得中…', - betaChannelFetchBtn: '最新 Beta を確認', - betaChannelLatestPrefix: '最新 Beta:', - betaChannelDownloadBtn: 'ダウンロード ページを開く', - betaChannelRefresh: '再取得', - betaChannelNoBeta: 'まだ Beta リリースは公開されていません。', - betaChannelFetchError: 'Beta バージョン情報の取得に失敗しました。後で再試行してください。', - betaChannelUpToDate: '最新です', - betaChannelUpdateNow: '今すぐ更新', - betaChannelUpdateNowTitle: '最新 Beta を確認・ダウンロードし、更新ダイアログを表示します', - betaChannelChecking: '確認中…', - updateDialog: { - available: { - title: '新しいバージョンがあります', - desc: 'OpenLess {{version}} が見つかりました。今すぐ更新しますか?', - }, - downloading: { - title: 'アップデートをダウンロード中', - desc: 'OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。', - }, - downloaded: { - title: 'アップデートの準備完了', - desc: 'OpenLess {{version}} のインストールが完了しました。今すぐ自動再起動して適用しますか?', - }, - installing: { - title: 'アップデートをインストール中', - desc: 'OpenLess {{version}} をインストール中です。アプリを開いたままにしてください。', - }, - install: '今すぐ更新', - downloadingLabel: 'ダウンロード中…', - installingLabel: 'インストール中…', - later: '後で手動再起動', - restartNow: '今すぐ再起動', - progress: '{{progress}}% · {{downloaded}} / {{total}}', - progressUnknown: 'ダウンロード済み {{downloaded}}', - }, + translation: { + kicker: "TRANSLATION", + title: "翻訳", + desc: "録音後に自動翻訳してから入力。", + statusEnabled: "有効", + statusDisabled: "無効", + working: { + title: "作業言語", + desc: "日常使用する言語を選択し、整文と翻訳に反映。", + }, + target: { + title: "翻訳ターゲット言語", + desc: "録音中に Shift で翻訳を起動。「無効」で Shift 無効化。", + disabled: "無効(Shift で翻訳を発動しない)", + }, + save: { + workingFailed: + "作業言語の保存に失敗しました。もう一度お試しください。", + targetFailed: + "翻訳ターゲット言語の保存に失敗しました。もう一度お試しください。", + hotkeyRegisterFailed: + "翻訳ショートカットの登録に失敗しました。設定は保存されていません。", + hotkeySaveFailed: + "翻訳ショートカットの保存に失敗しました。もう一度お試しください。", + }, + howto: { + title: "使い方", + step1: "任意の入力欄にカーソルを置く。", + step2: "{{trigger}} を押して録音開始。", + step3: "録音中に Shift を一度押して翻訳を起動。", + step4: "再度 {{trigger}} を押して停止。", + step5: "翻訳結果がカーソル位置に挿入されます。", + indicatorTitle: "翻訳モードの確認方法", + indicatorDesc: + "Shift を押すと画面下部に青い「翻訳中」表示が出ます。", + fallbackTitle: "セーフティフォールバック", + fallbackDesc: "翻訳失敗時は原文がそのまま挿入されます。", + }, }, - }, - modal: { - sections: { - account: 'アカウント', - settings: '設定', - personalize: 'パーソナライズ', - about: '情報', - helpCenter: 'ヘルプセンター', - releaseNotes: 'リリースノート', + selectionAsk: { + kicker: "SELECTION ASK", + title: "選択追問", + desc: "テキストを選択して音声で質問。複数ターンの追問対応。", + statusEnabled: "有効", + statusDisabled: "無効", + hotkey: { + title: "フロートウィンドウのショートカット", + desc: "パネルの開閉を制御。パネル内の録音は {{recordHotkey}}。", + optionDisabled: "無効", + chordWarning: "", + }, + save: { + hotkeyRegisterFailed: + "選択追問ショートカットの登録に失敗しました。設定は保存されていません。", + hotkeySaveFailed: + "選択追問ショートカットの保存に失敗しました。もう一度お試しください。", + historySaveFailed: + "Q&A 履歴設定の保存に失敗しました。もう一度お試しください。", + }, + history: { + title: "履歴を保存", + desc: "有効時、Q&A 記録をローカルに保存。デフォルト OFF。", + }, + howto: { + title: "使い方", + step1: "{{hotkey}} でパネルを開く。", + step2: "任意のアプリでテキストを選択。", + step3: "{{recordHotkey}} で録音、再度押して送信。", + step4: "{{recordHotkey}} で続けて追問可能。", + step5: "Esc でパネルを閉じ、履歴をクリア。", + windowTitle: "ウィンドウの位置 + ドラッグ + ピン留め", + windowDesc: + "パネルはドラッグ可能で位置を記憶。ピン留めで開いたままに。", + privacyTitle: "プライバシー契約", + privacyDesc: + "選択テキストはメモリ上のみ。パネルを閉じると破棄。4000 字超は自動切り詰め。", + }, }, - account: { - localUser: 'ローカルユーザー', - localUserDesc: '未ログイン · すべてのデータはローカルに保存', - loginSync: 'ログイン / 同期', - footer: 'デフォルトで完全ローカル動作。ログインすると語彙とスタイルプリセットをデバイス間で同期。', + settings: { + kicker: "SETTINGS", + title: "設定", + desc: "録音、プロバイダー、ショートカット、権限の設定。", + sections: { + recording: "録音", + providers: "プロバイダー", + shortcuts: "ショートカット", + permissions: "権限", + language: "言語", + advanced: "詳細設定", + about: "情報", + }, + recording: { + title: "録音", + desc: "グローバル録音のショートカットとトリガー方式を定義します。", + hotkeyLabel: "録音ショートカット", + hotkeyDescAcc: + "押すと音声キャプチャを開始(グローバル)。アクセシビリティ権限が必要です。", + hotkeyDescNoAcc: + "押すと音声キャプチャを開始(グローバル)。追加の権限は不要。", + modeLabel: "録音方式", + modeDesc: + "トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。", + modeToggle: "トグル式", + modeHold: "押し続けて話す", + migrationNoticeTitle: "デフォルトがトグル式に変更されました", + migrationNoticeDesc: + "以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。", + microphoneLabel: "優先マイク", + microphoneDesc: + "優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。", + microphoneDefault: "システムのデフォルトマイク", + microphoneDefaultDesc: "システムのデフォルト入力デバイスを使用", + microphoneSystemDefault: "システムデフォルト", + microphoneUnavailable: "利用不可", + microphoneLoadError: "マイクの読み込みに失敗:{{message}}", + microphoneDialogTitle: "マイク", + microphoneDialogDesc: + "声を拾えるマイクを選択してください。メーターが動かない場合は別のマイクを試してください。", + microphoneMonitorError: "入力レベルの監視に失敗:{{message}}", + capsuleLabel: "録音カプセル", + capsuleDesc: "録音 / 転写中、画面下部に半透明のカプセルを表示。", + muteDuringRecordingLabel: "録音中はミュート", + muteDuringRecordingDesc: + "録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。", + insertGroupTitle: "挿入とクリップボード", + restoreClipboardLabel: "入力後にクリップボードを復元", + restoreClipboardDesc: + "ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。", + pasteShortcutLabel: "貼り付けショートカット", + pasteShortcutDesc: + "挿入時に模擬するペーストショートカット。一部のターミナルでは Ctrl+Shift+V が必要(Windows / Linux のみ)。", + pasteShortcutCtrlV: "Ctrl+V(既定 / ほとんどのアプリ)", + pasteShortcutCtrlShiftV: + "Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)", + pasteShortcutShiftInsert: "Shift+Insert(xterm / urxvt)", + comboRecordLabel: "ショートカットを記録", + comboRecordDesc: + "クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。", + comboRecordBtn: "ショートカットを記録", + comboRecordHint: "ショートカットの組み合わせを押してください…", + comboRecorded: "記録済み", + comboClear: "クリア", + comboConflict: "このショートカットの組み合わせは使用できません", + allowNonTsfFallbackLabel: "非 TSF フォールバックを許可", + allowNonTsfFallbackDesc: + "Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。", + historyGroupTitle: "履歴とコンテキスト", + historyRetentionLabel: "履歴保持期間(日)", + historyRetentionDesc: + "保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。", + historyMaxEntriesLabel: "履歴件数の上限", + historyMaxEntriesDesc: + "ローカル保持セッション上限。空欄 = 200。範囲 5–200。", + polishContextWindowLabel: "会話コンテキスト窓(分)", + polishContextWindowDesc: + "直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。", + recordAudioForDebugLabel: "元の録音を保持(デバッグ)", + recordAudioForDebugDesc: + "生のマイク音声を wav で保存し、認識問題の診断に利用。", + audioRecordingMaxEntriesLabel: "元音声の保持件数", + audioRecordingMaxEntriesDesc: + "ローカル保持 wav ファイル上限。空欄 = 200。", + startupGroupTitle: "起動", + startMinimizedLabel: "起動時にメインウィンドウを表示しない", + startMinimizedDesc: + "どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。", + autoUpdateCheckLabel: "アップデートを自動チェック", + autoUpdateCheckDesc: "起動時および 60 分ごとに自動チェック。", + marketplaceGroupTitle: "スタイルパックマーケット", + marketplaceBaseUrlLabel: "バックエンド URL", + marketplaceBaseUrlDesc: + "マーケットプレイスの URL。空欄でデフォルト値。", + marketplaceDevLoginLabel: "GitHub ログイン名(アップロード ID)", + marketplaceDevLoginDesc: + "アップロード者を識別。空欄でアップロード・いいね無効。", + startupAtBoot: "起動時に自動起動", + startupAtBootDesc: "ログイン時に OpenLess を自動起動。", + startupAtBootError: "自動起動の切り替えに失敗:{{message}}", + }, + providers: { + llmTitle: "LLM モデル(整文)", + llmDesc: + "OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。", + providerLabel: "サプライヤー", + llmProviderDesc: + "選択するとデフォルトの Base URL が自動入力されます。", + credentialStorageNotice: + "資格情報は OS の資格情報ストアに保存されます。", + codexOAuthNotice: + "Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。", + asrProviderDesc: "切り替えると対応する認証情報が自動選択されます。", + asrTitle: "ASR 音声(転写)", + asrDesc: "口述をリアルタイムでテキストに転写。", + presets: { + ark: "ARK(Volcengine Ark)", + deepseek: "DeepSeek", + siliconflow: "SiliconFlow", + openai: "OpenAI", + gemini: "Google Gemini", + codexOAuth: "Codex OAuth", + mimo: "Xiaomi MiMo", + cometapi: "CometAPI", + openrouterFree: "OpenRouter(無料モデル)", + alibabaCoding: "Alibaba Cloud Coding Plan", + codingPlanX: "CodingPlanX", + custom: "カスタム", + asrVolcengine: "Volcengine bigasr", + asrBailian: "Alibaba Bailian リアルタイム ASR", + asrSiliconflow: "SiliconFlow SenseVoice", + asrZhipu: "Zhipu GLM-ASR", + asrGroq: "Groq Whisper-large-v3", + asrWhisper: "OpenAI Whisper(互換)", + asrFoundryLocalWhisper: "ローカル Whisper(Foundry Local)", + asrSherpaOnnxLocal: "ローカル sherpa-onnx(実験的)", + asrLocalQwen3: "ローカル Qwen3-ASR", + }, + volcengineAppKeyLabel: "APP ID", + volcengineAccessKeyLabel: "Access Token", + volcengineResourceIdLabel: "Resource ID", + volcengineMappingNote: + "Secret Key は現在不要。Resource ID のデフォルトは volc.bigasr.sauc.duration。", + localAsrActiveNotice: + "現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。", + localAsrTakeoverHint: + "「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。", + asrProviderTakenOver: "ASR プロバイダーは引き継ぎ済み", + localAsrHint: + "デバイス上で動作、API キー不要。HuggingFace からモデルをダウンロード。", + foundryLocalAsrHint: + "デバイス上で動作、ASR API キー不要。初回はランタイムとモデルをダウンロード。", + localAsrPerformanceWarning: + "ローカル推論はクラウドより遅く、中国語の精度が低くなる場合があります。オフラインまたはプライバシー重視の場合に。", + localAsrReady: "{{model}} ダウンロード済み", + localAsrNotReady: "{{model}} 未ダウンロード", + localAsrGoDownload: "モデル設定でダウンロード", + localAsrManage: "モデル設定を開く", + localAsrDownloadedTitle: "ダウンロード済みモデル", + localAsrDelete: "削除", + fillDefault: "デフォルト値を入力", + readFailed: "読み込み失敗", + apiKeyLabel: "API キー", + baseUrlLabel: "エンドポイント", + modelLabel: "モデル", + thinkingModeLabel: "思考", + thinkingModeOn: "オン", + thinkingModeOff: "オフ", + thinkingModeHint: + "オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。", + bailianVocabularyIdLabel: "ホットワード Vocabulary ID(任意)", + bailianVocabularyIdNote: + "DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。", + appIdLabel: "App ID(アプリケーション ID)", + accessKeyLabel: "Access Key", + resourceIdLabel: "Resource ID", + toolsLabel: "接続チェック", + toolsDesc: + "上記の設定を保存してから、現在のモデル接続性を検証またはモデル一覧を取得します。失敗してもモデル ID を手動入力できます。", + validate: "検証", + validating: "検証中…", + fetchModels: "モデル一覧", + loadingModels: "モデル取得中…", + modelMissing: + "モデルが未設定です。先にモデル ID を入力してください。", + modelsEmpty: + "認証成功ですが、利用可能なモデルが返されませんでした。", + modelsLoaded: "{{count}} 個のモデルを取得しました。", + selectModel: "モデルを選んで上記欄に入力", + modelSaved: "モデル {{model}} を保存しました。", + validateSuccess: "接続チェックに合格しました。", + providerHttpStatus: + "サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。", + endpointMustUseHttps: + "Endpoint は HTTPS を使用する必要があります(localhost/127.0.0.1 を除く)。", + endpointInvalid: "Endpoint の形式が無効です。", + responseTooLarge: + "サプライヤーの応答が大きすぎるため、安全のため検証を停止しました。", + asrInvalidJson: "ASR の応答が有効な JSON ではありません。", + asrMissingTextField: "ASR の応答に text フィールドがありません。", + apiKeyMissing: "API Key が空です。", + endpointMissing: "Endpoint が空です。", + requestTimeout: + "リクエストがタイムアウトしました。後で再試行してください。", + }, + shortcuts: { + title: "ショートカット一覧", + descAcc: + "すべてのショートカットはグローバルで有効。権限設定でアクセシビリティを許可する必要があります。", + descNoAcc: + "すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。", + startStop: "録音開始 / 停止", + cancel: "本回の録音をキャンセル", + confirm: "カプセル入力を確定", + switchStyle: "前のスタイルに切り替え", + openApp: "OpenLess を開く", + confirmHint: "右側の ✓ をクリック", + notSupported: "未対応", + }, + permissions: { + title: "権限", + descAcc: + "OpenLess は正常動作のため以下のシステム権限が必要です。許可後は通常、App を完全に終了して再起動する必要があります。", + descNoAcc: + "OpenLess はマイクへのアクセスと、グローバルショートカット監視状態を通じてネイティブフックの正常動作を判定する必要があります。", + micLabel: "マイク", + micDesc: "音声入力の取得に使用します。", + accLabel: "アクセシビリティ", + accDesc: + "グローバルショートカットの監視と認識結果のカーソル位置への入力に使用。", + hotkeyLabel: "グローバルショートカット", + hotkeyDescWithAdapter: + "現在のアダプタ:{{adapter}}。ショートカット監視がインストール済みかを判定します。", + hotkeyDescPlain: + "ショートカット監視がインストール済みかを判定します。", + networkLabel: "ネットワーク", + networkDesc: + "クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。", + networkOk: "利用可能", + checking: "確認中…", + granted: "許可済み", + notApplicable: "権限不要", + denied: "未許可", + indeterminate: "未確定", + openSystem: "システム設定を開く", + grant: "許可する", + hotkeyInstalled: "インストール済み", + hotkeyStarting: "インストール中…", + hotkeyFailed: "監視失敗", + windowsImeLabel: "Windows 入力メソッドバックエンド", + windowsImeDesc: + "音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。", + windowsImeInstalled: "インストール済み", + windowsImeUnavailable: "利用不可", + windowsIme: { + installed: + "インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。", + notInstalled: + "未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。", + registrationBroken: + "登録が破損しています。OpenLess IME を再インストールしてください。", + notWindows: "Windows のみ利用可能。", + }, + }, + advanced: { + streamingInsertTitle: "ストリーミング入力", + streamingInsertTitleLinux: "ストリーミング入力(実験的)", + streamingInsertDesc: + "逐字リアルタイム挿入で体感遅延を低減。条件不一致時はワンショット貼り付けにフォールバック。", + streamingInsertLabel: "ストリーミング入力", + streamingInsertHintMac: + "ストリーミング中は一時的に ABC 入力ソースへ切替(CJK IME による傍受を回避)。セッション終了時に自動で元へ戻ります。", + streamingInsertHintWindows: + "SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。", + streamingInsertHintLinux: + "fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。", + streamingInsertSaveClipboardLabel: "クリップボードに保存", + streamingInsertSaveClipboardHint: + "挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。", + localAsrTitle: "ローカル ASR モデル(実験的)", + localAsrDesc: + "転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。", + localAsrWarningShort: + "ローカル推論は遅く、スペック不足では欠字の可能性があります。", + qwen3Desc: "有効化すると ASR プロバイダーが引き継がれます。", + foundryDesc: "有効化すると ASR プロバイダーが引き継がれます。", + sherpaDesc: "有効化すると ASR プロバイダーが引き継がれます。", + notSupportedHere: + "このプラットフォームでは未対応(推論モジュール未組込)。", + enable: "有効化", + alreadyActive: "有効", + disableLocalLabel: "ローカル ASR を無効化", + disableLocalDesc: + "クラウド ASR(既定は Volcengine bigasr)に戻します。", + disable: "無効化", + platformNotSupported: + "このプラットフォームではローカル ASR モデル統合に対応していません。", + confirmEnableLocalTitle: "ローカル ASR を有効化しますか?", + confirmEnableLocalBody: + "有効にすると転写はクラウドより遅くなり、精度が低くなる場合があります。", + confirm: "有効化する", + }, + language: { + title: "表示言語", + desc: "UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。", + label: "言語", + labelDesc: "「システムに従う」を選ぶと OS の言語に合わせます。", + followSystem: "システムに従う", + zh: "简体中文", + zhTW: "繁體中文", + en: "English", + ja: "日本語 (Beta)", + ko: "한국어 (Beta)", + restartHint: + "一部のネイティブメニュー(トレイ等)は再起動後に反映されます。", + }, + about: { + tagline: "自然に話し、きれいに書く", + checkUpdate: "アップデート確認", + checkUpdateBtn: "確認", + checkingUpdate: "確認中…", + upToDate: "現在最新バージョンです。", + updateError: + "確認またはアップデートに失敗しました。後で再試行してください。", + openReleases: "Releases を開く", + source: "ソース", + docs: "ドキュメント", + feedback: "フィードバック", + qq: "コミュニティ QQ グループ", + qqDesc: "QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。", + copyQq: "グループ番号をコピー", + privacy: "プライバシー", + privacyDesc: + "すべてのデータはローカルに保存。クラウド API は録音を保持しません。", + localFirst: "ローカル優先", + betaChannelLabel: "Beta チャンネルに参加", + betaChannelDesc: + "有効にすると Beta 更新を受信。不安定な場合があります。", + betaChannelFetching: "最新 Beta 版を取得中…", + betaChannelFetchBtn: "最新 Beta を確認", + betaChannelLatestPrefix: "最新 Beta:", + betaChannelDownloadBtn: "ダウンロード ページを開く", + betaChannelRefresh: "再取得", + betaChannelNoBeta: "まだ Beta リリースは公開されていません。", + betaChannelFetchError: + "Beta バージョン情報の取得に失敗しました。後で再試行してください。", + betaChannelUpToDate: "最新です", + betaChannelUpdateNow: "今すぐ更新", + betaChannelUpdateNowTitle: + "最新 Beta を確認・ダウンロードし、更新ダイアログを表示します", + betaChannelChecking: "確認中…", + updateDialog: { + available: { + title: "新しいバージョンがあります", + desc: "OpenLess {{version}} が見つかりました。今すぐ更新しますか?", + }, + downloading: { + title: "アップデートをダウンロード中", + desc: "OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。", + }, + downloaded: { + title: "アップデートの準備完了", + desc: "OpenLess {{version}} のインストールが完了しました。今すぐ自動再起動して適用しますか?", + }, + installing: { + title: "アップデートをインストール中", + desc: "OpenLess {{version}} をインストール中です。アプリを開いたままにしてください。", + }, + install: "今すぐ更新", + downloadingLabel: "ダウンロード中…", + installingLabel: "インストール中…", + later: "後で手動再起動", + restartNow: "今すぐ再起動", + progress: "{{progress}}% · {{downloaded}} / {{total}}", + progressUnknown: "ダウンロード済み {{downloaded}}", + }, + }, }, - personalize: { - appearance: '外観', - appearanceDesc: 'システムに従う / ライト / ダーク', - appearanceSystem: 'システムに従う', - appearanceLight: 'ライト', - appearanceDark: 'ダーク', - font: 'フォントサイズ', - fontDesc: 'UI のフォントサイズを全体的にスケール。即時反映。', - fontSmall: '小', - fontMedium: '中', - fontLarge: '大', - blur: 'すりガラス強度', - blurDesc: 'ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。', - startupOpen: '起動時に開く', - startupOverview: '概要', - startupLast: '前回の位置', - startupAtBoot: '起動時に自動起動', + modal: { + sections: { + account: "アカウント", + settings: "設定", + personalize: "パーソナライズ", + about: "情報", + helpCenter: "ヘルプセンター", + releaseNotes: "リリースノート", + }, + account: { + localUser: "ローカルユーザー", + localUserDesc: "未ログイン · すべてのデータはローカルに保存", + loginSync: "ログイン / 同期", + footer: "デフォルトで完全ローカル動作。ログインすると語彙とスタイルプリセットをデバイス間で同期。", + }, + personalize: { + appearance: "外観", + appearanceDesc: "システムに従う / ライト / ダーク", + appearanceSystem: "システムに従う", + appearanceLight: "ライト", + appearanceDark: "ダーク", + font: "フォントサイズ", + fontDesc: "UI のフォントサイズを全体的にスケール。即時反映。", + fontSmall: "小", + fontMedium: "中", + fontLarge: "大", + blur: "すりガラス強度", + blurDesc: + "ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。", + startupOpen: "起動時に開く", + startupOverview: "概要", + startupLast: "前回の位置", + startupAtBoot: "起動時に自動起動", + }, + about: { + tagline: "自然に話し、きれいに書く", + checkUpdate: "アップデート確認", + checkUpdateBtn: "確認", + docs: "ドキュメント", + docsBtn: "openless.app/docs ↗", + feedback: "フィードバックチャネル", + feedbackBtn: "GitHub Issues ↗", + source: "ソース", + qq: "コミュニティ QQ グループ", + qqDesc: "QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。", + copyQq: "グループ番号をコピー", + exportErrorLog: "エラーログをエクスポート", + exportErrorLogDesc: + "現在のセッションの実行ログをローカルに保存。問題の調査やフィードバック送付にお使いください。", + exportErrorLogBtn: "エクスポート", + exporting: "エクスポート中…", + exportSuccess: "保存しました", + exportFailed: "エクスポート失敗", + privacy: "プライバシー", + privacyDesc: + "すべての認識結果はローカルにのみ保存されます。クラウド API はリアルタイム呼び出しにのみ使用。", + localFirst: "ローカル優先", + }, }, - about: { - tagline: '自然に話し、きれいに書く', - checkUpdate: 'アップデート確認', - checkUpdateBtn: '確認', - docs: 'ドキュメント', - docsBtn: 'openless.app/docs ↗', - feedback: 'フィードバックチャネル', - feedbackBtn: 'GitHub Issues ↗', - source: 'ソース', - qq: 'コミュニティ QQ グループ', - qqDesc: 'QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。', - copyQq: 'グループ番号をコピー', - exportErrorLog: 'エラーログをエクスポート', - exportErrorLogDesc: '現在のセッションの実行ログをローカルに保存。問題の調査やフィードバック送付にお使いください。', - exportErrorLogBtn: 'エクスポート', - exporting: 'エクスポート中…', - exportSuccess: '保存しました', - exportFailed: 'エクスポート失敗', - privacy: 'プライバシー', - privacyDesc: 'すべての認識結果はローカルにのみ保存されます。クラウド API はリアルタイム呼び出しにのみ使用。', - localFirst: 'ローカル優先', + windowChrome: { + minimize: "最小化", + maximize: "最大化", + close: "閉じる", }, - }, - windowChrome: { - minimize: '最小化', - maximize: '最大化', - close: '閉じる', - }, - hotkey: { - triggers: { - rightOption: '右 Option', - leftOption: '左 Option', - rightControl: '右 Control', - leftControl: '左 Control', - rightCommand: '右 Command', - fn: 'Fn (地球キー)', - rightAlt: '右 Alt', - custom: 'カスタム組み合わせ…', + hotkey: { + triggers: { + rightOption: "右 Option", + leftOption: "左 Option", + rightControl: "右 Control", + leftControl: "左 Control", + rightCommand: "右 Command", + fn: "Fn (地球キー)", + rightAlt: "右 Alt", + custom: "カスタム組み合わせ…", + }, + fallback: "グローバルショートカット", + modeHoldSuffix: "(押し続けて話す)", + modeToggleSuffix: "(開始 / 停止)", + usageHold: "{{trigger}} を押し続けて話し、離して終了。", + usageToggle: "{{trigger}} で録音開始、もう 1 回押して終了。", + adapter: { + macEventTap: "macOS Event Tap", + windowsLowLevel: "Windows 低レベルキーボードフック", + fcitx5: "fcitx5 インプットメソッドプラグイン", + }, }, - fallback: 'グローバルショートカット', - modeHoldSuffix: '(押し続けて話す)', - modeToggleSuffix: '(開始 / 停止)', - usageHold: '{{trigger}} を押し続けて話し、離して終了。', - usageToggle: '{{trigger}} で録音開始、もう 1 回押して終了。', - adapter: { - macEventTap: 'macOS Event Tap', - windowsLowLevel: 'Windows 低レベルキーボードフック', - fcitx5: 'fcitx5 インプットメソッドプラグイン', + localAsr: { + kicker: "ローカル ASR", + title: "モデル設定", + desc: "デバイス上の音声認識モデルを管理。", + qwenTitle: "Qwen3-ASR モデル管理", + qwenExperimentalBadge: "実験的", + engineUnavailable: + "現在のプラットフォームには Qwen3-ASR 推論エンジンが同梱されていません。モデルのダウンロードは可能ですが、ここではまだ Qwen3-ASR を有効化できません。", + qwenUnavailableOnWindows: + "Windows では Qwen3-ASR にまだ対応していません。上記の Foundry Local Whisper をご利用ください。", + foundryTitle: "Windows Foundry Local Whisper", + foundryDesc: + "デバイス上で音声認識。ASR API キー不要。初回はランタイムとモデルのダウンロードが必要。", + foundryAvailable: "Windows で利用可能", + foundryUnavailable: "Windows のみ対応", + foundryRuntimeReady: "ランタイムコンポーネントはダウンロード済み", + foundryRuntimeMissing: "ランタイムコンポーネント未ダウンロード", + foundryRuntimeSourceLabel: "ランタイムコンポーネントの取得元", + foundryRuntimeSourceAuto: "自動(NuGet 優先)", + foundryRuntimeSourceNuget: "NuGet 公式フィード", + foundryRuntimeSourceOrtNightly: "Microsoft ORT-Nightly フィード", + foundryRuntimeSourceDesc: + "初回使用前にランタイムコンポーネントをダウンロード。", + foundrySelectedModel: "選択中のモデル", + foundryActiveModel: "現在の既定 alias", + foundryLoadedModel: "読み込み済みモデル", + foundryNotLoaded: "未読み込み", + foundryError: "Foundry 状態", + foundrySetDefault: "既定に設定 / Windows ローカル ASR を有効化", + foundryEnabling: "有効化中…", + foundryPrepare: "準備 / ダウンロード / 読み込み", + foundryPreparing: "準備中…", + foundryReleasing: "解放中…", + foundryRetryPrepare: "準備を続行 / 再試行", + foundryCancelPrepare: "準備をキャンセル", + foundryCancelRequested: "キャンセル要求済み", + foundryCancelling: "キャンセル中…", + foundryCancelBestEffort: + "キャンセルをリクエスト済み。現在のステップ完了後に停止します。後で再試行できます。", + foundryPrepareRuntime: "ランタイムコンポーネントを準備", + foundryPrepareModel: "モデルをダウンロード", + foundryPrepareLoad: "モデルを読み込み", + foundryPrepareModelSkipped: + "モデルはダウンロード済みのため、ダウンロードをスキップ", + foundryPrepareDone: "完了", + foundryPrepareWaiting: "待機中", + foundryApproxSizeMb: "約 {{mb}} MB", + foundryLanguageLabel: "認識言語", + foundryLanguageAuto: "自動", + foundryLanguageZh: "中国語 zh", + foundryLanguageEn: "英語 en", + foundryLanguageDesc: + "中国語聞き取りは「中文」を、混用は「自動」を選択。", + foundryModelSmall: "Whisper Small(既定 / バランス)", + foundryModelSmallDesc: + "品質とリソース使用量のバランスを取った既定オプション。", + foundryModelMedium: "Whisper Medium(高品質)", + foundryModelMediumDesc: + "より高い精度。大きなダウンロードと遅めの推論を許容できる高性能デバイス向け。", + foundryModelLarge: "Whisper Large V3 Turbo(最高品質)", + foundryModelLargeDesc: + "高性能デバイスと品質優先の用途に向く大きなモデル。", + foundryModelBase: "Whisper Base(高速 / 低リソース)", + 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)", + mirrorHfMirror: "中国ミラー (hf-mirror.com)", + mirrorGithubRelease: "GitHub Release アーカイブ", + activeBadge: "使用中", + downloadedBadge: "ダウンロード済み", + notDownloadedBadge: "未ダウンロード", + download: "ダウンロード", + resume: "続行", + cancel: "キャンセル", + delete: "削除", + setActive: "デフォルトに設定", + failed: "失敗", + cancelled: "キャンセル済み", + files: "ファイル", + sizeLoading: "サイズ問い合わせ中…", + sizeUnknown: "サイズ不明", + performanceWarning: + "ローカル ASR はオフラインやプライバシー重視のシーンに最適。初回使用時にモデルのダウンロードが必要。", + test: "ロードしてテスト", + testRunning: "テスト中…", + testHeading: "内蔵オーディオテスト", + testExpected: "原文", + testActual: "認識", + testStats: + "音声長 {{audio}}s · ロード {{load}}s · 推論 {{transcribe}}s · バックエンド {{backend}}", + testFailed: "テスト失敗", + engineStatusLabel: "メモリ上のエンジン", + engineLoaded: "ロード済み:{{model}}(約 1.2-3.4 GB のメモリを使用)", + engineUnloaded: + "未ロード(初回ディクテーション時に約 3-5 秒のロードが必要)", + loadNow: "今すぐロード", + releaseNow: "今すぐ解放", + keepLoadedLabel: "ロード保持時間", + keepLoadedDesc: + "ローカル ASR を使用後、何分でメモリから解放するかを決定。1+ GB の RAM 占有を回避。", + keepImmediate: "使用直後に解放", + keep1min: "最終使用から 1 分", + keep5min: "最終使用から 5 分(既定)", + keep30min: "最終使用から 30 分", + keepForever: "解放しない(常にロード)", }, - }, - localAsr: { - kicker: 'ローカル ASR', - title: 'モデル設定', - desc: 'デバイス上の音声認識モデルを管理。', - qwenTitle: 'Qwen3-ASR モデル管理', - qwenExperimentalBadge: '実験的', - engineUnavailable: '現在のプラットフォームには Qwen3-ASR 推論エンジンが同梱されていません。モデルのダウンロードは可能ですが、ここではまだ Qwen3-ASR を有効化できません。', - qwenUnavailableOnWindows: 'Windows では Qwen3-ASR にまだ対応していません。上記の Foundry Local Whisper をご利用ください。', - foundryTitle: 'Windows Foundry Local Whisper', - foundryDesc: 'デバイス上で音声認識。ASR API キー不要。初回はランタイムとモデルのダウンロードが必要。', - foundryAvailable: 'Windows で利用可能', - foundryUnavailable: 'Windows のみ対応', - foundryRuntimeReady: 'ランタイムコンポーネントはダウンロード済み', - foundryRuntimeMissing: 'ランタイムコンポーネント未ダウンロード', - foundryRuntimeSourceLabel: 'ランタイムコンポーネントの取得元', - foundryRuntimeSourceAuto: '自動(NuGet 優先)', - foundryRuntimeSourceNuget: 'NuGet 公式フィード', - foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly フィード', - foundryRuntimeSourceDesc: '初回使用前にランタイムコンポーネントをダウンロード。', - foundrySelectedModel: '選択中のモデル', - foundryActiveModel: '現在の既定 alias', - foundryLoadedModel: '読み込み済みモデル', - foundryNotLoaded: '未読み込み', - foundryError: 'Foundry 状態', - foundrySetDefault: '既定に設定 / Windows ローカル ASR を有効化', - foundryEnabling: '有効化中…', - foundryPrepare: '準備 / ダウンロード / 読み込み', - foundryPreparing: '準備中…', - foundryReleasing: '解放中…', - foundryRetryPrepare: '準備を続行 / 再試行', - foundryCancelPrepare: '準備をキャンセル', - foundryCancelRequested: 'キャンセル要求済み', - foundryCancelling: 'キャンセル中…', - foundryCancelBestEffort: 'キャンセルをリクエスト済み。現在のステップ完了後に停止します。後で再試行できます。', - foundryPrepareRuntime: 'ランタイムコンポーネントを準備', - foundryPrepareModel: 'モデルをダウンロード', - foundryPrepareLoad: 'モデルを読み込み', - foundryPrepareModelSkipped: 'モデルはダウンロード済みのため、ダウンロードをスキップ', - foundryPrepareDone: '完了', - foundryPrepareWaiting: '待機中', - foundryApproxSizeMb: '約 {{mb}} MB', - foundryLanguageLabel: '認識言語', - foundryLanguageAuto: '自動', - foundryLanguageZh: '中国語 zh', - foundryLanguageEn: '英語 en', - foundryLanguageDesc: '中国語聞き取りは「中文」を、混用は「自動」を選択。', - foundryModelSmall: 'Whisper Small(既定 / バランス)', - foundryModelSmallDesc: '品質とリソース使用量のバランスを取った既定オプション。', - foundryModelMedium: 'Whisper Medium(高品質)', - foundryModelMediumDesc: 'より高い精度。大きなダウンロードと遅めの推論を許容できる高性能デバイス向け。', - foundryModelLarge: 'Whisper Large V3 Turbo(最高品質)', - foundryModelLargeDesc: '高性能デバイスと品質優先の用途に向く大きなモデル。', - foundryModelBase: 'Whisper Base(高速 / 低リソース)', - foundryModelBaseDesc: 'より高速でリソース消費が少なく、日常の軽量ディクテーションに適しています。', - foundryModelTiny: 'Whisper Tiny(最速 / スモークテスト)', - foundryModelTinyDesc: 'Foundry 経路が動作するか確認するための最速オプション。', - mirrorLabel: 'ダウンロードミラー', - mirrorDesc: '公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。', - mirrorHuggingface: 'HuggingFace 公式 (huggingface.co)', - mirrorHfMirror: '中国ミラー (hf-mirror.com)', - activeBadge: '使用中', - downloadedBadge: 'ダウンロード済み', - notDownloadedBadge: '未ダウンロード', - download: 'ダウンロード', - resume: '続行', - cancel: 'キャンセル', - delete: '削除', - setActive: 'デフォルトに設定', - failed: '失敗', - cancelled: 'キャンセル済み', - files: 'ファイル', - sizeLoading: 'サイズ問い合わせ中…', - sizeUnknown: 'サイズ不明', - performanceWarning: 'ローカル ASR はオフラインやプライバシー重視のシーンに最適。初回使用時にモデルのダウンロードが必要。', - test: 'ロードしてテスト', - testRunning: 'テスト中…', - testHeading: '内蔵オーディオテスト', - testExpected: '原文', - testActual: '認識', - testStats: '音声長 {{audio}}s · ロード {{load}}s · 推論 {{transcribe}}s · バックエンド {{backend}}', - testFailed: 'テスト失敗', - engineStatusLabel: 'メモリ上のエンジン', - engineLoaded: 'ロード済み:{{model}}(約 1.2-3.4 GB のメモリを使用)', - engineUnloaded: '未ロード(初回ディクテーション時に約 3-5 秒のロードが必要)', - loadNow: '今すぐロード', - releaseNow: '今すぐ解放', - keepLoadedLabel: 'ロード保持時間', - keepLoadedDesc: 'ローカル ASR を使用後、何分でメモリから解放するかを決定。1+ GB の RAM 占有を回避。', - keepImmediate: '使用直後に解放', - keep1min: '最終使用から 1 分', - keep5min: '最終使用から 5 分(既定)', - keep30min: '最終使用から 30 分', - keepForever: '解放しない(常にロード)', - }, -}; +} diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 3994cc98..5be972b5 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1,985 +1,1139 @@ -import type { zhCN } from './zh-CN'; -import { en } from './en'; +import type { zhCN } from "./zh-CN" +import { en } from "./en" // 한국어 (Beta) — AI 기계 번역 기반. 원어민 검토를 권장합니다. // 구조는 zh-CN.ts 를 따릅니다. 새 key 를 추가할 때 ja.ts / ko.ts 도 동시에 // 갱신해 주세요(갱신되지 않은 key 는 ...en 으로 영어로 fallback 됩니다). export const ko: typeof zhCN = { - ...en, - app: { - name: 'OpenLess', - tagline: '자연스럽게 말하고, 정확하게 작성하세요', - }, - common: { - loading: '로딩 중…', - retry: '다시 시도', - settingsLoadFailed: '설정 로드 실패', - refresh: '새로고침', - clear: '지우기', - copy: '복사', - delete: '삭제', - later: '나중에', - cancel: '취소', - close: '닫기', - show: '표시', - hide: '숨기기', - saved: '저장됨', - saving: '저장 중', - copied: '복사됨', - operationFailed: '작업 실패', - add: '추가', - durationSeconds: '{{value}}초', - durationMinutes: '{{value}}분', - }, - capsule: { - thinking: 'thinking', - cancelled: '취소됨', - error: '오류 발생', - inserted: '{{count}}자 입력됨', - translating: '번역 중', - }, - qa: { - thinking: '생각 중…', - error: '오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - errorRetry: '재시도', - errorRetryHint: '{{recordHotkey}} 를 다시 눌러 질문하세요.', - pinTooltip: '고정(자동으로 닫히지 않음)', - unpinTooltip: '고정 해제', - closeTooltip: '닫기', - selectionPreview: '선택된 텍스트 기반:', - emptyTitle: '{{recordHotkey}} 를 눌러 질문 시작', - emptyDesc: '아무 앱에서 텍스트를 선택한 후 {{recordHotkey}} 를 한 번 눌러 녹음을 시작하고, 다시 한 번 눌러 종료 후 제출합니다. 답변이 여기에 표시되며 연속해서 후속 질문이 가능합니다.', - recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문', - statusIdle: '{{recordHotkey}} 로 질문', - statusRecording: '녹음 중', - statusThinking: '생각 중', - statusError: '오류', - }, - nav: { - overview: '개요', - history: '기록', - vocab: '어휘', - style: '스타일', - marketplace: '마켓', - translation: '번역', - selectionAsk: '선택 질문', - localAsr: '모델 설정', - }, - marketplace: { - kicker: 'MARKETPLACE', - title: '스타일 팩 마켓', - desc: '커뮤니티 스타일 팩 둘러보기, 설치, 공유.', - searchPlaceholder: '이름 / 설명 / 태그 검색…', - sortPopular: '인기순', - sortNew: '최신', - uploadBtn: '업로드', - uploadDisabledHint: '먼저 설정 → 마켓에서 GitHub 사용자명을 설정하세요', - refreshBtn: '새로고침', - empty: '아직 스타일 팩이 없습니다', - emptyHint: '다른 키워드로 검색하거나 직접 업로드해 보세요', - loadFailed: '불러오기 실패: {{err}}', - noDescription: '(설명 없음)', - installBtn: '설치', - likeBtn: '좋아요', - installed: '"{{name}}"을(를) 로컬에 설치했습니다', - uploaded: '업로드 완료, 심사 대기 중', - uploadTitle: '업로드할 팩 선택', - uploadHint: '{{login}}(으)로 업로드합니다. 콘텐츠는 클라우드 심사 큐로 전송됩니다.', - uploadNoLocal: '업로드 가능한 로컬 팩이 없습니다', - errors: { - detail: '상세 불러오기 실패: {{err}}', - install: '설치 실패: {{err}}', - like: '좋아요 실패: {{err}}', - upload: '업로드 실패: {{err}}', - loadLocal: '로컬 팩 불러오기 실패: {{err}}', + ...en, + app: { + name: "OpenLess", + tagline: "자연스럽게 말하고, 정확하게 작성하세요", }, - sortLiked: '좋아요한 팩', - likedEmpty: '아직 좋아요한 팩이 없습니다', - likedEmptyHint: '팩을 열고 별을 누르면 여기에 표시됩니다', - derivativeBadge: '@{{login}}에서 파생', - detail: { - withdrawBtn: '게시 취소', - withdrawConfirm: '"{{name}}"을(를) 마켓에서 내릴까요? 로컬 사본은 유지됩니다.', - withdrawSuccess: '마켓에서 내렸습니다', - withdrawFailed: '취소 실패: {{err}}', + common: { + loading: "로딩 중…", + retry: "다시 시도", + settingsLoadFailed: "설정 로드 실패", + refresh: "새로고침", + clear: "지우기", + copy: "복사", + delete: "삭제", + later: "나중에", + cancel: "취소", + close: "닫기", + show: "표시", + hide: "숨기기", + saved: "저장됨", + saving: "저장 중", + copied: "복사됨", + operationFailed: "작업 실패", + add: "추가", + durationSeconds: "{{value}}초", + durationMinutes: "{{value}}분", }, - myPacks: { - buttonLabel: '내 게시물', - buttonTitle: '{{login}}의 게시물 보기', - buttonTitleEmpty: '먼저 설정 → 마켓에서 게시자 이름을 입력하세요', - searchPlaceholder: '이름·태그 검색', - notLoggedIn: '먼저 설정 → 마켓에서 게시자 이름을 입력하세요', - emptyTitle: '아직 게시한 팩이 없습니다', - emptyHint: '"스타일" 페이지에서 편집 후 "마켓에 게시"를 누르거나, 오른쪽 위에서 로컬 팩을 업로드하세요.', - noMatch: '일치하는 팩이 없습니다', - summary: '게시 {{count}}개', - summaryPending: '게시 {{count}}개 · 심사 중 {{pending}}개', - versionDate: 'v{{version}} · {{date}}', - stats: '★ {{likes}} · ↓ {{downloads}}', - actions: { - update: '업데이트', - withdraw: '내리기', - }, - loadFailed: '내 게시물 불러오기 실패: {{err}}', - loadingTitle: '불러오는 중…', - loadingHint: '마켓에서 최신 게시물을 가져오는 중입니다.', - loadErrorTitle: '불러오기 실패', - loadErrorRetry: '다시 시도', + capsule: { + thinking: "thinking", + cancelled: "취소됨", + error: "오류 발생", + inserted: "{{count}}자 입력됨", + translating: "번역 중", }, - upload: { - confirmBtn: '업로드 확정', - updateTitle: '"{{name}}" 업데이트', - updateHint: '업로드할 로컬 최신본을 선택하고 "업로드 확정"을 누르세요. 동명 팩이 기본 선택됩니다.', - recommendedBadge: '권장', + qa: { + thinking: "생각 중…", + error: "오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + errorRetry: "재시도", + errorRetryHint: "{{recordHotkey}} 를 다시 눌러 질문하세요.", + pinTooltip: "고정(자동으로 닫히지 않음)", + unpinTooltip: "고정 해제", + closeTooltip: "닫기", + selectionPreview: "선택된 텍스트 기반:", + emptyTitle: "{{recordHotkey}} 를 눌러 질문 시작", + emptyDesc: + "아무 앱에서 텍스트를 선택한 후 {{recordHotkey}} 를 한 번 눌러 녹음을 시작하고, 다시 한 번 눌러 종료 후 제출합니다. 답변이 여기에 표시되며 연속해서 후속 질문이 가능합니다.", + recordingHint: "녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문", + statusIdle: "{{recordHotkey}} 로 질문", + statusRecording: "녹음 중", + statusThinking: "생각 중", + statusError: "오류", }, - state: { - pending: '심사 중', - approved: '게시됨', - rejected: '거부', - withdrawn: '내려짐', - superseded: '신버전으로 대체', - unknown: '알 수 없음', + nav: { + overview: "개요", + history: "기록", + vocab: "어휘", + style: "스타일", + marketplace: "마켓", + translation: "번역", + selectionAsk: "선택 질문", + localAsr: "모델 설정", }, - oauth: { - title: 'GitHub로 로그인', - generating: '디바이스 코드 생성 중…', - browserHint: '브라우저에서 {{uri}}을(를) 열고 아래 코드를 입력하세요:', - copyBtn: '복사', - copied: '디바이스 코드 복사됨', - copyFailed: '복사 실패: {{err}}', - openBrowserBtn: '브라우저 열기', - cancelBtn: '취소', - waiting: '브라우저에서 인증을 기다리는 중…', - successAs: '@{{login}}(으)로 로그인', - retryBtn: '다시 시도', - closeBtn: '닫기', - loginBtn: '로그인', - loginTooltip: 'GitHub로 로그인', - reloginTooltip: '다시 로그인 / 계정 전환(현재 @{{login}})', - }, - modal: { - loggedIn: '현재 로그인 ID — 설정 → 녹음 → 마켓에서 변경', - notLoggedIn: '로그인되지 않음 — 설정 → 녹음 → 마켓에서 게시자 이름을 설정', - notLoggedInLabel: '로그인 안 됨', - }, - }, - shell: { - shortcutLabel: '녹음 단축키', - shortcutHint: '시작 / 정지', - betaTag: 'BETA', - betaNote: '모든 데이터는 로컬에만 저장됩니다.', - footer: { - account: '계정', - feedback: '피드백', - settings: '설정', - help: '도움말', - version: '버전 {{version}}', - helpPopover: { - tagline: '로컬 기반 음성 입력 레이어', - releaseNotes: '릴리스 노트 보기 ↗', - docs: '도움말 센터 ↗', - }, - }, - providerPrompt: { - title: '음성 공급자 설정', - body: 'ASR 또는 LLM 공급자가 설정되지 않아 음성 입력과 정리가 일시적으로 작동하지 않습니다.', - later: '나중에', - openSettings: '설정 열기', - }, - hotkeyModePrompt: { - title: '녹음 방식 확인', - body: '기본값이 토글로 변경되었습니다. 이전에 트리거 방식을 변경한 경우 녹음 설정에서 확인하세요.', - later: '나중에 알림', - openSettings: '녹음 설정 열기', - }, - }, - onboarding: { - welcome: 'OpenLess 에 오신 것을 환영합니다', - intro: '로컬에서 말하고 로컬에서 입력합니다. 시작 전에 두 가지 시스템 권한이 필요합니다.', - accessibilityTitle: '접근성', - hotkeyTitle: '전역 단축키', - accessibilityDesc: '전역 단축키(기본 {{trigger}}) 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.', - hotkeyDesc: '전역 단축키 감지가 사용 가능한지 확인하기 위해 사용합니다.', - micTitle: '마이크', - micDesc: '음성 입력을 캡처하기 위해 사용합니다.', - actionNotApplicable: '권한 불필요', - actionGranted: '허용됨', - actionOpenSystem: '시스템 설정 열기', - actionGrant: '허용', - actionRequestMic: '권한 대화상자 표시', - accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).', - footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.', - }, - overview: { - kicker: 'DASHBOARD', - title: '오늘 개요', - desc: '오늘의 받아쓰기 통계와 시스템 상태.', - pressPrefix: '누르기', - pressSuffix: '녹음 시작', - asrKind: 'ASR 음성', - llmKind: 'LLM 모델', - asrName: 'Volcengine', - asrSubname: 'bigmodel', - llmName: 'OpenAI 호환', - llmConfigured: '활성 LLM 구성됨', - llmNotConfigured: '구성되지 않음', - statusConfigured: '구성됨', - statusNotConfigured: '구성되지 않음', - statusUnknown: '읽을 수 없음', - credentialsLoadError: '자격 증명 상태를 읽을 수 없습니다', - metricChars: '오늘 글자 수', - metricSegments: '{{count}} 세그먼트', - metricDuration: '오늘 총 시간', - metricAvg: '평균 세그먼트', - metricAvgTrend: '오늘 평균', - metricNoData: '데이터 없음', - historyLoadError: '기록 로드 실패', - metricTotal: '누적 기록', - metricTotalTrend: '로컬 보관(상한 200)', - weekTitle: '최근 7일', - weekUnit: '건/일', - recentTitle: '최근 인식', - recentAll: '전체 보기 →', - recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.', - recentLoadFailed: '최근 인식 기록을 불러올 수 없습니다. 다시 시도해 주세요.', - historyRetry: '다시 시도', - weekDays: ['일', '월', '화', '수', '목', '금', '토'], - }, - history: { - kicker: 'HISTORY', - title: '기록', - desc: '로컬에 저장된 인식 기록.', - filterAll: '전체', - summary: '총 {{total}}건 · 표시 {{shown}}', - empty: '기록이 없습니다. {{trigger}} 를 눌러 한 번 녹음해 보세요.', - loadFailed: '기록 로드 실패: {{err}}', - retry: '다시 시도', - clearFailed: '기록 비우기 실패: {{err}}', - deleteFailed: '항목 삭제 실패: {{err}}', - copyFailed: '복사 실패: {{err}}', - playRecording: '녹음 재생', - audioLoading: '로딩 중…', - exportRecording: '녹음 내보내기', - exportFailed: '내보내기 실패: {{err}}', - rawLabel: '원문', - rawEmpty: '(비어 있음)', - selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', - insertedTo: '입력 위치', - chars: '{{count}}자', - vocabHits: '핫워드 {{count}}개', - inserted: '입력됨', - pasteSent: '붙여넣기 시도됨', - copiedFallback: '복사됨({{shortcut}} 필요)', - insertFailed: '입력 실패', - confirmClear: '전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', - }, - vocab: { - kicker: 'VOCABULARY', - title: '어휘', - desc: '새 단어나 전문 용어를 추가하여 인식 정확도 향상.', - sectionTitle: '항목', - placeholder: '단어를 입력하고 Enter 또는 추가 클릭…', - tip: '한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트', - loadFailed: '로드 실패: {{err}}', - empty: '어휘가 없습니다. 위에 새 단어나 전문 용어를 입력하면 받아쓰기 시 우선 매칭됩니다.', - tipDisabled: '클릭하여 비활성화', - tipEnabled: '클릭하여 활성화', - removeAria: '삭제', - corrections: { - title: '교정 규칙', - tip: 'ASR 오인식 수정. {num} 숫자 와일드카드 지원.', - patternPlaceholder: '오인식 표현, 예: {num}粒', - replacementPlaceholder: '대상 표현, 예: {num}例', - empty: '아직 교정 규칙이 없습니다.', - invalid: '문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.', - tipDisabled: '이 규칙 비활성화', - tipEnabled: '이 규칙 활성화', - removeAria: '교정 규칙 삭제', - }, - presets: { - title: '시나리오 프리셋', - tip: '다중 선택 일괄 적용 가능. 편집 및 생성 지원.', - create: '프리셋 새로 만들기', - apply: '선택 활성화', - save: '프리셋 저장', - edit: '{{name}} 편집', - newPreset: '새 프리셋', - namePlaceholder: '프리셋 이름', - wordsPlaceholder: '어휘(쉼표 또는 줄바꿈으로 구분)', - }, - }, - style: { - kicker: 'STYLE', - title: '출력 스타일', - desc: '녹음의 기본 출력 스타일 선택.', - masterToggle: '전체 활성화', - currentDefault: '현재 기본', - ariaSetDefault: '기본으로 설정', - saveFailed: '저장 실패: {{error}}', - customPromptTitle: '사용자 프롬프트', - customPromptPlaceholder: '선택 사항입니다. 이 스타일의 기본 system prompt 끝에 추가됩니다.', - customPromptHint: '비워 두면 현재 동작이 그대로 유지됩니다. 저장 후 이 스타일의 실시간 다듬기와 repolish 모두에 적용됩니다. Ctrl/Cmd+Enter로도 저장할 수 있습니다.', - customPromptSave: '프롬프트 저장', - customPromptDirty: '미저장', - systemPromptMovedHint: '전체 system prompt 편집은 Settings -> Providers 로 이동했습니다. 이 페이지는 이제 스타일 활성화와 기본값만 다룹니다.', - modes: { - raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' }, - light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' }, - structured: { name: '명확한 구조', desc: '여러 주제나 단계가 있을 때 자동으로 항목별 목록으로 정리합니다.', sample: '1. 주제 1\na. 포인트\nb. 포인트\n2. 주제 2\na. 포인트\nb. 포인트' }, - formal: { name: '정식 표현', desc: '업무 커뮤니케이션과 메일에 적합. 더 전문적이고 완성도 높은 문체.', sample: '메일 시나리오에서 인사말과 맺음말을 자동 인식. 공허한 상투어는 추가하지 않습니다.' }, - }, - pack: { - kicker: 'STYLE PACKS', - title: '스타일 팩', - desc: '로컬 스타일 팩 관리.', - marketplaceBtn: '마켓', - loadFailed: '스타일 팩 불러오기 실패: {{err}}', - importZip: 'ZIP 가져오기', - exportZip: 'ZIP 내보내기', - exportShort: '내보내기', - publishMarketplace: '마켓에 게시', - updateMarketplace: '마켓 새 버전으로 업데이트', - publishDisabledHint: '먼저 설정 → 마켓에서 GitHub 사용자명을 설정하세요', - publishSuccess: '게시 완료, 마켓 심사 대기 중', - publishFailed: '게시 실패: {{err}}', - publishBuiltinRejected: '기본 팩은 직접 게시할 수 없습니다. 먼저 편집해서 가져오기 버전을 만드세요.', - builtin: '기본', - imported: '가져옴', - active: '사용 중', - activate: '활성화', - edit: '편집', - closeEditor: '닫기', - unsaved: '저장 안 됨', - listTitle: '로컬 팩', - listDesc: '팩 둘러보기·전환.', - listCount: '{{count}}개', - addPackTileTitle: '새 팩', - addPackTileHint: '빈 템플릿으로 시작.', - createSuccess: '새 팩이 생성되었습니다', - createFailed: '팩 생성 실패: {{err}}', - save: '저장', - revert: '되돌리기', - saveSuccess: '스타일 팩이 저장되었습니다', - saveFailed: '스타일 팩 저장 실패: {{err}}', - activateSuccess: '"{{name}}"을(를) 사용 중으로 설정했습니다', - activateFailed: '사용 중 설정 실패: {{err}}', - importSuccess: '"{{name}}"을(를) 가져왔습니다', - importFailed: 'ZIP 가져오기 실패: {{err}}', - exportSuccess: '{{path}}에 내보냈습니다', - exportFailed: 'ZIP 내보내기 실패: {{err}}', - exportDirtyFirst: 'ZIP을 내보내기 전에 현재 팩을 저장하세요.', - resetBuiltin: '재설정', - resetSuccess: '"{{name}}"을(를) 재설정했습니다', - resetFailed: '팩 재설정 실패: {{err}}', - deleteImported: '삭제', - deleteConfirm: '"{{name}}"을(를) 삭제할까요? 되돌릴 수 없습니다.', - deleteSuccess: '"{{name}}"을(를) 삭제했습니다', - deleteFailed: '팩 삭제 실패: {{err}}', - summaryCurrentEmpty: '아직 팩이 선택되지 않았습니다', - editorTitle: '팩 편집', - editorDesc: '이 팩을 편집합니다.', - metaTitle: '설치 정보', - metaSource: '소스', - metaBaseMode: '베이스 모드', - metaUpdatedAt: '업데이트', - fieldName: '이름', - fieldAuthor: '작성자', - fieldAuthorPlaceholder: '선택. 출처 표시용', - fieldVersion: '버전', - fieldTags: '태그', - fieldTagsPlaceholder: '쉼표로 구분, 예: community, voiceover, formal', - fieldDescription: '설명', - fieldModel: '권장 모델(메타데이터)', - fieldModelPlaceholder: '선택. 예: gpt-4.1 / deepseek-v3', - fieldModelHint: '메타데이터일 뿐 실제 모델을 전환하지 않습니다.', - fieldCompatibility: '호환 앱 버전', - fieldCompatibilityPlaceholder: '선택. 예: >=1.3.0', - fullPromptTitle: 'System Prompt', - fullPromptHint: '이 팩만의 Prompt입니다.', - promptChars: '{{count}}자', - runtimeTitle: 'OpenLess 런타임 추가 지시', - runtimeDesc: '읽기 전용 런타임 보조.', - runtimeContextTitle: '컨텍스트 전제', - runtimeContextDesc: '언어·앱 컨텍스트에서', - runtimeContextEmpty: '현재 미리보기에는 추가되지 않습니다.', - runtimeHotwordTitle: '핫워드 블록', - runtimeHotwordDesc: '활성화된 핫워드에서', - runtimeHotwordEmpty: '현재 미리보기에는 추가되지 않습니다.', - runtimeHistoryTitle: '멀티턴 히스토리 가드', - runtimeHistoryDesc: '실시간 멀티턴 polish 전용', - runtimeHistoryEmpty: '이전 턴이 있을 때만 추가됩니다.', - runtimeActive: '활성', - runtimeInactive: '비활성', - runtimePreviewFailed: '런타임 미리보기 생성 실패: {{err}}', - runtimePreviewOmittedFrontApp: '미리보기에서 프런트앱 라벨이 생략되었습니다.', - examplesTitle: '효과 예시', - examplesDesc: '팩과 함께 내보내집니다.', - addExample: '예시 추가', - examplesEmpty: '아직 예시가 없습니다.', - exampleTitlePlaceholder: '예시 {{index}} 제목', - exampleInput: '입력', - exampleOutput: '출력', - examplesCount: '{{count}}개 예시', - discardCloseConfirm: '저장하지 않은 변경 사항을 버리고 에디터를 닫을까요?', - discardSwitchConfirm: '저장하지 않은 변경 사항을 버리고 "{{name}}"(으)로 전환할까요?', - derivativeBadge: '@{{login}}에서 파생', - }, - }, - translation: { - kicker: 'TRANSLATION', - title: '번역', - desc: '녹음 후 대상 언어로 자동 번역하여 삽입.', - statusEnabled: '활성화됨', - statusDisabled: '비활성화됨', - working: { - title: '작업 언어', - desc: '일상적으로 사용하는 언어를 선택하여 정리와 번역에 반영.', - }, - target: { - title: '번역 대상 언어', - desc: '녹음 중 Shift 로 번역 실행. "비활성화" 시 Shift 무효.', - disabled: '비활성화 (Shift 로 번역 발동 안 함)', - }, - save: { - workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.', - targetFailed: '번역 대상 언어 저장에 실패했습니다. 다시 시도하세요.', - hotkeyRegisterFailed: '번역 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.', - hotkeySaveFailed: '번역 단축키 저장에 실패했습니다. 다시 시도하세요.', + marketplace: { + kicker: "MARKETPLACE", + title: "스타일 팩 마켓", + desc: "커뮤니티 스타일 팩 둘러보기, 설치, 공유.", + searchPlaceholder: "이름 / 설명 / 태그 검색…", + sortPopular: "인기순", + sortNew: "최신", + uploadBtn: "업로드", + uploadDisabledHint: "먼저 설정 → 마켓에서 GitHub 사용자명을 설정하세요", + refreshBtn: "새로고침", + empty: "아직 스타일 팩이 없습니다", + emptyHint: "다른 키워드로 검색하거나 직접 업로드해 보세요", + loadFailed: "불러오기 실패: {{err}}", + noDescription: "(설명 없음)", + installBtn: "설치", + likeBtn: "좋아요", + installed: '"{{name}}"을(를) 로컬에 설치했습니다', + uploaded: "업로드 완료, 심사 대기 중", + uploadTitle: "업로드할 팩 선택", + uploadHint: + "{{login}}(으)로 업로드합니다. 콘텐츠는 클라우드 심사 큐로 전송됩니다.", + uploadNoLocal: "업로드 가능한 로컬 팩이 없습니다", + errors: { + detail: "상세 불러오기 실패: {{err}}", + install: "설치 실패: {{err}}", + like: "좋아요 실패: {{err}}", + upload: "업로드 실패: {{err}}", + loadLocal: "로컬 팩 불러오기 실패: {{err}}", + }, + sortLiked: "좋아요한 팩", + likedEmpty: "아직 좋아요한 팩이 없습니다", + likedEmptyHint: "팩을 열고 별을 누르면 여기에 표시됩니다", + derivativeBadge: "@{{login}}에서 파생", + detail: { + withdrawBtn: "게시 취소", + withdrawConfirm: + '"{{name}}"을(를) 마켓에서 내릴까요? 로컬 사본은 유지됩니다.', + withdrawSuccess: "마켓에서 내렸습니다", + withdrawFailed: "취소 실패: {{err}}", + }, + myPacks: { + buttonLabel: "내 게시물", + buttonTitle: "{{login}}의 게시물 보기", + buttonTitleEmpty: "먼저 설정 → 마켓에서 게시자 이름을 입력하세요", + searchPlaceholder: "이름·태그 검색", + notLoggedIn: "먼저 설정 → 마켓에서 게시자 이름을 입력하세요", + emptyTitle: "아직 게시한 팩이 없습니다", + emptyHint: + '"스타일" 페이지에서 편집 후 "마켓에 게시"를 누르거나, 오른쪽 위에서 로컬 팩을 업로드하세요.', + noMatch: "일치하는 팩이 없습니다", + summary: "게시 {{count}}개", + summaryPending: "게시 {{count}}개 · 심사 중 {{pending}}개", + versionDate: "v{{version}} · {{date}}", + stats: "★ {{likes}} · ↓ {{downloads}}", + actions: { + update: "업데이트", + withdraw: "내리기", + }, + loadFailed: "내 게시물 불러오기 실패: {{err}}", + loadingTitle: "불러오는 중…", + loadingHint: "마켓에서 최신 게시물을 가져오는 중입니다.", + loadErrorTitle: "불러오기 실패", + loadErrorRetry: "다시 시도", + }, + upload: { + confirmBtn: "업로드 확정", + updateTitle: '"{{name}}" 업데이트', + updateHint: + '업로드할 로컬 최신본을 선택하고 "업로드 확정"을 누르세요. 동명 팩이 기본 선택됩니다.', + recommendedBadge: "권장", + }, + state: { + pending: "심사 중", + approved: "게시됨", + rejected: "거부", + withdrawn: "내려짐", + superseded: "신버전으로 대체", + unknown: "알 수 없음", + }, + oauth: { + title: "GitHub로 로그인", + generating: "디바이스 코드 생성 중…", + browserHint: + "브라우저에서 {{uri}}을(를) 열고 아래 코드를 입력하세요:", + copyBtn: "복사", + copied: "디바이스 코드 복사됨", + copyFailed: "복사 실패: {{err}}", + openBrowserBtn: "브라우저 열기", + cancelBtn: "취소", + waiting: "브라우저에서 인증을 기다리는 중…", + successAs: "@{{login}}(으)로 로그인", + retryBtn: "다시 시도", + closeBtn: "닫기", + loginBtn: "로그인", + loginTooltip: "GitHub로 로그인", + reloginTooltip: "다시 로그인 / 계정 전환(현재 @{{login}})", + }, + modal: { + loggedIn: "현재 로그인 ID — 설정 → 녹음 → 마켓에서 변경", + notLoggedIn: + "로그인되지 않음 — 설정 → 녹음 → 마켓에서 게시자 이름을 설정", + notLoggedInLabel: "로그인 안 됨", + }, }, - howto: { - title: '사용 방법', - step1: '아무 입력 필드에 커서를 놓으세요.', - step2: '{{trigger}} 를 눌러 녹음 시작.', - step3: '녹음 중 Shift 를 한 번 눌러 번역 활성화.', - step4: '다시 {{trigger}} 를 눌러 정지.', - step5: '번역 결과가 커서 위치에 삽입됩니다.', - indicatorTitle: '번역 모드 활성화 확인 방법', - indicatorDesc: 'Shift 를 누르면 화면 하단에 파란색 "번역 중" 표시가 나타납니다.', - fallbackTitle: '안전 폴백', - fallbackDesc: '번역 실패 시 원본 전사가 삽입됩니다.', + shell: { + shortcutLabel: "녹음 단축키", + shortcutHint: "시작 / 정지", + betaTag: "BETA", + betaNote: "모든 데이터는 로컬에만 저장됩니다.", + footer: { + account: "계정", + feedback: "피드백", + settings: "설정", + help: "도움말", + version: "버전 {{version}}", + helpPopover: { + tagline: "로컬 기반 음성 입력 레이어", + releaseNotes: "릴리스 노트 보기 ↗", + docs: "도움말 센터 ↗", + }, + }, + providerPrompt: { + title: "음성 공급자 설정", + body: "ASR 또는 LLM 공급자가 설정되지 않아 음성 입력과 정리가 일시적으로 작동하지 않습니다.", + later: "나중에", + openSettings: "설정 열기", + }, + hotkeyModePrompt: { + title: "녹음 방식 확인", + body: "기본값이 토글로 변경되었습니다. 이전에 트리거 방식을 변경한 경우 녹음 설정에서 확인하세요.", + later: "나중에 알림", + openSettings: "녹음 설정 열기", + }, }, - }, - selectionAsk: { - kicker: 'SELECTION ASK', - title: '선택 질문', - desc: '텍스트 선택 후 음성으로 질문. 다중 라운드 후속 질문 지원.', - statusEnabled: '활성화됨', - statusDisabled: '비활성화됨', - hotkey: { - title: '플로팅 창 단축키', - desc: '패널 열기/닫기 제어. 패널 내 녹음은 {{recordHotkey}} 사용.', - optionDisabled: '비활성화', - chordWarning: '', + onboarding: { + welcome: "OpenLess 에 오신 것을 환영합니다", + intro: "로컬에서 말하고 로컬에서 입력합니다. 시작 전에 두 가지 시스템 권한이 필요합니다.", + accessibilityTitle: "접근성", + hotkeyTitle: "전역 단축키", + accessibilityDesc: + "전역 단축키(기본 {{trigger}}) 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.", + hotkeyDesc: + "전역 단축키 감지가 사용 가능한지 확인하기 위해 사용합니다.", + micTitle: "마이크", + micDesc: "음성 입력을 캡처하기 위해 사용합니다.", + actionNotApplicable: "권한 불필요", + actionGranted: "허용됨", + actionOpenSystem: "시스템 설정 열기", + actionGrant: "허용", + actionRequestMic: "권한 대화상자 표시", + accessibilityHint: + "허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).", + footerHint: + "모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.", }, - save: { - hotkeyRegisterFailed: '선택 질문 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.', - hotkeySaveFailed: '선택 질문 단축키 저장에 실패했습니다. 다시 시도하세요.', - historySaveFailed: 'Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.', + overview: { + kicker: "DASHBOARD", + title: "오늘 개요", + desc: "오늘의 받아쓰기 통계와 시스템 상태.", + pressPrefix: "누르기", + pressSuffix: "녹음 시작", + asrKind: "ASR 음성", + llmKind: "LLM 모델", + asrName: "Volcengine", + asrSubname: "bigmodel", + llmName: "OpenAI 호환", + llmConfigured: "활성 LLM 구성됨", + llmNotConfigured: "구성되지 않음", + statusConfigured: "구성됨", + statusNotConfigured: "구성되지 않음", + statusUnknown: "읽을 수 없음", + credentialsLoadError: "자격 증명 상태를 읽을 수 없습니다", + metricChars: "오늘 글자 수", + metricSegments: "{{count}} 세그먼트", + metricDuration: "오늘 총 시간", + metricAvg: "평균 세그먼트", + metricAvgTrend: "오늘 평균", + metricNoData: "데이터 없음", + historyLoadError: "기록 로드 실패", + metricTotal: "누적 기록", + metricTotalTrend: "로컬 보관(상한 200)", + weekTitle: "최근 7일", + weekUnit: "건/일", + recentTitle: "최근 인식", + recentAll: "전체 보기 →", + recentEmpty: + "아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.", + recentLoadFailed: + "최근 인식 기록을 불러올 수 없습니다. 다시 시도해 주세요.", + historyRetry: "다시 시도", + weekDays: ["일", "월", "화", "수", "목", "금", "토"], }, history: { - title: '기록 저장', - desc: '활성화 시 Q&A 기록을 로컬에 저장. 기본 OFF.', - }, - howto: { - title: '사용 방법', - step1: '{{hotkey}} 로 패널 열기.', - step2: '아무 앱에서 텍스트 선택.', - step3: '{{recordHotkey}} 로 녹음, 다시 눌러 제출.', - step4: '{{recordHotkey}} 로 계속 후속 질문 가능.', - step5: 'Esc 로 패널 닫기 및 기록 삭제.', - windowTitle: '창 위치 + 드래그 + 고정', - windowDesc: '패널은 드래그 가능하며 위치를 기억합니다. 고정하면 열린 상태 유지.', - privacyTitle: '프라이버시 계약', - privacyDesc: '선택 텍스트는 메모리에만 존재하며 패널 닫으면 삭제. 4000 자 초과 시 자동 축소.', - }, - }, - settings: { - kicker: 'SETTINGS', - title: '설정', - desc: '녹음, 공급자, 단축키, 권한 설정.', - sections: { - recording: '녹음', - providers: '공급자', - shortcuts: '단축키', - permissions: '권한', - language: '언어', - advanced: '고급', - about: '정보', - }, - recording: { - title: '녹음', - desc: '전역 녹음의 단축키와 트리거 방식을 정의합니다.', - hotkeyLabel: '녹음 단축키', - hotkeyDescAcc: '누르면 음성 캡처 시작(전역). 접근성 권한이 필요합니다.', - hotkeyDescNoAcc: '누르면 음성 캡처 시작(전역). 추가 권한 불필요.', - modeLabel: '녹음 방식', - modeDesc: '토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.', - modeToggle: '토글 방식', - modeHold: '눌러서 말하기', - migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', - migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', - microphoneLabel: '기본 선택 마이크', - microphoneDesc: '우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.', - microphoneDefault: '시스템 기본 마이크', - microphoneDefaultDesc: '시스템 기본 입력 장치 사용', - microphoneSystemDefault: '시스템 기본값', - microphoneUnavailable: '사용할 수 없음', - microphoneLoadError: '마이크 로드 실패: {{message}}', - microphoneDialogTitle: '마이크', - microphoneDialogDesc: '목소리를 받을 수 있는 마이크를 선택하세요. 미터가 움직이지 않으면 다른 마이크를 시도하세요.', - microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}', - capsuleLabel: '녹음 캡슐', - capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.', - muteDuringRecordingLabel: '녹음 중 음소거', - muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.', - insertGroupTitle: '삽입 및 클립보드', - restoreClipboardLabel: '입력 후 클립보드 복원', - restoreClipboardDesc: '붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).', - pasteShortcutLabel: '붙여넣기 단축키', - pasteShortcutDesc: '삽입 시 시뮬레이션할 붙여넣기 단축키. 일부 터미널은 Ctrl+Shift+V 가 필요 (Windows / Linux 만).', - pasteShortcutCtrlV: 'Ctrl+V (기본 / 대부분 앱)', - pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)', - pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)', - comboRecordLabel: '단축키 녹화', - comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.', - comboRecordBtn: '단축키 녹화', - comboRecordHint: '단축키 조합을 눌러 주세요…', - comboRecorded: '녹화됨', - comboClear: '지우기', - comboConflict: '이 단축키 조합은 사용할 수 없습니다', - allowNonTsfFallbackLabel: '비 TSF 폴백 허용', - allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', - historyGroupTitle: '기록 및 컨텍스트', - historyRetentionLabel: '기록 보관 기간(일)', - historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', - historyMaxEntriesLabel: '기록 개수 상한', - historyMaxEntriesDesc: '로컬 보관 세션 상한. 빈칸 = 200. 범위 5–200.', - polishContextWindowLabel: '대화 컨텍스트 윈도(분)', - polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.', - recordAudioForDebugLabel: '원본 녹음 보관(디버그)', - recordAudioForDebugDesc: '원시 마이크 오디오를 wav 로 저장하여 인식 문제 진단.', - audioRecordingMaxEntriesLabel: '원본 녹음 보관 개수', - audioRecordingMaxEntriesDesc: '로컬 보관 wav 파일 상한. 빈칸 = 200.', - startupGroupTitle: '시작', - startMinimizedLabel: '시작 시 메인 창 숨기기', - startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.', - autoUpdateCheckLabel: '자동 업데이트 확인', - autoUpdateCheckDesc: '시작 시 및 60 분마다 자동 확인.', - marketplaceGroupTitle: '스타일 팩 마켓플레이스', - marketplaceBaseUrlLabel: '백엔드 URL', - marketplaceBaseUrlDesc: '마켓플레이스 백엔드 URL. 빈칸은 기본값 사용.', - marketplaceDevLoginLabel: 'GitHub 로그인 이름 (업로드 ID)', - marketplaceDevLoginDesc: '업로더를 식별합니다. 빈칸 시 업로드 및 좋아요 비활성화.', - startupAtBoot: '부팅 시 자동 시작', - startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', - startupAtBootError: '자동 시작 전환 실패: {{message}}', - }, - providers: { - llmTitle: 'LLM 모델(정리)', - llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.', - providerLabel: '공급자', - llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.', - credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 보관됩니다.', - codexOAuthNotice: 'Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.', - asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', - asrTitle: 'ASR 음성(전사)', - asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', - presets: { - ark: 'ARK (Volcengine Ark)', - deepseek: 'DeepSeek', - siliconflow: 'SiliconFlow', - openai: 'OpenAI', - gemini: 'Google Gemini', - codexOAuth: 'Codex OAuth', - mimo: 'Xiaomi MiMo', - cometapi: 'CometAPI', - openrouterFree: 'OpenRouter(무료 모델)', - alibabaCoding: 'Alibaba Cloud Coding Plan', - codingPlanX: 'CodingPlanX', - custom: '사용자 정의', - asrVolcengine: 'Volcengine bigasr', - asrBailian: 'Alibaba Bailian 실시간 ASR', - asrSiliconflow: 'SiliconFlow SenseVoice', - asrZhipu: 'Zhipu GLM-ASR', - asrGroq: 'Groq Whisper-large-v3', - asrWhisper: 'OpenAI Whisper(호환)', - asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)', - asrLocalQwen3: '로컬 Qwen3-ASR', - }, - volcengineAppKeyLabel: 'APP ID', - volcengineAccessKeyLabel: 'Access Token', - volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.bigasr.sauc.duration.', - localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', - localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', - asrProviderTakenOver: 'ASR 프로바이더 인수 완료', - localAsrHint: '기기에서 실행, API 키 불필요. HuggingFace 에서 모델 다운로드.', - foundryLocalAsrHint: '기기에서 실행, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드.', - localAsrPerformanceWarning: '로컬 추론은 클라우드보다 느리며 중국어 정확도가 낮을 수 있습니다. 오프라인 또는 개인정보 보호 시나리오에 적합.', - localAsrReady: '{{model}} 다운로드됨', - localAsrNotReady: '{{model}} 다운로드되지 않음', - localAsrGoDownload: '모델 설정에서 다운로드', - localAsrManage: '모델 설정으로 이동', - localAsrDownloadedTitle: '다운로드된 모델', - localAsrDelete: '삭제', - fillDefault: '기본값 입력', - readFailed: '읽기 실패', - apiKeyLabel: 'API 키', - baseUrlLabel: '엔드포인트', - modelLabel: '모델', - thinkingModeLabel: '사고', - thinkingModeOn: '켜짐', - thinkingModeOff: '꺼짐', - thinkingModeHint: '꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.', - bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)', - bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.', - appIdLabel: 'App ID(애플리케이션 ID)', - accessKeyLabel: 'Access Key', - resourceIdLabel: 'Resource ID', - toolsLabel: '연결 확인', - toolsDesc: '위 설정을 먼저 저장한 후 현재 모델 연결성을 검증하거나 모델을 가져오세요. 실패해도 모델 ID 를 수동 입력할 수 있습니다.', - validate: '검증', - validating: '검증 중…', - fetchModels: '모델 가져오기', - loadingModels: '모델 가져오는 중…', - modelMissing: '모델이 설정되지 않았습니다. 먼저 모델 ID 를 입력해 주세요.', - modelsEmpty: '인증 성공이지만 사용 가능한 모델이 반환되지 않았습니다.', - modelsLoaded: '{{count}}개의 모델을 가져왔습니다.', - selectModel: '모델을 선택해 위 필드에 입력', - modelSaved: '모델 {{model}} 을(를) 저장했습니다.', - validateSuccess: '연결 확인을 통과했습니다.', - providerHttpStatus: '공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.', - endpointMustUseHttps: 'Endpoint 는 HTTPS 를 사용해야 합니다(localhost/127.0.0.1 제외).', - endpointInvalid: 'Endpoint 형식이 올바르지 않습니다.', - responseTooLarge: '공급자 응답이 너무 커서 안전을 위해 검증을 중단했습니다.', - asrInvalidJson: 'ASR 응답이 유효한 JSON 이 아닙니다.', - asrMissingTextField: 'ASR 응답에 text 필드가 없습니다.', - apiKeyMissing: 'API Key 가 비어 있습니다.', - endpointMissing: 'Endpoint 가 비어 있습니다.', - requestTimeout: '요청 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', - }, - shortcuts: { - title: '단축키 한눈에 보기', - descAcc: '모든 단축키는 전역에서 작동. 권한 설정에서 접근성을 활성화해야 합니다.', - descNoAcc: '모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.', - startStop: '녹음 시작 / 정지', - cancel: '이번 녹음 취소', - confirm: '캡슐 입력 확정', - switchStyle: '이전 스타일로 전환', - openApp: 'OpenLess 열기', - confirmHint: '오른쪽 ✓ 클릭', - notSupported: '지원되지 않음', - }, - permissions: { - title: '권한', - descAcc: 'OpenLess 가 정상 작동하려면 다음 시스템 권한이 필요합니다. 허용 후에는 일반적으로 앱을 완전히 종료한 후 재시작해야 적용됩니다.', - descNoAcc: 'OpenLess 는 마이크 사용과 전역 단축키 감지 상태를 통해 네이티브 후크의 정상 동작을 판정해야 합니다.', - micLabel: '마이크', - micDesc: '음성 입력을 캡처하기 위해 사용합니다.', - accLabel: '접근성', - accDesc: '전역 단축키 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.', - hotkeyLabel: '전역 단축키', - hotkeyDescWithAdapter: '현재 어댑터: {{adapter}}. 단축키 감지가 설치되었는지 판정하기 위해 사용.', - hotkeyDescPlain: '단축키 감지가 설치되었는지 판정하기 위해 사용.', - networkLabel: '네트워크', - networkDesc: '클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.', - networkOk: '사용 가능', - checking: '확인 중…', - granted: '허용됨', - notApplicable: '권한 불필요', - denied: '허용되지 않음', - indeterminate: '미결정', - openSystem: '시스템 설정 열기', - grant: '허용', - hotkeyInstalled: '설치됨', - hotkeyStarting: '설치 중…', - hotkeyFailed: '감지 실패', - windowsImeLabel: 'Windows 입력기 백엔드', - windowsImeDesc: '음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.', - windowsImeInstalled: '설치됨', - windowsImeUnavailable: '사용 불가', - windowsIme: { - installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', - notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', - registrationBroken: '등록이 손상되었습니다. OpenLess 입력기를 재설치하세요.', - notWindows: 'Windows 만 사용 가능.', - }, + kicker: "HISTORY", + title: "기록", + desc: "로컬에 저장된 인식 기록.", + filterAll: "전체", + summary: "총 {{total}}건 · 표시 {{shown}}", + empty: "기록이 없습니다. {{trigger}} 를 눌러 한 번 녹음해 보세요.", + loadFailed: "기록 로드 실패: {{err}}", + retry: "다시 시도", + clearFailed: "기록 비우기 실패: {{err}}", + deleteFailed: "항목 삭제 실패: {{err}}", + copyFailed: "복사 실패: {{err}}", + playRecording: "녹음 재생", + audioLoading: "로딩 중…", + exportRecording: "녹음 내보내기", + exportFailed: "내보내기 실패: {{err}}", + rawLabel: "원문", + rawEmpty: "(비어 있음)", + selectHint: "왼쪽에서 하나를 선택하여 자세히 보기.", + insertedTo: "입력 위치", + chars: "{{count}}자", + vocabHits: "핫워드 {{count}}개", + inserted: "입력됨", + pasteSent: "붙여넣기 시도됨", + copiedFallback: "복사됨({{shortcut}} 필요)", + insertFailed: "입력 실패", + confirmClear: + "전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", }, - advanced: { - streamingInsertTitle: '스트리밍 입력', - streamingInsertTitleLinux: '스트리밍 입력 (실험적)', - streamingInsertDesc: - '실시간 글자별 삽입으로 체감 지연 감소. 조건 불충족 시 일괄 붙여넣기로 전환.', - streamingInsertLabel: '스트리밍 입력', - streamingInsertHintMac: - '스트리밍 중 입력 소스를 ABC 로 임시 전환 (CJK IME 가로채기 방지). 세션 종료 시 자동 복원.', - streamingInsertHintWindows: - 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', - streamingInsertHintLinux: - 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', - streamingInsertSaveClipboardLabel: '클립보드에 저장', - streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.', - localAsrTitle: '로컬 ASR 모델 (실험적)', - localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.', - localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.', - qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.', - foundryDesc: '활성화하면 ASR 프로바이더가 인수됩니다.', - notSupportedHere: '이 플랫폼에서는 미지원 (추론 모듈 미내장).', - enable: '활성화', - alreadyActive: '활성', - disableLocalLabel: '로컬 ASR 비활성화', - disableLocalDesc: '클라우드 ASR (기본 Volcengine bigasr) 로 돌아갑니다.', - disable: '비활성화', - platformNotSupported: '이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.', - confirmEnableLocalTitle: '로컬 ASR 을 활성화할까요?', - confirmEnableLocalBody: '활성화 후 전사는 클라우드보다 느리고 정확도가 낮을 수 있습니다.', - confirm: '활성화', + vocab: { + kicker: "VOCABULARY", + title: "어휘", + desc: "새 단어나 전문 용어를 추가하여 인식 정확도 향상.", + sectionTitle: "항목", + placeholder: "단어를 입력하고 Enter 또는 추가 클릭…", + tip: "한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트", + loadFailed: "로드 실패: {{err}}", + empty: "어휘가 없습니다. 위에 새 단어나 전문 용어를 입력하면 받아쓰기 시 우선 매칭됩니다.", + tipDisabled: "클릭하여 비활성화", + tipEnabled: "클릭하여 활성화", + removeAria: "삭제", + corrections: { + title: "교정 규칙", + tip: "ASR 오인식 수정. {num} 숫자 와일드카드 지원.", + patternPlaceholder: "오인식 표현, 예: {num}粒", + replacementPlaceholder: "대상 표현, 예: {num}例", + empty: "아직 교정 규칙이 없습니다.", + invalid: + "문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.", + tipDisabled: "이 규칙 비활성화", + tipEnabled: "이 규칙 활성화", + removeAria: "교정 규칙 삭제", + }, + presets: { + title: "시나리오 프리셋", + tip: "다중 선택 일괄 적용 가능. 편집 및 생성 지원.", + create: "프리셋 새로 만들기", + apply: "선택 활성화", + save: "프리셋 저장", + edit: "{{name}} 편집", + newPreset: "새 프리셋", + namePlaceholder: "프리셋 이름", + wordsPlaceholder: "어휘(쉼표 또는 줄바꿈으로 구분)", + }, }, - language: { - title: '인터페이스 언어', - desc: 'UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.', - label: '언어', - labelDesc: '"시스템 따라가기"를 선택하면 OS 언어를 따릅니다.', - followSystem: '시스템 따라가기', - zh: '简体中文', - zhTW: '繁體中文', - en: 'English', - ja: '日本語 (Beta)', - ko: '한국어 (Beta)', - restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.', + style: { + kicker: "STYLE", + title: "출력 스타일", + desc: "녹음의 기본 출력 스타일 선택.", + masterToggle: "전체 활성화", + currentDefault: "현재 기본", + ariaSetDefault: "기본으로 설정", + saveFailed: "저장 실패: {{error}}", + customPromptTitle: "사용자 프롬프트", + customPromptPlaceholder: + "선택 사항입니다. 이 스타일의 기본 system prompt 끝에 추가됩니다.", + customPromptHint: + "비워 두면 현재 동작이 그대로 유지됩니다. 저장 후 이 스타일의 실시간 다듬기와 repolish 모두에 적용됩니다. Ctrl/Cmd+Enter로도 저장할 수 있습니다.", + customPromptSave: "프롬프트 저장", + customPromptDirty: "미저장", + systemPromptMovedHint: + "전체 system prompt 편집은 Settings -> Providers 로 이동했습니다. 이 페이지는 이제 스타일 활성화와 기본값만 다룹니다.", + modes: { + raw: { + name: "원문", + desc: "구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.", + sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.', + }, + light: { + name: "가벼운 정리", + desc: "입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.", + sample: "원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.", + }, + structured: { + name: "명확한 구조", + desc: "여러 주제나 단계가 있을 때 자동으로 항목별 목록으로 정리합니다.", + sample: "1. 주제 1\na. 포인트\nb. 포인트\n2. 주제 2\na. 포인트\nb. 포인트", + }, + formal: { + name: "정식 표현", + desc: "업무 커뮤니케이션과 메일에 적합. 더 전문적이고 완성도 높은 문체.", + sample: "메일 시나리오에서 인사말과 맺음말을 자동 인식. 공허한 상투어는 추가하지 않습니다.", + }, + }, + pack: { + kicker: "STYLE PACKS", + title: "스타일 팩", + desc: "로컬 스타일 팩 관리.", + marketplaceBtn: "마켓", + loadFailed: "스타일 팩 불러오기 실패: {{err}}", + importZip: "ZIP 가져오기", + exportZip: "ZIP 내보내기", + exportShort: "내보내기", + publishMarketplace: "마켓에 게시", + updateMarketplace: "마켓 새 버전으로 업데이트", + publishDisabledHint: + "먼저 설정 → 마켓에서 GitHub 사용자명을 설정하세요", + publishSuccess: "게시 완료, 마켓 심사 대기 중", + publishFailed: "게시 실패: {{err}}", + publishBuiltinRejected: + "기본 팩은 직접 게시할 수 없습니다. 먼저 편집해서 가져오기 버전을 만드세요.", + builtin: "기본", + imported: "가져옴", + active: "사용 중", + activate: "활성화", + edit: "편집", + closeEditor: "닫기", + unsaved: "저장 안 됨", + listTitle: "로컬 팩", + listDesc: "팩 둘러보기·전환.", + listCount: "{{count}}개", + addPackTileTitle: "새 팩", + addPackTileHint: "빈 템플릿으로 시작.", + createSuccess: "새 팩이 생성되었습니다", + createFailed: "팩 생성 실패: {{err}}", + save: "저장", + revert: "되돌리기", + saveSuccess: "스타일 팩이 저장되었습니다", + saveFailed: "스타일 팩 저장 실패: {{err}}", + activateSuccess: '"{{name}}"을(를) 사용 중으로 설정했습니다', + activateFailed: "사용 중 설정 실패: {{err}}", + importSuccess: '"{{name}}"을(를) 가져왔습니다', + importFailed: "ZIP 가져오기 실패: {{err}}", + exportSuccess: "{{path}}에 내보냈습니다", + exportFailed: "ZIP 내보내기 실패: {{err}}", + exportDirtyFirst: "ZIP을 내보내기 전에 현재 팩을 저장하세요.", + resetBuiltin: "재설정", + resetSuccess: '"{{name}}"을(를) 재설정했습니다', + resetFailed: "팩 재설정 실패: {{err}}", + deleteImported: "삭제", + deleteConfirm: '"{{name}}"을(를) 삭제할까요? 되돌릴 수 없습니다.', + deleteSuccess: '"{{name}}"을(를) 삭제했습니다', + deleteFailed: "팩 삭제 실패: {{err}}", + summaryCurrentEmpty: "아직 팩이 선택되지 않았습니다", + editorTitle: "팩 편집", + editorDesc: "이 팩을 편집합니다.", + metaTitle: "설치 정보", + metaSource: "소스", + metaBaseMode: "베이스 모드", + metaUpdatedAt: "업데이트", + fieldName: "이름", + fieldAuthor: "작성자", + fieldAuthorPlaceholder: "선택. 출처 표시용", + fieldVersion: "버전", + fieldTags: "태그", + fieldTagsPlaceholder: + "쉼표로 구분, 예: community, voiceover, formal", + fieldDescription: "설명", + fieldModel: "권장 모델(메타데이터)", + fieldModelPlaceholder: "선택. 예: gpt-4.1 / deepseek-v3", + fieldModelHint: "메타데이터일 뿐 실제 모델을 전환하지 않습니다.", + fieldCompatibility: "호환 앱 버전", + fieldCompatibilityPlaceholder: "선택. 예: >=1.3.0", + fullPromptTitle: "System Prompt", + fullPromptHint: "이 팩만의 Prompt입니다.", + promptChars: "{{count}}자", + runtimeTitle: "OpenLess 런타임 추가 지시", + runtimeDesc: "읽기 전용 런타임 보조.", + runtimeContextTitle: "컨텍스트 전제", + runtimeContextDesc: "언어·앱 컨텍스트에서", + runtimeContextEmpty: "현재 미리보기에는 추가되지 않습니다.", + runtimeHotwordTitle: "핫워드 블록", + runtimeHotwordDesc: "활성화된 핫워드에서", + runtimeHotwordEmpty: "현재 미리보기에는 추가되지 않습니다.", + runtimeHistoryTitle: "멀티턴 히스토리 가드", + runtimeHistoryDesc: "실시간 멀티턴 polish 전용", + runtimeHistoryEmpty: "이전 턴이 있을 때만 추가됩니다.", + runtimeActive: "활성", + runtimeInactive: "비활성", + runtimePreviewFailed: "런타임 미리보기 생성 실패: {{err}}", + runtimePreviewOmittedFrontApp: + "미리보기에서 프런트앱 라벨이 생략되었습니다.", + examplesTitle: "효과 예시", + examplesDesc: "팩과 함께 내보내집니다.", + addExample: "예시 추가", + examplesEmpty: "아직 예시가 없습니다.", + exampleTitlePlaceholder: "예시 {{index}} 제목", + exampleInput: "입력", + exampleOutput: "출력", + examplesCount: "{{count}}개 예시", + discardCloseConfirm: + "저장하지 않은 변경 사항을 버리고 에디터를 닫을까요?", + discardSwitchConfirm: + '저장하지 않은 변경 사항을 버리고 "{{name}}"(으)로 전환할까요?', + derivativeBadge: "@{{login}}에서 파생", + }, }, - about: { - tagline: '자연스럽게 말하고, 정확하게 작성하세요', - checkUpdate: '업데이트 확인', - checkUpdateBtn: '확인', - checkingUpdate: '확인 중…', - upToDate: '현재 최신 버전입니다.', - updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.', - openReleases: 'Releases 열기', - source: '소스', - docs: '문서', - feedback: '피드백', - qq: '커뮤니티 QQ 그룹', - qqDesc: 'QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.', - copyQq: '그룹 번호 복사', - privacy: '프라이버시', - privacyDesc: '모든 데이터는 로컬에 저장됩니다. 클라우드 API 는 녹음을 보관하지 않습니다.', - localFirst: '로컬 우선', - betaChannelLabel: 'Beta 채널 참여', - betaChannelDesc: '활성화 시 Beta 업데이트 수신. 불안정할 수 있으며 얼리 어답터만 권장.', - betaChannelFetching: '최신 Beta 버전을 가져오는 중…', - betaChannelFetchBtn: '최신 Beta 확인', - betaChannelLatestPrefix: '최신 Beta:', - betaChannelDownloadBtn: '다운로드 페이지 열기', - betaChannelRefresh: '새로 고침', - betaChannelNoBeta: '아직 게시된 Beta 릴리스가 없습니다.', - betaChannelFetchError: 'Beta 릴리스 정보를 가져오지 못했습니다. 잠시 후 다시 시도하세요.', - betaChannelUpToDate: '최신', - betaChannelUpdateNow: '지금 업데이트', - betaChannelUpdateNowTitle: '최신 Beta를 확인·다운로드하고 업데이트 대화상자를 표시합니다', - betaChannelChecking: '확인 중…', - updateDialog: { - available: { - title: '새 버전 발견', - desc: 'OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?', - }, - downloading: { - title: '업데이트 다운로드 중', - desc: 'OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.', - }, - downloaded: { - title: '업데이트 준비 완료', - desc: 'OpenLess {{version}} 설치가 완료되었습니다. 지금 자동 재시작하여 적용하시겠습니까?', - }, - installing: { - title: '업데이트 설치 중', - desc: 'OpenLess {{version}} 을(를) 설치 중입니다. 앱을 열어 두세요.', - }, - install: '지금 업데이트', - downloadingLabel: '다운로드 중…', - installingLabel: '설치 중…', - later: '나중에 수동 재시작', - restartNow: '지금 재시작', - progress: '{{progress}}% · {{downloaded}} / {{total}}', - progressUnknown: '다운로드됨 {{downloaded}}', - }, + translation: { + kicker: "TRANSLATION", + title: "번역", + desc: "녹음 후 대상 언어로 자동 번역하여 삽입.", + statusEnabled: "활성화됨", + statusDisabled: "비활성화됨", + working: { + title: "작업 언어", + desc: "일상적으로 사용하는 언어를 선택하여 정리와 번역에 반영.", + }, + target: { + title: "번역 대상 언어", + desc: '녹음 중 Shift 로 번역 실행. "비활성화" 시 Shift 무효.', + disabled: "비활성화 (Shift 로 번역 발동 안 함)", + }, + save: { + workingFailed: "작업 언어 저장에 실패했습니다. 다시 시도하세요.", + targetFailed: + "번역 대상 언어 저장에 실패했습니다. 다시 시도하세요.", + hotkeyRegisterFailed: + "번역 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.", + hotkeySaveFailed: + "번역 단축키 저장에 실패했습니다. 다시 시도하세요.", + }, + howto: { + title: "사용 방법", + step1: "아무 입력 필드에 커서를 놓으세요.", + step2: "{{trigger}} 를 눌러 녹음 시작.", + step3: "녹음 중 Shift 를 한 번 눌러 번역 활성화.", + step4: "다시 {{trigger}} 를 눌러 정지.", + step5: "번역 결과가 커서 위치에 삽입됩니다.", + indicatorTitle: "번역 모드 활성화 확인 방법", + indicatorDesc: + 'Shift 를 누르면 화면 하단에 파란색 "번역 중" 표시가 나타납니다.', + fallbackTitle: "안전 폴백", + fallbackDesc: "번역 실패 시 원본 전사가 삽입됩니다.", + }, }, - }, - modal: { - sections: { - account: '계정', - settings: '설정', - personalize: '개인 설정', - about: '정보', - helpCenter: '도움말 센터', - releaseNotes: '릴리스 노트', + selectionAsk: { + kicker: "SELECTION ASK", + title: "선택 질문", + desc: "텍스트 선택 후 음성으로 질문. 다중 라운드 후속 질문 지원.", + statusEnabled: "활성화됨", + statusDisabled: "비활성화됨", + hotkey: { + title: "플로팅 창 단축키", + desc: "패널 열기/닫기 제어. 패널 내 녹음은 {{recordHotkey}} 사용.", + optionDisabled: "비활성화", + chordWarning: "", + }, + save: { + hotkeyRegisterFailed: + "선택 질문 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.", + hotkeySaveFailed: + "선택 질문 단축키 저장에 실패했습니다. 다시 시도하세요.", + historySaveFailed: + "Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.", + }, + history: { + title: "기록 저장", + desc: "활성화 시 Q&A 기록을 로컬에 저장. 기본 OFF.", + }, + howto: { + title: "사용 방법", + step1: "{{hotkey}} 로 패널 열기.", + step2: "아무 앱에서 텍스트 선택.", + step3: "{{recordHotkey}} 로 녹음, 다시 눌러 제출.", + step4: "{{recordHotkey}} 로 계속 후속 질문 가능.", + step5: "Esc 로 패널 닫기 및 기록 삭제.", + windowTitle: "창 위치 + 드래그 + 고정", + windowDesc: + "패널은 드래그 가능하며 위치를 기억합니다. 고정하면 열린 상태 유지.", + privacyTitle: "프라이버시 계약", + privacyDesc: + "선택 텍스트는 메모리에만 존재하며 패널 닫으면 삭제. 4000 자 초과 시 자동 축소.", + }, }, - account: { - localUser: '로컬 사용자', - localUserDesc: '로그인하지 않음 · 모든 데이터는 로컬에 저장', - loginSync: '로그인 / 동기화', - footer: '기본적으로 완전 로컬 실행. 로그인하면 어휘와 스타일 프리셋을 기기 간 동기화.', + settings: { + kicker: "SETTINGS", + title: "설정", + desc: "녹음, 공급자, 단축키, 권한 설정.", + sections: { + recording: "녹음", + providers: "공급자", + shortcuts: "단축키", + permissions: "권한", + language: "언어", + advanced: "고급", + about: "정보", + }, + recording: { + title: "녹음", + desc: "전역 녹음의 단축키와 트리거 방식을 정의합니다.", + hotkeyLabel: "녹음 단축키", + hotkeyDescAcc: + "누르면 음성 캡처 시작(전역). 접근성 권한이 필요합니다.", + hotkeyDescNoAcc: "누르면 음성 캡처 시작(전역). 추가 권한 불필요.", + modeLabel: "녹음 방식", + modeDesc: + "토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.", + modeToggle: "토글 방식", + modeHold: "눌러서 말하기", + migrationNoticeTitle: "기본값이 토글 방식으로 변경됨", + migrationNoticeDesc: + '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', + microphoneLabel: "기본 선택 마이크", + microphoneDesc: + "우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.", + microphoneDefault: "시스템 기본 마이크", + microphoneDefaultDesc: "시스템 기본 입력 장치 사용", + microphoneSystemDefault: "시스템 기본값", + microphoneUnavailable: "사용할 수 없음", + microphoneLoadError: "마이크 로드 실패: {{message}}", + microphoneDialogTitle: "마이크", + microphoneDialogDesc: + "목소리를 받을 수 있는 마이크를 선택하세요. 미터가 움직이지 않으면 다른 마이크를 시도하세요.", + microphoneMonitorError: "입력 레벨 모니터링 실패: {{message}}", + capsuleLabel: "녹음 캡슐", + capsuleDesc: "녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.", + muteDuringRecordingLabel: "녹음 중 음소거", + muteDuringRecordingDesc: + "녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.", + insertGroupTitle: "삽입 및 클립보드", + restoreClipboardLabel: "입력 후 클립보드 복원", + restoreClipboardDesc: + "붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).", + pasteShortcutLabel: "붙여넣기 단축키", + pasteShortcutDesc: + "삽입 시 시뮬레이션할 붙여넣기 단축키. 일부 터미널은 Ctrl+Shift+V 가 필요 (Windows / Linux 만).", + pasteShortcutCtrlV: "Ctrl+V (기본 / 대부분 앱)", + pasteShortcutCtrlShiftV: + "Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)", + pasteShortcutShiftInsert: "Shift+Insert (xterm / urxvt)", + comboRecordLabel: "단축키 녹화", + comboRecordDesc: + "클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.", + comboRecordBtn: "단축키 녹화", + comboRecordHint: "단축키 조합을 눌러 주세요…", + comboRecorded: "녹화됨", + comboClear: "지우기", + comboConflict: "이 단축키 조합은 사용할 수 없습니다", + allowNonTsfFallbackLabel: "비 TSF 폴백 허용", + allowNonTsfFallbackDesc: + "Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.", + historyGroupTitle: "기록 및 컨텍스트", + historyRetentionLabel: "기록 보관 기간(일)", + historyRetentionDesc: + "보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.", + historyMaxEntriesLabel: "기록 개수 상한", + historyMaxEntriesDesc: + "로컬 보관 세션 상한. 빈칸 = 200. 범위 5–200.", + polishContextWindowLabel: "대화 컨텍스트 윈도(분)", + polishContextWindowDesc: + "최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.", + recordAudioForDebugLabel: "원본 녹음 보관(디버그)", + recordAudioForDebugDesc: + "원시 마이크 오디오를 wav 로 저장하여 인식 문제 진단.", + audioRecordingMaxEntriesLabel: "원본 녹음 보관 개수", + audioRecordingMaxEntriesDesc: + "로컬 보관 wav 파일 상한. 빈칸 = 200.", + startupGroupTitle: "시작", + startMinimizedLabel: "시작 시 메인 창 숨기기", + startMinimizedDesc: + "모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.", + autoUpdateCheckLabel: "자동 업데이트 확인", + autoUpdateCheckDesc: "시작 시 및 60 분마다 자동 확인.", + marketplaceGroupTitle: "스타일 팩 마켓플레이스", + marketplaceBaseUrlLabel: "백엔드 URL", + marketplaceBaseUrlDesc: + "마켓플레이스 백엔드 URL. 빈칸은 기본값 사용.", + marketplaceDevLoginLabel: "GitHub 로그인 이름 (업로드 ID)", + marketplaceDevLoginDesc: + "업로더를 식별합니다. 빈칸 시 업로드 및 좋아요 비활성화.", + startupAtBoot: "부팅 시 자동 시작", + startupAtBootDesc: "로그인 시 OpenLess 자동 시작.", + startupAtBootError: "자동 시작 전환 실패: {{message}}", + }, + providers: { + llmTitle: "LLM 모델(정리)", + llmDesc: "OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.", + providerLabel: "공급자", + llmProviderDesc: "선택 시 Base URL 기본값이 자동 입력됩니다.", + credentialStorageNotice: + "자격 증명은 OS 자격 증명 저장소에 보관됩니다.", + codexOAuthNotice: + "Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.", + asrProviderDesc: "전환 시 해당하는 자격 증명이 자동 선택됩니다.", + asrTitle: "ASR 음성(전사)", + asrDesc: "구술을 실시간으로 텍스트로 전사합니다.", + presets: { + ark: "ARK (Volcengine Ark)", + deepseek: "DeepSeek", + siliconflow: "SiliconFlow", + openai: "OpenAI", + gemini: "Google Gemini", + codexOAuth: "Codex OAuth", + mimo: "Xiaomi MiMo", + cometapi: "CometAPI", + openrouterFree: "OpenRouter(무료 모델)", + alibabaCoding: "Alibaba Cloud Coding Plan", + codingPlanX: "CodingPlanX", + custom: "사용자 정의", + asrVolcengine: "Volcengine bigasr", + asrBailian: "Alibaba Bailian 실시간 ASR", + asrSiliconflow: "SiliconFlow SenseVoice", + asrZhipu: "Zhipu GLM-ASR", + asrGroq: "Groq Whisper-large-v3", + asrWhisper: "OpenAI Whisper(호환)", + asrFoundryLocalWhisper: "로컬 Whisper(Foundry Local)", + asrSherpaOnnxLocal: "로컬 sherpa-onnx(실험적)", + asrLocalQwen3: "로컬 Qwen3-ASR", + }, + volcengineAppKeyLabel: "APP ID", + volcengineAccessKeyLabel: "Access Token", + volcengineResourceIdLabel: "Resource ID", + volcengineMappingNote: + "Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.bigasr.sauc.duration.", + localAsrActiveNotice: + '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', + localAsrTakeoverHint: + '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', + asrProviderTakenOver: "ASR 프로바이더 인수 완료", + localAsrHint: + "기기에서 실행, API 키 불필요. HuggingFace 에서 모델 다운로드.", + foundryLocalAsrHint: + "기기에서 실행, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드.", + localAsrPerformanceWarning: + "로컬 추론은 클라우드보다 느리며 중국어 정확도가 낮을 수 있습니다. 오프라인 또는 개인정보 보호 시나리오에 적합.", + localAsrReady: "{{model}} 다운로드됨", + localAsrNotReady: "{{model}} 다운로드되지 않음", + localAsrGoDownload: "모델 설정에서 다운로드", + localAsrManage: "모델 설정으로 이동", + localAsrDownloadedTitle: "다운로드된 모델", + localAsrDelete: "삭제", + fillDefault: "기본값 입력", + readFailed: "읽기 실패", + apiKeyLabel: "API 키", + baseUrlLabel: "엔드포인트", + modelLabel: "모델", + thinkingModeLabel: "사고", + thinkingModeOn: "켜짐", + thinkingModeOff: "꺼짐", + thinkingModeHint: + "꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.", + bailianVocabularyIdLabel: "핫워드 Vocabulary ID(선택)", + bailianVocabularyIdNote: + "DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.", + appIdLabel: "App ID(애플리케이션 ID)", + accessKeyLabel: "Access Key", + resourceIdLabel: "Resource ID", + toolsLabel: "연결 확인", + toolsDesc: + "위 설정을 먼저 저장한 후 현재 모델 연결성을 검증하거나 모델을 가져오세요. 실패해도 모델 ID 를 수동 입력할 수 있습니다.", + validate: "검증", + validating: "검증 중…", + fetchModels: "모델 가져오기", + loadingModels: "모델 가져오는 중…", + modelMissing: + "모델이 설정되지 않았습니다. 먼저 모델 ID 를 입력해 주세요.", + modelsEmpty: + "인증 성공이지만 사용 가능한 모델이 반환되지 않았습니다.", + modelsLoaded: "{{count}}개의 모델을 가져왔습니다.", + selectModel: "모델을 선택해 위 필드에 입력", + modelSaved: "모델 {{model}} 을(를) 저장했습니다.", + validateSuccess: "연결 확인을 통과했습니다.", + providerHttpStatus: + "공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.", + endpointMustUseHttps: + "Endpoint 는 HTTPS 를 사용해야 합니다(localhost/127.0.0.1 제외).", + endpointInvalid: "Endpoint 형식이 올바르지 않습니다.", + responseTooLarge: + "공급자 응답이 너무 커서 안전을 위해 검증을 중단했습니다.", + asrInvalidJson: "ASR 응답이 유효한 JSON 이 아닙니다.", + asrMissingTextField: "ASR 응답에 text 필드가 없습니다.", + apiKeyMissing: "API Key 가 비어 있습니다.", + endpointMissing: "Endpoint 가 비어 있습니다.", + requestTimeout: + "요청 시간이 초과되었습니다. 잠시 후 다시 시도하세요.", + }, + shortcuts: { + title: "단축키 한눈에 보기", + descAcc: + "모든 단축키는 전역에서 작동. 권한 설정에서 접근성을 활성화해야 합니다.", + descNoAcc: + "모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.", + startStop: "녹음 시작 / 정지", + cancel: "이번 녹음 취소", + confirm: "캡슐 입력 확정", + switchStyle: "이전 스타일로 전환", + openApp: "OpenLess 열기", + confirmHint: "오른쪽 ✓ 클릭", + notSupported: "지원되지 않음", + }, + permissions: { + title: "권한", + descAcc: + "OpenLess 가 정상 작동하려면 다음 시스템 권한이 필요합니다. 허용 후에는 일반적으로 앱을 완전히 종료한 후 재시작해야 적용됩니다.", + descNoAcc: + "OpenLess 는 마이크 사용과 전역 단축키 감지 상태를 통해 네이티브 후크의 정상 동작을 판정해야 합니다.", + micLabel: "마이크", + micDesc: "음성 입력을 캡처하기 위해 사용합니다.", + accLabel: "접근성", + accDesc: + "전역 단축키 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.", + hotkeyLabel: "전역 단축키", + hotkeyDescWithAdapter: + "현재 어댑터: {{adapter}}. 단축키 감지가 설치되었는지 판정하기 위해 사용.", + hotkeyDescPlain: "단축키 감지가 설치되었는지 판정하기 위해 사용.", + networkLabel: "네트워크", + networkDesc: + "클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.", + networkOk: "사용 가능", + checking: "확인 중…", + granted: "허용됨", + notApplicable: "권한 불필요", + denied: "허용되지 않음", + indeterminate: "미결정", + openSystem: "시스템 설정 열기", + grant: "허용", + hotkeyInstalled: "설치됨", + hotkeyStarting: "설치 중…", + hotkeyFailed: "감지 실패", + windowsImeLabel: "Windows 입력기 백엔드", + windowsImeDesc: + "음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.", + windowsImeInstalled: "설치됨", + windowsImeUnavailable: "사용 불가", + windowsIme: { + installed: + "설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.", + notInstalled: + "설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.", + registrationBroken: + "등록이 손상되었습니다. OpenLess 입력기를 재설치하세요.", + notWindows: "Windows 만 사용 가능.", + }, + }, + advanced: { + streamingInsertTitle: "스트리밍 입력", + streamingInsertTitleLinux: "스트리밍 입력 (실험적)", + streamingInsertDesc: + "실시간 글자별 삽입으로 체감 지연 감소. 조건 불충족 시 일괄 붙여넣기로 전환.", + streamingInsertLabel: "스트리밍 입력", + streamingInsertHintMac: + "스트리밍 중 입력 소스를 ABC 로 임시 전환 (CJK IME 가로채기 방지). 세션 종료 시 자동 복원.", + streamingInsertHintWindows: + "SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.", + streamingInsertHintLinux: + "fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.", + streamingInsertSaveClipboardLabel: "클립보드에 저장", + streamingInsertSaveClipboardHint: + "삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.", + localAsrTitle: "로컬 ASR 모델 (실험적)", + localAsrDesc: + "전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.", + localAsrWarningShort: + "로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.", + qwen3Desc: "활성화하면 ASR 프로바이더가 인수됩니다.", + foundryDesc: "활성화하면 ASR 프로바이더가 인수됩니다.", + sherpaDesc: "활성화하면 ASR 프로바이더가 인수됩니다.", + notSupportedHere: "이 플랫폼에서는 미지원 (추론 모듈 미내장).", + enable: "활성화", + alreadyActive: "활성", + disableLocalLabel: "로컬 ASR 비활성화", + disableLocalDesc: + "클라우드 ASR (기본 Volcengine bigasr) 로 돌아갑니다.", + disable: "비활성화", + platformNotSupported: + "이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.", + confirmEnableLocalTitle: "로컬 ASR 을 활성화할까요?", + confirmEnableLocalBody: + "활성화 후 전사는 클라우드보다 느리고 정확도가 낮을 수 있습니다.", + confirm: "활성화", + }, + language: { + title: "인터페이스 언어", + desc: "UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.", + label: "언어", + labelDesc: '"시스템 따라가기"를 선택하면 OS 언어를 따릅니다.', + followSystem: "시스템 따라가기", + zh: "简体中文", + zhTW: "繁體中文", + en: "English", + ja: "日本語 (Beta)", + ko: "한국어 (Beta)", + restartHint: + "일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.", + }, + about: { + tagline: "자연스럽게 말하고, 정확하게 작성하세요", + checkUpdate: "업데이트 확인", + checkUpdateBtn: "확인", + checkingUpdate: "확인 중…", + upToDate: "현재 최신 버전입니다.", + updateError: + "확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.", + openReleases: "Releases 열기", + source: "소스", + docs: "문서", + feedback: "피드백", + qq: "커뮤니티 QQ 그룹", + qqDesc: "QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.", + copyQq: "그룹 번호 복사", + privacy: "프라이버시", + privacyDesc: + "모든 데이터는 로컬에 저장됩니다. 클라우드 API 는 녹음을 보관하지 않습니다.", + localFirst: "로컬 우선", + betaChannelLabel: "Beta 채널 참여", + betaChannelDesc: + "활성화 시 Beta 업데이트 수신. 불안정할 수 있으며 얼리 어답터만 권장.", + betaChannelFetching: "최신 Beta 버전을 가져오는 중…", + betaChannelFetchBtn: "최신 Beta 확인", + betaChannelLatestPrefix: "최신 Beta:", + betaChannelDownloadBtn: "다운로드 페이지 열기", + betaChannelRefresh: "새로 고침", + betaChannelNoBeta: "아직 게시된 Beta 릴리스가 없습니다.", + betaChannelFetchError: + "Beta 릴리스 정보를 가져오지 못했습니다. 잠시 후 다시 시도하세요.", + betaChannelUpToDate: "최신", + betaChannelUpdateNow: "지금 업데이트", + betaChannelUpdateNowTitle: + "최신 Beta를 확인·다운로드하고 업데이트 대화상자를 표시합니다", + betaChannelChecking: "확인 중…", + updateDialog: { + available: { + title: "새 버전 발견", + desc: "OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?", + }, + downloading: { + title: "업데이트 다운로드 중", + desc: "OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.", + }, + downloaded: { + title: "업데이트 준비 완료", + desc: "OpenLess {{version}} 설치가 완료되었습니다. 지금 자동 재시작하여 적용하시겠습니까?", + }, + installing: { + title: "업데이트 설치 중", + desc: "OpenLess {{version}} 을(를) 설치 중입니다. 앱을 열어 두세요.", + }, + install: "지금 업데이트", + downloadingLabel: "다운로드 중…", + installingLabel: "설치 중…", + later: "나중에 수동 재시작", + restartNow: "지금 재시작", + progress: "{{progress}}% · {{downloaded}} / {{total}}", + progressUnknown: "다운로드됨 {{downloaded}}", + }, + }, }, - personalize: { - appearance: '모양', - appearanceDesc: '시스템 따라가기 / 라이트 / 다크', - appearanceSystem: '시스템 따라가기', - appearanceLight: '라이트', - appearanceDark: '다크', - font: '글꼴 크기', - fontDesc: 'UI 글꼴 크기를 전체 스케일. 즉시 반영.', - fontSmall: '소', - fontMedium: '중', - fontLarge: '대', - blur: '서리유리 강도', - blurDesc: '창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).', - startupOpen: '시작 시 열기', - startupOverview: '개요', - startupLast: '마지막 위치', - startupAtBoot: '부팅 시 자동 시작', + modal: { + sections: { + account: "계정", + settings: "설정", + personalize: "개인 설정", + about: "정보", + helpCenter: "도움말 센터", + releaseNotes: "릴리스 노트", + }, + account: { + localUser: "로컬 사용자", + localUserDesc: "로그인하지 않음 · 모든 데이터는 로컬에 저장", + loginSync: "로그인 / 동기화", + footer: "기본적으로 완전 로컬 실행. 로그인하면 어휘와 스타일 프리셋을 기기 간 동기화.", + }, + personalize: { + appearance: "모양", + appearanceDesc: "시스템 따라가기 / 라이트 / 다크", + appearanceSystem: "시스템 따라가기", + appearanceLight: "라이트", + appearanceDark: "다크", + font: "글꼴 크기", + fontDesc: "UI 글꼴 크기를 전체 스케일. 즉시 반영.", + fontSmall: "소", + fontMedium: "중", + fontLarge: "대", + blur: "서리유리 강도", + blurDesc: + "창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).", + startupOpen: "시작 시 열기", + startupOverview: "개요", + startupLast: "마지막 위치", + startupAtBoot: "부팅 시 자동 시작", + }, + about: { + tagline: "자연스럽게 말하고, 정확하게 작성하세요", + checkUpdate: "업데이트 확인", + checkUpdateBtn: "확인", + docs: "문서", + docsBtn: "openless.app/docs ↗", + feedback: "피드백 채널", + feedbackBtn: "GitHub Issues ↗", + source: "소스", + qq: "커뮤니티 QQ 그룹", + qqDesc: "QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.", + copyQq: "그룹 번호 복사", + exportErrorLog: "오류 로그 내보내기", + exportErrorLogDesc: + "현재 세션의 실행 로그를 로컬에 저장합니다. 문제 조사나 피드백 전송에 사용하세요.", + exportErrorLogBtn: "내보내기", + exporting: "내보내는 중…", + exportSuccess: "저장됨", + exportFailed: "내보내기 실패", + privacy: "프라이버시", + privacyDesc: + "모든 인식 결과는 로컬에만 저장됩니다. 클라우드 API 는 실시간 호출에만 사용됩니다.", + localFirst: "로컬 우선", + }, }, - about: { - tagline: '자연스럽게 말하고, 정확하게 작성하세요', - checkUpdate: '업데이트 확인', - checkUpdateBtn: '확인', - docs: '문서', - docsBtn: 'openless.app/docs ↗', - feedback: '피드백 채널', - feedbackBtn: 'GitHub Issues ↗', - source: '소스', - qq: '커뮤니티 QQ 그룹', - qqDesc: 'QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.', - copyQq: '그룹 번호 복사', - exportErrorLog: '오류 로그 내보내기', - exportErrorLogDesc: '현재 세션의 실행 로그를 로컬에 저장합니다. 문제 조사나 피드백 전송에 사용하세요.', - exportErrorLogBtn: '내보내기', - exporting: '내보내는 중…', - exportSuccess: '저장됨', - exportFailed: '내보내기 실패', - privacy: '프라이버시', - privacyDesc: '모든 인식 결과는 로컬에만 저장됩니다. 클라우드 API 는 실시간 호출에만 사용됩니다.', - localFirst: '로컬 우선', + windowChrome: { + minimize: "최소화", + maximize: "최대화", + close: "닫기", }, - }, - windowChrome: { - minimize: '최소화', - maximize: '최대화', - close: '닫기', - }, - hotkey: { - triggers: { - rightOption: '오른쪽 Option', - leftOption: '왼쪽 Option', - rightControl: '오른쪽 Control', - leftControl: '왼쪽 Control', - rightCommand: '오른쪽 Command', - fn: 'Fn (지구본 키)', - rightAlt: '오른쪽 Alt', - custom: '사용자 지정 조합…', + hotkey: { + triggers: { + rightOption: "오른쪽 Option", + leftOption: "왼쪽 Option", + rightControl: "오른쪽 Control", + leftControl: "왼쪽 Control", + rightCommand: "오른쪽 Command", + fn: "Fn (지구본 키)", + rightAlt: "오른쪽 Alt", + custom: "사용자 지정 조합…", + }, + fallback: "전역 단축키", + modeHoldSuffix: "(눌러서 말하기)", + modeToggleSuffix: "(시작 / 정지)", + usageHold: "{{trigger}} 를 누르고 말한 후 떼면 종료.", + usageToggle: "{{trigger}} 로 녹음 시작, 다시 누르면 종료.", + adapter: { + macEventTap: "macOS Event Tap", + windowsLowLevel: "Windows 저수준 키보드 후크", + fcitx5: "fcitx5 입력기 플러그인", + }, }, - fallback: '전역 단축키', - modeHoldSuffix: '(눌러서 말하기)', - modeToggleSuffix: '(시작 / 정지)', - usageHold: '{{trigger}} 를 누르고 말한 후 떼면 종료.', - usageToggle: '{{trigger}} 로 녹음 시작, 다시 누르면 종료.', - adapter: { - macEventTap: 'macOS Event Tap', - windowsLowLevel: 'Windows 저수준 키보드 후크', - fcitx5: 'fcitx5 입력기 플러그인', + localAsr: { + kicker: "로컬 ASR", + title: "모델 설정", + desc: "기기 내 음성 인식 모델 관리.", + qwenTitle: "Qwen3-ASR 모델 관리", + qwenExperimentalBadge: "실험적", + engineUnavailable: + "현재 플랫폼에는 Qwen3-ASR 추론 엔진이 포함되어 있지 않습니다. 모델은 다운로드할 수 있지만 여기서는 아직 Qwen3-ASR 을 활성화할 수 없습니다.", + qwenUnavailableOnWindows: + "Windows 에서는 아직 Qwen3-ASR 을 지원하지 않습니다. 위의 Foundry Local Whisper 를 사용해 주세요.", + foundryTitle: "Windows Foundry Local Whisper", + foundryDesc: + "기기 내 음성 인식, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드 필요.", + foundryAvailable: "Windows 에서 사용 가능", + foundryUnavailable: "Windows 전용", + foundryRuntimeReady: "런타임 구성 요소 다운로드됨", + foundryRuntimeMissing: "런타임 구성 요소 미다운로드", + foundryRuntimeSourceLabel: "런타임 구성 요소 다운로드 소스", + foundryRuntimeSourceAuto: "자동(NuGet 우선)", + foundryRuntimeSourceNuget: "NuGet 공식 피드", + foundryRuntimeSourceOrtNightly: "Microsoft ORT-Nightly 피드", + foundryRuntimeSourceDesc: "첫 사용 전 런타임 구성 요소 다운로드 필요.", + foundrySelectedModel: "선택한 모델", + foundryActiveModel: "현재 기본 alias", + foundryLoadedModel: "로드된 모델", + foundryNotLoaded: "로드되지 않음", + foundryError: "Foundry 상태", + foundrySetDefault: "기본값으로 설정 / Windows 로컬 ASR 활성화", + foundryEnabling: "활성화 중…", + foundryPrepare: "준비 / 다운로드 / 로드", + foundryPreparing: "준비 중…", + foundryReleasing: "해제 중…", + foundryRetryPrepare: "준비 계속 / 다시 시도", + foundryCancelPrepare: "준비 취소", + foundryCancelRequested: "취소 요청됨", + foundryCancelling: "취소 중…", + foundryCancelBestEffort: + "취소 요청됨. 현재 단계 완료 후 중지. 나중에 재시도 가능.", + foundryPrepareRuntime: "런타임 구성 요소 준비", + foundryPrepareModel: "모델 다운로드", + foundryPrepareLoad: "모델 로드", + foundryPrepareModelSkipped: + "모델이 이미 다운로드되어 다운로드 단계를 건너뜀", + foundryPrepareDone: "완료", + foundryPrepareWaiting: "대기 중", + foundryApproxSizeMb: "약 {{mb}} MB", + foundryLanguageLabel: "인식 언어", + foundryLanguageAuto: "자동", + foundryLanguageZh: "중국어 zh", + foundryLanguageEn: "영어 en", + foundryLanguageDesc: '중국어는 "중문", 혼합 사용은 "자동" 선택.', + foundryModelSmall: "Whisper Small(기본 / 균형)", + foundryModelSmallDesc: + "품질과 리소스 사용량을 균형 있게 맞춘 기본 옵션.", + foundryModelMedium: "Whisper Medium(더 높은 품질)", + foundryModelMediumDesc: + "더 높은 정확도. 더 큰 다운로드와 느린 추론을 감당할 수 있는 고성능 기기에 적합합니다.", + foundryModelLarge: "Whisper Large V3 Turbo(최고 품질)", + foundryModelLargeDesc: + "고성능 기기와 품질 우선 사용에 맞는 대형 모델 옵션.", + foundryModelBase: "Whisper Base(더 빠름 / 낮은 리소스)", + 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)", + mirrorHfMirror: "중국 미러 (hf-mirror.com)", + mirrorGithubRelease: "GitHub Release 아카이브", + activeBadge: "사용 중", + downloadedBadge: "다운로드됨", + notDownloadedBadge: "다운로드되지 않음", + download: "다운로드", + resume: "계속 다운로드", + cancel: "취소", + delete: "삭제", + setActive: "기본으로 설정", + failed: "실패", + cancelled: "취소됨", + files: "파일", + sizeLoading: "크기 조회 중…", + sizeUnknown: "크기 알 수 없음", + performanceWarning: + "로컬 ASR 은 오프라인 또는 개인정보 보호 시나리오에 적합. 첫 사용 시 모델 다운로드 필요.", + test: "로드하여 테스트", + testRunning: "테스트 중…", + testHeading: "내장 오디오 테스트", + testExpected: "원문", + testActual: "인식", + testStats: + "오디오 길이 {{audio}}s · 로드 {{load}}s · 추론 {{transcribe}}s · 백엔드 {{backend}}", + testFailed: "테스트 실패", + engineStatusLabel: "메모리에 있는 엔진", + engineLoaded: "로드됨: {{model}}(약 1.2-3.4 GB 메모리 사용)", + engineUnloaded: "로드되지 않음(첫 받아쓰기 시 약 3-5 초 로드 필요)", + loadNow: "지금 로드", + releaseNow: "지금 해제", + keepLoadedLabel: "로드 유지 시간", + keepLoadedDesc: + "로컬 ASR 사용 후 메모리에서 해제되기까지의 시간을 결정. 1+ GB RAM 장기 점유 회피.", + keepImmediate: "말하기 직후 해제", + keep1min: "마지막 사용 후 1분", + keep5min: "마지막 사용 후 5분(기본)", + keep30min: "마지막 사용 후 30분", + keepForever: "해제하지 않음(항상 유지)", }, - }, - localAsr: { - kicker: '로컬 ASR', - title: '모델 설정', - desc: '기기 내 음성 인식 모델 관리.', - qwenTitle: 'Qwen3-ASR 모델 관리', - qwenExperimentalBadge: '실험적', - engineUnavailable: '현재 플랫폼에는 Qwen3-ASR 추론 엔진이 포함되어 있지 않습니다. 모델은 다운로드할 수 있지만 여기서는 아직 Qwen3-ASR 을 활성화할 수 없습니다.', - qwenUnavailableOnWindows: 'Windows 에서는 아직 Qwen3-ASR 을 지원하지 않습니다. 위의 Foundry Local Whisper 를 사용해 주세요.', - foundryTitle: 'Windows Foundry Local Whisper', - foundryDesc: '기기 내 음성 인식, ASR API 키 불필요. 첫 사용 시 런타임과 모델 다운로드 필요.', - foundryAvailable: 'Windows 에서 사용 가능', - foundryUnavailable: 'Windows 전용', - foundryRuntimeReady: '런타임 구성 요소 다운로드됨', - foundryRuntimeMissing: '런타임 구성 요소 미다운로드', - foundryRuntimeSourceLabel: '런타임 구성 요소 다운로드 소스', - foundryRuntimeSourceAuto: '자동(NuGet 우선)', - foundryRuntimeSourceNuget: 'NuGet 공식 피드', - foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly 피드', - foundryRuntimeSourceDesc: '첫 사용 전 런타임 구성 요소 다운로드 필요.', - foundrySelectedModel: '선택한 모델', - foundryActiveModel: '현재 기본 alias', - foundryLoadedModel: '로드된 모델', - foundryNotLoaded: '로드되지 않음', - foundryError: 'Foundry 상태', - foundrySetDefault: '기본값으로 설정 / Windows 로컬 ASR 활성화', - foundryEnabling: '활성화 중…', - foundryPrepare: '준비 / 다운로드 / 로드', - foundryPreparing: '준비 중…', - foundryReleasing: '해제 중…', - foundryRetryPrepare: '준비 계속 / 다시 시도', - foundryCancelPrepare: '준비 취소', - foundryCancelRequested: '취소 요청됨', - foundryCancelling: '취소 중…', - foundryCancelBestEffort: '취소 요청됨. 현재 단계 완료 후 중지. 나중에 재시도 가능.', - foundryPrepareRuntime: '런타임 구성 요소 준비', - foundryPrepareModel: '모델 다운로드', - foundryPrepareLoad: '모델 로드', - foundryPrepareModelSkipped: '모델이 이미 다운로드되어 다운로드 단계를 건너뜀', - foundryPrepareDone: '완료', - foundryPrepareWaiting: '대기 중', - foundryApproxSizeMb: '약 {{mb}} MB', - foundryLanguageLabel: '인식 언어', - foundryLanguageAuto: '자동', - foundryLanguageZh: '중국어 zh', - foundryLanguageEn: '영어 en', - foundryLanguageDesc: '중국어는 "중문", 혼합 사용은 "자동" 선택.', - foundryModelSmall: 'Whisper Small(기본 / 균형)', - foundryModelSmallDesc: '품질과 리소스 사용량을 균형 있게 맞춘 기본 옵션.', - foundryModelMedium: 'Whisper Medium(더 높은 품질)', - foundryModelMediumDesc: '더 높은 정확도. 더 큰 다운로드와 느린 추론을 감당할 수 있는 고성능 기기에 적합합니다.', - foundryModelLarge: 'Whisper Large V3 Turbo(최고 품질)', - foundryModelLargeDesc: '고성능 기기와 품질 우선 사용에 맞는 대형 모델 옵션.', - foundryModelBase: 'Whisper Base(더 빠름 / 낮은 리소스)', - foundryModelBaseDesc: '더 빠르고 리소스를 적게 사용해 가벼운 일상 받아쓰기에 적합합니다.', - foundryModelTiny: 'Whisper Tiny(가장 빠름 / 스모크 테스트)', - foundryModelTinyDesc: 'Foundry 경로가 작동하는지 확인하기 위한 가장 빠른 옵션.', - mirrorLabel: '다운로드 미러', - mirrorDesc: '공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.', - mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', - mirrorHfMirror: '중국 미러 (hf-mirror.com)', - activeBadge: '사용 중', - downloadedBadge: '다운로드됨', - notDownloadedBadge: '다운로드되지 않음', - download: '다운로드', - resume: '계속 다운로드', - cancel: '취소', - delete: '삭제', - setActive: '기본으로 설정', - failed: '실패', - cancelled: '취소됨', - files: '파일', - sizeLoading: '크기 조회 중…', - sizeUnknown: '크기 알 수 없음', - performanceWarning: '로컬 ASR 은 오프라인 또는 개인정보 보호 시나리오에 적합. 첫 사용 시 모델 다운로드 필요.', - test: '로드하여 테스트', - testRunning: '테스트 중…', - testHeading: '내장 오디오 테스트', - testExpected: '원문', - testActual: '인식', - testStats: '오디오 길이 {{audio}}s · 로드 {{load}}s · 추론 {{transcribe}}s · 백엔드 {{backend}}', - testFailed: '테스트 실패', - engineStatusLabel: '메모리에 있는 엔진', - engineLoaded: '로드됨: {{model}}(약 1.2-3.4 GB 메모리 사용)', - engineUnloaded: '로드되지 않음(첫 받아쓰기 시 약 3-5 초 로드 필요)', - loadNow: '지금 로드', - releaseNow: '지금 해제', - keepLoadedLabel: '로드 유지 시간', - keepLoadedDesc: '로컬 ASR 사용 후 메모리에서 해제되기까지의 시간을 결정. 1+ GB RAM 장기 점유 회피.', - keepImmediate: '말하기 직후 해제', - keep1min: '마지막 사용 후 1분', - keep5min: '마지막 사용 후 5분(기본)', - keep30min: '마지막 사용 후 30분', - keepForever: '해제하지 않음(항상 유지)', - }, -}; +} diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 538e54cd..6cc42450 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2,980 +2,1082 @@ // 添加新 key 时,必须同步更新 en.ts,否则首次切换到 English 会回落到中文残留。 export const zhCN = { - app: { - name: 'OpenLess', - tagline: '自然说话,完美书写', - }, - common: { - loading: '加载中…', - retry: '重试', - settingsLoadFailed: '设置加载失败', - refresh: '刷新', - clear: '清空', - copy: '复制', - delete: '删除', - later: '稍后', - cancel: '取消', - close: '关闭', - show: '显示', - hide: '隐藏', - saved: '已保存', - saving: '保存中', - copied: '已复制', - operationFailed: '操作失败', - add: '添加', - durationSeconds: '{{value}} 秒', - durationMinutes: '{{value}} 分钟', - }, - capsule: { - thinking: 'thinking', - cancelled: '已取消', - error: '出错了', - inserted: '已插入 {{count}}', - translating: '正在翻译', - }, - qa: { - thinking: '思考中…', - error: '出错了,请稍后再试。', - errorRetry: '重试', - errorRetryHint: '再按 {{recordHotkey}} 重新提问。', - pinTooltip: '固定(不自动关闭)', - unpinTooltip: '取消固定', - closeTooltip: '关闭', - selectionPreview: '基于选中文本:', - emptyTitle: '按 {{recordHotkey}} 开始提问', - emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。', - recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问', - statusIdle: '按 {{recordHotkey}} 提问', - statusRecording: '录音中', - statusThinking: '思考中', - statusError: '出错了', - }, - nav: { - overview: '概览', - history: '历史', - vocab: '词汇表', - style: '风格', - marketplace: '风格市场', - translation: '翻译', - selectionAsk: '划词追问', - localAsr: '模型设置', - }, - marketplace: { - kicker: 'MARKETPLACE', - title: '风格包市场', - desc: '浏览、安装和分享社区风格包。', - searchPlaceholder: '搜索名称 / 描述 / 标签…', - sortPopular: '按热度', - sortNew: '最新', - uploadBtn: '上传', - uploadDisabledHint: '请先在 设置 → 风格市场 配置 GitHub 用户名', - refreshBtn: '刷新', - empty: '还没有风格包', - emptyHint: '换个搜索词,或自己上传一个分享给社区', - loadFailed: '加载失败:{{err}}', - noDescription: '(暂无描述)', - installBtn: '安装到本地', - likeBtn: '点赞', - installed: '已安装「{{name}}」到本地风格包', - uploaded: '上传成功,等待审核', - uploadTitle: '选择要上传的风格包', - uploadHint: '上传以 {{login}} 身份登录。包内容会发到云端审核队列。', - uploadNoLocal: '本地没有可上传的风格包', - errors: { - detail: '加载详情失败:{{err}}', - install: '安装失败:{{err}}', - like: '点赞失败:{{err}}', - upload: '上传失败:{{err}}', - loadLocal: '加载本地风格包失败:{{err}}', + app: { + name: "OpenLess", + tagline: "自然说话,完美书写", }, - sortLiked: '我赞过的', - likedEmpty: '你还没有赞过任何风格包', - likedEmptyHint: '点开任一风格包,红色星星点亮后会出现在这里', - derivativeBadge: '衍生自 @{{login}}', - detail: { - withdrawBtn: '撤回发布', - withdrawConfirm: '确认从风格市场撤回「{{name}}」?本地副本不会被删除。', - withdrawSuccess: '已从风格市场撤回', - withdrawFailed: '撤回失败:{{err}}', + common: { + loading: "加载中…", + retry: "重试", + settingsLoadFailed: "设置加载失败", + refresh: "刷新", + clear: "清空", + copy: "复制", + delete: "删除", + later: "稍后", + cancel: "取消", + close: "关闭", + show: "显示", + hide: "隐藏", + saved: "已保存", + saving: "保存中", + copied: "已复制", + operationFailed: "操作失败", + add: "添加", + durationSeconds: "{{value}} 秒", + durationMinutes: "{{value}} 分钟", }, - myPacks: { - buttonLabel: '我的发布', - buttonTitle: '查看 {{login}} 的发布', - buttonTitleEmpty: '先在 Settings → 风格市场 填写发布身份', - searchPlaceholder: '搜索名称、标签', - notLoggedIn: '请先在 Settings → 风格市场 填写发布身份', - emptyTitle: '你还没有发布过风格包', - emptyHint: '在「风格」页面编辑后点「发布到风格市场」,或点击右上角上传本地风格包。', - noMatch: '没有匹配的风格包', - summary: '已发布 {{count}} 个风格包', - summaryPending: '已发布 {{count}} 个风格包 · {{pending}} 个审核中', - versionDate: 'v{{version}} · {{date}}', - stats: '★ {{likes}} · ↓ {{downloads}}', - actions: { - update: '更新', - withdraw: '下架', - }, - loadFailed: '我的发布加载失败:{{err}}', - loadingTitle: '正在拉取,请稍后…', - loadingHint: '从风格市场获取你最新发布的风格包。', - loadErrorTitle: '加载失败', - loadErrorRetry: '重试', + capsule: { + thinking: "thinking", + cancelled: "已取消", + error: "出错了", + inserted: "已插入 {{count}}", + translating: "正在翻译", }, - upload: { - confirmBtn: '确定上传', - updateTitle: '更新「{{name}}」', - updateHint: '选中要上传的本地新版本风格包,下方点「确定上传」。同名包默认预选。', - recommendedBadge: '建议更新', + qa: { + thinking: "思考中…", + error: "出错了,请稍后再试。", + errorRetry: "重试", + errorRetryHint: "再按 {{recordHotkey}} 重新提问。", + pinTooltip: "固定(不自动关闭)", + unpinTooltip: "取消固定", + closeTooltip: "关闭", + selectionPreview: "基于选中文本:", + emptyTitle: "按 {{recordHotkey}} 开始提问", + emptyDesc: + "在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。", + recordingHint: "录音中…再按一次 {{recordHotkey}} 结束并提问", + statusIdle: "按 {{recordHotkey}} 提问", + statusRecording: "录音中", + statusThinking: "思考中", + statusError: "出错了", }, - state: { - pending: '审核中', - approved: '已上架', - rejected: '未通过', - withdrawn: '已下架', - superseded: '已被新版替换', - unknown: '未知', + nav: { + overview: "概览", + history: "历史", + vocab: "词汇表", + style: "风格", + marketplace: "风格市场", + translation: "翻译", + selectionAsk: "划词追问", + localAsr: "模型设置", }, - oauth: { - title: '用 GitHub 登录', - generating: '正在生成设备验证码…', - browserHint: '在浏览器中打开 {{uri}} 并输入下方代码:', - copyBtn: '复制', - copied: '已复制设备码', - copyFailed: '复制失败:{{err}}', - openBrowserBtn: '打开浏览器', - cancelBtn: '取消', - waiting: '等待你在浏览器中授权…', - successAs: '已登录为 @{{login}}', - retryBtn: '重试', - closeBtn: '关闭', - loginBtn: '登录', - loginTooltip: '点击用 GitHub 登录', - reloginTooltip: '点击重新登录 / 切换账号(当前 @{{login}})', - }, - modal: { - loggedIn: '当前登录身份 —— 在 Settings → 录音 → 风格市场 修改', - notLoggedIn: '未登录 —— 去 Settings → 录音 → 风格市场 填一个发布者名', - notLoggedInLabel: '未登录', - }, - }, - shell: { - shortcutLabel: '录音快捷键', - shortcutHint: '开始 / 停止', - betaTag: 'BETA', - betaNote: '所有数据都只保存在本机。', - footer: { - account: '账户', - feedback: '反馈', - settings: '设置', - help: '帮助', - version: '版本 {{version}}', - helpPopover: { - tagline: '本地驱动的语音输入层', - releaseNotes: '查看发布日志 ↗', - docs: '帮助中心 ↗', - }, - }, - providerPrompt: { - title: '设置语音提供商', - body: '还没有配置 ASR 或 LLM 提供商,语音输入和润色暂时无法正常工作。', - later: '稍后', - openSettings: '去设置', - }, - hotkeyModePrompt: { - title: '检查录音方式', - body: '默认已改为切换式。如果之前改过触发方式,请到录音设置确认一次。', - later: '稍后提醒', - openSettings: '去录音设置', - }, - }, - onboarding: { - welcome: '欢迎使用 OpenLess', - intro: '本地说出,本地落字。开始前需要两个系统权限。', - accessibilityTitle: '辅助功能', - hotkeyTitle: '全局快捷键', - accessibilityDesc: '用于监听全局快捷键(默认 {{trigger}})并把识别结果写入光标位置。', - hotkeyDesc: '用于确认全局快捷键监听可用。', - micTitle: '麦克风', - micDesc: '用于捕获你的语音输入。', - actionNotApplicable: '无需授权', - actionGranted: '已授权', - actionOpenSystem: '打开系统设置', - actionGrant: '授权', - actionRequestMic: '弹出授权', - accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', - footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。', - }, - overview: { - kicker: 'DASHBOARD', - title: '今日概览', - desc: '今日口述统计与系统状态。', - pressPrefix: '按', - pressSuffix: '开始录音', - asrKind: 'ASR 语音', - llmKind: 'LLM 模型', - asrName: '火山引擎', - asrSubname: 'bigmodel', - llmName: 'OpenAI 兼容', - llmConfigured: '已配置 active LLM', - llmNotConfigured: '未配置', - statusConfigured: '已配置', - statusNotConfigured: '未配置', - statusUnknown: '无法读取', - credentialsLoadError: '无法读取凭据状态', - metricChars: '今日字数', - metricSegments: '{{count}} 段', - metricDuration: '今日总时长', - metricAvg: '平均段落', - metricAvgTrend: '今日均值', - metricNoData: '暂无数据', - historyLoadError: '历史读取失败', - metricTotal: '累计记录', - metricTotalTrend: '本机存档 (上限 200)', - weekTitle: '近 7 天', - weekUnit: '条数 / 天', - recentTitle: '最近识别', - recentAll: '全部记录 →', - recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。', - recentLoadFailed: '无法读取最近识别,请重试。', - historyRetry: '重试', - weekDays: ['日', '一', '二', '三', '四', '五', '六'], - }, - history: { - kicker: 'HISTORY', - title: '历史记录', - desc: '本机保存的识别记录。', - filterAll: '全部', - summary: '共 {{total}} 条 · 显示 {{shown}}', - empty: '还没有历史记录。按 {{trigger}} 录一段试试。', - loadFailed: '加载历史失败:{{err}}', - retry: '重试', - clearFailed: '清空失败:{{err}}', - deleteFailed: '删除失败:{{err}}', - copyFailed: '复制失败:{{err}}', - playRecording: '播放录音', - audioLoading: '加载中…', - exportRecording: '导出录音', - exportFailed: '导出失败:{{err}}', - rawLabel: '原文', - rawEmpty: '(空)', - selectHint: '左侧选一条查看详情。', - insertedTo: '插入到', - chars: '{{count}} 字', - vocabHits: '{{count}} 个热词', - inserted: '已插入', - pasteSent: '已尝试粘贴', - copiedFallback: '已复制(需 {{shortcut}})', - insertFailed: '插入失败', - confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。', - }, - vocab: { - kicker: 'VOCABULARY', - title: '词汇表', - desc: '添加生词或专业术语,提高识别准确率。', - sectionTitle: '词条', - placeholder: '输入词语,按 Enter 或点添加…', - tip: '支持中英混合 · 数字开头按字面识别 · 命中次数自动计数', - loadFailed: '加载失败:{{err}}', - empty: '还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。', - tipDisabled: '点击禁用此词条', - tipEnabled: '点击启用此词条', - removeAria: '删除', - corrections: { - title: '纠正规则', - tip: '修正常见 ASR 误识别,支持 {num} 数字通配。', - patternPlaceholder: '误识别写法,如 {num}粒', - replacementPlaceholder: '目标写法,如 {num}例', - empty: '还没有纠正规则。', - invalid: '仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。', - tipDisabled: '点击禁用此规则', - tipEnabled: '点击启用此规则', - removeAria: '删除纠正规则', - }, - presets: { - title: '场景预设', - tip: '可多选批量启用,支持编辑和新建。', - create: '新建预设', - apply: '启用所选', - save: '保存预设', - edit: '编辑 {{name}}', - newPreset: '新预设', - namePlaceholder: '预设名称', - wordsPlaceholder: '词条(用逗号或换行分隔)', - }, - }, - style: { - kicker: 'STYLE', - title: '输出风格', - desc: '选择录音的默认输出风格。', - masterToggle: '整体启用', - currentDefault: '当前默认', - ariaSetDefault: '设为默认', - saveFailed: '保存失败:{{error}}', - customPromptTitle: '自定义提示词', - customPromptPlaceholder: '可选,追加到这个风格的内置 system prompt 末尾。', - customPromptHint: '留空则保持当前行为不变。保存后会在该风格的润色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。', - customPromptSave: '保存提示词', - customPromptDirty: '未保存', - systemPromptMovedHint: '完整 System Prompt 已移到 设置 -> Providers 页面统一编辑。这里现在只负责风格启停和默认风格。', - modes: { - raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' }, - light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' }, - structured: { name: '清晰结构', desc: '多个主题或步骤时,自动组织为分点列表。', sample: '1. 主题一\na. 要点\nb. 要点\n2. 主题二\na. 要点\nb. 要点' }, - formal: { name: '正式表达', desc: '工作沟通和邮件场景,更专业更完整。', sample: '邮件场景自动识别问候 / 落款;不引入空泛客套。' }, - }, - pack: { - kicker: 'STYLE PACKS', - title: '风格包', - desc: '管理本地风格包。', - marketplaceBtn: '风格市场', - loadFailed: '加载风格包失败:{{err}}', - importZip: '导入 ZIP', - exportZip: '导出 ZIP', - exportShort: '导出', - publishMarketplace: '发布到风格市场', - updateMarketplace: '更新到风格市场新版本', - publishDisabledHint: '请先在 设置 → 风格市场 配置 GitHub 用户名', - publishSuccess: '发布成功,等待 marketplace 审核', - publishFailed: '发布失败:{{err}}', - publishBuiltinRejected: '内置风格包不能直接发布,请先编辑生成一份导入版。', - builtin: '内置', - imported: '导入', - active: '当前', - activate: '激活', - edit: '编辑', - closeEditor: '关闭', - unsaved: '未保存', - listTitle: '本地风格包', - listDesc: '浏览和切换风格包。', - listCount: '{{count}} 个风格包', - addPackTileTitle: '新建风格包', - addPackTileHint: '从空白模板开始。', - createSuccess: '已创建新风格包', - createFailed: '创建风格包失败:{{err}}', - save: '保存', - revert: '撤销', - saveSuccess: '风格包已保存', - saveFailed: '保存风格包失败:{{err}}', - activateSuccess: '已将"{{name}}"设为当前风格', - activateFailed: '设为当前风格失败:{{err}}', - importSuccess: '已导入"{{name}}"', - importFailed: '导入 ZIP 失败:{{err}}', - exportSuccess: '已导出到 {{path}}', - exportFailed: '导出 ZIP 失败:{{err}}', - exportDirtyFirst: '请先保存当前风格包,再导出 ZIP。', - resetBuiltin: '重置', - resetSuccess: '已重置"{{name}}"', - resetFailed: '重置风格包失败:{{err}}', - deleteImported: '删除', - deleteConfirm: '确定删除"{{name}}"吗?删除后无法恢复。', - deleteSuccess: '已删除"{{name}}"', - deleteFailed: '删除风格包失败:{{err}}', - summaryCurrentEmpty: '还没有选中风格包', - editorTitle: '编辑风格', - editorDesc: '编辑当前风格包。', - metaTitle: '安装信息', - metaSource: '来源', - metaBaseMode: '基础模式', - metaUpdatedAt: '更新时间', - fieldName: '名称', - fieldAuthor: '作者', - fieldAuthorPlaceholder: '可选,方便标注来源', - fieldVersion: '版本', - fieldTags: '标签', - fieldTagsPlaceholder: '用英文逗号分隔,例如 community, voiceover, formal', - fieldDescription: '描述', - fieldModel: '推荐模型(仅元数据)', - fieldModelPlaceholder: '可选,例如 gpt-4.1 / deepseek-v3', - fieldModelHint: '仅作说明,不会切换实际模型。', - fieldCompatibility: '兼容版本', - fieldCompatibilityPlaceholder: '可选,例如 >=1.3.0', - fullPromptTitle: 'System Prompt', - fullPromptHint: '这就是这套风格包自己的 Prompt。', - promptChars: '{{count}} 字符', - runtimeTitle: 'OpenLess 运行时附加指令', - runtimeDesc: '只读的运行时辅助项。', - runtimeContextTitle: '上下文前提', - runtimeContextDesc: '来自语言与应用上下文', - runtimeContextEmpty: '当前不会附加', - runtimeHotwordTitle: '热词提示段', - runtimeHotwordDesc: '来自已启用热词', - runtimeHotwordEmpty: '当前不会附加', - runtimeHistoryTitle: '多轮历史保护段', - runtimeHistoryDesc: '仅用于实时多轮 polish', - runtimeHistoryEmpty: '只有存在 prior turns 时才会附加', - runtimeActive: '当前生效', - runtimeInactive: '当前未生效', - runtimePreviewFailed: '生成运行时预览失败:{{err}}', - runtimePreviewOmittedFrontApp: '预览已省略前台 app 标签。', - examplesTitle: '效果示例', - examplesDesc: '会随风格包一起导出。', - addExample: '新增示例', - examplesEmpty: '还没有示例。', - exampleTitlePlaceholder: '示例 {{index}} 标题', - exampleInput: '输入', - exampleOutput: '输出', - examplesCount: '{{count}} 个示例', - discardCloseConfirm: '关闭编辑面板前要放弃未保存修改吗?', - discardSwitchConfirm: '要放弃当前未保存修改,并切换到"{{name}}"吗?', - derivativeBadge: '衍生自 @{{login}}', - }, - }, - translation: { - kicker: 'TRANSLATION', - title: '翻译', - desc: '录音后自动翻译为目标语言再插入。', - statusEnabled: '已启用', - statusDisabled: '未启用', - working: { - title: '工作语言', - desc: '勾选日常使用的语言,影响润色与翻译效果。', - }, - target: { - title: '翻译目标语言', - desc: '录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。', - disabled: '不启用(Shift 按下不触发翻译)', - }, - save: { - workingFailed: '工作语言保存失败,请重试。', - targetFailed: '翻译目标语言保存失败,请重试。', - hotkeyRegisterFailed: '翻译快捷键注册失败,未继续保存。', - hotkeySaveFailed: '翻译快捷键保存失败,请重试。', + marketplace: { + kicker: "MARKETPLACE", + title: "风格包市场", + desc: "浏览、安装和分享社区风格包。", + searchPlaceholder: "搜索名称 / 描述 / 标签…", + sortPopular: "按热度", + sortNew: "最新", + uploadBtn: "上传", + uploadDisabledHint: "请先在 设置 → 风格市场 配置 GitHub 用户名", + refreshBtn: "刷新", + empty: "还没有风格包", + emptyHint: "换个搜索词,或自己上传一个分享给社区", + loadFailed: "加载失败:{{err}}", + noDescription: "(暂无描述)", + installBtn: "安装到本地", + likeBtn: "点赞", + installed: "已安装「{{name}}」到本地风格包", + uploaded: "上传成功,等待审核", + uploadTitle: "选择要上传的风格包", + uploadHint: "上传以 {{login}} 身份登录。包内容会发到云端审核队列。", + uploadNoLocal: "本地没有可上传的风格包", + errors: { + detail: "加载详情失败:{{err}}", + install: "安装失败:{{err}}", + like: "点赞失败:{{err}}", + upload: "上传失败:{{err}}", + loadLocal: "加载本地风格包失败:{{err}}", + }, + sortLiked: "我赞过的", + likedEmpty: "你还没有赞过任何风格包", + likedEmptyHint: "点开任一风格包,红色星星点亮后会出现在这里", + derivativeBadge: "衍生自 @{{login}}", + detail: { + withdrawBtn: "撤回发布", + withdrawConfirm: + "确认从风格市场撤回「{{name}}」?本地副本不会被删除。", + withdrawSuccess: "已从风格市场撤回", + withdrawFailed: "撤回失败:{{err}}", + }, + myPacks: { + buttonLabel: "我的发布", + buttonTitle: "查看 {{login}} 的发布", + buttonTitleEmpty: "先在 Settings → 风格市场 填写发布身份", + searchPlaceholder: "搜索名称、标签", + notLoggedIn: "请先在 Settings → 风格市场 填写发布身份", + emptyTitle: "你还没有发布过风格包", + emptyHint: + "在「风格」页面编辑后点「发布到风格市场」,或点击右上角上传本地风格包。", + noMatch: "没有匹配的风格包", + summary: "已发布 {{count}} 个风格包", + summaryPending: "已发布 {{count}} 个风格包 · {{pending}} 个审核中", + versionDate: "v{{version}} · {{date}}", + stats: "★ {{likes}} · ↓ {{downloads}}", + actions: { + update: "更新", + withdraw: "下架", + }, + loadFailed: "我的发布加载失败:{{err}}", + loadingTitle: "正在拉取,请稍后…", + loadingHint: "从风格市场获取你最新发布的风格包。", + loadErrorTitle: "加载失败", + loadErrorRetry: "重试", + }, + upload: { + confirmBtn: "确定上传", + updateTitle: "更新「{{name}}」", + updateHint: + "选中要上传的本地新版本风格包,下方点「确定上传」。同名包默认预选。", + recommendedBadge: "建议更新", + }, + state: { + pending: "审核中", + approved: "已上架", + rejected: "未通过", + withdrawn: "已下架", + superseded: "已被新版替换", + unknown: "未知", + }, + oauth: { + title: "用 GitHub 登录", + generating: "正在生成设备验证码…", + browserHint: "在浏览器中打开 {{uri}} 并输入下方代码:", + copyBtn: "复制", + copied: "已复制设备码", + copyFailed: "复制失败:{{err}}", + openBrowserBtn: "打开浏览器", + cancelBtn: "取消", + waiting: "等待你在浏览器中授权…", + successAs: "已登录为 @{{login}}", + retryBtn: "重试", + closeBtn: "关闭", + loginBtn: "登录", + loginTooltip: "点击用 GitHub 登录", + reloginTooltip: "点击重新登录 / 切换账号(当前 @{{login}})", + }, + modal: { + loggedIn: "当前登录身份 —— 在 Settings → 录音 → 风格市场 修改", + notLoggedIn: + "未登录 —— 去 Settings → 录音 → 风格市场 填一个发布者名", + notLoggedInLabel: "未登录", + }, }, - howto: { - title: '使用方法', - step1: '在任意输入框聚焦光标。', - step2: '按 {{trigger}} 开始录音。', - step3: '录音中按一下 Shift 激活翻译。', - step4: '再按 {{trigger}} 停止录音。', - step5: '翻译结果自动插入到光标位置。', - indicatorTitle: '翻译模式指示', - indicatorDesc: '按 Shift 后屏幕底部会显示蓝色「正在翻译」标识。', - fallbackTitle: '安全兜底', - fallbackDesc: '翻译失败时回退为插入原始转写,不会丢字。', + shell: { + shortcutLabel: "录音快捷键", + shortcutHint: "开始 / 停止", + betaTag: "BETA", + betaNote: "所有数据都只保存在本机。", + footer: { + account: "账户", + feedback: "反馈", + settings: "设置", + help: "帮助", + version: "版本 {{version}}", + helpPopover: { + tagline: "本地驱动的语音输入层", + releaseNotes: "查看发布日志 ↗", + docs: "帮助中心 ↗", + }, + }, + providerPrompt: { + title: "设置语音提供商", + body: "还没有配置 ASR 或 LLM 提供商,语音输入和润色暂时无法正常工作。", + later: "稍后", + openSettings: "去设置", + }, + hotkeyModePrompt: { + title: "检查录音方式", + body: "默认已改为切换式。如果之前改过触发方式,请到录音设置确认一次。", + later: "稍后提醒", + openSettings: "去录音设置", + }, }, - }, - selectionAsk: { - kicker: 'SELECTION ASK', - title: '划词追问', - desc: '选中文字后语音提问,支持多轮追问。', - statusEnabled: '已启用', - statusDisabled: '未启用', - hotkey: { - title: '弹出浮窗的快捷键', - desc: '控制浮窗开关。浮窗内录音用 {{recordHotkey}}。', - optionDisabled: '不启用', - chordWarning: '', + onboarding: { + welcome: "欢迎使用 OpenLess", + intro: "本地说出,本地落字。开始前需要两个系统权限。", + accessibilityTitle: "辅助功能", + hotkeyTitle: "全局快捷键", + accessibilityDesc: + "用于监听全局快捷键(默认 {{trigger}})并把识别结果写入光标位置。", + hotkeyDesc: "用于确认全局快捷键监听可用。", + micTitle: "麦克风", + micDesc: "用于捕获你的语音输入。", + actionNotApplicable: "无需授权", + actionGranted: "已授权", + actionOpenSystem: "打开系统设置", + actionGrant: "授权", + actionRequestMic: "弹出授权", + accessibilityHint: + "授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。", + footerHint: + "授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。", }, - save: { - hotkeyRegisterFailed: '划词追问快捷键注册失败,未继续保存。', - hotkeySaveFailed: '划词追问快捷键保存失败,请重试。', - historySaveFailed: 'Q&A 历史保存设置保存失败,请重试。', + overview: { + kicker: "DASHBOARD", + title: "今日概览", + desc: "今日口述统计与系统状态。", + pressPrefix: "按", + pressSuffix: "开始录音", + asrKind: "ASR 语音", + llmKind: "LLM 模型", + asrName: "火山引擎", + asrSubname: "bigmodel", + llmName: "OpenAI 兼容", + llmConfigured: "已配置 active LLM", + llmNotConfigured: "未配置", + statusConfigured: "已配置", + statusNotConfigured: "未配置", + statusUnknown: "无法读取", + credentialsLoadError: "无法读取凭据状态", + metricChars: "今日字数", + metricSegments: "{{count}} 段", + metricDuration: "今日总时长", + metricAvg: "平均段落", + metricAvgTrend: "今日均值", + metricNoData: "暂无数据", + historyLoadError: "历史读取失败", + metricTotal: "累计记录", + metricTotalTrend: "本机存档 (上限 200)", + weekTitle: "近 7 天", + weekUnit: "条数 / 天", + recentTitle: "最近识别", + recentAll: "全部记录 →", + recentEmpty: "还没有记录。按 {{trigger}} 开始第一次录音。", + recentLoadFailed: "无法读取最近识别,请重试。", + historyRetry: "重试", + weekDays: ["日", "一", "二", "三", "四", "五", "六"], }, history: { - title: '保存历史', - desc: '开启后在本地保存问答记录,默认关闭。', - }, - howto: { - title: '使用方法', - step1: '按 {{hotkey}} 打开浮窗。', - step2: '在任意 app 选中文字。', - step3: '按 {{recordHotkey}} 录音,再按一次提交。', - step4: '可继续按 {{recordHotkey}} 多轮追问。', - step5: '按 Esc 关闭浮窗并清空历史。', - windowTitle: '浮窗操作', - windowDesc: '浮窗可拖动,位置自动记忆。钉住可保持窗口不关。', - privacyTitle: '隐私', - privacyDesc: '选中文本仅存于内存,关闭浮窗即销毁。超 4000 字符自动截断。', - }, - }, - settings: { - kicker: 'SETTINGS', - title: '设置', - desc: '录音、提供商、快捷键与权限配置。', - sections: { - recording: '录音', - providers: '提供商', - shortcuts: '快捷键', - permissions: '权限', - language: '语言', - advanced: '高级', - about: '关于', - }, - recording: { - title: '录音', - desc: '全局录音的快捷键与触发方式。', - hotkeyLabel: '录音快捷键', - hotkeyDescAcc: '按下开始捕获语音,全局生效(需辅助功能权限)。', - hotkeyDescNoAcc: '按下开始捕获语音,全局生效。', - modeLabel: '录音方式', - modeDesc: '切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。', - modeToggle: '切换式', - modeHold: '按住说话', - migrationNoticeTitle: '默认已改为切换式说话', - migrationNoticeDesc: '本次更新调整了默认值,如果习惯按住说话,请在此处切回。', - microphoneLabel: '首选麦克风', - microphoneDesc: '选择优先输入设备。设备断开时自动切到系统默认。', - microphoneDefault: '系统默认麦克风', - microphoneDefaultDesc: '使用系统默认输入设备', - microphoneSystemDefault: '系统默认', - microphoneUnavailable: '不可用', - microphoneLoadError: '麦克风列表读取失败:{{message}}', - microphoneDialogTitle: '麦克风', - microphoneDialogDesc: '选择能捕捉到您声音的麦克风。', - microphoneMonitorError: '输入电平监听失败:{{message}}', - capsuleLabel: '录音胶囊', - capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。', - muteDuringRecordingLabel: '录音时静音', - muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。', - insertGroupTitle: '插入与剪贴板', - restoreClipboardLabel: '插入后恢复剪贴板', - restoreClipboardDesc: '粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。', - pasteShortcutLabel: '模拟粘贴快捷键', - pasteShortcutDesc: '插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。', - pasteShortcutCtrlV: 'Ctrl+V(默认 / 多数应用)', - pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)', - pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', - comboRecordLabel: '录制快捷键', - comboRecordDesc: '点击后按下想要的快捷键组合(如 ⌘⇧D)。', - comboRecordBtn: '录制快捷键', - comboRecordHint: '请按下快捷键组合…', - comboRecorded: '已录制', - comboClear: '清除', - comboConflict: '该快捷键组合不可用', - allowNonTsfFallbackLabel: '允许非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', - historyGroupTitle: '历史与上下文', - historyRetentionLabel: '历史保留天数', - historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', - historyMaxEntriesLabel: '历史条数上限', - historyMaxEntriesDesc: '本地保留会话上限,留空 = 200。范围 5–200。', - polishContextWindowLabel: '对话上下文窗口(分钟)', - polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。', - recordAudioForDebugLabel: '保留原始录音(调试)', - recordAudioForDebugDesc: '保存原始麦克风音频为 wav,便于排查识别问题。', - audioRecordingMaxEntriesLabel: '原始录音保留条数', - audioRecordingMaxEntriesDesc: '本地保留 wav 文件数上限,留空 = 200。', - startupGroupTitle: '启动', - startMinimizedLabel: '启动时静默运行', - startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。', - autoUpdateCheckLabel: '自动检查更新', - autoUpdateCheckDesc: '启动时及每 60 分钟自动检查更新。', - marketplaceGroupTitle: '风格市场', - marketplaceBaseUrlLabel: '云端服务地址', - marketplaceBaseUrlDesc: '风格市场后端 URL,留空使用默认值。', - marketplaceDevLoginLabel: 'GitHub 用户名(上传身份)', - marketplaceDevLoginDesc: '标识上传者身份,为空时无法上传或点赞。', - startupAtBoot: '开机自启', - startupAtBootDesc: '登录系统时自动启动 OpenLess。', - startupAtBootError: '开机自启切换失败:{{message}}', - }, - providers: { - llmTitle: 'LLM 模型(润色)', - llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', - providerLabel: '供应商', - llmProviderDesc: '选择后将自动填入 Base URL 默认值。', - credentialStorageNotice: '凭据保存在系统凭据库中。', - codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。', - asrProviderDesc: '切换后将自动选用对应凭据。', - asrTitle: 'ASR 语音(转写)', - asrDesc: '用于将口述实时转写为文本。', - presets: { - ark: 'ARK(火山方舟)', - deepseek: 'DeepSeek', - siliconflow: '硅基流动', - openai: 'OpenAI', - gemini: 'Google Gemini', - codexOAuth: 'Codex OAuth', - mimo: '小米 MiMo', - cometapi: 'CometAPI', - openrouterFree: 'OpenRouter(免费模型)', - alibabaCoding: '阿里云 Coding Plan', - codingPlanX: 'CodingPlanX', - custom: '自定义', - asrVolcengine: '火山引擎 bigasr', - asrBailian: '阿里云百炼实时 ASR', - asrSiliconflow: '硅基流动 SenseVoice', - asrZhipu: '智谱 GLM-ASR', - asrGroq: 'Groq Whisper-large-v3', - asrWhisper: 'OpenAI Whisper(兼容)', - asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', - asrLocalQwen3: '本地 Qwen3-ASR', - }, - volcengineAppKeyLabel: 'APP ID', - volcengineAccessKeyLabel: 'Access Token', - volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', - localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', - localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。', - asrProviderTakenOver: 'ASR 提供商已被接管', - localAsrHint: '在本机运行,无需 API Key。从 HuggingFace 下载模型即可使用。', - foundryLocalAsrHint: '在本机运行,无需 ASR API Key。首次使用需下载运行组件和模型。', - localAsrPerformanceWarning: '本地推理比云端慢,中文准确率可能更低。适合离线或隐私敏感场景。', - localAsrReady: '{{model}} 已下载', - localAsrNotReady: '{{model}} 未下载', - localAsrGoDownload: '前往模型设置下载', - localAsrManage: '前往模型设置', - localAsrDownloadedTitle: '已下载模型', - localAsrDelete: '删除', - fillDefault: '填入默认值', - readFailed: '读取失败', - apiKeyLabel: 'API 密钥', - baseUrlLabel: '接口地址', - modelLabel: '模型', - thinkingModeLabel: '思考', - thinkingModeOn: '开启', - thinkingModeOff: '关闭', - thinkingModeHint: '关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。', - bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)', - bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。', - appIdLabel: 'App ID(应用 ID)', - accessKeyLabel: 'Access Key', - resourceIdLabel: '资源 ID', - toolsLabel: '连接检查', - toolsDesc: '先保存上方配置,再验证当前模型连通性或拉取模型;失败时仍可手动填写模型 ID。', - validate: '验证', - validating: '验证中…', - fetchModels: '拉取模型', - loadingModels: '拉取模型中…', - modelMissing: '未配置模型,请先填写模型 ID。', - modelsEmpty: '鉴权成功,但没有返回可用模型。', - modelsLoaded: '已拉取 {{count}} 个模型。', - selectModel: '选择一个模型写入上方字段', - modelSaved: '已保存模型 {{model}}。', - validateSuccess: '连接检查通过。', - providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', - endpointMustUseHttps: 'Endpoint 必须使用 HTTPS(本地 localhost/127.0.0.1 测试除外)。', - endpointInvalid: 'Endpoint 格式不合法。', - responseTooLarge: '供应商响应过大,已停止验证以保证安全。', - asrInvalidJson: 'ASR 响应不是有效 JSON。', - asrMissingTextField: 'ASR 响应缺少 text 字段。', - apiKeyMissing: 'API Key 为空。', - endpointMissing: 'Endpoint 为空。', - requestTimeout: '请求超时,请稍后重试。', - }, - shortcuts: { - title: '快捷键速查', - descAcc: '所有快捷键全局生效,需要在权限设置中开启辅助功能。', - descNoAcc: '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。', - startStop: '开始 / 停止录音', - cancel: '取消本次录音', - confirm: '胶囊确认插入', - switchStyle: '切换上一次风格', - openApp: '打开 OpenLess', - confirmHint: '点击右侧 ✓', - notSupported: '暂未支持', - }, - permissions: { - title: '权限', - descAcc: 'OpenLess 需要以下系统权限。授权后通常要完全退出 App 重启一次才生效。', - descNoAcc: '麦克风必需;全局快捷键状态用来检测 native hook 是否运行。', - micLabel: '麦克风', - micDesc: '用于捕获你的语音输入。', - accLabel: '辅助功能', - accDesc: '监听全局快捷键并把识别结果写入光标。', - hotkeyLabel: '全局快捷键', - hotkeyDescWithAdapter: '适配器:{{adapter}}。', - hotkeyDescPlain: '判断快捷键监听是否已安装。', - networkLabel: '网络', - networkDesc: '云端 ASR / LLM 必需,本地模式可关。', - networkOk: '可用', - checking: '检查中…', - granted: '已授权', - notApplicable: '无需授权', - denied: '未授权', - indeterminate: '未确定', - openSystem: '打开系统设置', - grant: '授权', - hotkeyInstalled: '已安装', - hotkeyStarting: '安装中…', - hotkeyFailed: '监听失败', - windowsImeLabel: 'Windows 输入法后端', - windowsImeDesc: '语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。', - windowsImeInstalled: '已安装', - windowsImeUnavailable: '不可用', - windowsIme: { - installed: '已安装,按需切到 OpenLess 输入法。', - notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', - registrationBroken: '注册损坏,请重装 OpenLess 输入法。', - notWindows: '仅 Windows 可用。', - }, + kicker: "HISTORY", + title: "历史记录", + desc: "本机保存的识别记录。", + filterAll: "全部", + summary: "共 {{total}} 条 · 显示 {{shown}}", + empty: "还没有历史记录。按 {{trigger}} 录一段试试。", + loadFailed: "加载历史失败:{{err}}", + retry: "重试", + clearFailed: "清空失败:{{err}}", + deleteFailed: "删除失败:{{err}}", + copyFailed: "复制失败:{{err}}", + playRecording: "播放录音", + audioLoading: "加载中…", + exportRecording: "导出录音", + exportFailed: "导出失败:{{err}}", + rawLabel: "原文", + rawEmpty: "(空)", + selectHint: "左侧选一条查看详情。", + insertedTo: "插入到", + chars: "{{count}} 字", + vocabHits: "{{count}} 个热词", + inserted: "已插入", + pasteSent: "已尝试粘贴", + copiedFallback: "已复制(需 {{shortcut}})", + insertFailed: "插入失败", + confirmClear: "确定清空全部 {{count}} 条记录?此操作不可恢复。", }, - advanced: { - streamingInsertTitle: '流式输入', - streamingInsertTitleLinux: '流式输入(实验)', - streamingInsertDesc: - '逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。', - streamingInsertLabel: '流式输入', - streamingInsertHintMac: - '临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。', - streamingInsertHintWindows: - 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', - streamingInsertHintLinux: - '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', - streamingInsertSaveClipboardLabel: '同步到剪贴板', - streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。', - localAsrTitle: '本地 ASR 模型(实验性)', - localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。', - localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。', - qwen3Desc: '启动之后,ASR 提供商将被接管。', - foundryDesc: '启动之后,ASR 提供商将被接管。', - notSupportedHere: '本平台暂不支持,未集成推理模块。', - enable: '启用', - alreadyActive: '已启用', - disableLocalLabel: '禁用本地 ASR', - disableLocalDesc: '切回云端 ASR(默认火山引擎 bigasr)。', - disable: '禁用', - platformNotSupported: '该平台暂未支持本地 ASR 模型集成。', - confirmEnableLocalTitle: '启用本地 ASR?', - confirmEnableLocalBody: '启用后转写会比云端慢,准确率可能更低。', - confirm: '确认启用', + vocab: { + kicker: "VOCABULARY", + title: "词汇表", + desc: "添加生词或专业术语,提高识别准确率。", + sectionTitle: "词条", + placeholder: "输入词语,按 Enter 或点添加…", + tip: "支持中英混合 · 数字开头按字面识别 · 命中次数自动计数", + loadFailed: "加载失败:{{err}}", + empty: "还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。", + tipDisabled: "点击禁用此词条", + tipEnabled: "点击启用此词条", + removeAria: "删除", + corrections: { + title: "纠正规则", + tip: "修正常见 ASR 误识别,支持 {num} 数字通配。", + patternPlaceholder: "误识别写法,如 {num}粒", + replacementPlaceholder: "目标写法,如 {num}例", + empty: "还没有纠正规则。", + invalid: + "仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。", + tipDisabled: "点击禁用此规则", + tipEnabled: "点击启用此规则", + removeAria: "删除纠正规则", + }, + presets: { + title: "场景预设", + tip: "可多选批量启用,支持编辑和新建。", + create: "新建预设", + apply: "启用所选", + save: "保存预设", + edit: "编辑 {{name}}", + newPreset: "新预设", + namePlaceholder: "预设名称", + wordsPlaceholder: "词条(用逗号或换行分隔)", + }, }, - language: { - title: '界面语言', - desc: '切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。', - label: '语言', - labelDesc: '选择「跟随系统」时按操作系统当前语言显示。', - followSystem: '跟随系统', - zh: '简体中文', - zhTW: '繁體中文', - en: 'English', - ja: '日本語 (Beta)', - ko: '한국어 (Beta)', - restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。', + style: { + kicker: "STYLE", + title: "输出风格", + desc: "选择录音的默认输出风格。", + masterToggle: "整体启用", + currentDefault: "当前默认", + ariaSetDefault: "设为默认", + saveFailed: "保存失败:{{error}}", + customPromptTitle: "自定义提示词", + customPromptPlaceholder: + "可选,追加到这个风格的内置 system prompt 末尾。", + customPromptHint: + "留空则保持当前行为不变。保存后会在该风格的润色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。", + customPromptSave: "保存提示词", + customPromptDirty: "未保存", + systemPromptMovedHint: + "完整 System Prompt 已移到 设置 -> Providers 页面统一编辑。这里现在只负责风格启停和默认风格。", + modes: { + raw: { + name: "原文", + desc: "只补标点和必要分句,不改写不扩写。", + sample: "保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。", + }, + light: { + name: "轻度润色", + desc: "去口癖、补标点,整理为可发送的自然文字。", + sample: "让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。", + }, + structured: { + name: "清晰结构", + desc: "多个主题或步骤时,自动组织为分点列表。", + sample: "1. 主题一\na. 要点\nb. 要点\n2. 主题二\na. 要点\nb. 要点", + }, + formal: { + name: "正式表达", + desc: "工作沟通和邮件场景,更专业更完整。", + sample: "邮件场景自动识别问候 / 落款;不引入空泛客套。", + }, + }, + pack: { + kicker: "STYLE PACKS", + title: "风格包", + desc: "管理本地风格包。", + marketplaceBtn: "风格市场", + loadFailed: "加载风格包失败:{{err}}", + importZip: "导入 ZIP", + exportZip: "导出 ZIP", + exportShort: "导出", + publishMarketplace: "发布到风格市场", + updateMarketplace: "更新到风格市场新版本", + publishDisabledHint: "请先在 设置 → 风格市场 配置 GitHub 用户名", + publishSuccess: "发布成功,等待 marketplace 审核", + publishFailed: "发布失败:{{err}}", + publishBuiltinRejected: + "内置风格包不能直接发布,请先编辑生成一份导入版。", + builtin: "内置", + imported: "导入", + active: "当前", + activate: "激活", + edit: "编辑", + closeEditor: "关闭", + unsaved: "未保存", + listTitle: "本地风格包", + listDesc: "浏览和切换风格包。", + listCount: "{{count}} 个风格包", + addPackTileTitle: "新建风格包", + addPackTileHint: "从空白模板开始。", + createSuccess: "已创建新风格包", + createFailed: "创建风格包失败:{{err}}", + save: "保存", + revert: "撤销", + saveSuccess: "风格包已保存", + saveFailed: "保存风格包失败:{{err}}", + activateSuccess: '已将"{{name}}"设为当前风格', + activateFailed: "设为当前风格失败:{{err}}", + importSuccess: '已导入"{{name}}"', + importFailed: "导入 ZIP 失败:{{err}}", + exportSuccess: "已导出到 {{path}}", + exportFailed: "导出 ZIP 失败:{{err}}", + exportDirtyFirst: "请先保存当前风格包,再导出 ZIP。", + resetBuiltin: "重置", + resetSuccess: '已重置"{{name}}"', + resetFailed: "重置风格包失败:{{err}}", + deleteImported: "删除", + deleteConfirm: '确定删除"{{name}}"吗?删除后无法恢复。', + deleteSuccess: '已删除"{{name}}"', + deleteFailed: "删除风格包失败:{{err}}", + summaryCurrentEmpty: "还没有选中风格包", + editorTitle: "编辑风格", + editorDesc: "编辑当前风格包。", + metaTitle: "安装信息", + metaSource: "来源", + metaBaseMode: "基础模式", + metaUpdatedAt: "更新时间", + fieldName: "名称", + fieldAuthor: "作者", + fieldAuthorPlaceholder: "可选,方便标注来源", + fieldVersion: "版本", + fieldTags: "标签", + fieldTagsPlaceholder: + "用英文逗号分隔,例如 community, voiceover, formal", + fieldDescription: "描述", + fieldModel: "推荐模型(仅元数据)", + fieldModelPlaceholder: "可选,例如 gpt-4.1 / deepseek-v3", + fieldModelHint: "仅作说明,不会切换实际模型。", + fieldCompatibility: "兼容版本", + fieldCompatibilityPlaceholder: "可选,例如 >=1.3.0", + fullPromptTitle: "System Prompt", + fullPromptHint: "这就是这套风格包自己的 Prompt。", + promptChars: "{{count}} 字符", + runtimeTitle: "OpenLess 运行时附加指令", + runtimeDesc: "只读的运行时辅助项。", + runtimeContextTitle: "上下文前提", + runtimeContextDesc: "来自语言与应用上下文", + runtimeContextEmpty: "当前不会附加", + runtimeHotwordTitle: "热词提示段", + runtimeHotwordDesc: "来自已启用热词", + runtimeHotwordEmpty: "当前不会附加", + runtimeHistoryTitle: "多轮历史保护段", + runtimeHistoryDesc: "仅用于实时多轮 polish", + runtimeHistoryEmpty: "只有存在 prior turns 时才会附加", + runtimeActive: "当前生效", + runtimeInactive: "当前未生效", + runtimePreviewFailed: "生成运行时预览失败:{{err}}", + runtimePreviewOmittedFrontApp: "预览已省略前台 app 标签。", + examplesTitle: "效果示例", + examplesDesc: "会随风格包一起导出。", + addExample: "新增示例", + examplesEmpty: "还没有示例。", + exampleTitlePlaceholder: "示例 {{index}} 标题", + exampleInput: "输入", + exampleOutput: "输出", + examplesCount: "{{count}} 个示例", + discardCloseConfirm: "关闭编辑面板前要放弃未保存修改吗?", + discardSwitchConfirm: + '要放弃当前未保存修改,并切换到"{{name}}"吗?', + derivativeBadge: "衍生自 @{{login}}", + }, }, - about: { - tagline: '自然说话,完美书写', - checkUpdate: '检查更新', - checkUpdateBtn: '检查', - checkingUpdate: '检查中…', - upToDate: '当前已是最新版本。', - updateError: '检查或更新失败,请稍后重试。', - openReleases: '打开 Releases', - source: '源码', - docs: '文档', - feedback: '反馈', - qq: '社区 QQ 群', - qqDesc: '使用 QQ 搜索群号加入,或扫码进群。', - copyQq: '复制群号', - privacy: '隐私', - privacyDesc: '所有数据仅保存在本机,云端 API 不保留录音。', - localFirst: '本地优先', - betaChannelLabel: '加入 Beta 渠道', - betaChannelDesc: '开启后自动接收 Beta 版本。可能不稳定,仅推荐愿意尝鲜的用户。', - betaChannelFetching: '正在获取最新 Beta 版本…', - betaChannelFetchBtn: '查询最新 Beta', - betaChannelLatestPrefix: '最新 Beta:', - betaChannelDownloadBtn: '前往下载', - betaChannelRefresh: '重新查询', - betaChannelNoBeta: '暂无已发布的 Beta 版。', - betaChannelFetchError: '获取 Beta 版本信息失败,请稍后重试。', - betaChannelUpToDate: '已是最新', - betaChannelUpdateNow: '立即更新', - betaChannelUpdateNowTitle: '检查并下载最新 Beta,然后弹出更新对话框', - betaChannelChecking: '检查中…', - updateDialog: { - available: { - title: '发现新版本', - desc: '发现 OpenLess {{version}},是否现在更新?', - }, - downloading: { - title: '正在下载更新', - desc: '正在下载 OpenLess {{version}},请保持应用打开。', - }, - downloaded: { - title: '更新已准备好', - desc: 'OpenLess {{version}} 已安装完成。是否现在自动重启以应用更新?', - }, - installing: { - title: '正在安装更新', - desc: '正在安装 OpenLess {{version}},请保持应用打开。', - }, - install: '现在更新', - downloadingLabel: '下载中…', - installingLabel: '安装中…', - later: '稍后手动重启', - restartNow: '现在重启', - progress: '{{progress}}% · {{downloaded}} / {{total}}', - progressUnknown: '已下载 {{downloaded}}', - }, + translation: { + kicker: "TRANSLATION", + title: "翻译", + desc: "录音后自动翻译为目标语言再插入。", + statusEnabled: "已启用", + statusDisabled: "未启用", + working: { + title: "工作语言", + desc: "勾选日常使用的语言,影响润色与翻译效果。", + }, + target: { + title: "翻译目标语言", + desc: "录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。", + disabled: "不启用(Shift 按下不触发翻译)", + }, + save: { + workingFailed: "工作语言保存失败,请重试。", + targetFailed: "翻译目标语言保存失败,请重试。", + hotkeyRegisterFailed: "翻译快捷键注册失败,未继续保存。", + hotkeySaveFailed: "翻译快捷键保存失败,请重试。", + }, + howto: { + title: "使用方法", + step1: "在任意输入框聚焦光标。", + step2: "按 {{trigger}} 开始录音。", + step3: "录音中按一下 Shift 激活翻译。", + step4: "再按 {{trigger}} 停止录音。", + step5: "翻译结果自动插入到光标位置。", + indicatorTitle: "翻译模式指示", + indicatorDesc: "按 Shift 后屏幕底部会显示蓝色「正在翻译」标识。", + fallbackTitle: "安全兜底", + fallbackDesc: "翻译失败时回退为插入原始转写,不会丢字。", + }, }, - }, - modal: { - sections: { - account: '账户', - settings: '设置', - personalize: '个性化', - about: '关于', - helpCenter: '帮助中心', - releaseNotes: '版本说明', + selectionAsk: { + kicker: "SELECTION ASK", + title: "划词追问", + desc: "选中文字后语音提问,支持多轮追问。", + statusEnabled: "已启用", + statusDisabled: "未启用", + hotkey: { + title: "弹出浮窗的快捷键", + desc: "控制浮窗开关。浮窗内录音用 {{recordHotkey}}。", + optionDisabled: "不启用", + chordWarning: "", + }, + save: { + hotkeyRegisterFailed: "划词追问快捷键注册失败,未继续保存。", + hotkeySaveFailed: "划词追问快捷键保存失败,请重试。", + historySaveFailed: "Q&A 历史保存设置保存失败,请重试。", + }, + history: { + title: "保存历史", + desc: "开启后在本地保存问答记录,默认关闭。", + }, + howto: { + title: "使用方法", + step1: "按 {{hotkey}} 打开浮窗。", + step2: "在任意 app 选中文字。", + step3: "按 {{recordHotkey}} 录音,再按一次提交。", + step4: "可继续按 {{recordHotkey}} 多轮追问。", + step5: "按 Esc 关闭浮窗并清空历史。", + windowTitle: "浮窗操作", + windowDesc: "浮窗可拖动,位置自动记忆。钉住可保持窗口不关。", + privacyTitle: "隐私", + privacyDesc: + "选中文本仅存于内存,关闭浮窗即销毁。超 4000 字符自动截断。", + }, }, - account: { - localUser: '本地用户', - localUserDesc: '未登录 · 所有数据保存在本机', - loginSync: '登录 / 同步', - footer: '默认完全本地运行。登录后可跨设备同步词汇表与风格预设。', + settings: { + kicker: "SETTINGS", + title: "设置", + desc: "录音、提供商、快捷键与权限配置。", + sections: { + recording: "录音", + providers: "提供商", + shortcuts: "快捷键", + permissions: "权限", + language: "语言", + advanced: "高级", + about: "关于", + }, + recording: { + title: "录音", + desc: "全局录音的快捷键与触发方式。", + hotkeyLabel: "录音快捷键", + hotkeyDescAcc: "按下开始捕获语音,全局生效(需辅助功能权限)。", + hotkeyDescNoAcc: "按下开始捕获语音,全局生效。", + modeLabel: "录音方式", + modeDesc: + "切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。", + modeToggle: "切换式", + modeHold: "按住说话", + migrationNoticeTitle: "默认已改为切换式说话", + migrationNoticeDesc: + "本次更新调整了默认值,如果习惯按住说话,请在此处切回。", + microphoneLabel: "首选麦克风", + microphoneDesc: "选择优先输入设备。设备断开时自动切到系统默认。", + microphoneDefault: "系统默认麦克风", + microphoneDefaultDesc: "使用系统默认输入设备", + microphoneSystemDefault: "系统默认", + microphoneUnavailable: "不可用", + microphoneLoadError: "麦克风列表读取失败:{{message}}", + microphoneDialogTitle: "麦克风", + microphoneDialogDesc: "选择能捕捉到您声音的麦克风。", + microphoneMonitorError: "输入电平监听失败:{{message}}", + capsuleLabel: "录音胶囊", + capsuleDesc: "录音 / 转写时显示屏幕底部胶囊。", + muteDuringRecordingLabel: "录音时静音", + muteDuringRecordingDesc: + "录音期间临时静音系统输出,避免扬声器回音。", + insertGroupTitle: "插入与剪贴板", + restoreClipboardLabel: "插入后恢复剪贴板", + restoreClipboardDesc: + "粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。", + pasteShortcutLabel: "模拟粘贴快捷键", + pasteShortcutDesc: + "插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。", + pasteShortcutCtrlV: "Ctrl+V(默认 / 多数应用)", + pasteShortcutCtrlShiftV: + "Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)", + pasteShortcutShiftInsert: "Shift+Insert(xterm / urxvt)", + comboRecordLabel: "录制快捷键", + comboRecordDesc: "点击后按下想要的快捷键组合(如 ⌘⇧D)。", + comboRecordBtn: "录制快捷键", + comboRecordHint: "请按下快捷键组合…", + comboRecorded: "已录制", + comboClear: "清除", + comboConflict: "该快捷键组合不可用", + allowNonTsfFallbackLabel: "允许非 TSF 兜底", + allowNonTsfFallbackDesc: + "Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。", + historyGroupTitle: "历史与上下文", + historyRetentionLabel: "历史保留天数", + historyRetentionDesc: + "超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。", + historyMaxEntriesLabel: "历史条数上限", + historyMaxEntriesDesc: "本地保留会话上限,留空 = 200。范围 5–200。", + polishContextWindowLabel: "对话上下文窗口(分钟)", + polishContextWindowDesc: + "把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。", + recordAudioForDebugLabel: "保留原始录音(调试)", + recordAudioForDebugDesc: + "保存原始麦克风音频为 wav,便于排查识别问题。", + audioRecordingMaxEntriesLabel: "原始录音保留条数", + audioRecordingMaxEntriesDesc: + "本地保留 wav 文件数上限,留空 = 200。", + startupGroupTitle: "启动", + startMinimizedLabel: "启动时静默运行", + startMinimizedDesc: + "所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。", + autoUpdateCheckLabel: "自动检查更新", + autoUpdateCheckDesc: "启动时及每 60 分钟自动检查更新。", + marketplaceGroupTitle: "风格市场", + marketplaceBaseUrlLabel: "云端服务地址", + marketplaceBaseUrlDesc: "风格市场后端 URL,留空使用默认值。", + marketplaceDevLoginLabel: "GitHub 用户名(上传身份)", + marketplaceDevLoginDesc: "标识上传者身份,为空时无法上传或点赞。", + startupAtBoot: "开机自启", + startupAtBootDesc: "登录系统时自动启动 OpenLess。", + startupAtBootError: "开机自启切换失败:{{message}}", + }, + providers: { + llmTitle: "LLM 模型(润色)", + llmDesc: "OpenAI 兼容协议,支持多家供应商切换。", + providerLabel: "供应商", + llmProviderDesc: "选择后将自动填入 Base URL 默认值。", + credentialStorageNotice: "凭据保存在系统凭据库中。", + codexOAuthNotice: + "Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。", + asrProviderDesc: "切换后将自动选用对应凭据。", + asrTitle: "ASR 语音(转写)", + asrDesc: "用于将口述实时转写为文本。", + presets: { + ark: "ARK(火山方舟)", + deepseek: "DeepSeek", + siliconflow: "硅基流动", + openai: "OpenAI", + gemini: "Google Gemini", + codexOAuth: "Codex OAuth", + mimo: "小米 MiMo", + cometapi: "CometAPI", + openrouterFree: "OpenRouter(免费模型)", + alibabaCoding: "阿里云 Coding Plan", + codingPlanX: "CodingPlanX", + custom: "自定义", + asrVolcengine: "火山引擎 bigasr", + asrBailian: "阿里云百炼实时 ASR", + asrSiliconflow: "硅基流动 SenseVoice", + asrZhipu: "智谱 GLM-ASR", + asrGroq: "Groq Whisper-large-v3", + asrWhisper: "OpenAI Whisper(兼容)", + asrFoundryLocalWhisper: "本地 Whisper(Foundry Local)", + asrSherpaOnnxLocal: "本地 sherpa-onnx(实验性)", + asrLocalQwen3: "本地 Qwen3-ASR", + }, + volcengineAppKeyLabel: "APP ID", + volcengineAccessKeyLabel: "Access Token", + volcengineResourceIdLabel: "Resource ID", + volcengineMappingNote: + "Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。", + localAsrActiveNotice: + "当前已启用「{{name}}」,可在「高级」中切换或禁用。", + localAsrTakeoverHint: "启动「{{name}}」后,ASR 提供商将被接管。", + asrProviderTakenOver: "ASR 提供商已被接管", + localAsrHint: + "在本机运行,无需 API Key。从 HuggingFace 下载模型即可使用。", + foundryLocalAsrHint: + "在本机运行,无需 ASR API Key。首次使用需下载运行组件和模型。", + localAsrPerformanceWarning: + "本地推理比云端慢,中文准确率可能更低。适合离线或隐私敏感场景。", + localAsrReady: "{{model}} 已下载", + localAsrNotReady: "{{model}} 未下载", + localAsrGoDownload: "前往模型设置下载", + localAsrManage: "前往模型设置", + localAsrDownloadedTitle: "已下载模型", + localAsrDelete: "删除", + fillDefault: "填入默认值", + readFailed: "读取失败", + apiKeyLabel: "API 密钥", + baseUrlLabel: "接口地址", + modelLabel: "模型", + thinkingModeLabel: "思考", + thinkingModeOn: "开启", + thinkingModeOff: "关闭", + thinkingModeHint: + "关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。", + bailianVocabularyIdLabel: "热词 Vocabulary ID(可选)", + bailianVocabularyIdNote: + "如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。", + appIdLabel: "App ID(应用 ID)", + accessKeyLabel: "Access Key", + resourceIdLabel: "资源 ID", + toolsLabel: "连接检查", + toolsDesc: + "先保存上方配置,再验证当前模型连通性或拉取模型;失败时仍可手动填写模型 ID。", + validate: "验证", + validating: "验证中…", + fetchModels: "拉取模型", + loadingModels: "拉取模型中…", + modelMissing: "未配置模型,请先填写模型 ID。", + modelsEmpty: "鉴权成功,但没有返回可用模型。", + modelsLoaded: "已拉取 {{count}} 个模型。", + selectModel: "选择一个模型写入上方字段", + modelSaved: "已保存模型 {{model}}。", + validateSuccess: "连接检查通过。", + providerHttpStatus: + "供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。", + endpointMustUseHttps: + "Endpoint 必须使用 HTTPS(本地 localhost/127.0.0.1 测试除外)。", + endpointInvalid: "Endpoint 格式不合法。", + responseTooLarge: "供应商响应过大,已停止验证以保证安全。", + asrInvalidJson: "ASR 响应不是有效 JSON。", + asrMissingTextField: "ASR 响应缺少 text 字段。", + apiKeyMissing: "API Key 为空。", + endpointMissing: "Endpoint 为空。", + requestTimeout: "请求超时,请稍后重试。", + }, + shortcuts: { + title: "快捷键速查", + descAcc: "所有快捷键全局生效,需要在权限设置中开启辅助功能。", + descNoAcc: + "所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。", + startStop: "开始 / 停止录音", + cancel: "取消本次录音", + confirm: "胶囊确认插入", + switchStyle: "切换上一次风格", + openApp: "打开 OpenLess", + confirmHint: "点击右侧 ✓", + notSupported: "暂未支持", + }, + permissions: { + title: "权限", + descAcc: + "OpenLess 需要以下系统权限。授权后通常要完全退出 App 重启一次才生效。", + descNoAcc: + "麦克风必需;全局快捷键状态用来检测 native hook 是否运行。", + micLabel: "麦克风", + micDesc: "用于捕获你的语音输入。", + accLabel: "辅助功能", + accDesc: "监听全局快捷键并把识别结果写入光标。", + hotkeyLabel: "全局快捷键", + hotkeyDescWithAdapter: "适配器:{{adapter}}。", + hotkeyDescPlain: "判断快捷键监听是否已安装。", + networkLabel: "网络", + networkDesc: "云端 ASR / LLM 必需,本地模式可关。", + networkOk: "可用", + checking: "检查中…", + granted: "已授权", + notApplicable: "无需授权", + denied: "未授权", + indeterminate: "未确定", + openSystem: "打开系统设置", + grant: "授权", + hotkeyInstalled: "已安装", + hotkeyStarting: "安装中…", + hotkeyFailed: "监听失败", + windowsImeLabel: "Windows 输入法后端", + windowsImeDesc: "语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。", + windowsImeInstalled: "已安装", + windowsImeUnavailable: "不可用", + windowsIme: { + installed: "已安装,按需切到 OpenLess 输入法。", + notInstalled: "未安装,走剪贴板 / WM_PASTE 兜底。", + registrationBroken: "注册损坏,请重装 OpenLess 输入法。", + notWindows: "仅 Windows 可用。", + }, + }, + advanced: { + streamingInsertTitle: "流式输入", + streamingInsertTitleLinux: "流式输入(实验)", + streamingInsertDesc: + "逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。", + streamingInsertLabel: "流式输入", + streamingInsertHintMac: + "临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。", + streamingInsertHintWindows: + "SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。", + streamingInsertHintLinux: + "通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。", + streamingInsertSaveClipboardLabel: "同步到剪贴板", + streamingInsertSaveClipboardHint: + "插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。", + localAsrTitle: "本地 ASR 模型(实验性)", + localAsrDesc: + "把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。", + localAsrWarningShort: "本地推理较慢,配置不足时可能吞字。", + qwen3Desc: "启动之后,ASR 提供商将被接管。", + foundryDesc: "启动之后,ASR 提供商将被接管。", + sherpaDesc: "启动之后,ASR 提供商将被接管。", + notSupportedHere: "本平台暂不支持,未集成推理模块。", + enable: "启用", + alreadyActive: "已启用", + disableLocalLabel: "禁用本地 ASR", + disableLocalDesc: "切回云端 ASR(默认火山引擎 bigasr)。", + disable: "禁用", + platformNotSupported: "该平台暂未支持本地 ASR 模型集成。", + confirmEnableLocalTitle: "启用本地 ASR?", + confirmEnableLocalBody: "启用后转写会比云端慢,准确率可能更低。", + confirm: "确认启用", + }, + language: { + title: "界面语言", + desc: "切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。", + label: "语言", + labelDesc: "选择「跟随系统」时按操作系统当前语言显示。", + followSystem: "跟随系统", + zh: "简体中文", + zhTW: "繁體中文", + en: "English", + ja: "日本語 (Beta)", + ko: "한국어 (Beta)", + restartHint: + "部分原生菜单(系统托盘等)可能需要重启 App 才会切换。", + }, + about: { + tagline: "自然说话,完美书写", + checkUpdate: "检查更新", + checkUpdateBtn: "检查", + checkingUpdate: "检查中…", + upToDate: "当前已是最新版本。", + updateError: "检查或更新失败,请稍后重试。", + openReleases: "打开 Releases", + source: "源码", + docs: "文档", + feedback: "反馈", + qq: "社区 QQ 群", + qqDesc: "使用 QQ 搜索群号加入,或扫码进群。", + copyQq: "复制群号", + privacy: "隐私", + privacyDesc: "所有数据仅保存在本机,云端 API 不保留录音。", + localFirst: "本地优先", + betaChannelLabel: "加入 Beta 渠道", + betaChannelDesc: + "开启后自动接收 Beta 版本。可能不稳定,仅推荐愿意尝鲜的用户。", + betaChannelFetching: "正在获取最新 Beta 版本…", + betaChannelFetchBtn: "查询最新 Beta", + betaChannelLatestPrefix: "最新 Beta:", + betaChannelDownloadBtn: "前往下载", + betaChannelRefresh: "重新查询", + betaChannelNoBeta: "暂无已发布的 Beta 版。", + betaChannelFetchError: "获取 Beta 版本信息失败,请稍后重试。", + betaChannelUpToDate: "已是最新", + betaChannelUpdateNow: "立即更新", + betaChannelUpdateNowTitle: + "检查并下载最新 Beta,然后弹出更新对话框", + betaChannelChecking: "检查中…", + updateDialog: { + available: { + title: "发现新版本", + desc: "发现 OpenLess {{version}},是否现在更新?", + }, + downloading: { + title: "正在下载更新", + desc: "正在下载 OpenLess {{version}},请保持应用打开。", + }, + downloaded: { + title: "更新已准备好", + desc: "OpenLess {{version}} 已安装完成。是否现在自动重启以应用更新?", + }, + installing: { + title: "正在安装更新", + desc: "正在安装 OpenLess {{version}},请保持应用打开。", + }, + install: "现在更新", + downloadingLabel: "下载中…", + installingLabel: "安装中…", + later: "稍后手动重启", + restartNow: "现在重启", + progress: "{{progress}}% · {{downloaded}} / {{total}}", + progressUnknown: "已下载 {{downloaded}}", + }, + }, }, - personalize: { - appearance: '外观', - appearanceDesc: '跟随系统 / 浅色 / 深色', - appearanceSystem: '跟随系统', - appearanceLight: '浅色', - appearanceDark: '深色', - font: '字体大小', - fontDesc: '整体缩放界面字号,立即生效。', - fontSmall: '小', - fontMedium: '中', - fontLarge: '大', - blur: '毛玻璃强度', - blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。', - startupOpen: '启动时打开', - startupOverview: '概览', - startupLast: '上次位置', - startupAtBoot: '开机自启', + modal: { + sections: { + account: "账户", + settings: "设置", + personalize: "个性化", + about: "关于", + helpCenter: "帮助中心", + releaseNotes: "版本说明", + }, + account: { + localUser: "本地用户", + localUserDesc: "未登录 · 所有数据保存在本机", + loginSync: "登录 / 同步", + footer: "默认完全本地运行。登录后可跨设备同步词汇表与风格预设。", + }, + personalize: { + appearance: "外观", + appearanceDesc: "跟随系统 / 浅色 / 深色", + appearanceSystem: "跟随系统", + appearanceLight: "浅色", + appearanceDark: "深色", + font: "字体大小", + fontDesc: "整体缩放界面字号,立即生效。", + fontSmall: "小", + fontMedium: "中", + fontLarge: "大", + blur: "毛玻璃强度", + blurDesc: + "影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。", + startupOpen: "启动时打开", + startupOverview: "概览", + startupLast: "上次位置", + startupAtBoot: "开机自启", + }, + about: { + tagline: "自然说话,完美书写", + checkUpdate: "检查更新", + checkUpdateBtn: "检查", + docs: "文档", + docsBtn: "openless.app/docs ↗", + feedback: "反馈渠道", + feedbackBtn: "GitHub Issues ↗", + source: "源码", + qq: "社区 QQ 群", + qqDesc: "使用 QQ 搜索群号加入,或扫码进群。", + copyQq: "复制群号", + exportErrorLog: "导出错误日志", + exportErrorLogDesc: + "把当前会话的运行日志保存到本地,便于排查问题或反馈给我们。", + exportErrorLogBtn: "导出", + exporting: "导出中…", + exportSuccess: "已保存", + exportFailed: "导出失败", + privacy: "隐私", + privacyDesc: "所有识别结果只保存在本机,云端 API 仅用于实时调用。", + localFirst: "本地优先", + }, }, - about: { - tagline: '自然说话,完美书写', - checkUpdate: '检查更新', - checkUpdateBtn: '检查', - docs: '文档', - docsBtn: 'openless.app/docs ↗', - feedback: '反馈渠道', - feedbackBtn: 'GitHub Issues ↗', - source: '源码', - qq: '社区 QQ 群', - qqDesc: '使用 QQ 搜索群号加入,或扫码进群。', - copyQq: '复制群号', - exportErrorLog: '导出错误日志', - exportErrorLogDesc: '把当前会话的运行日志保存到本地,便于排查问题或反馈给我们。', - exportErrorLogBtn: '导出', - exporting: '导出中…', - exportSuccess: '已保存', - exportFailed: '导出失败', - privacy: '隐私', - privacyDesc: '所有识别结果只保存在本机,云端 API 仅用于实时调用。', - localFirst: '本地优先', + windowChrome: { + minimize: "最小化", + maximize: "最大化", + close: "关闭", }, - }, - windowChrome: { - minimize: '最小化', - maximize: '最大化', - close: '关闭', - }, - hotkey: { - triggers: { - rightOption: '右 Option', - leftOption: '左 Option', - rightControl: '右 Control', - leftControl: '左 Control', - rightCommand: '右 Command', - fn: 'Fn (地球键)', - rightAlt: '右 Alt', - custom: '自定义组合键…', + hotkey: { + triggers: { + rightOption: "右 Option", + leftOption: "左 Option", + rightControl: "右 Control", + leftControl: "左 Control", + rightCommand: "右 Command", + fn: "Fn (地球键)", + rightAlt: "右 Alt", + custom: "自定义组合键…", + }, + fallback: "全局快捷键", + modeHoldSuffix: "(按住说话)", + modeToggleSuffix: "(开始 / 停止)", + usageHold: "按住 {{trigger}} 说话,松开结束。", + usageToggle: "按 {{trigger}} 开始录音,再按一次结束。", + adapter: { + macEventTap: "macOS Event Tap", + windowsLowLevel: "Windows 低层键盘 hook", + fcitx5: "fcitx5 输入法插件", + }, }, - fallback: '全局快捷键', - modeHoldSuffix: '(按住说话)', - modeToggleSuffix: '(开始 / 停止)', - usageHold: '按住 {{trigger}} 说话,松开结束。', - usageToggle: '按 {{trigger}} 开始录音,再按一次结束。', - adapter: { - macEventTap: 'macOS Event Tap', - windowsLowLevel: 'Windows 低层键盘 hook', - fcitx5: 'fcitx5 输入法插件', + localAsr: { + kicker: "本地 ASR", + title: "模型设置", + desc: "管理本机语音识别模型。", + qwenTitle: "Qwen3-ASR 模型管理", + qwenExperimentalBadge: "实验性", + engineUnavailable: + "当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。", + qwenUnavailableOnWindows: + "Windows 暂不支持 Qwen3-ASR,请使用上方 Foundry Local Whisper。", + foundryTitle: "Windows Foundry Local Whisper", + foundryDesc: + "在本机识别语音,无需 ASR API Key。首次使用需下载运行组件和模型。", + foundryAvailable: "Windows 可用", + foundryUnavailable: "仅 Windows 可用", + foundryRuntimeReady: "运行组件已下载", + foundryRuntimeMissing: "运行组件未下载", + foundryRuntimeSourceLabel: "运行组件下载源", + foundryRuntimeSourceAuto: "自动(NuGet 优先)", + foundryRuntimeSourceNuget: "NuGet 官方源", + foundryRuntimeSourceOrtNightly: "Microsoft ORT-Nightly 源", + foundryRuntimeSourceDesc: "首次使用前需下载运行组件。", + foundrySelectedModel: "选择模型", + foundryActiveModel: "当前默认 alias", + foundryLoadedModel: "已加载模型", + foundryNotLoaded: "未加载", + foundryError: "Foundry 状态", + foundrySetDefault: "设为默认 / 启用 Windows 本地 ASR", + foundryEnabling: "正在启用…", + foundryPrepare: "准备 / 下载 / 加载", + foundryPreparing: "正在准备…", + foundryReleasing: "正在释放…", + foundryRetryPrepare: "继续准备 / 重试", + foundryCancelPrepare: "取消准备", + foundryCancelRequested: "已请求取消", + foundryCancelling: "正在取消…", + foundryCancelBestEffort: + "已请求取消,会在当前步骤完成后停止。可稍后重试。", + foundryPrepareRuntime: "准备运行时组件", + foundryPrepareModel: "下载模型", + foundryPrepareLoad: "加载模型", + foundryPrepareModelSkipped: "模型已下载,跳过下载阶段", + foundryPrepareDone: "已完成", + foundryPrepareWaiting: "等待中", + foundryApproxSizeMb: "约 {{mb}} MB", + foundryLanguageLabel: "识别语言", + foundryLanguageAuto: "自动", + foundryLanguageZh: "中文 zh", + foundryLanguageEn: "英文 en", + foundryLanguageDesc: "中文听写选中文,中英混用选自动。", + foundryModelSmall: "Whisper Small(默认 / 平衡)", + foundryModelSmallDesc: "默认平衡选项,兼顾质量与资源占用。", + foundryModelMedium: "Whisper Medium(更高质量)", + foundryModelMediumDesc: + "更高准确率,适合性能更强、可接受更大下载和更慢推理的设备。", + foundryModelLarge: "Whisper Large V3 Turbo(最高质量)", + foundryModelLargeDesc: + "更高质量的大模型选项,适合高配设备和质量优先场景。", + foundryModelBase: "Whisper Base(更快 / 更省资源)", + 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)", + mirrorHfMirror: "国内镜像 (hf-mirror.com)", + mirrorGithubRelease: "GitHub Release 归档", + activeBadge: "当前使用", + downloadedBadge: "已下载", + notDownloadedBadge: "未下载", + download: "下载", + resume: "继续下载", + cancel: "取消", + delete: "删除", + setActive: "设为默认", + failed: "失败", + cancelled: "已取消", + files: "文件", + sizeLoading: "正在查询尺寸…", + sizeUnknown: "尺寸未知", + performanceWarning: + "本地 ASR 适合离线或隐私敏感场景,首次使用需下载模型。", + test: "加载并测试", + testRunning: "测试中…", + testHeading: "内置音频测试", + testExpected: "原文", + testActual: "识别", + testStats: + "音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}", + testFailed: "测试失败", + engineStatusLabel: "内存中的引擎", + engineLoaded: "已加载:{{model}}", + engineUnloaded: "未加载(首次听写需先加载模型)", + loadNow: "立即加载", + releaseNow: "立即释放", + keepLoadedLabel: "保持加载多久", + keepLoadedDesc: + "决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。", + keepImmediate: "说完话立即释放", + keep1min: "上次使用后 1 分钟", + keep5min: "上次使用后 5 分钟(默认)", + keep30min: "上次使用后 30 分钟", + keepForever: "不释放(始终保留)", }, - }, - localAsr: { - kicker: '本地 ASR', - title: '模型设置', - desc: '管理本机语音识别模型。', - qwenTitle: 'Qwen3-ASR 模型管理', - qwenExperimentalBadge: '实验性', - engineUnavailable: '当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。', - qwenUnavailableOnWindows: 'Windows 暂不支持 Qwen3-ASR,请使用上方 Foundry Local Whisper。', - foundryTitle: 'Windows Foundry Local Whisper', - foundryDesc: '在本机识别语音,无需 ASR API Key。首次使用需下载运行组件和模型。', - foundryAvailable: 'Windows 可用', - foundryUnavailable: '仅 Windows 可用', - foundryRuntimeReady: '运行组件已下载', - foundryRuntimeMissing: '运行组件未下载', - foundryRuntimeSourceLabel: '运行组件下载源', - foundryRuntimeSourceAuto: '自动(NuGet 优先)', - foundryRuntimeSourceNuget: 'NuGet 官方源', - foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly 源', - foundryRuntimeSourceDesc: '首次使用前需下载运行组件。', - foundrySelectedModel: '选择模型', - foundryActiveModel: '当前默认 alias', - foundryLoadedModel: '已加载模型', - foundryNotLoaded: '未加载', - foundryError: 'Foundry 状态', - foundrySetDefault: '设为默认 / 启用 Windows 本地 ASR', - foundryEnabling: '正在启用…', - foundryPrepare: '准备 / 下载 / 加载', - foundryPreparing: '正在准备…', - foundryReleasing: '正在释放…', - foundryRetryPrepare: '继续准备 / 重试', - foundryCancelPrepare: '取消准备', - foundryCancelRequested: '已请求取消', - foundryCancelling: '正在取消…', - foundryCancelBestEffort: '已请求取消,会在当前步骤完成后停止。可稍后重试。', - foundryPrepareRuntime: '准备运行时组件', - foundryPrepareModel: '下载模型', - foundryPrepareLoad: '加载模型', - foundryPrepareModelSkipped: '模型已下载,跳过下载阶段', - foundryPrepareDone: '已完成', - foundryPrepareWaiting: '等待中', - foundryApproxSizeMb: '约 {{mb}} MB', - foundryLanguageLabel: '识别语言', - foundryLanguageAuto: '自动', - foundryLanguageZh: '中文 zh', - foundryLanguageEn: '英文 en', - foundryLanguageDesc: '中文听写选中文,中英混用选自动。', - foundryModelSmall: 'Whisper Small(默认 / 平衡)', - foundryModelSmallDesc: '默认平衡选项,兼顾质量与资源占用。', - foundryModelMedium: 'Whisper Medium(更高质量)', - foundryModelMediumDesc: '更高准确率,适合性能更强、可接受更大下载和更慢推理的设备。', - foundryModelLarge: 'Whisper Large V3 Turbo(最高质量)', - foundryModelLargeDesc: '更高质量的大模型选项,适合高配设备和质量优先场景。', - foundryModelBase: 'Whisper Base(更快 / 更省资源)', - foundryModelBaseDesc: '更快、资源占用更低,适合日常轻量使用。', - foundryModelTiny: 'Whisper Tiny(最快 / 冒烟测试)', - foundryModelTinyDesc: '最快的检查选项,适合确认 Foundry 路径可用。', - mirrorLabel: '下载镜像源', - mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', - mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', - mirrorHfMirror: '国内镜像 (hf-mirror.com)', - activeBadge: '当前使用', - downloadedBadge: '已下载', - notDownloadedBadge: '未下载', - download: '下载', - resume: '继续下载', - cancel: '取消', - delete: '删除', - setActive: '设为默认', - failed: '失败', - cancelled: '已取消', - files: '文件', - sizeLoading: '正在查询尺寸…', - sizeUnknown: '尺寸未知', - performanceWarning: '本地 ASR 适合离线或隐私敏感场景,首次使用需下载模型。', - test: '加载并测试', - testRunning: '测试中…', - testHeading: '内置音频测试', - testExpected: '原文', - testActual: '识别', - testStats: '音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}', - testFailed: '测试失败', - engineStatusLabel: '内存中的引擎', - engineLoaded: '已加载:{{model}}', - engineUnloaded: '未加载(首次听写需先加载模型)', - loadNow: '立即加载', - releaseNow: '立即释放', - keepLoadedLabel: '保持加载多久', - keepLoadedDesc: '决定 Qwen3-ASR 用完后多久从内存释放,避免长期占用内存。', - keepImmediate: '说完话立即释放', - keep1min: '上次使用后 1 分钟', - keep5min: '上次使用后 5 分钟(默认)', - keep30min: '上次使用后 30 分钟', - keepForever: '不释放(始终保留)', - }, -}; +} diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 6a8014c7..1feefdbf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1,983 +1,1094 @@ -import type { zhCN } from './zh-CN'; +import type { zhCN } from "./zh-CN" // 繁體中文資源 — 與產品當前文案保持一致。 // 新增 key 時,必須同步更新 en.ts,避免切換到 English 後出現中文殘留。 export const zhTW: typeof zhCN = { - app: { - name: 'OpenLess', - tagline: '自然說話,完美書寫', - }, - common: { - loading: '加載中…', - retry: '重試', - settingsLoadFailed: '設置加載失敗', - refresh: '刷新', - clear: '清空', - copy: '複製', - delete: '刪除', - later: '稍後', - cancel: '取消', - close: '關閉', - show: '顯示', - hide: '隱藏', - saved: '已保存', - saving: '保存中', - copied: '已複製', - operationFailed: '操作失敗', - add: '添加', - durationSeconds: '{{value}} 秒', - durationMinutes: '{{value}} 分鐘', - }, - capsule: { - thinking: 'thinking', - cancelled: '已取消', - error: '出錯了', - inserted: '已插入 {{count}}', - translating: '正在翻譯', - }, - qa: { - thinking: '思考中…', - error: '出錯了,請稍後再試。', - errorRetry: '重試', - errorRetryHint: '再按 {{recordHotkey}} 重新提問。', - pinTooltip: '固定(不自動關閉)', - unpinTooltip: '取消固定', - closeTooltip: '關閉', - selectionPreview: '基於選中文本:', - emptyTitle: '按 {{recordHotkey}} 開始提問', - emptyDesc: '在任意 app 選中一段文字後,按一次 {{recordHotkey}} 開始錄音,再按一次結束並提交。回答會顯示在這裏,可以連續多輪追問。', - recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問', - statusIdle: '按 {{recordHotkey}} 提問', - statusRecording: '錄音中', - statusThinking: '思考中', - statusError: '出錯了', - }, - nav: { - overview: '概覽', - history: '歷史', - vocab: '詞彙表', - style: '風格', - marketplace: '風格市場', - translation: '翻譯', - selectionAsk: '劃詞追問', - localAsr: '模型設置', - }, - marketplace: { - kicker: 'MARKETPLACE', - title: '風格包市場', - desc: '瀏覽、安裝和分享社區風格包。', - searchPlaceholder: '搜尋名稱 / 描述 / 標籤…', - sortPopular: '按熱度', - sortNew: '最新', - uploadBtn: '上傳', - uploadDisabledHint: '請先在 設定 → 風格市場 配置 GitHub 使用者名稱', - refreshBtn: '重新整理', - empty: '還沒有風格包', - emptyHint: '換個搜尋詞,或自己上傳一個分享給社群', - loadFailed: '載入失敗:{{err}}', - noDescription: '(暫無描述)', - installBtn: '安裝到本機', - likeBtn: '點讚', - installed: '已安裝「{{name}}」到本機風格包', - uploaded: '上傳成功,等待審核', - uploadTitle: '選擇要上傳的風格包', - uploadHint: '以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。', - uploadNoLocal: '本機沒有可上傳的風格包', - errors: { - detail: '載入詳情失敗:{{err}}', - install: '安裝失敗:{{err}}', - like: '點讚失敗:{{err}}', - upload: '上傳失敗:{{err}}', - loadLocal: '載入本機風格包失敗:{{err}}', + app: { + name: "OpenLess", + tagline: "自然說話,完美書寫", }, - sortLiked: '我讚過的', - likedEmpty: '你還沒有讚過任何風格包', - likedEmptyHint: '點開任一風格包,紅色星星點亮後會出現在這裡', - derivativeBadge: '衍生自 @{{login}}', - detail: { - withdrawBtn: '撤回發布', - withdrawConfirm: '確認從風格市場撤回「{{name}}」?本機副本不會被刪除。', - withdrawSuccess: '已從風格市場撤回', - withdrawFailed: '撤回失敗:{{err}}', + common: { + loading: "加載中…", + retry: "重試", + settingsLoadFailed: "設置加載失敗", + refresh: "刷新", + clear: "清空", + copy: "複製", + delete: "刪除", + later: "稍後", + cancel: "取消", + close: "關閉", + show: "顯示", + hide: "隱藏", + saved: "已保存", + saving: "保存中", + copied: "已複製", + operationFailed: "操作失敗", + add: "添加", + durationSeconds: "{{value}} 秒", + durationMinutes: "{{value}} 分鐘", }, - myPacks: { - buttonLabel: '我的發布', - buttonTitle: '查看 {{login}} 的發布', - buttonTitleEmpty: '先在 Settings → 風格市場 填寫發布身份', - searchPlaceholder: '搜尋名稱、標籤', - notLoggedIn: '請先在 Settings → 風格市場 填寫發布身份', - emptyTitle: '你還沒有發布過風格包', - emptyHint: '在「風格」頁面編輯後點「發布到風格市場」,或點擊右上角上傳本機風格包。', - noMatch: '沒有符合的風格包', - summary: '已發布 {{count}} 個風格包', - summaryPending: '已發布 {{count}} 個風格包 · {{pending}} 個審核中', - versionDate: 'v{{version}} · {{date}}', - stats: '★ {{likes}} · ↓ {{downloads}}', - actions: { - update: '更新', - withdraw: '下架', - }, - loadFailed: '我的發布載入失敗:{{err}}', - loadingTitle: '正在拉取,請稍後…', - loadingHint: '從風格市場獲取你最新發布的風格包。', - loadErrorTitle: '載入失敗', - loadErrorRetry: '重試', + capsule: { + thinking: "thinking", + cancelled: "已取消", + error: "出錯了", + inserted: "已插入 {{count}}", + translating: "正在翻譯", }, - upload: { - confirmBtn: '確定上傳', - updateTitle: '更新「{{name}}」', - updateHint: '選中要上傳的本機新版本風格包,下方點「確定上傳」。同名包預設預選。', - recommendedBadge: '建議更新', + qa: { + thinking: "思考中…", + error: "出錯了,請稍後再試。", + errorRetry: "重試", + errorRetryHint: "再按 {{recordHotkey}} 重新提問。", + pinTooltip: "固定(不自動關閉)", + unpinTooltip: "取消固定", + closeTooltip: "關閉", + selectionPreview: "基於選中文本:", + emptyTitle: "按 {{recordHotkey}} 開始提問", + emptyDesc: + "在任意 app 選中一段文字後,按一次 {{recordHotkey}} 開始錄音,再按一次結束並提交。回答會顯示在這裏,可以連續多輪追問。", + recordingHint: "錄音中…再按一次 {{recordHotkey}} 結束並提問", + statusIdle: "按 {{recordHotkey}} 提問", + statusRecording: "錄音中", + statusThinking: "思考中", + statusError: "出錯了", }, - state: { - pending: '審核中', - approved: '已上架', - rejected: '未通過', - withdrawn: '已下架', - superseded: '已被新版替換', - unknown: '未知', + nav: { + overview: "概覽", + history: "歷史", + vocab: "詞彙表", + style: "風格", + marketplace: "風格市場", + translation: "翻譯", + selectionAsk: "劃詞追問", + localAsr: "模型設置", }, - oauth: { - title: '用 GitHub 登入', - generating: '正在產生裝置驗證碼…', - browserHint: '在瀏覽器中開啟 {{uri}} 並輸入下方代碼:', - copyBtn: '複製', - copied: '已複製裝置碼', - copyFailed: '複製失敗:{{err}}', - openBrowserBtn: '開啟瀏覽器', - cancelBtn: '取消', - waiting: '等待你在瀏覽器中授權…', - successAs: '已登入為 @{{login}}', - retryBtn: '重試', - closeBtn: '關閉', - loginBtn: '登入', - loginTooltip: '點擊用 GitHub 登入', - reloginTooltip: '點擊重新登入 / 切換帳號(目前 @{{login}})', - }, - modal: { - loggedIn: '目前登入身份 —— 在 Settings → 錄音 → 風格市場 修改', - notLoggedIn: '未登入 —— 去 Settings → 錄音 → 風格市場 填一個發布者名', - notLoggedInLabel: '未登入', - }, - }, - shell: { - shortcutLabel: '錄音快捷鍵', - shortcutHint: '開始 / 停止', - betaTag: 'BETA', - betaNote: '所有數據都只保存在本機。', - footer: { - account: '賬戶', - feedback: '反饋', - settings: '設置', - help: '幫助', - version: '版本 {{version}}', - helpPopover: { - tagline: '本地驅動的語音輸入層', - releaseNotes: '查看發佈日誌 ↗', - docs: '幫助中心 ↗', - }, - }, - providerPrompt: { - title: '設置語音提供商', - body: '還沒有配置 ASR 或 LLM 提供商,語音輸入和潤色暫時無法正常工作。', - later: '稍後', - openSettings: '去設置', - }, - hotkeyModePrompt: { - title: '檢查錄音方式', - body: '預設已改為切換式。如果之前改過觸發方式,請到錄音設定確認一次。', - later: '稍後提醒', - openSettings: '去錄音設置', - }, - }, - onboarding: { - welcome: '歡迎使用 OpenLess', - intro: '本地說出,本地落字。開始前需要兩個系統權限。', - accessibilityTitle: '輔助功能', - hotkeyTitle: '全局快捷鍵', - accessibilityDesc: '用於監聽全局快捷鍵(默認 {{trigger}})並把識別結果寫入光標位置。', - hotkeyDesc: '用於確認全局快捷鍵監聽可用。', - micTitle: '麥克風', - micDesc: '用於捕獲你的語音輸入。', - actionNotApplicable: '無需授權', - actionGranted: '已授權', - actionOpenSystem: '打開系統設置', - actionGrant: '授權', - actionRequestMic: '彈出授權', - accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。', - footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。', - }, - overview: { - kicker: 'DASHBOARD', - title: '今日概覽', - desc: '今日口述統計與系統狀態。', - pressPrefix: '按', - pressSuffix: '開始錄音', - asrKind: 'ASR 語音', - llmKind: 'LLM 模型', - asrName: '火山引擎', - asrSubname: 'bigmodel', - llmName: 'OpenAI 兼容', - llmConfigured: '已配置 active LLM', - llmNotConfigured: '未配置', - statusConfigured: '已配置', - statusNotConfigured: '未配置', - statusUnknown: '無法讀取', - credentialsLoadError: '無法讀取憑據狀態', - metricChars: '今日字數', - metricSegments: '{{count}} 段', - metricDuration: '今日總時長', - metricAvg: '平均段落', - metricAvgTrend: '今日均值', - metricNoData: '暫無數據', - historyLoadError: '歷史讀取失敗', - metricTotal: '累計記錄', - metricTotalTrend: '本機存檔 (上限 200)', - weekTitle: '近 7 天', - weekUnit: '條數 / 天', - recentTitle: '最近識別', - recentAll: '全部記錄 →', - recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。', - recentLoadFailed: '無法讀取最近識別,請重試。', - historyRetry: '重試', - weekDays: ['日', '一', '二', '三', '四', '五', '六'], - }, - history: { - kicker: 'HISTORY', - title: '歷史記錄', - desc: '本機保存的識別記錄。', - filterAll: '全部', - summary: '共 {{total}} 條 · 顯示 {{shown}}', - empty: '還沒有歷史記錄。按 {{trigger}} 錄一段試試。', - loadFailed: '加載歷史失敗:{{err}}', - retry: '重試', - clearFailed: '清空失敗:{{err}}', - deleteFailed: '刪除失敗:{{err}}', - copyFailed: '複製失敗:{{err}}', - playRecording: '播放錄音', - audioLoading: '載入中…', - exportRecording: '匯出錄音', - exportFailed: '匯出失敗:{{err}}', - rawLabel: '原文', - rawEmpty: '(空)', - selectHint: '左側選一條查看詳情。', - insertedTo: '插入到', - chars: '{{count}} 字', - vocabHits: '{{count}} 個熱詞', - inserted: '已插入', - pasteSent: '已嘗試粘貼', - copiedFallback: '已複製(需 {{shortcut}})', - insertFailed: '插入失敗', - confirmClear: '確定清空全部 {{count}} 條記錄?此操作不可恢復。', - }, - vocab: { - kicker: 'VOCABULARY', - title: '詞彙表', - desc: '添加生詞或專業術語,提高識別準確率。', - sectionTitle: '詞條', - placeholder: '輸入詞語,按 Enter 或點添加…', - tip: '支持中英混合 · 數字開頭按字面識別 · 命中次數自動計數', - loadFailed: '加載失敗:{{err}}', - empty: '還沒有詞條。在上面輸入一個生詞或專業術語,讓模型在聽寫時優先匹配。', - tipDisabled: '點擊禁用此詞條', - tipEnabled: '點擊啓用此詞條', - removeAria: '刪除', - corrections: { - title: '糾正規則', - tip: '修正常見 ASR 誤識別,支援 {num} 數字通配。', - patternPlaceholder: '誤識別寫法,如 {num}粒', - replacementPlaceholder: '目標寫法,如 {num}例', - empty: '還沒有糾正規則。', - invalid: '僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。', - tipDisabled: '點擊停用此規則', - tipEnabled: '點擊啟用此規則', - removeAria: '刪除糾正規則', - }, - presets: { - title: '場景預設', - tip: '可多選批量啟用,支援編輯和新建。', - create: '新建預設', - apply: '啓用所選', - save: '保存預設', - edit: '編輯 {{name}}', - newPreset: '新預設', - namePlaceholder: '預設名稱', - wordsPlaceholder: '詞條(用逗號或換行分隔)', - }, - }, - style: { - kicker: 'STYLE', - title: '輸出風格', - desc: '選擇錄音的預設輸出風格。', - masterToggle: '整體啓用', - currentDefault: '當前默認', - ariaSetDefault: '設爲默認', - saveFailed: '保存失敗:{{error}}', - customPromptTitle: '自定義提示詞', - customPromptPlaceholder: '可選,追加到這個風格的內建 system prompt 末尾。', - customPromptHint: '留空則保持當前行為不變。保存後會在該風格的潤色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。', - customPromptSave: '保存提示詞', - customPromptDirty: '未保存', - systemPromptMovedHint: '完整 System Prompt 已移到 設定 -> Providers 頁面統一編輯。這裡現在只負責風格啟停和預設風格。', - modes: { - raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' }, - light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' }, - structured: { name: '清晰結構', desc: '多個主題或步驟時,自動組織爲分點列表。', sample: '1. 主題一\na. 要點\nb. 要點\n2. 主題二\na. 要點\nb. 要點' }, - formal: { name: '正式表達', desc: '工作溝通和郵件場景,更專業更完整。', sample: '郵件場景自動識別問候 / 落款;不引入空泛客套。' }, - }, - pack: { - kicker: 'STYLE PACKS', - title: '風格包', - desc: '管理本機風格包。', - marketplaceBtn: '風格市場', - loadFailed: '載入風格包失敗:{{err}}', - importZip: '匯入 ZIP', - exportZip: '匯出 ZIP', - exportShort: '匯出', - publishMarketplace: '發布到風格市場', - updateMarketplace: '更新到風格市場新版本', - publishDisabledHint: '請先在 設定 → 風格市場 設定 GitHub 使用者名稱', - publishSuccess: '發布成功,等待 marketplace 審核', - publishFailed: '發布失敗:{{err}}', - publishBuiltinRejected: '內建風格包不能直接發布,請先編輯產生一份匯入版。', - builtin: '內建', - imported: '匯入', - active: '目前', - activate: '啟用', - edit: '編輯', - closeEditor: '關閉', - unsaved: '未儲存', - listTitle: '本機風格包', - listDesc: '瀏覽和切換風格包。', - listCount: '{{count}} 個風格包', - addPackTileTitle: '新建風格包', - addPackTileHint: '從空白範本開始。', - createSuccess: '已建立新風格包', - createFailed: '建立風格包失敗:{{err}}', - save: '儲存', - revert: '還原', - saveSuccess: '風格包已儲存', - saveFailed: '儲存風格包失敗:{{err}}', - activateSuccess: '已將"{{name}}"設為目前風格', - activateFailed: '設為目前風格失敗:{{err}}', - importSuccess: '已匯入"{{name}}"', - importFailed: '匯入 ZIP 失敗:{{err}}', - exportSuccess: '已匯出到 {{path}}', - exportFailed: '匯出 ZIP 失敗:{{err}}', - exportDirtyFirst: '請先儲存目前風格包,再匯出 ZIP。', - resetBuiltin: '重設', - resetSuccess: '已重設"{{name}}"', - resetFailed: '重設風格包失敗:{{err}}', - deleteImported: '刪除', - deleteConfirm: '確定刪除"{{name}}"嗎?刪除後無法復原。', - deleteSuccess: '已刪除"{{name}}"', - deleteFailed: '刪除風格包失敗:{{err}}', - summaryCurrentEmpty: '還沒有選中風格包', - editorTitle: '編輯風格', - editorDesc: '編輯目前風格包。', - metaTitle: '安裝資訊', - metaSource: '來源', - metaBaseMode: '基礎模式', - metaUpdatedAt: '更新時間', - fieldName: '名稱', - fieldAuthor: '作者', - fieldAuthorPlaceholder: '可選,方便標註來源', - fieldVersion: '版本', - fieldTags: '標籤', - fieldTagsPlaceholder: '用英文逗號分隔,例如 community, voiceover, formal', - fieldDescription: '描述', - fieldModel: '建議模型(僅元資料)', - fieldModelPlaceholder: '可選,例如 gpt-4.1 / deepseek-v3', - fieldModelHint: '僅作說明,不會切換實際模型。', - fieldCompatibility: '相容版本', - fieldCompatibilityPlaceholder: '可選,例如 >=1.3.0', - fullPromptTitle: 'System Prompt', - fullPromptHint: '這就是這套風格包自己的 Prompt。', - promptChars: '{{count}} 字元', - runtimeTitle: 'OpenLess 執行時附加指令', - runtimeDesc: '只讀的執行時輔助項。', - runtimeContextTitle: '上下文前提', - runtimeContextDesc: '來自語言與應用上下文', - runtimeContextEmpty: '目前不會附加', - runtimeHotwordTitle: '熱詞提示段', - runtimeHotwordDesc: '來自已啟用熱詞', - runtimeHotwordEmpty: '目前不會附加', - runtimeHistoryTitle: '多輪歷史保護段', - runtimeHistoryDesc: '僅用於即時多輪 polish', - runtimeHistoryEmpty: '只有存在 prior turns 時才會附加', - runtimeActive: '目前生效', - runtimeInactive: '目前未生效', - runtimePreviewFailed: '產生執行時預覽失敗:{{err}}', - runtimePreviewOmittedFrontApp: '預覽已省略前台 app 標籤。', - examplesTitle: '效果範例', - examplesDesc: '會隨風格包一起匯出。', - addExample: '新增範例', - examplesEmpty: '還沒有範例。', - exampleTitlePlaceholder: '範例 {{index}} 標題', - exampleInput: '輸入', - exampleOutput: '輸出', - examplesCount: '{{count}} 個範例', - discardCloseConfirm: '關閉編輯面板前要捨棄未儲存修改嗎?', - discardSwitchConfirm: '要捨棄目前未儲存修改,並切換到"{{name}}"嗎?', - derivativeBadge: '衍生自 @{{login}}', - }, - }, - translation: { - kicker: 'TRANSLATION', - title: '翻譯', - desc: '錄音後自動翻譯為目標語言再插入。', - statusEnabled: '已啓用', - statusDisabled: '未啓用', - working: { - title: '工作語言', - desc: '勾選日常使用的語言,影響潤色與翻譯效果。', - }, - target: { - title: '翻譯目標語言', - desc: '錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。', - disabled: '不啓用(Shift 按下不觸發翻譯)', - }, - save: { - workingFailed: '工作語言保存失敗,請重試。', - targetFailed: '翻譯目標語言保存失敗,請重試。', - hotkeyRegisterFailed: '翻譯快捷鍵註冊失敗,未繼續保存。', - hotkeySaveFailed: '翻譯快捷鍵保存失敗,請重試。', + marketplace: { + kicker: "MARKETPLACE", + title: "風格包市場", + desc: "瀏覽、安裝和分享社區風格包。", + searchPlaceholder: "搜尋名稱 / 描述 / 標籤…", + sortPopular: "按熱度", + sortNew: "最新", + uploadBtn: "上傳", + uploadDisabledHint: "請先在 設定 → 風格市場 配置 GitHub 使用者名稱", + refreshBtn: "重新整理", + empty: "還沒有風格包", + emptyHint: "換個搜尋詞,或自己上傳一個分享給社群", + loadFailed: "載入失敗:{{err}}", + noDescription: "(暫無描述)", + installBtn: "安裝到本機", + likeBtn: "點讚", + installed: "已安裝「{{name}}」到本機風格包", + uploaded: "上傳成功,等待審核", + uploadTitle: "選擇要上傳的風格包", + uploadHint: "以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。", + uploadNoLocal: "本機沒有可上傳的風格包", + errors: { + detail: "載入詳情失敗:{{err}}", + install: "安裝失敗:{{err}}", + like: "點讚失敗:{{err}}", + upload: "上傳失敗:{{err}}", + loadLocal: "載入本機風格包失敗:{{err}}", + }, + sortLiked: "我讚過的", + likedEmpty: "你還沒有讚過任何風格包", + likedEmptyHint: "點開任一風格包,紅色星星點亮後會出現在這裡", + derivativeBadge: "衍生自 @{{login}}", + detail: { + withdrawBtn: "撤回發布", + withdrawConfirm: + "確認從風格市場撤回「{{name}}」?本機副本不會被刪除。", + withdrawSuccess: "已從風格市場撤回", + withdrawFailed: "撤回失敗:{{err}}", + }, + myPacks: { + buttonLabel: "我的發布", + buttonTitle: "查看 {{login}} 的發布", + buttonTitleEmpty: "先在 Settings → 風格市場 填寫發布身份", + searchPlaceholder: "搜尋名稱、標籤", + notLoggedIn: "請先在 Settings → 風格市場 填寫發布身份", + emptyTitle: "你還沒有發布過風格包", + emptyHint: + "在「風格」頁面編輯後點「發布到風格市場」,或點擊右上角上傳本機風格包。", + noMatch: "沒有符合的風格包", + summary: "已發布 {{count}} 個風格包", + summaryPending: "已發布 {{count}} 個風格包 · {{pending}} 個審核中", + versionDate: "v{{version}} · {{date}}", + stats: "★ {{likes}} · ↓ {{downloads}}", + actions: { + update: "更新", + withdraw: "下架", + }, + loadFailed: "我的發布載入失敗:{{err}}", + loadingTitle: "正在拉取,請稍後…", + loadingHint: "從風格市場獲取你最新發布的風格包。", + loadErrorTitle: "載入失敗", + loadErrorRetry: "重試", + }, + upload: { + confirmBtn: "確定上傳", + updateTitle: "更新「{{name}}」", + updateHint: + "選中要上傳的本機新版本風格包,下方點「確定上傳」。同名包預設預選。", + recommendedBadge: "建議更新", + }, + state: { + pending: "審核中", + approved: "已上架", + rejected: "未通過", + withdrawn: "已下架", + superseded: "已被新版替換", + unknown: "未知", + }, + oauth: { + title: "用 GitHub 登入", + generating: "正在產生裝置驗證碼…", + browserHint: "在瀏覽器中開啟 {{uri}} 並輸入下方代碼:", + copyBtn: "複製", + copied: "已複製裝置碼", + copyFailed: "複製失敗:{{err}}", + openBrowserBtn: "開啟瀏覽器", + cancelBtn: "取消", + waiting: "等待你在瀏覽器中授權…", + successAs: "已登入為 @{{login}}", + retryBtn: "重試", + closeBtn: "關閉", + loginBtn: "登入", + loginTooltip: "點擊用 GitHub 登入", + reloginTooltip: "點擊重新登入 / 切換帳號(目前 @{{login}})", + }, + modal: { + loggedIn: "目前登入身份 —— 在 Settings → 錄音 → 風格市場 修改", + notLoggedIn: + "未登入 —— 去 Settings → 錄音 → 風格市場 填一個發布者名", + notLoggedInLabel: "未登入", + }, }, - howto: { - title: '使用方法', - step1: '在任意輸入框聚焦游標。', - step2: '按 {{trigger}} 開始錄音。', - step3: '錄音中按一下 Shift 啟動翻譯。', - step4: '再按 {{trigger}} 停止錄音。', - step5: '翻譯結果自動插入到游標位置。', - indicatorTitle: '怎麼知道翻譯模式生效了', - indicatorDesc: '按 Shift 後螢幕底部會顯示藍色「正在翻譯」標識。', - fallbackTitle: '安全兜底', - fallbackDesc: '翻譯失敗時回退為插入原始轉寫,不會丟字。', + shell: { + shortcutLabel: "錄音快捷鍵", + shortcutHint: "開始 / 停止", + betaTag: "BETA", + betaNote: "所有數據都只保存在本機。", + footer: { + account: "賬戶", + feedback: "反饋", + settings: "設置", + help: "幫助", + version: "版本 {{version}}", + helpPopover: { + tagline: "本地驅動的語音輸入層", + releaseNotes: "查看發佈日誌 ↗", + docs: "幫助中心 ↗", + }, + }, + providerPrompt: { + title: "設置語音提供商", + body: "還沒有配置 ASR 或 LLM 提供商,語音輸入和潤色暫時無法正常工作。", + later: "稍後", + openSettings: "去設置", + }, + hotkeyModePrompt: { + title: "檢查錄音方式", + body: "預設已改為切換式。如果之前改過觸發方式,請到錄音設定確認一次。", + later: "稍後提醒", + openSettings: "去錄音設置", + }, }, - }, - selectionAsk: { - kicker: 'SELECTION ASK', - title: '劃詞追問', - desc: '選中文字後語音提問,支援多輪追問。', - statusEnabled: '已啓用', - statusDisabled: '未啓用', - hotkey: { - title: '彈出浮窗的快捷鍵', - desc: '控制浮窗開關。浮窗內錄音用 {{recordHotkey}}。', - optionDisabled: '不啓用', - chordWarning: '', + onboarding: { + welcome: "歡迎使用 OpenLess", + intro: "本地說出,本地落字。開始前需要兩個系統權限。", + accessibilityTitle: "輔助功能", + hotkeyTitle: "全局快捷鍵", + accessibilityDesc: + "用於監聽全局快捷鍵(默認 {{trigger}})並把識別結果寫入光標位置。", + hotkeyDesc: "用於確認全局快捷鍵監聽可用。", + micTitle: "麥克風", + micDesc: "用於捕獲你的語音輸入。", + actionNotApplicable: "無需授權", + actionGranted: "已授權", + actionOpenSystem: "打開系統設置", + actionGrant: "授權", + actionRequestMic: "彈出授權", + accessibilityHint: + "授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。", + footerHint: + "授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。", }, - save: { - hotkeyRegisterFailed: '劃詞追問快捷鍵註冊失敗,未繼續保存。', - hotkeySaveFailed: '劃詞追問快捷鍵保存失敗,請重試。', - historySaveFailed: 'Q&A 歷史保存設置保存失敗,請重試。', + overview: { + kicker: "DASHBOARD", + title: "今日概覽", + desc: "今日口述統計與系統狀態。", + pressPrefix: "按", + pressSuffix: "開始錄音", + asrKind: "ASR 語音", + llmKind: "LLM 模型", + asrName: "火山引擎", + asrSubname: "bigmodel", + llmName: "OpenAI 兼容", + llmConfigured: "已配置 active LLM", + llmNotConfigured: "未配置", + statusConfigured: "已配置", + statusNotConfigured: "未配置", + statusUnknown: "無法讀取", + credentialsLoadError: "無法讀取憑據狀態", + metricChars: "今日字數", + metricSegments: "{{count}} 段", + metricDuration: "今日總時長", + metricAvg: "平均段落", + metricAvgTrend: "今日均值", + metricNoData: "暫無數據", + historyLoadError: "歷史讀取失敗", + metricTotal: "累計記錄", + metricTotalTrend: "本機存檔 (上限 200)", + weekTitle: "近 7 天", + weekUnit: "條數 / 天", + recentTitle: "最近識別", + recentAll: "全部記錄 →", + recentEmpty: "還沒有記錄。按 {{trigger}} 開始第一次錄音。", + recentLoadFailed: "無法讀取最近識別,請重試。", + historyRetry: "重試", + weekDays: ["日", "一", "二", "三", "四", "五", "六"], }, history: { - title: '保存歷史', - desc: '開啟後在本地保存問答記錄,預設關閉。', - }, - howto: { - title: '使用方法', - step1: '按 {{hotkey}} 開啟浮窗。', - step2: '在任意 app 選中文字。', - step3: '按 {{recordHotkey}} 錄音,再按一次提交。', - step4: '可繼續按 {{recordHotkey}} 多輪追問。', - step5: '按 Esc 關閉浮窗並清空歷史。', - windowTitle: '浮窗位置 + 拖動 + 釘住', - windowDesc: '浮窗可拖動,位置自動記憶。釘住可保持視窗不關。', - privacyTitle: '隱私契約', - privacyDesc: '選中文本僅存於記憶體,關閉浮窗即銷毀。超 4000 字元自動截斷。', - }, - }, - settings: { - kicker: 'SETTINGS', - title: '設置', - desc: '錄音、提供商、快捷鍵與權限配置。', - sections: { - recording: '錄音', - providers: '提供商', - shortcuts: '快捷鍵', - permissions: '權限', - language: '語言', - advanced: '高級', - about: '關於', - }, - recording: { - title: '錄音', - desc: '定義全局錄音的快捷鍵與觸發方式。', - hotkeyLabel: '錄音快捷鍵', - hotkeyDescAcc: '按下即開始捕獲語音,全局生效。需要授予輔助功能權限。', - hotkeyDescNoAcc: '按下即開始捕獲語音,全局生效。無需額外輔助功能授權。', - modeLabel: '錄音方式', - modeDesc: '切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。', - modeToggle: '切換式', - modeHold: '按住說話', - migrationNoticeTitle: '默認已改爲切換式說話', - migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。', - comboRecordLabel: '錄製快捷鍵', - comboRecordDesc: '點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。', - comboRecordBtn: '錄製快捷鍵', - comboRecordHint: '請按下快捷鍵組合…', - comboRecorded: '已錄製', - comboClear: '清除', - comboConflict: '此快捷鍵組合不可用', - microphoneLabel: '首選麥克風', - microphoneDesc: '選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。', - microphoneDefault: '系統默認麥克風', - microphoneDefaultDesc: '使用系統默認輸入設備', - microphoneSystemDefault: '系統默認', - microphoneUnavailable: '不可用', - microphoneLoadError: '麥克風列表讀取失敗:{{message}}', - microphoneDialogTitle: '麥克風', - microphoneDialogDesc: '選擇能捕捉到您聲音的麥克風。如果指示條沒有移動,請嘗試其他麥克風。', - microphoneMonitorError: '輸入電平監聽失敗:{{message}}', - capsuleLabel: '錄音膠囊', - capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。', - muteDuringRecordingLabel: '錄音時靜音', - muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。', - insertGroupTitle: '插入與剪貼板', - restoreClipboardLabel: '插入後恢復剪貼板', - restoreClipboardDesc: '粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。', - pasteShortcutLabel: '模擬粘貼快捷鍵', - pasteShortcutDesc: '插入時模擬按下的粘貼鍵,部分終端類應用需要 Ctrl+Shift+V(僅 Windows / Linux)。', - pasteShortcutCtrlV: 'Ctrl+V(默認 / 多數應用)', - pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)', - pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', - allowNonTsfFallbackLabel: '允許非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', - historyGroupTitle: '歷史與上下文', - historyRetentionLabel: '歷史保留天數', - historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', - historyMaxEntriesLabel: '歷史條數上限', - historyMaxEntriesDesc: '本地保留會話上限,留空 = 200。範圍 5–200。', - polishContextWindowLabel: '對話上下文窗口(分鐘)', - polishContextWindowDesc: '把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。', - recordAudioForDebugLabel: '保留原始錄音(除錯)', - recordAudioForDebugDesc: '保存原始麥克風音訊為 wav,便於排查識別問題。', - audioRecordingMaxEntriesLabel: '原始錄音保留條數', - audioRecordingMaxEntriesDesc: '本地保留 wav 檔案數上限,留空 = 200。', - startupGroupTitle: '啟動', - startMinimizedLabel: '啓動時靜默運行', - startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。', - autoUpdateCheckLabel: '自動檢查更新', - autoUpdateCheckDesc: '啟動時及每 60 分鐘自動檢查更新。', - marketplaceGroupTitle: '風格市場', - marketplaceBaseUrlLabel: '雲端服務位址', - marketplaceBaseUrlDesc: '風格市場後端 URL,留空使用預設值。', - marketplaceDevLoginLabel: 'GitHub 使用者名稱(上傳身份)', - marketplaceDevLoginDesc: '標識上傳者身分,為空時無法上傳或按讚。', - startupAtBoot: '開機自啓', - startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', - startupAtBootError: '開機自啓切換失敗:{{message}}', - }, - providers: { - llmTitle: 'LLM 模型(潤色)', - llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。', - providerLabel: '供應商', - llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', - credentialStorageNotice: '憑據保存在系統憑據庫中。', - codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。', - asrProviderDesc: '切換後將自動選用對應憑據。', - asrTitle: 'ASR 語音(轉寫)', - asrDesc: '用於將口述實時轉寫爲文本。', - presets: { - ark: 'ARK(火山方舟)', - deepseek: 'DeepSeek', - siliconflow: '硅基流動', - openai: 'OpenAI', - gemini: 'Google Gemini', - codexOAuth: 'Codex OAuth', - mimo: '小米 MiMo', - cometapi: 'CometAPI', - openrouterFree: 'OpenRouter(免費模型)', - alibabaCoding: '阿里雲 Coding Plan', - codingPlanX: 'CodingPlanX', - custom: '自定義', - asrVolcengine: '火山引擎 bigasr', - asrBailian: '阿里雲百煉即時 ASR', - asrSiliconflow: '硅基流動 SenseVoice', - asrZhipu: '智譜 GLM-ASR', - asrGroq: 'Groq Whisper-large-v3', - asrWhisper: 'OpenAI Whisper(兼容)', - asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', - asrLocalQwen3: '本地 Qwen3-ASR', - }, - volcengineAppKeyLabel: 'APP ID', - volcengineAccessKeyLabel: 'Access Token', - volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。', - localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', - localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。', - asrProviderTakenOver: 'ASR 提供商已被接管', - localAsrHint: '在本機運行,無需 API Key。從 HuggingFace 下載模型即可使用。', - foundryLocalAsrHint: '在本機運行,無需 ASR API Key。首次使用需下載運行元件和模型。', - localAsrPerformanceWarning: '本地推理比雲端慢,中文準確率可能更低。適合離線或隱私敏感場景。', - localAsrReady: '{{model}} 已下載', - localAsrNotReady: '{{model}} 未下載', - localAsrGoDownload: '前往模型設置下載', - localAsrManage: '前往模型設置', - localAsrDownloadedTitle: '已下載模型', - localAsrDelete: '刪除', - fillDefault: '填入默認值', - readFailed: '讀取失敗', - apiKeyLabel: 'API 密鑰', - baseUrlLabel: '接口地址', - modelLabel: '模型', - thinkingModeLabel: '思考', - thinkingModeOn: '開啟', - thinkingModeOff: '關閉', - thinkingModeHint: '關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。', - bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)', - bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。', - appIdLabel: 'App ID(應用 ID)', - accessKeyLabel: 'Access Key', - resourceIdLabel: '資源 ID', - toolsLabel: '連接檢查', - toolsDesc: '先保存上方配置,再驗證當前模型連通性或拉取模型;失敗時仍可手動填寫模型 ID。', - validate: '驗證', - validating: '驗證中…', - fetchModels: '拉取模型', - loadingModels: '拉取模型中…', - modelMissing: '未配置模型,請先填寫模型 ID。', - modelsEmpty: '鑑權成功,但沒有返回可用模型。', - modelsLoaded: '已拉取 {{count}} 個模型。', - selectModel: '選擇一個模型寫入上方字段', - modelSaved: '已保存模型 {{model}}。', - validateSuccess: '連接檢查通過。', - providerHttpStatus: '供應商接口返回 {{status}},請檢查 API Key 權限或 Endpoint。', - endpointMustUseHttps: 'Endpoint 必須使用 HTTPS(本地 localhost/127.0.0.1 測試除外)。', - endpointInvalid: 'Endpoint 格式不合法。', - responseTooLarge: '供應商響應過大,已停止驗證以保證安全。', - asrInvalidJson: 'ASR 響應不是有效 JSON。', - asrMissingTextField: 'ASR 響應缺少 text 字段。', - apiKeyMissing: 'API Key 爲空。', - endpointMissing: 'Endpoint 爲空。', - requestTimeout: '請求超時,請稍後重試。', - }, - shortcuts: { - title: '快捷鍵速查', - descAcc: '所有快捷鍵全局生效,需要在權限設置中開啓輔助功能。', - descNoAcc: '所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。', - startStop: '開始 / 停止錄音', - cancel: '取消本次錄音', - confirm: '膠囊確認插入', - switchStyle: '切換上一次風格', - openApp: '打開 OpenLess', - confirmHint: '點擊右側 ✓', - notSupported: '暫未支持', - }, - permissions: { - title: '權限', - descAcc: 'OpenLess 需要以下系統權限才能正常工作。授權後通常需要完全退出 App 重啓一次才生效。', - descNoAcc: 'OpenLess 需要麥克風可用,並依賴全局快捷鍵監聽狀態判斷 native hook 是否正常工作。', - micLabel: '麥克風', - micDesc: '用於捕獲你的語音輸入。', - accLabel: '輔助功能', - accDesc: '用於監聽全局快捷鍵並將識別結果寫入光標位置。', - hotkeyLabel: '全局快捷鍵', - hotkeyDescWithAdapter: '當前適配器:{{adapter}}。用於判斷快捷鍵監聽是否已經安裝。', - hotkeyDescPlain: '用於判斷快捷鍵監聽是否已經安裝。', - networkLabel: '網絡', - networkDesc: '雲端 ASR / LLM 調用所必需。本地模式可關閉。', - networkOk: '可用', - checking: '檢查中…', - granted: '已授權', - notApplicable: '無需授權', - denied: '未授權', - indeterminate: '未確定', - openSystem: '打開系統設置', - grant: '授權', - hotkeyInstalled: '已安裝', - hotkeyStarting: '安裝中…', - hotkeyFailed: '監聽失敗', - windowsImeLabel: 'Windows 輸入法後端', - windowsImeDesc: '用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼板插入限制。', - windowsImeInstalled: '已安裝', - windowsImeUnavailable: '不可用', - windowsIme: { - installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', - notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', - registrationBroken: '註冊已損壞。請重新安裝 OpenLess 輸入法。', - notWindows: '僅 Windows 可用。', - }, + kicker: "HISTORY", + title: "歷史記錄", + desc: "本機保存的識別記錄。", + filterAll: "全部", + summary: "共 {{total}} 條 · 顯示 {{shown}}", + empty: "還沒有歷史記錄。按 {{trigger}} 錄一段試試。", + loadFailed: "加載歷史失敗:{{err}}", + retry: "重試", + clearFailed: "清空失敗:{{err}}", + deleteFailed: "刪除失敗:{{err}}", + copyFailed: "複製失敗:{{err}}", + playRecording: "播放錄音", + audioLoading: "載入中…", + exportRecording: "匯出錄音", + exportFailed: "匯出失敗:{{err}}", + rawLabel: "原文", + rawEmpty: "(空)", + selectHint: "左側選一條查看詳情。", + insertedTo: "插入到", + chars: "{{count}} 字", + vocabHits: "{{count}} 個熱詞", + inserted: "已插入", + pasteSent: "已嘗試粘貼", + copiedFallback: "已複製(需 {{shortcut}})", + insertFailed: "插入失敗", + confirmClear: "確定清空全部 {{count}} 條記錄?此操作不可恢復。", }, - advanced: { - streamingInsertTitle: '流式輸入', - streamingInsertTitleLinux: '流式輸入(實驗)', - streamingInsertDesc: - '逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。', - streamingInsertLabel: '流式輸入', - streamingInsertHintMac: - '臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。', - streamingInsertHintWindows: - 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', - streamingInsertHintLinux: - '通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。', - streamingInsertSaveClipboardLabel: '同步到剪貼簿', - streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。', - localAsrTitle: '本地 ASR 模型(實驗性)', - localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。', - localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。', - qwen3Desc: '啓動之後,ASR 提供商將被接管。', - foundryDesc: '啓動之後,ASR 提供商將被接管。', - notSupportedHere: '本平臺暫不支持,未集成推理模塊。', - enable: '啓用', - alreadyActive: '已啓用', - disableLocalLabel: '停用本地 ASR', - disableLocalDesc: '切回雲端 ASR(默認火山引擎 bigasr)。', - disable: '停用', - platformNotSupported: '該平臺暫未支持本地 ASR 模型集成。', - confirmEnableLocalTitle: '啓用本地 ASR?', - confirmEnableLocalBody: '啟用後轉寫會比雲端慢,準確率可能更低。', - confirm: '確認啓用', + vocab: { + kicker: "VOCABULARY", + title: "詞彙表", + desc: "添加生詞或專業術語,提高識別準確率。", + sectionTitle: "詞條", + placeholder: "輸入詞語,按 Enter 或點添加…", + tip: "支持中英混合 · 數字開頭按字面識別 · 命中次數自動計數", + loadFailed: "加載失敗:{{err}}", + empty: "還沒有詞條。在上面輸入一個生詞或專業術語,讓模型在聽寫時優先匹配。", + tipDisabled: "點擊禁用此詞條", + tipEnabled: "點擊啓用此詞條", + removeAria: "刪除", + corrections: { + title: "糾正規則", + tip: "修正常見 ASR 誤識別,支援 {num} 數字通配。", + patternPlaceholder: "誤識別寫法,如 {num}粒", + replacementPlaceholder: "目標寫法,如 {num}例", + empty: "還沒有糾正規則。", + invalid: + "僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。", + tipDisabled: "點擊停用此規則", + tipEnabled: "點擊啟用此規則", + removeAria: "刪除糾正規則", + }, + presets: { + title: "場景預設", + tip: "可多選批量啟用,支援編輯和新建。", + create: "新建預設", + apply: "啓用所選", + save: "保存預設", + edit: "編輯 {{name}}", + newPreset: "新預設", + namePlaceholder: "預設名稱", + wordsPlaceholder: "詞條(用逗號或換行分隔)", + }, }, - language: { - title: '界面語言', - desc: '切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。', - label: '語言', - labelDesc: '選擇「跟隨系統」時按操作系統當前語言顯示。', - followSystem: '跟隨系統', - zh: '簡體中文', - zhTW: '繁體中文', - en: 'English', - ja: '日本語 (Beta)', - ko: '한국어 (Beta)', - restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。', + style: { + kicker: "STYLE", + title: "輸出風格", + desc: "選擇錄音的預設輸出風格。", + masterToggle: "整體啓用", + currentDefault: "當前默認", + ariaSetDefault: "設爲默認", + saveFailed: "保存失敗:{{error}}", + customPromptTitle: "自定義提示詞", + customPromptPlaceholder: + "可選,追加到這個風格的內建 system prompt 末尾。", + customPromptHint: + "留空則保持當前行為不變。保存後會在該風格的潤色和 repolish 中生效;按 Ctrl/Cmd+Enter 也可保存。", + customPromptSave: "保存提示詞", + customPromptDirty: "未保存", + systemPromptMovedHint: + "完整 System Prompt 已移到 設定 -> Providers 頁面統一編輯。這裡現在只負責風格啟停和預設風格。", + modes: { + raw: { + name: "原文", + desc: "只補標點和必要分句,不改寫不擴寫。", + sample: "保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。", + }, + light: { + name: "輕度潤色", + desc: "去口癖、補標點,整理爲可發送的自然文字。", + sample: "讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。", + }, + structured: { + name: "清晰結構", + desc: "多個主題或步驟時,自動組織爲分點列表。", + sample: "1. 主題一\na. 要點\nb. 要點\n2. 主題二\na. 要點\nb. 要點", + }, + formal: { + name: "正式表達", + desc: "工作溝通和郵件場景,更專業更完整。", + sample: "郵件場景自動識別問候 / 落款;不引入空泛客套。", + }, + }, + pack: { + kicker: "STYLE PACKS", + title: "風格包", + desc: "管理本機風格包。", + marketplaceBtn: "風格市場", + loadFailed: "載入風格包失敗:{{err}}", + importZip: "匯入 ZIP", + exportZip: "匯出 ZIP", + exportShort: "匯出", + publishMarketplace: "發布到風格市場", + updateMarketplace: "更新到風格市場新版本", + publishDisabledHint: + "請先在 設定 → 風格市場 設定 GitHub 使用者名稱", + publishSuccess: "發布成功,等待 marketplace 審核", + publishFailed: "發布失敗:{{err}}", + publishBuiltinRejected: + "內建風格包不能直接發布,請先編輯產生一份匯入版。", + builtin: "內建", + imported: "匯入", + active: "目前", + activate: "啟用", + edit: "編輯", + closeEditor: "關閉", + unsaved: "未儲存", + listTitle: "本機風格包", + listDesc: "瀏覽和切換風格包。", + listCount: "{{count}} 個風格包", + addPackTileTitle: "新建風格包", + addPackTileHint: "從空白範本開始。", + createSuccess: "已建立新風格包", + createFailed: "建立風格包失敗:{{err}}", + save: "儲存", + revert: "還原", + saveSuccess: "風格包已儲存", + saveFailed: "儲存風格包失敗:{{err}}", + activateSuccess: '已將"{{name}}"設為目前風格', + activateFailed: "設為目前風格失敗:{{err}}", + importSuccess: '已匯入"{{name}}"', + importFailed: "匯入 ZIP 失敗:{{err}}", + exportSuccess: "已匯出到 {{path}}", + exportFailed: "匯出 ZIP 失敗:{{err}}", + exportDirtyFirst: "請先儲存目前風格包,再匯出 ZIP。", + resetBuiltin: "重設", + resetSuccess: '已重設"{{name}}"', + resetFailed: "重設風格包失敗:{{err}}", + deleteImported: "刪除", + deleteConfirm: '確定刪除"{{name}}"嗎?刪除後無法復原。', + deleteSuccess: '已刪除"{{name}}"', + deleteFailed: "刪除風格包失敗:{{err}}", + summaryCurrentEmpty: "還沒有選中風格包", + editorTitle: "編輯風格", + editorDesc: "編輯目前風格包。", + metaTitle: "安裝資訊", + metaSource: "來源", + metaBaseMode: "基礎模式", + metaUpdatedAt: "更新時間", + fieldName: "名稱", + fieldAuthor: "作者", + fieldAuthorPlaceholder: "可選,方便標註來源", + fieldVersion: "版本", + fieldTags: "標籤", + fieldTagsPlaceholder: + "用英文逗號分隔,例如 community, voiceover, formal", + fieldDescription: "描述", + fieldModel: "建議模型(僅元資料)", + fieldModelPlaceholder: "可選,例如 gpt-4.1 / deepseek-v3", + fieldModelHint: "僅作說明,不會切換實際模型。", + fieldCompatibility: "相容版本", + fieldCompatibilityPlaceholder: "可選,例如 >=1.3.0", + fullPromptTitle: "System Prompt", + fullPromptHint: "這就是這套風格包自己的 Prompt。", + promptChars: "{{count}} 字元", + runtimeTitle: "OpenLess 執行時附加指令", + runtimeDesc: "只讀的執行時輔助項。", + runtimeContextTitle: "上下文前提", + runtimeContextDesc: "來自語言與應用上下文", + runtimeContextEmpty: "目前不會附加", + runtimeHotwordTitle: "熱詞提示段", + runtimeHotwordDesc: "來自已啟用熱詞", + runtimeHotwordEmpty: "目前不會附加", + runtimeHistoryTitle: "多輪歷史保護段", + runtimeHistoryDesc: "僅用於即時多輪 polish", + runtimeHistoryEmpty: "只有存在 prior turns 時才會附加", + runtimeActive: "目前生效", + runtimeInactive: "目前未生效", + runtimePreviewFailed: "產生執行時預覽失敗:{{err}}", + runtimePreviewOmittedFrontApp: "預覽已省略前台 app 標籤。", + examplesTitle: "效果範例", + examplesDesc: "會隨風格包一起匯出。", + addExample: "新增範例", + examplesEmpty: "還沒有範例。", + exampleTitlePlaceholder: "範例 {{index}} 標題", + exampleInput: "輸入", + exampleOutput: "輸出", + examplesCount: "{{count}} 個範例", + discardCloseConfirm: "關閉編輯面板前要捨棄未儲存修改嗎?", + discardSwitchConfirm: + '要捨棄目前未儲存修改,並切換到"{{name}}"嗎?', + derivativeBadge: "衍生自 @{{login}}", + }, }, - about: { - tagline: '自然說話,完美書寫', - checkUpdate: '檢查更新', - checkUpdateBtn: '檢查', - checkingUpdate: '檢查中…', - upToDate: '當前已是最新版本。', - updateError: '檢查或更新失敗,請稍後重試。', - openReleases: '打開 Releases', - source: '源碼', - docs: '文檔', - feedback: '反饋', - qq: '社區 QQ 羣', - qqDesc: '使用 QQ 搜索羣號加入,或掃碼進羣。', - copyQq: '複製羣號', - privacy: '隱私', - privacyDesc: '所有資料僅保存在本機,雲端 API 不保留錄音。', - localFirst: '本地優先', - betaChannelLabel: '加入 Beta 渠道', - betaChannelDesc: '開啟後自動接收 Beta 版本。可能不穩定,僅推薦願意嘗鮮的用戶。', - betaChannelFetching: '正在獲取最新 Beta 版本…', - betaChannelFetchBtn: '查詢最新 Beta', - betaChannelLatestPrefix: '最新 Beta:', - betaChannelDownloadBtn: '前往下載', - betaChannelRefresh: '重新查詢', - betaChannelNoBeta: '尚未發佈過 Beta 版。', - betaChannelFetchError: '獲取 Beta 版本資訊失敗,請稍後重試。', - betaChannelUpToDate: '已是最新', - betaChannelUpdateNow: '立即更新', - betaChannelUpdateNowTitle: '檢查並下載最新 Beta,然後彈出更新對話框', - betaChannelChecking: '檢查中…', - updateDialog: { - available: { - title: '發現新版本', - desc: '發現 OpenLess {{version}},是否現在更新?', - }, - downloading: { - title: '正在下載更新', - desc: '正在下載 OpenLess {{version}},請保持應用打開。', - }, - downloaded: { - title: '更新已準備好', - desc: 'OpenLess {{version}} 已安裝完成。是否現在自動重啓以應用更新?', - }, - installing: { - title: '正在安裝更新', - desc: '正在安裝 OpenLess {{version}},請保持應用打開。', - }, - install: '現在更新', - downloadingLabel: '下載中…', - installingLabel: '安裝中…', - later: '稍後手動重啓', - restartNow: '現在重啓', - progress: '{{progress}}% · {{downloaded}} / {{total}}', - progressUnknown: '已下載 {{downloaded}}', - }, + translation: { + kicker: "TRANSLATION", + title: "翻譯", + desc: "錄音後自動翻譯為目標語言再插入。", + statusEnabled: "已啓用", + statusDisabled: "未啓用", + working: { + title: "工作語言", + desc: "勾選日常使用的語言,影響潤色與翻譯效果。", + }, + target: { + title: "翻譯目標語言", + desc: "錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。", + disabled: "不啓用(Shift 按下不觸發翻譯)", + }, + save: { + workingFailed: "工作語言保存失敗,請重試。", + targetFailed: "翻譯目標語言保存失敗,請重試。", + hotkeyRegisterFailed: "翻譯快捷鍵註冊失敗,未繼續保存。", + hotkeySaveFailed: "翻譯快捷鍵保存失敗,請重試。", + }, + howto: { + title: "使用方法", + step1: "在任意輸入框聚焦游標。", + step2: "按 {{trigger}} 開始錄音。", + step3: "錄音中按一下 Shift 啟動翻譯。", + step4: "再按 {{trigger}} 停止錄音。", + step5: "翻譯結果自動插入到游標位置。", + indicatorTitle: "怎麼知道翻譯模式生效了", + indicatorDesc: "按 Shift 後螢幕底部會顯示藍色「正在翻譯」標識。", + fallbackTitle: "安全兜底", + fallbackDesc: "翻譯失敗時回退為插入原始轉寫,不會丟字。", + }, }, - }, - modal: { - sections: { - account: '賬戶', - settings: '設置', - personalize: '個性化', - about: '關於', - helpCenter: '幫助中心', - releaseNotes: '版本說明', + selectionAsk: { + kicker: "SELECTION ASK", + title: "劃詞追問", + desc: "選中文字後語音提問,支援多輪追問。", + statusEnabled: "已啓用", + statusDisabled: "未啓用", + hotkey: { + title: "彈出浮窗的快捷鍵", + desc: "控制浮窗開關。浮窗內錄音用 {{recordHotkey}}。", + optionDisabled: "不啓用", + chordWarning: "", + }, + save: { + hotkeyRegisterFailed: "劃詞追問快捷鍵註冊失敗,未繼續保存。", + hotkeySaveFailed: "劃詞追問快捷鍵保存失敗,請重試。", + historySaveFailed: "Q&A 歷史保存設置保存失敗,請重試。", + }, + history: { + title: "保存歷史", + desc: "開啟後在本地保存問答記錄,預設關閉。", + }, + howto: { + title: "使用方法", + step1: "按 {{hotkey}} 開啟浮窗。", + step2: "在任意 app 選中文字。", + step3: "按 {{recordHotkey}} 錄音,再按一次提交。", + step4: "可繼續按 {{recordHotkey}} 多輪追問。", + step5: "按 Esc 關閉浮窗並清空歷史。", + windowTitle: "浮窗位置 + 拖動 + 釘住", + windowDesc: "浮窗可拖動,位置自動記憶。釘住可保持視窗不關。", + privacyTitle: "隱私契約", + privacyDesc: + "選中文本僅存於記憶體,關閉浮窗即銷毀。超 4000 字元自動截斷。", + }, }, - account: { - localUser: '本地用戶', - localUserDesc: '未登錄 · 所有數據保存在本機', - loginSync: '登錄 / 同步', - footer: '預設完全本地運行。登入後可跨裝置同步詞彙表與風格預設。', + settings: { + kicker: "SETTINGS", + title: "設置", + desc: "錄音、提供商、快捷鍵與權限配置。", + sections: { + recording: "錄音", + providers: "提供商", + shortcuts: "快捷鍵", + permissions: "權限", + language: "語言", + advanced: "高級", + about: "關於", + }, + recording: { + title: "錄音", + desc: "定義全局錄音的快捷鍵與觸發方式。", + hotkeyLabel: "錄音快捷鍵", + hotkeyDescAcc: + "按下即開始捕獲語音,全局生效。需要授予輔助功能權限。", + hotkeyDescNoAcc: + "按下即開始捕獲語音,全局生效。無需額外輔助功能授權。", + modeLabel: "錄音方式", + modeDesc: + "切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。", + modeToggle: "切換式", + modeHold: "按住說話", + migrationNoticeTitle: "默認已改爲切換式說話", + migrationNoticeDesc: + "如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。", + comboRecordLabel: "錄製快捷鍵", + comboRecordDesc: + "點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。", + comboRecordBtn: "錄製快捷鍵", + comboRecordHint: "請按下快捷鍵組合…", + comboRecorded: "已錄製", + comboClear: "清除", + comboConflict: "此快捷鍵組合不可用", + microphoneLabel: "首選麥克風", + microphoneDesc: + "選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。", + microphoneDefault: "系統默認麥克風", + microphoneDefaultDesc: "使用系統默認輸入設備", + microphoneSystemDefault: "系統默認", + microphoneUnavailable: "不可用", + microphoneLoadError: "麥克風列表讀取失敗:{{message}}", + microphoneDialogTitle: "麥克風", + microphoneDialogDesc: + "選擇能捕捉到您聲音的麥克風。如果指示條沒有移動,請嘗試其他麥克風。", + microphoneMonitorError: "輸入電平監聽失敗:{{message}}", + capsuleLabel: "錄音膠囊", + capsuleDesc: "錄音 / 轉寫時在屏幕底部顯示半透明膠囊。", + muteDuringRecordingLabel: "錄音時靜音", + muteDuringRecordingDesc: + "錄音期間臨時靜音系統輸出,避免揚聲器回音。", + insertGroupTitle: "插入與剪貼板", + restoreClipboardLabel: "插入後恢復剪貼板", + restoreClipboardDesc: + "粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。", + pasteShortcutLabel: "模擬粘貼快捷鍵", + pasteShortcutDesc: + "插入時模擬按下的粘貼鍵,部分終端類應用需要 Ctrl+Shift+V(僅 Windows / Linux)。", + pasteShortcutCtrlV: "Ctrl+V(默認 / 多數應用)", + pasteShortcutCtrlShiftV: + "Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)", + pasteShortcutShiftInsert: "Shift+Insert(xterm / urxvt)", + allowNonTsfFallbackLabel: "允許非 TSF 兜底", + allowNonTsfFallbackDesc: + "Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。", + historyGroupTitle: "歷史與上下文", + historyRetentionLabel: "歷史保留天數", + historyRetentionDesc: + "超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。", + historyMaxEntriesLabel: "歷史條數上限", + historyMaxEntriesDesc: "本地保留會話上限,留空 = 200。範圍 5–200。", + polishContextWindowLabel: "對話上下文窗口(分鐘)", + polishContextWindowDesc: + "把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。", + recordAudioForDebugLabel: "保留原始錄音(除錯)", + recordAudioForDebugDesc: + "保存原始麥克風音訊為 wav,便於排查識別問題。", + audioRecordingMaxEntriesLabel: "原始錄音保留條數", + audioRecordingMaxEntriesDesc: + "本地保留 wav 檔案數上限,留空 = 200。", + startupGroupTitle: "啟動", + startMinimizedLabel: "啓動時靜默運行", + startMinimizedDesc: + "所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。", + autoUpdateCheckLabel: "自動檢查更新", + autoUpdateCheckDesc: "啟動時及每 60 分鐘自動檢查更新。", + marketplaceGroupTitle: "風格市場", + marketplaceBaseUrlLabel: "雲端服務位址", + marketplaceBaseUrlDesc: "風格市場後端 URL,留空使用預設值。", + marketplaceDevLoginLabel: "GitHub 使用者名稱(上傳身份)", + marketplaceDevLoginDesc: "標識上傳者身分,為空時無法上傳或按讚。", + startupAtBoot: "開機自啓", + startupAtBootDesc: "登錄系統時自動啓動 OpenLess。", + startupAtBootError: "開機自啓切換失敗:{{message}}", + }, + providers: { + llmTitle: "LLM 模型(潤色)", + llmDesc: "OpenAI 兼容協議,支持多家供應商切換。", + providerLabel: "供應商", + llmProviderDesc: "選擇後將自動填入 Base URL 默認值。", + credentialStorageNotice: "憑據保存在系統憑據庫中。", + codexOAuthNotice: + "Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。", + asrProviderDesc: "切換後將自動選用對應憑據。", + asrTitle: "ASR 語音(轉寫)", + asrDesc: "用於將口述實時轉寫爲文本。", + presets: { + ark: "ARK(火山方舟)", + deepseek: "DeepSeek", + siliconflow: "硅基流動", + openai: "OpenAI", + gemini: "Google Gemini", + codexOAuth: "Codex OAuth", + mimo: "小米 MiMo", + cometapi: "CometAPI", + openrouterFree: "OpenRouter(免費模型)", + alibabaCoding: "阿里雲 Coding Plan", + codingPlanX: "CodingPlanX", + custom: "自定義", + asrVolcengine: "火山引擎 bigasr", + asrBailian: "阿里雲百煉即時 ASR", + asrSiliconflow: "硅基流動 SenseVoice", + asrZhipu: "智譜 GLM-ASR", + asrGroq: "Groq Whisper-large-v3", + asrWhisper: "OpenAI Whisper(兼容)", + asrFoundryLocalWhisper: "本地 Whisper(Foundry Local)", + asrSherpaOnnxLocal: "本地 sherpa-onnx(實驗性)", + asrLocalQwen3: "本地 Qwen3-ASR", + }, + volcengineAppKeyLabel: "APP ID", + volcengineAccessKeyLabel: "Access Token", + volcengineResourceIdLabel: "Resource ID", + volcengineMappingNote: + "Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。", + localAsrActiveNotice: + "當前已啓用「{{name}}」,可在「高級」中切換或停用。", + localAsrTakeoverHint: "啓動「{{name}}」後,ASR 提供商將被接管。", + asrProviderTakenOver: "ASR 提供商已被接管", + localAsrHint: + "在本機運行,無需 API Key。從 HuggingFace 下載模型即可使用。", + foundryLocalAsrHint: + "在本機運行,無需 ASR API Key。首次使用需下載運行元件和模型。", + localAsrPerformanceWarning: + "本地推理比雲端慢,中文準確率可能更低。適合離線或隱私敏感場景。", + localAsrReady: "{{model}} 已下載", + localAsrNotReady: "{{model}} 未下載", + localAsrGoDownload: "前往模型設置下載", + localAsrManage: "前往模型設置", + localAsrDownloadedTitle: "已下載模型", + localAsrDelete: "刪除", + fillDefault: "填入默認值", + readFailed: "讀取失敗", + apiKeyLabel: "API 密鑰", + baseUrlLabel: "接口地址", + modelLabel: "模型", + thinkingModeLabel: "思考", + thinkingModeOn: "開啟", + thinkingModeOff: "關閉", + thinkingModeHint: + "關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。", + bailianVocabularyIdLabel: "熱詞 Vocabulary ID(可選)", + bailianVocabularyIdNote: + "如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。", + appIdLabel: "App ID(應用 ID)", + accessKeyLabel: "Access Key", + resourceIdLabel: "資源 ID", + toolsLabel: "連接檢查", + toolsDesc: + "先保存上方配置,再驗證當前模型連通性或拉取模型;失敗時仍可手動填寫模型 ID。", + validate: "驗證", + validating: "驗證中…", + fetchModels: "拉取模型", + loadingModels: "拉取模型中…", + modelMissing: "未配置模型,請先填寫模型 ID。", + modelsEmpty: "鑑權成功,但沒有返回可用模型。", + modelsLoaded: "已拉取 {{count}} 個模型。", + selectModel: "選擇一個模型寫入上方字段", + modelSaved: "已保存模型 {{model}}。", + validateSuccess: "連接檢查通過。", + providerHttpStatus: + "供應商接口返回 {{status}},請檢查 API Key 權限或 Endpoint。", + endpointMustUseHttps: + "Endpoint 必須使用 HTTPS(本地 localhost/127.0.0.1 測試除外)。", + endpointInvalid: "Endpoint 格式不合法。", + responseTooLarge: "供應商響應過大,已停止驗證以保證安全。", + asrInvalidJson: "ASR 響應不是有效 JSON。", + asrMissingTextField: "ASR 響應缺少 text 字段。", + apiKeyMissing: "API Key 爲空。", + endpointMissing: "Endpoint 爲空。", + requestTimeout: "請求超時,請稍後重試。", + }, + shortcuts: { + title: "快捷鍵速查", + descAcc: "所有快捷鍵全局生效,需要在權限設置中開啓輔助功能。", + descNoAcc: + "所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。", + startStop: "開始 / 停止錄音", + cancel: "取消本次錄音", + confirm: "膠囊確認插入", + switchStyle: "切換上一次風格", + openApp: "打開 OpenLess", + confirmHint: "點擊右側 ✓", + notSupported: "暫未支持", + }, + permissions: { + title: "權限", + descAcc: + "OpenLess 需要以下系統權限才能正常工作。授權後通常需要完全退出 App 重啓一次才生效。", + descNoAcc: + "OpenLess 需要麥克風可用,並依賴全局快捷鍵監聽狀態判斷 native hook 是否正常工作。", + micLabel: "麥克風", + micDesc: "用於捕獲你的語音輸入。", + accLabel: "輔助功能", + accDesc: "用於監聽全局快捷鍵並將識別結果寫入光標位置。", + hotkeyLabel: "全局快捷鍵", + hotkeyDescWithAdapter: + "當前適配器:{{adapter}}。用於判斷快捷鍵監聽是否已經安裝。", + hotkeyDescPlain: "用於判斷快捷鍵監聽是否已經安裝。", + networkLabel: "網絡", + networkDesc: "雲端 ASR / LLM 調用所必需。本地模式可關閉。", + networkOk: "可用", + checking: "檢查中…", + granted: "已授權", + notApplicable: "無需授權", + denied: "未授權", + indeterminate: "未確定", + openSystem: "打開系統設置", + grant: "授權", + hotkeyInstalled: "已安裝", + hotkeyStarting: "安裝中…", + hotkeyFailed: "監聽失敗", + windowsImeLabel: "Windows 輸入法後端", + windowsImeDesc: + "用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼板插入限制。", + windowsImeInstalled: "已安裝", + windowsImeUnavailable: "不可用", + windowsIme: { + installed: "已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。", + notInstalled: + "未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。", + registrationBroken: "註冊已損壞。請重新安裝 OpenLess 輸入法。", + notWindows: "僅 Windows 可用。", + }, + }, + advanced: { + streamingInsertTitle: "流式輸入", + streamingInsertTitleLinux: "流式輸入(實驗)", + streamingInsertDesc: + "逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。", + streamingInsertLabel: "流式輸入", + streamingInsertHintMac: + "臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。", + streamingInsertHintWindows: + "SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。", + streamingInsertHintLinux: + "通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。", + streamingInsertSaveClipboardLabel: "同步到剪貼簿", + streamingInsertSaveClipboardHint: + "插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。", + localAsrTitle: "本地 ASR 模型(實驗性)", + localAsrDesc: + "把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。", + localAsrWarningShort: "本地推理較慢,配置不足時可能吞字。", + qwen3Desc: "啓動之後,ASR 提供商將被接管。", + foundryDesc: "啓動之後,ASR 提供商將被接管。", + sherpaDesc: "啓動之後,ASR 提供商將被接管。", + notSupportedHere: "本平臺暫不支持,未集成推理模塊。", + enable: "啓用", + alreadyActive: "已啓用", + disableLocalLabel: "停用本地 ASR", + disableLocalDesc: "切回雲端 ASR(默認火山引擎 bigasr)。", + disable: "停用", + platformNotSupported: "該平臺暫未支持本地 ASR 模型集成。", + confirmEnableLocalTitle: "啓用本地 ASR?", + confirmEnableLocalBody: "啟用後轉寫會比雲端慢,準確率可能更低。", + confirm: "確認啓用", + }, + language: { + title: "界面語言", + desc: "切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。", + label: "語言", + labelDesc: "選擇「跟隨系統」時按操作系統當前語言顯示。", + followSystem: "跟隨系統", + zh: "簡體中文", + zhTW: "繁體中文", + en: "English", + ja: "日本語 (Beta)", + ko: "한국어 (Beta)", + restartHint: + "部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。", + }, + about: { + tagline: "自然說話,完美書寫", + checkUpdate: "檢查更新", + checkUpdateBtn: "檢查", + checkingUpdate: "檢查中…", + upToDate: "當前已是最新版本。", + updateError: "檢查或更新失敗,請稍後重試。", + openReleases: "打開 Releases", + source: "源碼", + docs: "文檔", + feedback: "反饋", + qq: "社區 QQ 羣", + qqDesc: "使用 QQ 搜索羣號加入,或掃碼進羣。", + copyQq: "複製羣號", + privacy: "隱私", + privacyDesc: "所有資料僅保存在本機,雲端 API 不保留錄音。", + localFirst: "本地優先", + betaChannelLabel: "加入 Beta 渠道", + betaChannelDesc: + "開啟後自動接收 Beta 版本。可能不穩定,僅推薦願意嘗鮮的用戶。", + betaChannelFetching: "正在獲取最新 Beta 版本…", + betaChannelFetchBtn: "查詢最新 Beta", + betaChannelLatestPrefix: "最新 Beta:", + betaChannelDownloadBtn: "前往下載", + betaChannelRefresh: "重新查詢", + betaChannelNoBeta: "尚未發佈過 Beta 版。", + betaChannelFetchError: "獲取 Beta 版本資訊失敗,請稍後重試。", + betaChannelUpToDate: "已是最新", + betaChannelUpdateNow: "立即更新", + betaChannelUpdateNowTitle: + "檢查並下載最新 Beta,然後彈出更新對話框", + betaChannelChecking: "檢查中…", + updateDialog: { + available: { + title: "發現新版本", + desc: "發現 OpenLess {{version}},是否現在更新?", + }, + downloading: { + title: "正在下載更新", + desc: "正在下載 OpenLess {{version}},請保持應用打開。", + }, + downloaded: { + title: "更新已準備好", + desc: "OpenLess {{version}} 已安裝完成。是否現在自動重啓以應用更新?", + }, + installing: { + title: "正在安裝更新", + desc: "正在安裝 OpenLess {{version}},請保持應用打開。", + }, + install: "現在更新", + downloadingLabel: "下載中…", + installingLabel: "安裝中…", + later: "稍後手動重啓", + restartNow: "現在重啓", + progress: "{{progress}}% · {{downloaded}} / {{total}}", + progressUnknown: "已下載 {{downloaded}}", + }, + }, }, - personalize: { - appearance: '外觀', - appearanceDesc: '跟隨系統 / 淺色 / 深色', - appearanceSystem: '跟隨系統', - appearanceLight: '淺色', - appearanceDark: '深色', - font: '字體大小', - fontDesc: '整體縮放界面字號,立即生效。', - fontSmall: '小', - fontMedium: '中', - fontLarge: '大', - blur: '毛玻璃強度', - blurDesc: '影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。', - startupOpen: '啓動時打開', - startupOverview: '概覽', - startupLast: '上次位置', - startupAtBoot: '開機自啓', + modal: { + sections: { + account: "賬戶", + settings: "設置", + personalize: "個性化", + about: "關於", + helpCenter: "幫助中心", + releaseNotes: "版本說明", + }, + account: { + localUser: "本地用戶", + localUserDesc: "未登錄 · 所有數據保存在本機", + loginSync: "登錄 / 同步", + footer: "預設完全本地運行。登入後可跨裝置同步詞彙表與風格預設。", + }, + personalize: { + appearance: "外觀", + appearanceDesc: "跟隨系統 / 淺色 / 深色", + appearanceSystem: "跟隨系統", + appearanceLight: "淺色", + appearanceDark: "深色", + font: "字體大小", + fontDesc: "整體縮放界面字號,立即生效。", + fontSmall: "小", + fontMedium: "中", + fontLarge: "大", + blur: "毛玻璃強度", + blurDesc: + "影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。", + startupOpen: "啓動時打開", + startupOverview: "概覽", + startupLast: "上次位置", + startupAtBoot: "開機自啓", + }, + about: { + tagline: "自然說話,完美書寫", + checkUpdate: "檢查更新", + checkUpdateBtn: "檢查", + docs: "文檔", + docsBtn: "openless.app/docs ↗", + feedback: "反饋渠道", + feedbackBtn: "GitHub Issues ↗", + source: "原始碼", + qq: "社群 QQ 群", + qqDesc: "使用 QQ 搜尋群號加入,或掃碼進群。", + copyQq: "複製群號", + exportErrorLog: "匯出錯誤日誌", + exportErrorLogDesc: + "把當前會話的執行日誌儲存到本地,便於排查問題或反饋給我們。", + exportErrorLogBtn: "匯出", + exporting: "匯出中…", + exportSuccess: "已儲存", + exportFailed: "匯出失敗", + privacy: "隱私", + privacyDesc: "所有識別結果只保存在本機,雲端 API 僅用於實時調用。", + localFirst: "本地優先", + }, }, - about: { - tagline: '自然說話,完美書寫', - checkUpdate: '檢查更新', - checkUpdateBtn: '檢查', - docs: '文檔', - docsBtn: 'openless.app/docs ↗', - feedback: '反饋渠道', - feedbackBtn: 'GitHub Issues ↗', - source: '原始碼', - qq: '社群 QQ 群', - qqDesc: '使用 QQ 搜尋群號加入,或掃碼進群。', - copyQq: '複製群號', - exportErrorLog: '匯出錯誤日誌', - exportErrorLogDesc: '把當前會話的執行日誌儲存到本地,便於排查問題或反饋給我們。', - exportErrorLogBtn: '匯出', - exporting: '匯出中…', - exportSuccess: '已儲存', - exportFailed: '匯出失敗', - privacy: '隱私', - privacyDesc: '所有識別結果只保存在本機,雲端 API 僅用於實時調用。', - localFirst: '本地優先', + windowChrome: { + minimize: "最小化", + maximize: "最大化", + close: "關閉", }, - }, - windowChrome: { - minimize: '最小化', - maximize: '最大化', - close: '關閉', - }, - hotkey: { - triggers: { - rightOption: '右 Option', - leftOption: '左 Option', - rightControl: '右 Control', - leftControl: '左 Control', - rightCommand: '右 Command', - fn: 'Fn (地球鍵)', - rightAlt: '右 Alt', - custom: '自訂組合…', + hotkey: { + triggers: { + rightOption: "右 Option", + leftOption: "左 Option", + rightControl: "右 Control", + leftControl: "左 Control", + rightCommand: "右 Command", + fn: "Fn (地球鍵)", + rightAlt: "右 Alt", + custom: "自訂組合…", + }, + fallback: "全局快捷鍵", + modeHoldSuffix: "(按住說話)", + modeToggleSuffix: "(開始 / 停止)", + usageHold: "按住 {{trigger}} 說話,鬆開結束。", + usageToggle: "按 {{trigger}} 開始錄音,再按一次結束。", + adapter: { + macEventTap: "macOS Event Tap", + windowsLowLevel: "Windows 低層鍵盤 hook", + fcitx5: "fcitx5 輸入法插件", + }, }, - fallback: '全局快捷鍵', - modeHoldSuffix: '(按住說話)', - modeToggleSuffix: '(開始 / 停止)', - usageHold: '按住 {{trigger}} 說話,鬆開結束。', - usageToggle: '按 {{trigger}} 開始錄音,再按一次結束。', - adapter: { - macEventTap: 'macOS Event Tap', - windowsLowLevel: 'Windows 低層鍵盤 hook', - fcitx5: 'fcitx5 輸入法插件', + localAsr: { + kicker: "本地 ASR", + title: "模型設置", + desc: "管理本機語音識別模型。", + qwenTitle: "Qwen3-ASR 模型管理", + qwenExperimentalBadge: "實驗性", + engineUnavailable: + "當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。", + qwenUnavailableOnWindows: + "Windows 暫不支援 Qwen3-ASR,請使用上方的 Foundry Local Whisper。", + foundryTitle: "Windows Foundry Local Whisper", + foundryDesc: + "在本機識別語音,無需 ASR API Key。首次使用需下載運行元件和模型。", + foundryAvailable: "Windows 可用", + foundryUnavailable: "僅 Windows 可用", + foundryRuntimeReady: "運行組件已下載", + foundryRuntimeMissing: "運行組件未下載", + foundryRuntimeSourceLabel: "運行組件下載源", + foundryRuntimeSourceAuto: "自動(NuGet 優先)", + foundryRuntimeSourceNuget: "NuGet 官方源", + foundryRuntimeSourceOrtNightly: "Microsoft ORT-Nightly 源", + foundryRuntimeSourceDesc: "首次使用前需下載運行元件。", + foundrySelectedModel: "選擇模型", + foundryActiveModel: "當前默認 alias", + foundryLoadedModel: "已加載模型", + foundryNotLoaded: "未加載", + foundryError: "Foundry 狀態", + foundrySetDefault: "設為默認 / 啟用 Windows 本地 ASR", + foundryEnabling: "正在啟用…", + foundryPrepare: "準備 / 下載 / 加載", + foundryPreparing: "正在準備…", + foundryReleasing: "正在釋放…", + foundryRetryPrepare: "繼續準備 / 重試", + foundryCancelPrepare: "取消準備", + foundryCancelRequested: "已請求取消", + foundryCancelling: "正在取消…", + foundryCancelBestEffort: + "已請求取消,會在當前步驟完成後停止。可稍後重試。", + foundryPrepareRuntime: "準備運行時組件", + foundryPrepareModel: "下載模型", + foundryPrepareLoad: "加載模型", + foundryPrepareModelSkipped: "模型已下載,跳過下載階段", + foundryPrepareDone: "已完成", + foundryPrepareWaiting: "等待中", + foundryApproxSizeMb: "約 {{mb}} MB", + foundryLanguageLabel: "識別語言", + foundryLanguageAuto: "自動", + foundryLanguageZh: "中文 zh", + foundryLanguageEn: "英文 en", + foundryLanguageDesc: "中文聽寫選中文,中英混用選自動。", + foundryModelSmall: "Whisper Small(默認 / 平衡)", + foundryModelSmallDesc: "默認平衡選項,兼顧質量與資源佔用。", + foundryModelMedium: "Whisper Medium(更高質量)", + foundryModelMediumDesc: + "更高準確率,適合性能更強、可接受更大下載和更慢推理的設備。", + foundryModelLarge: "Whisper Large V3 Turbo(最高質量)", + foundryModelLargeDesc: + "更高質量的大模型選項,適合高配設備和質量優先場景。", + foundryModelBase: "Whisper Base(更快 / 更省資源)", + 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)", + mirrorHfMirror: "國內鏡像 (hf-mirror.com)", + mirrorGithubRelease: "GitHub Release 歸檔", + activeBadge: "當前使用", + downloadedBadge: "已下載", + notDownloadedBadge: "未下載", + download: "下載", + resume: "繼續下載", + cancel: "取消", + delete: "刪除", + setActive: "設為默認", + failed: "失敗", + cancelled: "已取消", + files: "文件", + sizeLoading: "正在查詢尺寸…", + sizeUnknown: "尺寸未知", + performanceWarning: + "本地 ASR 適合離線或隱私敏感場景,首次使用需下載模型。", + test: "加載並測試", + testRunning: "測試中…", + testHeading: "內置音頻測試", + testExpected: "原文", + testActual: "識別", + testStats: + "音頻時長 {{audio}}s · 加載 {{load}}s · 推理 {{transcribe}}s · 後端 {{backend}}", + testFailed: "測試失敗", + engineStatusLabel: "內存中的引擎", + engineLoaded: "已加載:{{model}}", + engineUnloaded: "未加載(首次聽寫需先加載模型)", + loadNow: "立即加載", + releaseNow: "立即釋放", + keepLoadedLabel: "保持加載多久", + keepLoadedDesc: + "決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。", + keepImmediate: "說完話立即釋放", + keep1min: "上次使用後 1 分鐘", + keep5min: "上次使用後 5 分鐘(默認)", + keep30min: "上次使用後 30 分鐘", + keepForever: "不釋放(始終保留)", }, - }, - localAsr: { - kicker: '本地 ASR', - title: '模型設置', - desc: '管理本機語音識別模型。', - qwenTitle: 'Qwen3-ASR 模型管理', - qwenExperimentalBadge: '實驗性', - engineUnavailable: '當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。', - qwenUnavailableOnWindows: 'Windows 暫不支援 Qwen3-ASR,請使用上方的 Foundry Local Whisper。', - foundryTitle: 'Windows Foundry Local Whisper', - foundryDesc: '在本機識別語音,無需 ASR API Key。首次使用需下載運行元件和模型。', - foundryAvailable: 'Windows 可用', - foundryUnavailable: '僅 Windows 可用', - foundryRuntimeReady: '運行組件已下載', - foundryRuntimeMissing: '運行組件未下載', - foundryRuntimeSourceLabel: '運行組件下載源', - foundryRuntimeSourceAuto: '自動(NuGet 優先)', - foundryRuntimeSourceNuget: 'NuGet 官方源', - foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly 源', - foundryRuntimeSourceDesc: '首次使用前需下載運行元件。', - foundrySelectedModel: '選擇模型', - foundryActiveModel: '當前默認 alias', - foundryLoadedModel: '已加載模型', - foundryNotLoaded: '未加載', - foundryError: 'Foundry 狀態', - foundrySetDefault: '設為默認 / 啟用 Windows 本地 ASR', - foundryEnabling: '正在啟用…', - foundryPrepare: '準備 / 下載 / 加載', - foundryPreparing: '正在準備…', - foundryReleasing: '正在釋放…', - foundryRetryPrepare: '繼續準備 / 重試', - foundryCancelPrepare: '取消準備', - foundryCancelRequested: '已請求取消', - foundryCancelling: '正在取消…', - foundryCancelBestEffort: '已請求取消,會在當前步驟完成後停止。可稍後重試。', - foundryPrepareRuntime: '準備運行時組件', - foundryPrepareModel: '下載模型', - foundryPrepareLoad: '加載模型', - foundryPrepareModelSkipped: '模型已下載,跳過下載階段', - foundryPrepareDone: '已完成', - foundryPrepareWaiting: '等待中', - foundryApproxSizeMb: '約 {{mb}} MB', - foundryLanguageLabel: '識別語言', - foundryLanguageAuto: '自動', - foundryLanguageZh: '中文 zh', - foundryLanguageEn: '英文 en', - foundryLanguageDesc: '中文聽寫選中文,中英混用選自動。', - foundryModelSmall: 'Whisper Small(默認 / 平衡)', - foundryModelSmallDesc: '默認平衡選項,兼顧質量與資源佔用。', - foundryModelMedium: 'Whisper Medium(更高質量)', - foundryModelMediumDesc: '更高準確率,適合性能更強、可接受更大下載和更慢推理的設備。', - foundryModelLarge: 'Whisper Large V3 Turbo(最高質量)', - foundryModelLargeDesc: '更高質量的大模型選項,適合高配設備和質量優先場景。', - foundryModelBase: 'Whisper Base(更快 / 更省資源)', - foundryModelBaseDesc: '更快、資源佔用更低,適合日常輕量使用。', - foundryModelTiny: 'Whisper Tiny(最快 / 冒煙測試)', - foundryModelTinyDesc: '最快的檢查選項,適合確認 Foundry 路徑可用。', - mirrorLabel: '下載鏡像源', - mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。', - mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', - mirrorHfMirror: '國內鏡像 (hf-mirror.com)', - activeBadge: '當前使用', - downloadedBadge: '已下載', - notDownloadedBadge: '未下載', - download: '下載', - resume: '繼續下載', - cancel: '取消', - delete: '刪除', - setActive: '設為默認', - failed: '失敗', - cancelled: '已取消', - files: '文件', - sizeLoading: '正在查詢尺寸…', - sizeUnknown: '尺寸未知', - performanceWarning: '本地 ASR 適合離線或隱私敏感場景,首次使用需下載模型。', - test: '加載並測試', - testRunning: '測試中…', - testHeading: '內置音頻測試', - testExpected: '原文', - testActual: '識別', - testStats: '音頻時長 {{audio}}s · 加載 {{load}}s · 推理 {{transcribe}}s · 後端 {{backend}}', - testFailed: '測試失敗', - engineStatusLabel: '內存中的引擎', - engineLoaded: '已加載:{{model}}', - engineUnloaded: '未加載(首次聽寫需先加載模型)', - loadNow: '立即加載', - releaseNow: '立即釋放', - keepLoadedLabel: '保持加載多久', - keepLoadedDesc: '決定 Qwen3-ASR 用完後多久從內存釋放,避免長期佔用內存。', - keepImmediate: '說完話立即釋放', - keep1min: '上次使用後 1 分鐘', - keep5min: '上次使用後 5 分鐘(默認)', - keep30min: '上次使用後 30 分鐘', - keepForever: '不釋放(始終保留)', - }, -}; +} diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index fb551d5d..9f3f24be 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, + ["原文", "最小改写"], + ), + makeMockStylePack( + "builtin.light", + "builtin", + "light", + "轻度润色", + "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", + mockSettings.styleSystemPrompts.light, + ["沟通", "自然"], + ), + makeMockStylePack( + "builtin.structured", + "builtin", + "structured", + "清晰结构", + "适合多事项和多主题口述,自动整理为层次清楚的结构化输出。", + mockSettings.styleSystemPrompts.structured, + ["结构化", "条理"], + ), + makeMockStylePack( + "builtin.formal", + "builtin", + "formal", + "正式表达", + "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", + mockSettings.styleSystemPrompts.formal, + ["正式", "工作沟通"], ), - author: 'Demo Community', - }, -]; + { + ...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,548 +527,700 @@ 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 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) ─────────────── @@ -1034,34 +1231,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) ──────────────── @@ -1081,123 +1288,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 } From 7072c30f69593490a86e28770c1ec6633fbc4ba7 Mon Sep 17 00:00:00 2001 From: weikeyi Date: Wed, 20 May 2026 14:45:07 +0800 Subject: [PATCH 2/3] test(windows): include sherpa stub in backend test crate --- .../app/src-tauri/backend-tests/tests/backend_rust.rs | 9 +++++++++ 1 file changed, 9 insertions(+) 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 + } + } } } From c7011f789332e520b21bcccc49a69ad9fd889ab4 Mon Sep 17 00:00:00 2001 From: weikeyi Date: Thu, 21 May 2026 18:37:15 +0800 Subject: [PATCH 3/3] fix(windows): report extracted sherpa archive size --- .../src/asr/local/sherpa_download.rs | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) 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 index 056e1d9f..797099c9 100644 --- a/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_download.rs @@ -231,11 +231,7 @@ pub fn downloaded_bytes(model_alias: &str) -> u64 { return 0; }; if let Some(archive) = sherpa::release_archive_for_alias(model_alias) { - let dest = dir.join(archive.file_name); - if let Ok(meta) = std::fs::metadata(&dest) { - return meta.len(); - } - return partial_actual_size(&dest.with_extension("partial")); + return downloaded_release_archive_bytes(&dir, model_alias, archive); } let Ok(files) = sherpa::download_files_for_alias(model_alias) else { return 0; @@ -253,6 +249,47 @@ pub fn downloaded_bytes(model_alias: &str) -> u64 { .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,