Merge fix/settings-experimental-consistency into codex/ui-2.0-details(偏好写入门控 + 语言区域同步与 2.0 布局偏好并存)
This commit is contained in:
commit
cd18fe7174
16 files changed
+1004
-54
No files matched your search
@@ -0,0 +1,422 @@
|
||||
# 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<SherpaOnnxAsr>)`
|
||||
- `coordinator/dictation.rs`:`begin_session` / `end_session` 增加
|
||||
`#[cfg(target_os = "windows")]` 分支
|
||||
- `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令
|
||||
- `types.rs`:增加 `UserPreferences` 字段
|
||||
- 前端 Settings 服务页:本地模型板块负责模型管理;是否启用由「AI 提供商」中的
|
||||
`sherpa-onnx-local` 渠道开关统一控制,不再保留第二套本地 ASR 总开关
|
||||
|
||||
---
|
||||
|
||||
## 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\<alias>\
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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<LoadedHandle>`
|
||||
- `transcribe_pcm(pcm: &[i16]) -> Result<String>`(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<SherpaRuntime>,
|
||||
model_alias: String,
|
||||
language_hint: Option<String>,
|
||||
buffer: Mutex<Vec<u8>>, // 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<RawTranscript> { ... }
|
||||
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<dyn AudioConsumer> = 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` 桩函数
|
||||
- 前端渠道开关 + 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 红线」一节
|
||||
@@ -48,6 +48,7 @@ impl<'a> TauriSettingsRuntime<'a> {
|
||||
impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> {
|
||||
fn prepare(
|
||||
&self,
|
||||
<<<<<<< HEAD
|
||||
plan: &openless_core::SettingsEffectPlan,
|
||||
) -> Result<openless_core::SettingsEffectReceipt, openless_core::SettingsEffectFailure> {
|
||||
let mut receipt = openless_core::SettingsEffectReceipt::default();
|
||||
@@ -55,6 +56,299 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> {
|
||||
if let Err(error) = self.apply_windows_keyboard(&change.next) {
|
||||
return Err(openless_core::SettingsEffectFailure::after_side_effect(
|
||||
error, receipt,
|
||||
=======
|
||||
prefs: UserPreferences,
|
||||
) -> Result<(), String> {
|
||||
(**self).write_settings_preserving_current_style_preferences(prefs)
|
||||
}
|
||||
|
||||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
|
||||
(**self).sync_active_asr_provider(provider)
|
||||
}
|
||||
|
||||
fn refresh_dictation_hotkey(&self) {
|
||||
(**self).refresh_dictation_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_qa_hotkey(&self) {
|
||||
(**self).refresh_qa_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_combo_hotkey(&self) {
|
||||
(**self).refresh_combo_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_translation_hotkey(&self) {
|
||||
(**self).refresh_translation_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_switch_style_hotkey(&self) {
|
||||
(**self).refresh_switch_style_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_open_app_hotkey(&self) {
|
||||
(**self).refresh_open_app_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_selection_polish_hotkey(&self) {
|
||||
(**self).refresh_selection_polish_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_coding_agent_hotkey(&self) {
|
||||
(**self).refresh_coding_agent_hotkey();
|
||||
}
|
||||
|
||||
fn refresh_style_pack_hotkeys(&self) {
|
||||
(**self).refresh_style_pack_hotkeys();
|
||||
}
|
||||
}
|
||||
|
||||
/// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum NonCoreHotkey {
|
||||
Translation,
|
||||
Qa,
|
||||
SwitchStyle,
|
||||
OpenApp,
|
||||
SelectionPolish,
|
||||
LessComputer,
|
||||
}
|
||||
|
||||
impl NonCoreHotkey {
|
||||
fn get(&self, prefs: &UserPreferences) -> Option<ShortcutBinding> {
|
||||
match self {
|
||||
Self::Translation => Some(prefs.translation_hotkey.clone()),
|
||||
Self::Qa => prefs.qa_hotkey.clone(),
|
||||
Self::SwitchStyle => prefs.switch_style_hotkey.clone(),
|
||||
Self::OpenApp => prefs.open_app_hotkey.clone(),
|
||||
Self::SelectionPolish => prefs.selection_polish_hotkey.clone(),
|
||||
Self::LessComputer => prefs.coding_agent_voice_hotkey.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, prefs: &mut UserPreferences, value: Option<ShortcutBinding>) {
|
||||
match self {
|
||||
// translation 是必填键,None 表示恢复失败时保持旧值不动。
|
||||
Self::Translation => {
|
||||
if let Some(value) = value {
|
||||
prefs.translation_hotkey = value;
|
||||
}
|
||||
}
|
||||
Self::Qa => prefs.qa_hotkey = value,
|
||||
Self::SwitchStyle => prefs.switch_style_hotkey = value,
|
||||
Self::OpenApp => prefs.open_app_hotkey = value,
|
||||
Self::SelectionPolish => prefs.selection_polish_hotkey = value,
|
||||
Self::LessComputer => prefs.coding_agent_voice_hotkey = value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个非核心热键是否非法。与 `reject_non_dictation_side_specific_shortcuts`
|
||||
/// 的逐键校验保持精确一致,避免把非冲突键一并停用。
|
||||
fn non_core_hotkey_invalid(key: NonCoreHotkey, binding: &ShortcutBinding) -> bool {
|
||||
if crate::shortcut_binding::reject_side_specific_non_dictation(binding).is_err() {
|
||||
return true;
|
||||
}
|
||||
match key {
|
||||
NonCoreHotkey::SelectionPolish => {
|
||||
crate::shortcut_binding::validate_binding(binding).is_err()
|
||||
|| reject_bare_shift_dictation_shortcut(binding).is_err()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存兜底(#904):热键冲突不能把整份设置挡在保存之外。
|
||||
///
|
||||
/// 按核心度从高到低处理每个非核心热键:凡与更高优先级键重叠、或本身非法
|
||||
/// (侧特定修饰键等)的,恢复为旧值;旧值仍冲突/非法(历史遗留,例如 1.3.15
|
||||
/// 升级注入的选区润色默认键与录音键重复)时停用(translation 回退默认 Shift)。
|
||||
/// 返回被调整的键数量。dictation 永远保留,不参与调整。
|
||||
pub(crate) fn reconcile_hotkey_collisions(
|
||||
prefs: &mut UserPreferences,
|
||||
previous: &UserPreferences,
|
||||
) -> usize {
|
||||
// 处理顺序 = 核心度从高到低:处理某项时,更高优先级的键已定稿。
|
||||
const ORDER: [NonCoreHotkey; 6] = [
|
||||
NonCoreHotkey::Translation,
|
||||
NonCoreHotkey::Qa,
|
||||
NonCoreHotkey::SwitchStyle,
|
||||
NonCoreHotkey::OpenApp,
|
||||
NonCoreHotkey::SelectionPolish,
|
||||
NonCoreHotkey::LessComputer,
|
||||
];
|
||||
let mut higher: Vec<ShortcutBinding> = vec![prefs.dictation_hotkey.clone()];
|
||||
let mut adjusted = 0;
|
||||
for key in ORDER {
|
||||
let Some(current) = key.get(prefs) else {
|
||||
continue;
|
||||
};
|
||||
let collides = higher
|
||||
.iter()
|
||||
.any(|held| crate::shortcut_binding::bindings_overlap(held, ¤t));
|
||||
if !collides && !non_core_hotkey_invalid(key, ¤t) {
|
||||
higher.push(current);
|
||||
continue;
|
||||
}
|
||||
let fallback = key.get(previous).filter(|candidate| {
|
||||
!higher
|
||||
.iter()
|
||||
.any(|held| crate::shortcut_binding::bindings_overlap(held, candidate))
|
||||
&& !non_core_hotkey_invalid(key, candidate)
|
||||
});
|
||||
// translation 不能停用:旧值仍冲突/非法时回退到默认 Shift(不会与任何键重叠)。
|
||||
let resolved = if key == NonCoreHotkey::Translation && fallback.is_none() {
|
||||
Some(UserPreferences::default().translation_hotkey.clone())
|
||||
} else {
|
||||
fallback
|
||||
};
|
||||
key.set(prefs, resolved.clone());
|
||||
adjusted += 1;
|
||||
if let Some(value) = resolved {
|
||||
higher.push(value);
|
||||
}
|
||||
}
|
||||
// 风格包直达快捷键是最低优先级:与更高优先级键重叠、非法或集合内重复的条目,
|
||||
// 先尝试恢复该风格包的旧绑定,仍不行则整条移除(不影响其余设置落盘)。
|
||||
let mut kept: Vec<StylePackHotkey> = Vec::new();
|
||||
for entry in &prefs.style_pack_hotkeys {
|
||||
let candidate_ok = |candidate: &StylePackHotkey| {
|
||||
!candidate.pack_id.trim().is_empty()
|
||||
&& crate::shortcut_binding::validate_binding(&candidate.binding).is_ok()
|
||||
&& crate::shortcut_binding::reject_side_specific_non_dictation(&candidate.binding)
|
||||
.is_ok()
|
||||
&& reject_modifier_only_action_shortcut(&candidate.binding).is_ok()
|
||||
&& !kept.iter().any(|held: &StylePackHotkey| {
|
||||
held.pack_id == candidate.pack_id
|
||||
|| crate::shortcut_binding::bindings_overlap(
|
||||
&held.binding,
|
||||
&candidate.binding,
|
||||
)
|
||||
})
|
||||
&& !higher.iter().any(|held| {
|
||||
crate::shortcut_binding::bindings_overlap(held, &candidate.binding)
|
||||
})
|
||||
};
|
||||
if candidate_ok(entry) {
|
||||
kept.push(entry.clone());
|
||||
continue;
|
||||
}
|
||||
adjusted += 1;
|
||||
if let Some(fallback) = previous
|
||||
.style_pack_hotkeys
|
||||
.iter()
|
||||
.find(|old| old.pack_id == entry.pack_id)
|
||||
.filter(|old| candidate_ok(old))
|
||||
{
|
||||
kept.push(fallback.clone());
|
||||
}
|
||||
}
|
||||
if kept != prefs.style_pack_hotkeys {
|
||||
prefs.style_pack_hotkeys = kept;
|
||||
}
|
||||
adjusted
|
||||
}
|
||||
|
||||
pub(crate) fn persist_settings<T: SettingsWriter>(
|
||||
coord: &T,
|
||||
prefs: UserPreferences,
|
||||
) -> Result<UserPreferences, String> {
|
||||
persist_settings_with_keyboard_apply(
|
||||
coord,
|
||||
prefs,
|
||||
crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
|
||||
coord: &T,
|
||||
mut prefs: UserPreferences,
|
||||
apply_keyboard_list: impl Fn(&UserPreferences) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let mut previous = coord.read_settings();
|
||||
sync_dictation_hotkey_legacy_fields(&mut previous);
|
||||
sync_dictation_hotkey_legacy_fields(&mut prefs);
|
||||
if let Err(collision_error) = reject_hotkey_collisions(&prefs) {
|
||||
// 兜底(#904):热键冲突(含历史遗留的重复键)不能拒绝整份设置保存。
|
||||
// 自动把冲突/非法的非核心热键恢复旧值或停用,其余设置照常落盘。
|
||||
let adjusted = reconcile_hotkey_collisions(&mut prefs, &previous);
|
||||
reject_hotkey_collisions(&prefs).map_err(|leftover| {
|
||||
format!("{collision_error}; 自动化解 {adjusted} 项后仍无法通过校验: {leftover}")
|
||||
})?;
|
||||
log::warn!(
|
||||
"[settings] 热键冲突已自动化解(调整 {adjusted} 项)后保存: {collision_error}"
|
||||
);
|
||||
}
|
||||
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;
|
||||
let style_pack_hotkeys_changed = previous.style_pack_hotkeys != prefs.style_pack_hotkeys;
|
||||
let selection_polish_changed =
|
||||
previous.selection_polish_hotkey != prefs.selection_polish_hotkey;
|
||||
let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled
|
||||
|| previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey;
|
||||
let windows_keyboard_list_changed = previous.windows_sendinput_insertion_only
|
||||
!= prefs.windows_sendinput_insertion_only
|
||||
|| previous.windows_show_openless_in_keyboard_list
|
||||
!= prefs.windows_show_openless_in_keyboard_list;
|
||||
let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider;
|
||||
let active_asr_provider = prefs.active_asr_provider.clone();
|
||||
|
||||
if windows_keyboard_list_changed {
|
||||
apply_keyboard_list(&prefs)?;
|
||||
}
|
||||
|
||||
if active_asr_provider_changed {
|
||||
if let Err(asr_err) = coord.sync_active_asr_provider(&active_asr_provider) {
|
||||
if windows_keyboard_list_changed {
|
||||
if let Err(kb_rollback_err) = apply_keyboard_list(&previous) {
|
||||
return Err(format!(
|
||||
"{asr_err}; additionally failed to rollback keyboard list visibility: {kb_rollback_err}"
|
||||
));
|
||||
}
|
||||
log::warn!(
|
||||
"[windows-ime] rolled back keyboard list visibility after ASR provider sync failure"
|
||||
);
|
||||
}
|
||||
return Err(asr_err);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = coord.write_settings_preserving_current_style_preferences(prefs.clone()) {
|
||||
if active_asr_provider_changed {
|
||||
match coord.sync_active_asr_provider(&previous.active_asr_provider) {
|
||||
Ok(()) => {
|
||||
if windows_keyboard_list_changed {
|
||||
if let Err(rollback_err) = apply_keyboard_list(&previous) {
|
||||
return Err(format!(
|
||||
"{error}; additionally failed to rollback keyboard list visibility: {rollback_err}"
|
||||
));
|
||||
}
|
||||
log::warn!(
|
||||
"[windows-ime] rolled back keyboard list visibility after settings write failure"
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Err(rollback_error) => {
|
||||
// ASR vault 无法回滚时 roll-forward prefs;键盘列表保持新状态,避免三者分叉。
|
||||
coord
|
||||
.write_settings_preserving_current_style_preferences(prefs)
|
||||
.map_err(|roll_forward_error| {
|
||||
format!(
|
||||
"{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
} else if windows_keyboard_list_changed {
|
||||
if let Err(rollback_err) = apply_keyboard_list(&previous) {
|
||||
return Err(format!(
|
||||
"{error}; additionally failed to rollback keyboard list visibility: {rollback_err}"
|
||||
>>>>>>> fix/settings-experimental-consistency
|
||||
));
|
||||
}
|
||||
receipt
|
||||
@@ -206,7 +500,7 @@ async fn invalidate_llm_tests_if_thinking_changed(
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
#[cfg(not(mobile))]
|
||||
@@ -215,7 +509,7 @@ pub async fn set_settings(
|
||||
coord: CoordinatorState<'_>,
|
||||
app: AppHandle,
|
||||
mut prefs: UserPreferences,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<UserPreferences, String> {
|
||||
// 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。
|
||||
let remote_prev = coord.backend().get_preferences();
|
||||
let packs = coord
|
||||
@@ -271,7 +565,7 @@ pub async fn set_settings(
|
||||
.await
|
||||
.map_err(|error| error.message)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -58,6 +58,7 @@ export const en: typeof zhCN = {
|
||||
hide: 'Hide',
|
||||
saved: 'Saved',
|
||||
saving: 'Saving…',
|
||||
experimental: 'Experimental',
|
||||
copied: 'Copied',
|
||||
operationFailed: 'Operation failed',
|
||||
add: 'Add',
|
||||
@@ -1277,7 +1278,7 @@ export const en: typeof zhCN = {
|
||||
asrXiaomiMimo: 'Xiaomi MiMo ASR',
|
||||
asrIflytek: 'iFlytek Realtime ASR',
|
||||
asrElevenLabs: 'ElevenLabs Scribe',
|
||||
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
|
||||
asrSherpaOnnxLocal: 'Local sherpa-onnx (Experimental)',
|
||||
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
|
||||
asrLocalWhisper: 'Local Whisper (batch)',
|
||||
asrLocalQwen3: 'Local Qwen3-ASR',
|
||||
@@ -1639,14 +1640,14 @@ export const en: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: 'Multimodal recognition pipeline (experimental)',
|
||||
multimodalPipelineTitle: 'Multimodal recognition pipeline',
|
||||
multimodalPipelineTitleHint:
|
||||
'One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.',
|
||||
multimodalPipelineLabel: 'Enable multimodal pipeline',
|
||||
multimodalPipelineHint:
|
||||
'Adds a Traditional / Multimodal switch on the AI providers page. Traditional = ASR + LLM; Multimodal = one audio-capable model. The two configurations are stored separately and never share credentials.',
|
||||
streamingInsertTitle: 'Streaming insertion',
|
||||
streamingInsertTitleLinux: 'Streaming insertion (experimental)',
|
||||
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',
|
||||
@@ -1659,7 +1660,7 @@ export const en: typeof zhCN = {
|
||||
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)',
|
||||
localAsrTitle: 'Local ASR models',
|
||||
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.',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { zhCN } from './zh-CN';
|
||||
import { zhTW } from './zh-TW';
|
||||
import { en } from './en';
|
||||
import { ja } from './ja';
|
||||
import { ko } from './ko';
|
||||
|
||||
const locales = { zhCN, zhTW, en, ja, ko };
|
||||
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
const marker = messages.common.experimental;
|
||||
if (!marker) throw new Error(`${locale} must define the shared experimental badge`);
|
||||
|
||||
for (const [key, title] of Object.entries({
|
||||
multimodalPipelineTitle: messages.settings.advanced.multimodalPipelineTitle,
|
||||
localAsrTitle: messages.settings.advanced.localAsrTitle,
|
||||
})) {
|
||||
if (title.includes(marker)) {
|
||||
throw new Error(`${locale}.${key} must leave the experimental marker to the shared title component`);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.localAsr.qwenExperimentalBadge !== marker) {
|
||||
throw new Error(`${locale} Qwen badge must use the same experimental wording`);
|
||||
}
|
||||
|
||||
for (const [key, title] of Object.entries({
|
||||
streamingInsertTitleLinux: messages.settings.advanced.streamingInsertTitleLinux,
|
||||
asrSherpaOnnxLocal: messages.settings.providers.presets.asrSherpaOnnxLocal,
|
||||
sherpaTitle: messages.localAsr.sherpaTitle,
|
||||
})) {
|
||||
if (!title.includes(marker)) {
|
||||
throw new Error(`${locale}.${key} must use the shared experimental wording`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('experimental title translations are consistent');
|
||||
@@ -59,6 +59,7 @@ export const ja: typeof zhCN = {
|
||||
hide: '非表示',
|
||||
saved: '保存しました',
|
||||
saving: '保存中',
|
||||
experimental: '実験的',
|
||||
copied: 'コピーしました',
|
||||
operationFailed: '操作に失敗しました',
|
||||
add: '追加',
|
||||
@@ -1620,7 +1621,7 @@ export const ja: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: 'マルチモーダル認識パイプライン(実験的)',
|
||||
multimodalPipelineTitle: 'マルチモーダル認識パイプライン',
|
||||
multimodalPipelineTitleHint:
|
||||
'1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。',
|
||||
multimodalPipelineLabel: 'マルチモーダルパイプラインを有効化',
|
||||
@@ -1640,7 +1641,7 @@ export const ja: typeof zhCN = {
|
||||
streamingInsertSaveClipboardLabel: 'クリップボードに保存',
|
||||
streamingInsertSaveClipboardHint:
|
||||
'挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。',
|
||||
localAsrTitle: 'ローカル ASR モデル(実験的)',
|
||||
localAsrTitle: 'ローカル ASR モデル',
|
||||
localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。',
|
||||
localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。',
|
||||
qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。',
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ko: typeof zhCN = {
|
||||
hide: '숨기기',
|
||||
saved: '저장됨',
|
||||
saving: '저장 중',
|
||||
experimental: '실험적',
|
||||
copied: '복사됨',
|
||||
operationFailed: '작업 실패',
|
||||
add: '추가',
|
||||
@@ -1609,7 +1610,7 @@ export const ko: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: '멀티모달 인식 파이프라인 (실험적)',
|
||||
multimodalPipelineTitle: '멀티모달 인식 파이프라인 ',
|
||||
multimodalPipelineTitleHint:
|
||||
'단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.',
|
||||
multimodalPipelineLabel: '멀티모달 파이프라인 활성화',
|
||||
@@ -1628,7 +1629,7 @@ export const ko: typeof zhCN = {
|
||||
streamingInsertSaveClipboardLabel: '클립보드에 저장',
|
||||
streamingInsertSaveClipboardHint:
|
||||
'삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.',
|
||||
localAsrTitle: '로컬 ASR 모델 (실험적)',
|
||||
localAsrTitle: '로컬 ASR 모델 ',
|
||||
localAsrDesc:
|
||||
'전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.',
|
||||
localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.',
|
||||
|
||||
@@ -52,6 +52,7 @@ export const zhCN = {
|
||||
hide: '隐藏',
|
||||
saved: '已保存',
|
||||
saving: '保存中',
|
||||
experimental: '实验性',
|
||||
copied: '已复制',
|
||||
operationFailed: '操作失败',
|
||||
add: '添加',
|
||||
@@ -1550,7 +1551,7 @@ export const zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: '多模态识别管线(实验性)',
|
||||
multimodalPipelineTitle: '多模态识别管线',
|
||||
multimodalPipelineTitleHint:
|
||||
'用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。',
|
||||
multimodalPipelineLabel: '启用多模态识别管线',
|
||||
@@ -1566,7 +1567,7 @@ export const zhCN = {
|
||||
streamingInsertSaveClipboardLabel: '同步到剪贴板',
|
||||
streamingInsertSaveClipboardHint:
|
||||
'插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。',
|
||||
localAsrTitle: '本地 ASR 模型(实验性)',
|
||||
localAsrTitle: '本地 ASR 模型',
|
||||
localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。',
|
||||
localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。',
|
||||
qwen3Desc: '启动之后,ASR 提供商将被接管。',
|
||||
|
||||
@@ -54,6 +54,7 @@ export const zhTW: typeof zhCN = {
|
||||
hide: '隱藏',
|
||||
saved: '已保存',
|
||||
saving: '保存中',
|
||||
experimental: '實驗性',
|
||||
copied: '已複製',
|
||||
operationFailed: '操作失敗',
|
||||
add: '添加',
|
||||
@@ -1536,7 +1537,7 @@ export const zhTW: typeof zhCN = {
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
multimodalPipelineTitle: '多模態辨識管線(實驗性)',
|
||||
multimodalPipelineTitle: '多模態辨識管線',
|
||||
multimodalPipelineTitleHint:
|
||||
'用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。',
|
||||
multimodalPipelineLabel: '啟用多模態辨識管線',
|
||||
@@ -1552,7 +1553,7 @@ export const zhTW: typeof zhCN = {
|
||||
streamingInsertSaveClipboardLabel: '同步到剪貼簿',
|
||||
streamingInsertSaveClipboardHint:
|
||||
'插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。',
|
||||
localAsrTitle: '本地 ASR 模型(實驗性)',
|
||||
localAsrTitle: '本地 ASR 模型',
|
||||
localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。',
|
||||
localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。',
|
||||
qwen3Desc: '啓動之後,ASR 提供商將被接管。',
|
||||
|
||||
@@ -26,11 +26,11 @@ export function getDefaultStyleSystemPrompts(): Promise<StyleSystemPrompts> {
|
||||
}));
|
||||
}
|
||||
|
||||
export function setSettings(prefs: UserPreferences): Promise<void> {
|
||||
export function setSettings(prefs: UserPreferences): Promise<UserPreferences> {
|
||||
return invokeOrMock('set_settings', { prefs }, () => {
|
||||
const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled;
|
||||
mockSetSettings(prefs);
|
||||
if (thinkingChanged) invalidateMockChannelTests('llm');
|
||||
return undefined;
|
||||
return prefs;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ExperimentalSectionTitle } from './shared';
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
ExperimentalSectionTitle({ children: 'Local ASR models', badge: 'Experimental' }),
|
||||
);
|
||||
|
||||
if (!markup.includes('Local ASR models')) {
|
||||
throw new Error('experimental section title must render its title');
|
||||
}
|
||||
if (!markup.includes('Experimental')) {
|
||||
throw new Error('experimental section title must render the shared badge');
|
||||
}
|
||||
if ((markup.match(/Experimental/g) ?? []).length !== 1) {
|
||||
throw new Error('experimental badge must be rendered exactly once');
|
||||
}
|
||||
|
||||
console.log('experimental section title tests passed');
|
||||
@@ -1,15 +1,23 @@
|
||||
// 本地 ASR 模型管理入口。下载与运行状态由 LocalAsr 展示,
|
||||
// 是否使用该引擎由 AI 服务与模型中的语音识别渠道决定。
|
||||
// 服务 → 本地模型:本地 ASR 模型的管理入口。含 Qwen3(macOS)/ Foundry Local +
|
||||
// sherpa-onnx(Windows)三条本地引擎。
|
||||
//
|
||||
// 2026-08 重构:本地 ASR 不再有「启用开关」——本地 ASR 始终可用,是否激活由
|
||||
// 「服务 → AI 提供商」的 ASR 语音转写供应商决定:选到本地模型供应商即使用本地
|
||||
// 引擎(与 Apple 语音同理)。这里只保留模型下载 / 管理看板(<LocalAsr embedded />)。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PlatformCapabilities } from '../../lib/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalAsr } from '../LocalAsr';
|
||||
import { detectOS } from '../../components/WindowChrome';
|
||||
import { getPlatformCapabilities } from '../../lib/platform';
|
||||
import '../LocalAsr/local-asr.css';
|
||||
import { Card } from '../_atoms';
|
||||
import { ExperimentalSectionTitle } from './shared';
|
||||
|
||||
export function LocalModelSection() {
|
||||
const { t } = useTranslation();
|
||||
const os = detectOS();
|
||||
const isWin = os === 'win';
|
||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,23 +27,41 @@ export function LocalModelSection() {
|
||||
const platformSupported = platformCaps?.supportsLocalAsr === true;
|
||||
|
||||
return (
|
||||
<section className="ol-local-model-section">
|
||||
<header className="ol-local-model-heading">
|
||||
<div>
|
||||
<h2>{t('modal.serviceViews.models')}</h2>
|
||||
<p>{t('localAsr.performanceWarning')}</p>
|
||||
<Card>
|
||||
{/* 标题 + 右上角 inline 警告小字(实验性标记保留)。
|
||||
Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
|
||||
<ExperimentalSectionTitle badge={t('common.experimental')} style={{ marginBottom: 0 }}>
|
||||
{t('settings.advanced.localAsrTitle')}
|
||||
</ExperimentalSectionTitle>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: '#A04500',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'right',
|
||||
flexShrink: 0,
|
||||
maxWidth: '52%',
|
||||
paddingTop: 2,
|
||||
opacity: isWin ? 0.45 : 1,
|
||||
}}>
|
||||
⚠️ {t('settings.advanced.localAsrWarningShort')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!platformSupported ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
|
||||
{t('settings.advanced.platformNotSupported')}
|
||||
</div>
|
||||
<span className="ol-model-status">{t('localAsr.qwenExperimentalBadge')}</span>
|
||||
</header>
|
||||
{platformCaps === null ? (
|
||||
<p className="ol-model-muted" role="status">
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
) : !platformSupported ? (
|
||||
<p className="ol-model-muted">{t('settings.advanced.platformNotSupported')}</p>
|
||||
) : (
|
||||
<LocalAsr embedded />
|
||||
/* 模型下载 / 管理看板(模型选择 · 下载 · 删除 · 测试 · 镜像源收纳)——
|
||||
较大的内嵌框。启用入口在「AI 提供商 → ASR 语音转写」选择本地供应商。 */
|
||||
<div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
|
||||
<LocalAsr embedded />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { Card } from '../_atoms';
|
||||
import { SettingRow, Toggle } from './shared';
|
||||
import { ExperimentalSectionTitle, SettingRow, Toggle } from './shared';
|
||||
|
||||
export function MultimodalPipelineSection() {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,13 +21,19 @@ export function MultimodalPipelineSection() {
|
||||
}
|
||||
|
||||
const onToggle = (multimodalPipelineEnabled: boolean) => {
|
||||
void updatePrefs({ ...prefs, multimodalPipelineEnabled }).catch((error) => {
|
||||
void updatePrefs(current => ({ ...current, multimodalPipelineEnabled })).catch(error => {
|
||||
console.error('[settings] failed to update multimodal pipeline flag', error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<ExperimentalSectionTitle
|
||||
badge={t('common.experimental')}
|
||||
hint={t('settings.advanced.multimodalPipelineTitleHint')}
|
||||
>
|
||||
{t('settings.advanced.multimodalPipelineTitle')}
|
||||
</ExperimentalSectionTitle>
|
||||
<SettingRow
|
||||
label={t('settings.advanced.multimodalPipelineLabel')}
|
||||
desc={t('settings.advanced.multimodalPipelineHint')}
|
||||
|
||||
@@ -48,6 +48,40 @@ export function SectionTitle({
|
||||
);
|
||||
}
|
||||
|
||||
export function ExperimentalSectionTitle({
|
||||
children,
|
||||
badge,
|
||||
hint,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode
|
||||
badge: string
|
||||
hint?: string
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<SectionTitle hint={hint} style={style}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
|
||||
<span>{children}</span>
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
borderRadius: 999,
|
||||
background: "var(--ol-blue-soft)",
|
||||
color: "var(--ol-blue)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</span>
|
||||
</SectionTitle>
|
||||
)
|
||||
}
|
||||
|
||||
// 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。
|
||||
export function SectionDesc(_props: { children: ReactNode; style?: CSSProperties }) {
|
||||
return null;
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
} 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';
|
||||
import { applyThemeFromPreference } from '../lib/themeMode';
|
||||
import { applyStackedLayoutFromPrefs } from '../lib/stackedLayout';
|
||||
import { applyConservativeLayout } from '../lib/conservativeLayout';
|
||||
import { emitSaved } from '../lib/savedEvent';
|
||||
import { PreferencesWriteGate } from './preferencesWriteGate';
|
||||
|
||||
interface HotkeySettingsContextValue {
|
||||
prefs: UserPreferences | null;
|
||||
@@ -39,6 +41,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const persistQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const latestPrefsRef = useRef<UserPreferences | null>(null);
|
||||
const persistedPrefsRef = useRef<UserPreferences | null>(null);
|
||||
const writeGateRef = useRef(new PreferencesWriteGate<UserPreferences>());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -78,16 +81,36 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueSetSettings = useCallback((resolved: UserPreferences) => {
|
||||
const task = persistQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await setSettings(resolved);
|
||||
});
|
||||
persistQueueRef.current = task;
|
||||
return task;
|
||||
const applyIncomingPrefs = useCallback((nextPrefs: UserPreferences) => {
|
||||
latestPrefsRef.current = nextPrefs;
|
||||
persistedPrefsRef.current = nextPrefs;
|
||||
setPrefs(nextPrefs);
|
||||
applyThemeFromPreference(nextPrefs.themeMode ?? 'system');
|
||||
applyStackedLayoutFromPrefs(nextPrefs.stackedRowLayout);
|
||||
applyConservativeLayout(nextPrefs.conservativeLayout === true);
|
||||
}, []);
|
||||
|
||||
const queueSetSettings = useCallback(
|
||||
(resolved: UserPreferences) => {
|
||||
const finishWrite = writeGateRef.current.beginWrite();
|
||||
let savedPrefs: UserPreferences | null = null;
|
||||
const task = persistQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
savedPrefs = await setSettings(resolved);
|
||||
})
|
||||
.then(() => {
|
||||
persistedPrefsRef.current = savedPrefs;
|
||||
})
|
||||
.finally(() => {
|
||||
if (finishWrite() && savedPrefs) applyIncomingPrefs(savedPrefs);
|
||||
});
|
||||
persistQueueRef.current = task;
|
||||
return task;
|
||||
},
|
||||
[applyIncomingPrefs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
@@ -102,12 +125,10 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const handle = await listen<UserPreferences>('prefs:changed', (event) => {
|
||||
const nextPrefs = event.payload;
|
||||
if (!nextPrefs) return;
|
||||
latestPrefsRef.current = nextPrefs;
|
||||
persistedPrefsRef.current = nextPrefs;
|
||||
setPrefs(nextPrefs);
|
||||
applyThemeFromPreference(nextPrefs.themeMode ?? 'system');
|
||||
applyStackedLayoutFromPrefs(nextPrefs.stackedRowLayout);
|
||||
applyConservativeLayout(nextPrefs.conservativeLayout === true);
|
||||
// 一次保存的广播先于其 IPC promise resolve 到达。若不拦截,较旧的
|
||||
// 排队保存会覆盖较新的乐观点击,让开关看起来「弹回去」。
|
||||
const applicable = writeGateRef.current.receiveIncoming(nextPrefs);
|
||||
if (applicable) applyIncomingPrefs(applicable);
|
||||
});
|
||||
if (cancelled) {
|
||||
handle();
|
||||
@@ -122,7 +143,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
}, [applyIncomingPrefs]);
|
||||
|
||||
useEffect(() => {
|
||||
latestPrefsRef.current = prefs;
|
||||
@@ -155,6 +176,38 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
[queueSetSettings],
|
||||
);
|
||||
|
||||
// App 语言与输出语言偏好保持一致:界面切到某个语言后,输出偏好
|
||||
// (chineseScriptPreference / outputLanguagePreference)跟随该语言,
|
||||
// 避免「界面中文、输出英文」的隐性不一致。
|
||||
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(merged).catch((error) => {
|
||||
console.warn('[settings] sync locale output preferences failed', error);
|
||||
});
|
||||
}, [prefs, queueSetSettings]);
|
||||
|
||||
const value = useMemo<HotkeySettingsContextValue>(
|
||||
() => ({
|
||||
prefs,
|
||||
@@ -168,8 +221,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
[capability, error, loading, prefs, refresh, updatePrefs],
|
||||
);
|
||||
|
||||
return <HotkeySettingsContext.Provider value={value}>{children}</HotkeySettingsContext.Provider>;
|
||||
}
|
||||
return <HotkeySettingsContext.Provider value={value}>{children}</HotkeySettingsContext.Provider>;}
|
||||
|
||||
export function useHotkeySettings() {
|
||||
const value = useContext(HotkeySettingsContext);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PreferencesWriteGate } from './preferencesWriteGate';
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const gate = new PreferencesWriteGate<string>();
|
||||
assert(gate.shouldApplyIncoming(), 'remote preference events should apply while idle');
|
||||
assert(gate.receiveIncoming('idle') === 'idle', 'idle events should be returned immediately');
|
||||
|
||||
const finishFirst = gate.beginWrite();
|
||||
assert(!gate.shouldApplyIncoming(), 'an older event must not replace an optimistic local update');
|
||||
assert(gate.receiveIncoming('latest') === null, 'events during a write must not replace an optimistic local update');
|
||||
|
||||
const finishSecond = gate.beginWrite();
|
||||
assert(gate.receiveIncoming('old') === null, 'a delayed older event must not be replayed after local writes finish');
|
||||
assert(finishFirst() === false, 'only the final queued write may apply its canonical preference payload');
|
||||
assert(!gate.shouldApplyIncoming(), 'all queued local writes must finish before events resume');
|
||||
|
||||
assert(finishSecond(), 'the final queued write must apply its canonical preference payload');
|
||||
assert(gate.shouldApplyIncoming(), 'remote preference events should resume after local writes finish');
|
||||
|
||||
// Completion callbacks can be reached from defensive cleanup paths; they must be idempotent.
|
||||
assert(finishSecond() === false, 'a completion callback must only release its event once');
|
||||
assert(gate.shouldApplyIncoming(), 'finishing one write twice must not corrupt the gate');
|
||||
|
||||
console.log('preference write gate tests passed');
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Prevents an older `prefs:changed` broadcast from overwriting an optimistic
|
||||
* local edit while one or more local writes are queued. Events cannot be
|
||||
* correlated to a particular write, so they are discarded instead of replayed
|
||||
* after the queue drains; the latest local value is already optimistic.
|
||||
*/
|
||||
export class PreferencesWriteGate<T = unknown> {
|
||||
private pendingWrites = 0;
|
||||
|
||||
beginWrite(): () => boolean {
|
||||
this.pendingWrites += 1;
|
||||
let finished = false;
|
||||
return () => {
|
||||
if (finished) return false;
|
||||
finished = true;
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
return this.pendingWrites === 0;
|
||||
};
|
||||
}
|
||||
|
||||
shouldApplyIncoming(): boolean {
|
||||
return this.pendingWrites === 0;
|
||||
}
|
||||
|
||||
receiveIncoming(value: T): T | null {
|
||||
if (this.shouldApplyIncoming()) return value;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user