diff --git a/docs/windows-sherpa-onnx-asr-plan.md b/docs/windows-sherpa-onnx-asr-plan.md new file mode 100644 index 00000000..da98e911 --- /dev/null +++ b/docs/windows-sherpa-onnx-asr-plan.md @@ -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)` +- `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\\ + ``` + +--- + +## 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` 桩函数 +- 前端渠道开关 + 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/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 9dea0b06..3834bd25 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -48,6 +48,7 @@ impl<'a> TauriSettingsRuntime<'a> { impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> { fn prepare( &self, +<<<<<<< HEAD plan: &openless_core::SettingsEffectPlan, ) -> Result { 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 { + 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) { + 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 = 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 = 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( + coord: &T, + prefs: UserPreferences, +) -> Result { + 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( + 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 { // 捕获旧值用于远程输入服务的 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)] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d550ce4c..70592fd6 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -58,6 +58,7 @@ export const en: typeof zhCN = { 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.', diff --git a/openless-all/app/src/i18n/experimentalTitles.test.ts b/openless-all/app/src/i18n/experimentalTitles.test.ts new file mode 100644 index 00000000..c3e7ccdb --- /dev/null +++ b/openless-all/app/src/i18n/experimentalTitles.test.ts @@ -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'); diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 023c4092..08a9d41d 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -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 プロバイダーが引き継がれます。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 6714c558..12073c7f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -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: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 35c4b90a..efdf5a76 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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 提供商将被接管。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 71086cc2..9b79d769 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -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 提供商將被接管。', diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index 32b3b455..cfb63bca 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -26,11 +26,11 @@ export function getDefaultStyleSystemPrompts(): Promise { })); } -export function setSettings(prefs: UserPreferences): Promise { +export function setSettings(prefs: UserPreferences): Promise { return invokeOrMock('set_settings', { prefs }, () => { const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled; mockSetSettings(prefs); if (thinkingChanged) invalidateMockChannelTests('llm'); - return undefined; + return prefs; }); } diff --git a/openless-all/app/src/pages/settings/ExperimentalSectionTitle.test.ts b/openless-all/app/src/pages/settings/ExperimentalSectionTitle.test.ts new file mode 100644 index 00000000..039ba35e --- /dev/null +++ b/openless-all/app/src/pages/settings/ExperimentalSectionTitle.test.ts @@ -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'); diff --git a/openless-all/app/src/pages/settings/LocalModelSection.tsx b/openless-all/app/src/pages/settings/LocalModelSection.tsx index 76c849c6..445b06e6 100644 --- a/openless-all/app/src/pages/settings/LocalModelSection.tsx +++ b/openless-all/app/src/pages/settings/LocalModelSection.tsx @@ -1,15 +1,23 @@ -// 本地 ASR 模型管理入口。下载与运行状态由 LocalAsr 展示, -// 是否使用该引擎由 AI 服务与模型中的语音识别渠道决定。 +// 服务 → 本地模型:本地 ASR 模型的管理入口。含 Qwen3(macOS)/ Foundry Local + +// sherpa-onnx(Windows)三条本地引擎。 +// +// 2026-08 重构:本地 ASR 不再有「启用开关」——本地 ASR 始终可用,是否激活由 +// 「服务 → AI 提供商」的 ASR 语音转写供应商决定:选到本地模型供应商即使用本地 +// 引擎(与 Apple 语音同理)。这里只保留模型下载 / 管理看板()。 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(null); useEffect(() => { @@ -19,23 +27,41 @@ export function LocalModelSection() { const platformSupported = platformCaps?.supportsLocalAsr === true; return ( -
-
-
-

{t('modal.serviceViews.models')}

-

{t('localAsr.performanceWarning')}

+ + {/* 标题 + 右上角 inline 警告小字(实验性标记保留)。 + Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */} +
+
+ + {t('settings.advanced.localAsrTitle')} + +
+
+ ⚠️ {t('settings.advanced.localAsrWarningShort')} +
+
+ + {!platformSupported ? ( +
+ {t('settings.advanced.platformNotSupported')}
- {t('localAsr.qwenExperimentalBadge')} -
- {platformCaps === null ? ( -

- {t('common.loading')} -

- ) : !platformSupported ? ( -

{t('settings.advanced.platformNotSupported')}

) : ( - + /* 模型下载 / 管理看板(模型选择 · 下载 · 删除 · 测试 · 镜像源收纳)—— + 较大的内嵌框。启用入口在「AI 提供商 → ASR 语音转写」选择本地供应商。 */ +
+ +
)} -
+ ); } diff --git a/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx b/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx index b009b84b..d5c3cd3e 100644 --- a/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx +++ b/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx @@ -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 ( + + {t('settings.advanced.multimodalPipelineTitle')} + + + {children} + + {badge} + + + + ) +} + // 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。 export function SectionDesc(_props: { children: ReactNode; style?: CSSProperties }) { return null; diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index e06936ba..3710ab4f 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -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.resolve()); const latestPrefsRef = useRef(null); const persistedPrefsRef = useRef(null); + const writeGateRef = useRef(new PreferencesWriteGate()); 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('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( () => ({ prefs, @@ -168,8 +221,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { [capability, error, loading, prefs, refresh, updatePrefs], ); - return {children}; -} + return {children};} export function useHotkeySettings() { const value = useContext(HotkeySettingsContext); diff --git a/openless-all/app/src/state/preferencesWriteGate.test.ts b/openless-all/app/src/state/preferencesWriteGate.test.ts new file mode 100644 index 00000000..221a6401 --- /dev/null +++ b/openless-all/app/src/state/preferencesWriteGate.test.ts @@ -0,0 +1,27 @@ +import { PreferencesWriteGate } from './preferencesWriteGate'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +const gate = new PreferencesWriteGate(); +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'); diff --git a/openless-all/app/src/state/preferencesWriteGate.ts b/openless-all/app/src/state/preferencesWriteGate.ts new file mode 100644 index 00000000..17c1ecb0 --- /dev/null +++ b/openless-all/app/src/state/preferencesWriteGate.ts @@ -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 { + 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; + } +}