Merge fix/settings-experimental-consistency into codex/ui-2.0-details(偏好写入门控 + 语言区域同步与 2.0 布局偏好并存)

This commit is contained in:
sim committed 2026-09-09 13:22:00 +08:00
commit cd18fe7174
16 files changed
+1004 -54

No files matched your search

+422
View File
@@ -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, &current));
if !collides && !non_core_hotkey_invalid(key, &current) {
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)]
+5 -4
View File
@@ -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');
+3 -2
View File
@@ -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 プロバイダーが引き継がれます。',
+3 -2
View File
@@ -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: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.',
+3 -2
View File
@@ -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 提供商将被接管。',
+3 -2
View File
@@ -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 提供商將被接管。',
+2 -2
View File
@@ -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;
}
}