Skip to content

chore(release): prepare 1.3.17 stable - #955

Merged
appergb merged 31 commits into
mainfrom
release/1.3.17-stable
Aug 15, 2026
Merged

chore(release): prepare 1.3.17 stable#955
appergb merged 31 commits into
mainfrom
release/1.3.17-stable

Conversation

@appergb

@appergb appergb commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • merge the latest beta into main for the Stable channel
  • synchronize the application version to 1.3.17 across all five release files
  • prepare the commit that will receive tag v1.3.17-tauri after CI passes

Local integration validation

  • npm test — production frontend build plus 48 frontend/contract tests passed
  • cargo check --locked --manifest-path src-tauri/Cargo.toml passed
  • cargo test --locked --manifest-path src-tauri/Cargo.toml --lib — 1115 passed
  • cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml — 159 passed
  • version synchronization gate passed for all five files

Rust 1.88 compatibility and all-platform checks are delegated to repository CI.

appergb and others added 30 commits August 5, 2026 16:22
* feat(settings): 本地 ASR 模型管理重构——两栏看板 + 下载弹框 + 右上角进度 + 提供商本地模型标注

- 模型管理改为两栏看板:左侧模型选择(竖排,已下载绿勾,全平台 Qwen3 /
  sherpa-onnx / foundry 归一化),右侧详情看板(HF 实时抓取的尺寸/文件数/
  repo + 状态 + 操作按钮),嵌套在设置里作为单独板块
- 下载弹框:左侧模型选择 + 右侧大小/文件数详情,底部「开始下载」,布局与
  看板一致(用户原话:单独的下载界面)
- 右上角下载进度浮层:Qwen3 与 sherpa 下载进度聚合 fixed 显示,完成/取消/
  失败即消失
- ASR 提供商界面:本地引擎激活时直接展示本地已下载模型(标注「本地下载
  (本地下载)」),可在此选用;Apple 语音显示零模型选择说明
- i18n:新增 19 个 key(5 语言)

* style(settings): 输入框/下拉底色统一 + 提供商下拉去 inputStyle + 检查更新按钮动画

- inputStyle 底色 surface-2 → select-trigger-bg:所有输入框(提供商凭据、
  数据存储数字框等)与 SelectLite 下拉(麦克风/胶囊样式)颜色统一
- ProvidersSection 5 处 SelectLite 去掉 inputStyle,改 SelectLite 默认触发器
  样式;LLM/ASR 供应商下拉固定 200 宽与麦克风对齐(用户反馈颜色与大小问题)
- CheckUpdateButton:状态图标颜色过渡 + 文案切换淡入动画(ol-select-value-in)

* fix(settings): 弹框 portal 到 body 修复定位/变灰 bug + 本地 ASR 逻辑重构

- DownloadDialog 全页式重设计:createPortal 到 document.body(WindowChrome
  的常驻 transform/will-change 会创建 containing block,导致 fixed 弹框相对
  设置面板定位、被裁剪且点不到),尺寸与设置弹窗一致、顶部锚定、左侧模型
  选择 + 右上角 ✕ 关闭;GlobalDownloadProgress / UpdateDialog 同步 portal,
  修复「弹框弹出时设置内容变灰、断层感」的同类 bug
- 本地 ASR 逻辑:删除 Qwen3/Foundry/Sherpa 启用开关与确认 modal——本地引擎
  始终可用,激活 = 在 ASR 语音转写选择本地模型供应商(保留「实验性」标记);
  供应商下拉直接列出本地引擎(标注「本地」)
- 删除「使用 Apple 语音」卡片:选中 apple-speech 供应商即等于使用
- 镜像源 / 模型存储位置 / 内存引擎收纳进「下载与存储设置」Collapsible(默认收起)
- 模型操作按钮:去掉「设为默认」,保留「加载并测试」(加载即激活为当前模型),
  打开目录 + 删除同一行;修复本地模型选项标签「本地下载(本地下载)」文案 bug
- ASR 板块供应商切换时 keyed ol-tab-fade 动画

* fix(asr): sherpa/foundry 分派显式传 alias + 本地模型下拉受控 value

- dispatchEntryAction 的 setTimeout 闭包读的是当前 render 的 selected*Alias,
  点非当前选中的模型会操作到上一个模型;四个 handler 改为接受 alias 参数并显式传递
- ProvidersSection 本地模型下拉 value 固定为第一个已下载模型,选择不生效;
  新增 selectedLocalModelId 受控状态,随后端 active 模型与已下载列表联动

* fix(asr): 删除当前选中模型后回落选择 + Foundry 下载改顺序执行

- useLayoutEffect 只在 selectedModelId 仍存在于条目列表时提前返回,删除选中
  模型后自动落到第一个已下载模型(pr-agent #922)
- Foundry 侧栏「下载」由并行 enable+prepare 改为 handleEnableAndPrepareFoundry
  顺序执行:消除 foundryBusy 竞态与 syncFoundryPrefs(true/false) 冲突

---------

Co-authored-by: sim <sim@local>
* feat(settings): 模型 ID 直选 ASR 供应商 + 看板右侧布局改版 + 弹窗重合

- ASR 供应商下拉:本地引擎的已下载模型直接以「引擎:模型ID」选项列出,
  选中即使用该模型(不再需要先选引擎再选模型);下载完成后经进度事件自动刷新;
  引擎级入口与删除后无匹配的兜底选项保留
- 下载弹窗改为与设置弹窗完全重合:垂直居中、同尺寸 880×600(此前顶部锚定偏高)
- 看板右侧改版:模型名在左上,Hugging Face 仓库/镜像源 pill 在右上;
  下载 / 加载并测试 / 打开目录 / 删除 合并为底部一行;测试结果显示在看板里
  (此前「加载并测试」无任何反馈,旧 ModelRow 是死代码)
- 删除看板「下载模型会自动出现在上方…」描述文案

* fix(settings): 平台不匹配时去掉重复的本地引擎兜底选项(pr-agent #923)

---------

Co-authored-by: sim <sim@local>
* fix(windows-ime): TSF 插入后输入法未切回原输入法(#852)

- restore_decision 改为依据会话已知的激活状态(openless_was_activated / activation_failed),不再依赖 GetActiveProfile 探测结果,避免后台线程误判导致跳过恢复
- restore_profile 中 legacy 失败不再短路现代 ActivateProfile,任一成功即视为整体成功
- restore_session 增加恢复后校验与一次重试;saved 本身是 OpenLess 时跳过(粘滞态防护)
- prepare/restore 补充诊断日志,便于区分未走恢复/恢复被跳过/恢复失败

* fix(windows-ime): 恢复流程抽离可测核心 + 重试等待让出 runtime 线程(#852 审查跟进)

- restore_profile 合并 TextService/KeyboardLayout 同形分支,差异收敛为参数
- 恢复重试等待在 tokio runtime 上改用 block_in_place 让出工作线程
- 粘滞防护/校验/重试抽为 run_restore_flow,可跨平台注入测试
- 新增 5 个恢复流程测试(粘滞跳过/一次成功/重试/restore 报错仍校验/探测报错)

* fix(windows-ime): 恢复校验降级为诊断日志,重试依据改为 restore 返回值(#852 审查跟进)

- run_restore_flow 重试条件改为 restore_profile 返回值(legacy+modern 均失败才重试)
- is_openless_profile_active 探测降级为恢复后诊断日志,不再参与控制流(线程局限见 #852)
- 更新恢复流程测试覆盖新语义(6 个用例,47/47 通过)

* refactor(windows-ime): 恢复策略层迁入跨平台模块并纳入 CI 单测(#852 审查跟进)

- 新增 windows_ime_restore 模块:run_restore_flow/RestoreOutcome/重试等待
  迁出 windows_ime_session;sleep_restore_retry 增加 MultiThread runtime 守卫
- report_restore_step_results 提升为无门控顶层函数,测试可在任意平台执行
- restore_profile 参数收敛为 RestoreArgs 结构体(resolve_restore_args)
- 测试夹具 openless_snapshot_for_test 由生产常量派生,替换重复 GUID 字面量
- restore_session 消费 RestoreOutcome 补 debug 诊断日志
- backend-tests 纳入 windows_ime_profile/windows_ime_restore(tokio/winreg/features)
- 新增 9 个单测现已在 macOS/Linux cargo test --lib 与 Windows backend-tests 执行
该分支与上一条完全一致,属于死代码,cargo check 会报
warning: unreachable pattern。

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(activity): 每日活动汇总记录字数与时长

activity.json 此前每天只存一个条数。概览页想给出「近 30 天说了多少字」
这类周期指标时,只能回头从 list_history() 现算 —— 而历史有 200 条硬上限,
日均上百次的用户两三天就把上周挤没了,算出来的字数与时长必然偏低。

把单日值从裸数字扩成 {count, chars, durationMs}:
- chars 口径 = 最终插入文本的 Unicode 字符数,与历史详情页的「N 字」一致;
- durationMs 口径 = 录音时长,不含识别/润色耗时,与详情页「录音 x.x 秒」同源。
两个口径都刻意跟已有 UI 对齐,避免同一个数在两处对不上。

磁盘格式用 untagged enum 兼容旧文件:老用户的 activity.json 全是裸数字,
读不回来的话年度热力图会一次性清空(会被当成数据丢失)。旧日期没有字数/
时长,读回 0 —— 诚实缺省,好过整天丢掉条数。四个单测钉住这个契约:纯旧
格式、纯新格式、新旧混排(升级当天必然出现)、以及缺字段的对象。

存储代价可忽略:只存聚合数字、不存文本,一天一行,两年上限 731 行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(overview): 「近 7 天」改为周期 × 指标可切换,并脱离历史 200 条上限

两件事,同一张卡:

1. 修数据源。「近 7 天」此前从 list_history() 现算,而历史有 200 条硬上限。
   实测(日均约 88 次听写的真实数据):history.json 正好卡在 200 条,只装得下
   两天多,于是近 7 天里前 4 天全画成 0 —— 而同一页的年度热力图上那 4 天分别是
   40 / 118 / 108 / 37,亮着的。同一个页面两块数据自相矛盾。改读 activity 后
   两者同源,柱子和格子对得上。

2. 加周期与指标切换。卡片标题位变成「近 7 天 / 近 30 天」,右侧原来的「条数 /
   天」变成「条数 / 字数 / 时长」。月度字数是用户实际想看的数(跟按月计费的
   竞品比较时要用),此前完全看不到。

卡片顶部显示周期总计(大字)+ 日均:想知道「这个月总共说了多少字」是要一个数,
不是在 30 根柱子里目测求和。30 天模式下柱子只有几像素宽,逐柱数字会糊成一片,
改为 title 悬浮显示、横轴只标首/中/末三个日期。

聚合逻辑抽成 lib/activityMetrics.ts 并带单测:窗口长度恒定、缺失日期补 0、
跨月边界、老数据无 chars/durationMs 时不产生 NaN(NaN 会把柱状图的 max 算坏)。
日期键一律用本地年月日拼,不能用 toISOString() —— 后者按 UTC 切日,东八区凌晨
的会话会算到前一天,与后端 chrono::Local 写的键对不上。

顺带:活动数据的 IPC 不再按 mobile 跳过。热力图在移动端仍不渲染(issue #861),
但周期指标卡是要渲染的,跳过 IPC 会让它在移动端永远空。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(overview): 概览页支持 ⌘R / Ctrl+R 刷新

历史页早就有这个键(History.tsx),概览页没有 —— 想看到新数据只能切到别的
页再切回来触发重挂载。⌘R 是「重新加载」的通用直觉,两个数据页应该一致。

一次刷新本页全部三份数据:历史、活动、凭据。preventDefault 拦掉 webview
默认的整页 reload,只重发 IPC,不让整个前端重挂载。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(history): 详情页字数按 Unicode 码点统计,与概览页/后端口径一致

finalText.length 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符
会被双算;后端 activity 的 chars 用 polished.chars().count()(Unicode 标量),
概览页「字数」指标也以此为准。改用 Array.from(...).length 后三处同口径。

* fix(overview): 今日字数按 Unicode 码点统计,与历史详情/后端口径统一

概览页顶部「今日字数」此前用 finalText.length 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符会被双算;历史详情页(commit 70464a1)与后端 activity 的 chars 已是 Unicode 标量口径。抽成 lib/unicode.ts 的 countCodePoints 让两处前端共用,消除第三处口径漂移。

* fix(overview): 历史与活动刷新加请求序号防竞态

⌘R 一次触发历史、活动、凭据三份刷新,历史与活动此前没有 credentialsRequestSeq 那样的防竞态:快速连按可能让较早的慢响应后到并覆盖新数据。沿用同一模式,请求前自增序号、响应校验一致才 setState。

* style(overview): 分段切换器按钮光标改 pointer

SegmentedToggle 是可点击按钮,与 _atoms 中 Btn 的 cursor: pointer 交互约定保持一致(此前为 default,看起来像不可点)。

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
* fix(translation): 翻译不会生效时不再进入翻译态

两个症状同源:`translation_modifier_seen` 的语义只是「按过 Shift」,
而「是否真的翻译」的判定散落在读取侧,胶囊那一侧漏了目标语言检查。

- 没在翻译页选目标语言,录音中按 Shift → 光标处显示「正在翻译」,
  但 end_session 走的是普通润色,什么也没翻。
- 目标语言等于用户唯一的工作语言(例:工作语言只勾了简体中文、目标也选
  简体中文)→ 源语言必定就是目标语言,仍会照常发起一次 LLM 翻译往返,
  纯浪费时延和 token。

新增 `types::translation_effective` 作为唯一判定入口,并把判定收到写入侧
(`mark_translation_modifier_seen`):该函数每次按键才跑一次,读一次
prefs 无所谓;而读取侧之一是音频回调线程上的 emit_capsule,按帧执行,
不能碰偏好锁(capsule_focus.rs 既有注释已就此立过规矩)。收紧后 flag 的
语义变成「本次会话真的要翻译」,胶囊提示与 polish 分派读同一个真值。

工作语言有多个时不拦:中/英双语用户把目标设成英文是正常用法(说中文出
英文),源语言无法预先判定。简体/繁体是语言列表里两个独立条目,简→繁
仍照常翻译。安卓浮层的 start_dictation_with_translation 一并走同一个 gate。

「按了但不翻」的情况会记一条 INFO 说明原因,否则用户只看到胶囊没提示,
无从判断是没生效还是没按到。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(translation): 目标语言与唯一工作语言相同时在翻译页给出提示

后端已经不会在这种组合下发起翻译(上一个 commit),但用户在设置页看不出
任何异常:状态灯仍写「已启用」,按 Shift 却什么也不会发生,只能靠翻日志
才知道为什么。

- 翻译目标语言卡片下方出现一条警示:说明这个组合不会生效,并给出两条
  出路(换目标语言,或多勾一个工作语言)。
- 「已启用 / 未启用」状态灯改为同时看目标语言和这个冗余判定,不再谎报。

判定逻辑抽成 `lib/translationTarget.ts`,与后端 `translation_effective`
同一套规则(多工作语言不拦、简→繁不误判),两侧各带单测钉住契约。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(translation): translation_modifier_seen 更名 translation_active + 修正安卓 overlay 日志

* refactor(translation): 更名 arm_translation_if_effective 并简化冗余判定表达式

审查跟进(PR #908):mark_translation_active 的实际行为是「只在翻译会
生效时置位并返回是否置位」,名字却像无条件 setter,改为
arm_translation_if_effective 更诚实;translation_effective 里的
matches!([only] if ...) 改写为显式 len==1 + trim 相等(语义完全等价)。

行为零变化:translation_effective 6 个单测全过(backend-tests),
cargo check 全绿。

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
* feat(history): 记录听写落到了哪个应用

历史详情页的「插入」行早就会渲染 app_name(History.tsx),但听写写历史时这个
字段写死 None —— 实测本机 200 条历史全部 appName=null,那一行永远只有字数,
看不出这段话落到了哪个 app。

前台应用其实 begin_session 就采过(capture_frontmost_app,日志里能看到
`front_app captured: Claude (com.anthropic.claudefordesktop)`),只是一直
只喂给 polish prompt,没写进历史。补上写入即可,前端不用改。

顺带把成功、空转写、转录失败三条路径都补上:排查「在某个 app 里总是识别不到」
时,这一列本身就是线索。

macOS 采到的是 `名称 (bundle id)` 的显示串,新增 `split_front_app_label` 拆成
两个字段落库,详情页只显示人读得懂的应用名,bundle id 进它自己的字段。划词润色
与语音问答两条路径此前把整串塞在 app_name 里,一并改走同一个拆分,避免同一个
列表里两种写法。

拆分对 Windows 的窗口标题要保守:只有括号内容形如反向域名(含点、无空格)才认
作 bundle id,「未命名文档 (未保存)」「Inbox (12)」这类标题保持原样不截断。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(history): 详情页把「识别 / 润色 / 插入」明细移到正文卡片之上

这三行是这条记录的元信息(走了哪个模型、各步多久、落到哪个 app),读的时候
应该先看到它、再读正文;放在两张长文本卡片之后,长内容一多就要滚到底才看得见。

纯挪位:块内容不变,分隔线从上缘(marginTop/borderTop)改到下缘
(marginBottom/borderBottom)。这也给下一步在正文卡片下方增加「重新润色」
区域腾出了位置。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(history): 历史详情页支持重试与换风格重新润色

两个入口,一条后端通道:
- 「用原风格重试」:同样的原文用当前风格再跑一遍。用户说「AI 这次不太对」时,
  真正想做的是这个对照实验 —— 判断上次结果是模型抖动还是稳定行为。
- 选风格 +「应用」:同一段话换个风格包看效果,结果作为新卡片出现在下方。

后端 `repolish` 命令本来就在(commands/dictation.rs),但写死用当前激活的风格包,
而且前端没有任何页面调它 —— 等于一条建好没接通的通道。这里给它加一个可选的
`style_pack_id`:省略 = 当前激活包(重试),给了 id = 用指定包试算一次。指定的包
不需要处于激活状态,也不会改变激活状态 —— 这只是一次性试算,不该有把用户当前
风格换掉的副作用。显式指定时按 id 精确取,不走 get_or_default_active 的兜底链:
静默回落到别的包会让结果无从解释。

结果只在本次查看时显示,不写回历史条目:历史的 finalText 是「当时真的插进去的
那段文字」,是一条事实记录,不该被事后试算覆盖。面板上的说明把这点直说了,结果
卡带复制按钮。切换到另一条记录时结果清空(key={item.id}),不会把上一条的结果
留在新条目下面。

范围说明:这里只重跑润色、不重跑识别。成功听写的录音在插入后就删了(隐私设计),
原文是唯一还留着的输入。真正的「重新转录」入口仍然只对留有录音的失败条目开放,
本次不动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(history): 流水线明细右列的耗时不再被从中间截断换行

「143 毫秒」会显示成「143 毫 / 秒」两行。原因是这一行用的是
`grid-template-columns: auto 1fr auto`:右侧 auto 列会缩到 min-content,
而中文可以在任意字符间断行,所以它的 min-content 只有一个字宽 —— 中间那个
贪心的 1fr 列就把它挤扁了。

三个右列(识别耗时 / 润色耗时 / 插入状态)都加 nowrap,min-content 变成整串
宽度。挤压转移到中间的 provider·model 列,那一列本来就带 overflowWrap:anywhere,
换行是它该做的事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(history): 重新润色超时时说人话,别把 "timeout" 原样甩给用户

后端的 LLMError::Timeout 过 IPC 边界后退化成裸字符串 "timeout",前端直接拼进
「重新润色失败:{err}」——用户看到的就是「重新润色失败:timeout」。这句话既没
说是谁超时了,也没说能怎么办,第一反应必然是「这个新功能坏了」。

实际原因通常是当前 LLM provider 30 秒内没回包。实测一次:某免费模型池
(openrouter free)13 次非流式请求里 6 次撞上 30s 客户端超时,成功的那 7 次
耗时从 2.3s 到 21.2s 不等。功能本身是通的,换个 provider 就好。

超时单独给一句能照着做的提示:说明是 provider 没响应、建议换更快的或稍后重试。
其余错误保持原样透传(那些通常是凭据/参数问题,原文更有诊断价值)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(history): 历史条目显示实际风格包名,与重新润色面板对齐

历史列表和详情页的 Pill 一直显示 `session.mode`,那是风格包的 baseMode ——
四个内置分类之一。所有自建包都会落进这四个桶,所以用「超级豆包」口述出来的
记录,Pill 上写的是「轻度润色」,看不出到底是哪个包产出的。

同页的「重新润色」面板用的却是包名(下拉选项、结果标题都是 pack.name),
一页两套命名:下拉里选「超级豆包」,上面的 Pill 却写「轻度润色」。

改成优先用 session.stylePackId 查真实包名(该字段后端早就在写),内置包仍走
i18n 的 MODE_LABEL —— 内置包名在后端是硬编码中文,直接显示会在英/日/韩界面
串语言。旧历史没有 stylePackId、或包已被删除时回落到原来的模式名。
Pill 的 tone 仍按 baseMode 走,颜色保留原有的粗分类信息。

顺带两处修复:
- 风格包加载提到 History 顶层,与 RepolishPanel 共用。面板按 key={item.id}
  重挂载,原先每切一条历史都要重发一次 list_style_packs。顶层存全部包(含已
  禁用,历史可能出自后来被禁用的包),面板自己再 filter(enabled)。
- 长包名撑破布局:Pill 自带 nowrap + flexShrink: 0,会把同排的「复制」按钮挤
  成文字竖排;grid 子项默认 min-width: auto,还会逼出横向滚动条。显示包名的
  Pill 统一改成可收缩 + 省略号(全名挂 title),两个 grid 子项补 minWidth: 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(history): 按审查意见修复重新润色与前台应用名记录

- 重试优先用原记录风格包(包已删除/旧历史回落当前激活包),结果标题显示真实包名
- split_front_app_label 增加平台开关:仅 macOS 拆分,Windows 窗口标题整串保留
- 重新润色面板排除 QA 记录
- 超时错误匹配收紧为整串形态,避免误判

* fix(history): 按 PR 910 审查意见修复重新润色面板与前台应用拆分

- 结果卡标题 Pill 截断 + minWidth:0,长包名不再撑破结果网格
- 下拉默认选中当前激活包(defaultPackId)
- 面板下拉与结果标题的内置包名统一走 i18n mode 名(packDisplayName)
- 重试回落显式落到当前激活包,结果标题与实际执行一致
- split_front_app 返回 FrontApp 结构体,6 个调用点同步更新
- repolish hint 补充回落说明(5 locale)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
- 进度浮层全局化:新 GlobalDownloadProgress 自监听三引擎下载事件,挂 App 根,
  主窗口所有页面常驻显示;含取消按钮(按引擎分派 cancel 命令)
- Rust 进度事件 ≥150ms 节流(qwen3/sherpa/foundry),修进度条高频抽搐
- 取消下载清理 .partial/.partial.idx(sparse 预分配全长,1.7B 模型残留巨大),
  新增 remove_partial_artifacts 单测
- 看板只列已下载/下载中的模型(下载弹窗保留全目录列表),条目行距加大
- 3s 轮询磁盘状态 + 选中瞬间校验 + 下载事件驱动刷新;fetchAll 按平台分支
  (macOS 上 sherpa/foundry catalog 命令未注册导致 Promise.all 整体失败)
- 同 provider 切换模型走轻量路径 + 乐观 draft 值,修下拉闪回旧模型

Co-authored-by: sim <sim@local>
- selection.rs:SelectionInsertionTarget 增加 macos 字段(前台应用名 + pid);
  capture/is_captured/validate/reactivate 四函数实现 macOS 分支:
  * validate = 前台应用一致性 + 选区重读(AX 直读,失败退化为模拟 Cmd+C)比较
  * reactivate = NSRunningApplication activateWithOptions 交还焦点
- types.rs:macOS 默认选中润色热键 = RightAlt(右 Option,CGEventTap keycode
  61 可区分左右,不占用 Cmd/Ctrl 常用组合),与 Windows 保持一致
- capabilities:selection-polish-preview 窗口加入默认能力列表(预览窗事件
  listen 之前会被 ACL 拒绝)
- SelectionPolishSection:设置入口去掉 os==='win' 门控,macOS 可见
- 单测:selection_text_matches 随 cfg 扩展在 macOS 编译,940 pass

Co-authored-by: sim <sim@local>
* feat(pipeline): 多模态识别管线(实验性),传统/多模态模式与 omni 凭据隔离 (#902)

新增实验性「多模态识别管线」:高级设置开启后,服务页出现传统/多模态模式切换。多模态模式用单个模型(OpenAI 兼容 input_audio 或 Gemini 原生 generateContent)一步完成「提示词 + 音频 → 最终文本」,覆盖主听写、划词问答(OpenAI 兼容流式 / Gemini 一次性)、选区润色(omni 当纯文本 LLM)、Less Computer 转写。两套配置在凭据库中完全隔离(新增 omni 命名空间),切换不删数据、不回退传统配置。

* fix(merge): 修正多模态路径对 beta API 的适配(translation_active/bump/front_app)与 ElevenLabs 重复分支
* feat(selection-polish): Linux 支持——fcitx5 插件热键路由 + PRIMARY 选区校验

- linux_fcitx.rs:新增 SetSelectionPolishHotkeyRaw DBus 方法 + sync_selection_polish_binding;
  start_dictation_signal_listener 增加 selection_polish_trigger 参数,监听
  SelectionPolishEvent 信号 → HotkeyEvent::SelectionPolishShortcutPressed,
  初始同步与 fcitx5 重启重同步均含选区润色触发键
- hotkey.rs Linux adapter:update_modifier_shortcuts 转发 selection_polish_trigger
  (移除 disabled on Linux 注释)
- hotkey_loops.rs:Linux 启动 fcitx5 监听时传入 selection_polish_trigger
- selection.rs Linux 分支:is_captured = PRIMARY 读到选区即可;validate 重读
  PRIMARY 与捕获文本比较(用户改选/清空即拒绝粘贴);selection_text_matches
  cfg 覆盖 linux
- fcitx5 插件 openless.cpp:新增 SelectionPolishEvent 信号 +
  SetSelectionPolishHotkeyRaw 方法(INI 持久化 SelectionPolishRawSym/States),
  按键分发仿 QA 模式;需重装插件(scripts/inject-fcitx5-plugin.sh)生效
- 注:Linux 代码由 Linux CI 验证编译;fcitx 插件 .so 需 Linux 环境重编译安装

* fix(selection-polish): 补传 selection_polish_trigger 到 fcitx 监听器(第二个调用点)

coordinator.rs 的 hotkey 安装路径调 start_dictation_signal_listener 时漏传
selection_polish_trigger(签名 7 参数只传 6 个),Linux CI E0061。hotkey_loops.rs
已传,此处遗漏导致 Linux 编译失败。

---------

Co-authored-by: sim <sim@local>
* fix(local-asr): 下载弹窗全量目录 + 抖动/模糊修复 + HF 模型卡片

- 下载弹窗左侧空列表死锁:allSidebarEntries 把未下载模型过滤掉了,
  零下载用户弹窗内无模型可选、开始下载禁用;改为全目录展示,弹窗
  默认选中第一项,开始下载回退到第一个未下载条目
- 弹窗上下抖动 + 画面模糊/清晰闪烁:ol-window-enter 去掉 filter blur
  (fill-mode both 保留终帧 + will-change:filter 在 WKWebView 反复重栅
  格化),WindowChrome willChange 去 filter;弹窗卡片动画 spring 过冲
  改 --ol-motion-soft;弹窗打开时暂停 3s 轮询,遮罩后看板不再跳动
- 右侧直接展示 HF 模型卡片(HF 禁止 iframe 嵌入):新增
  local_asr_fetch_hf_card 命令(downloads/likes/cardData.summary,
  summary 缺失回退 README 首段),弹窗右侧显示下载量/收藏数/简介

* fix(local-asr): 下载弹窗遮罩误关 + 切换闪烁根治(去 backdrop-filter 死代码 + 卡片预加载)

- 画面「闪一下又恢复」合成层根源:WindowChrome 根节点 backdrop-filter
  blur(36px) 在非透明窗口(--ol-window-bg 不透明渐变)是纯开销死代码,
  macOS WKWebView 切换模型/下载中高频重渲染时合成层故障 → 整窗瞬间
  消失恢复。全平台统一 none,视觉无变化
- 下载弹窗遮罩误关:busyModelId 在下载启动后即清空(Rust 同步返回),
  下载中 busy 恒 false → 点遮罩下的「下载与存储设置」落在遮罩上弹窗
  直接关闭,像被按了叉。新增 anyDownloadInFlight(progress phase 判定)
  驱动弹窗 busy 与「+下载新模型」禁用,下载中遮罩点击不关闭
- 切换闪烁:弹窗打开时预加载全部条目 HF 卡片(缓存),切换选项右侧
  直接出数据,无「加载中→内容」替换
- 弹窗左栏对齐设置页:rail 背景 + 200px 宽度,条目去掉外边框

* fix(local-asr): 下载弹窗重写——去入场动画与卡片预加载(WKWebView 闪烁根因),标题改「下载模型」,README 简介提取增强

---------

Co-authored-by: sim <sim@local>
* feat(android): add optional Shizuku accessibility recovery path

Integrate best-effort OpenLessAccessibilityService recovery via Shizuku UserService with safe rollback, manifest/Gradle scaffolding, JNI bridge, settings UI, and contract tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): add primary constructor for Shizuku UserService Stub

Kotlin requires an explicit supertype initializer when extending AIDL Stub; fixes Gradle compile error on CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): replace accessibility heartbeat with IPC paste results

Remove SharedPreferences cross-process heartbeat gating, use structured PASTE/PING IPC with safe retry rules, tighten editable target validation, and fix Shizuku manifest attribute merging for mixed-quote providers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): use literal const values in accessibility command receiver

Kotlin const val cannot reference enum codes; fixes release APK compile.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 修复无障碍组件短名与全名匹配误判

Settings.Secure 常以 pkg/.Class 存储服务列表,主检测路径改用 ComponentName 规范化比较,避免 enabled=false 误报未授权;同步 Rust JNI 路径并补齐测试。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 为 JNI 调用的无障碍检测方法添加 @keep

Release 构建中 R8 会剥离仅由 JNI 反射调用的 pingAccessibilityProcess,导致 NoSuchMethodError 与 operational=false 误报;同步保护 isEnabled 并补充契约测试。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 恢复无障碍粘贴的可编辑目标回退查找

粘贴失败日志为 NO_FOCUSED_EDITOR:过严的 isFocused 校验与移除树遍历导致找不到输入框。恢复 FOCUS_ACCESSIBILITY、宽松焦点匹配与 findEditableInTree 回退,并保留严格缓存校验。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 跨窗口扫描与缓存优先修复粘贴目标查找

微信等场景下 rootInActiveWindow 非输入窗口导致 NO_FOCUSED_EDITOR;改为缓存优先、遍历全部无障碍窗口,并加深树搜索。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 识别非 isEditable 的粘贴目标节点

微信等 App 不向无障碍暴露 isEditable;扩展为 EditText/ACTION_PASTE/ACTION_SET_TEXT 目标,并订阅文本变更事件以预热缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 三层粘贴回退(无障碍 → Shizuku → 剪贴板)

无障碍失败后尝试 Shizuku 注入 KEYCODE_PASTE,再回退剪贴板;精简 Tier1 为 findFocus + ACTION_PASTE。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 粘贴结果校验与 Shizuku processNameSuffix

ACTION_PASTE 返回 true 但文本未写入时降级为 PASTE_REJECTED 并走 Tier2;修复 UserService 绑定 NPE;Tier1 成功后跳过 Shizuku。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 恢复无障碍可编辑目标查找以修复微信等应用粘贴

aaa3fbf 将 Tier1 简化为 findFocus 导致微信等应用 PASTE_REJECTED;恢复跨窗口/树扫描与焦点缓存,保留粘贴结果校验与 Promise.allSettled 容错。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 通过 IPC 传递粘贴文本修复后台剪贴板读取被拒绝

无障碍子进程在应用未聚焦时无法读剪贴板,导致 Tier1 直接 PASTE_REJECTED;主进程将听写文本经 EXTRA_PASTE_TEXT 传入粘贴流程。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): Tier2 粘贴改用 Shizuku.newProcess 绕过 MTK UserService 崩溃

联发科/小米 ROM 上 UserService 进程在 LoadedApk.makeApplicationInner 因 procName 为 null 而 NPE;粘贴键注入优先走 Shizuku.newProcess,失败再回退 UserService。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): Tier2 粘贴改用 Shizuku daemon UserService

newProcess 为私有 API;粘贴键注入改走 daemon(true) 绑定,避免 MTK 上 :shizuku 子进程 Application NPE。

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(android): 修正 daemon 粘贴契约断言

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): daemon 粘贴绑定补全 processNameSuffix

Shizuku UserServiceArgs 要求 suffix 非空;daemon(true)+suffix=paste 避免此前 null NPE 与 MTK :shizuku 子进程崩溃。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): Tier2 粘贴经反射调用 Shizuku.newProcess

MTK 上 UserService 子进程仍触发 LoadedApk NPE;粘贴键注入优先反射 newProcess 执行 input keyevent 279,UserService 作回退。

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(android): 移除粘贴调试日志

微信等应用三层粘贴回退已在真机验证通过,清理 DBG-21a66f/DBG-53a00d 临时 instrumentation。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 修复 CI 单元测试在无 Robolectric 下失败

ComponentIds 改为纯 Kotlin 规范化(对齐 Rust);isValidServiceComponent 拒绝首尾空白;Target 测试改用 action id 常量。

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(providers): LLM/ASR 供应商改成渠道卡片——同一家可存多把 key,排序即优先级

原来一个供应商只能存一份配置,换 key 只能把旧的覆盖掉;想在主号/备号之间来回切,
每次都要重新粘贴。这个 PR 把「服务 → AI 提供商」从「下拉选厂商 + 一组字段」改成
一张张可命名、可排序、可开关的渠道卡片。

心智只有一条:**排序即优先级,列表里第一个启用的就是当前生效的渠道**。关掉的渠道
自动沉到末尾;后端不另存「当前选中」,避免「列表第一张是 A、实际请求打的是 B」
这种两处真相。

本 PR 只做多渠道的存储、编辑与切换,**不含失败重试与故障转移**(那是下一个 PR,
需要先把听写/润色链路里几十处隐式读 active 凭据的地方显式化)。

存储与迁移
- ChannelMeta(providerType / order / enabled / lastTest)以 serde(flatten) 嵌进
  ASR、LLM 两个 entry;v1 老 payload 照常反序列化。
- providerType 与渠道 id 解耦:同一家厂商的多张卡片各有自己的 id,但 providerType
  都指向同一个厂商实现。coordinator::resolve_effective_asr_provider 和 commands 里
  几十处 `== PROVIDER_ID` 的比较依赖它,拿成 id 会让整个 ASR 路由失效。
- v1→v2 迁移幂等:迁移出的渠道 **id 沿用原 preset id**,不生成 uuid,老用户的 map key
  一个字节都不变,重复执行结果一致。
- 迁移只在内存里做、不主动落盘:启动时写 keyring 会在 macOS 触发钥匙串 ACL 弹窗,
  留给下一次真实写入顺带固化。
- clean_credentials 原本会 retain(!is_empty) 删掉空 entry —— 刚点「添加渠道」、名字
  取好了还没填 key 的卡片会被静默删掉,改为「渠道卡片只能由用户显式删除」。
- ChannelMeta 手写 Default(derive 会让 enabled=false,而 write_account 用
  entry().or_default() 建 entry,新写入的渠道会一出生就被禁用)。
- Windows 全新安装预置一张 Foundry 本地 ASR 卡片,保住开箱即用。

IPC
- 新增 list/create/rename/delete/set_enabled/reorder/record_test 七个命令。
- set_credential/read_credential 的 provider 作用域原本硬性拒绝 LLM 账户,补上
  get/set_for_llm_provider —— 编辑列表里第 3 张 LLM 卡片必须能按 id 定位。
- validate_provider_credentials / list_provider_models 新增可选 channel_id:
  卡片上的「测试连通」要测用户点的那一张,而不是当前生效的那张。

前端
- ChannelList:卡片列表、拖拽排序、开关沉底、生效中/失败标红/延迟展示。
- 添加与编辑弹窗;本地引擎与 Codex OAuth 不做预置固定卡片,和云端厂商一样从
  「添加渠道」里选,只是编辑时没有 key/地址字段。
- 新手引导(Onboarding)列表为空时直接摊开添加表单,不让新用户对着空列表发呆。
- 五种语言文案齐备。

验证:cargo test --lib persistence::credentials(31 passed)、commands::(104 passed)、
npm test(含 tsc + vite build,退出码 0)、浏览器 mock 预览逐项走查四种卡片状态与两个弹窗。

设计与分期见 docs/provider-channels-plan.md。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channels): 添加改成一步、拖拽在真机能用、卡片状态不再假装"健康"

三处都是装机自用后暴露的问题。

**添加渠道少一步。** 原来是「先建卡片、再填凭据」两步 —— 那只是实现上需要先有渠道 id
才能写凭据(凭据按 id 作用域存),不该变成用户多点一次。改成点+ 直接开一个完整弹窗,
供应商、名字、密钥、地址、模型、连接检查都在一屏里;草稿卡片在后台先建出来,用户什么
都没填就关掉时由 delete_channel_if_blank 回收,不会在列表里留空卡片。换供应商也不再
需要重建卡片,走新增的 set_channel_provider_type。

**拖拽在打包后的 app 里根本不动。** Tauri 的 webview 默认开着 dragDropEnabled,会把
HTML5 的 dragstart/drop 当成文件拖放吞掉 —— 浏览器预览里是好的,真机里是坏的,只验
前者就会漏。改用 pointer 事件手写,顺带让 Windows / Android 行为一致。

改的过程中炸出第二个:最初用 setPointerCapture,它把后续事件重定向到手柄,浏览器补发
的 click 于是落到设置弹窗的遮罩上(遮罩挂着 onClick={onClose}),**拖一下卡片整个设置
面板就关了**。改成 window 级监听不动事件目标,并在捕获阶段吞掉拖拽后紧跟的那一次 click。

**卡片状态不再假装"健康"。** 原来当前那张有绿点 + 「生效中」文字,两者都被读成"这张
能用",可它只代表排在最前面 —— 一张 key 已经失效的卡片照样排第一。优先级和健康度是两
个正交的维度,被压成了一个视觉。现在:
- 绿点与「生效中」全部去掉,当前那张只用左侧一条竖条表达位置,不带健康暗示;
- 验证按钮移到卡片上(开关左侧),**按钮自己就是结果容器**:未验过显示「验证」、
  通过显示延迟数字(`284ms`,数字本身既说明通了又能比快慢)、失败显示能指导行动的短
  标签(`✗ 401` 改 key / `✗ 429` 等会儿 / `✗ 超时` 查网络);
- 副行显示上次验证是多久以前,超过一天的结果褪色 —— 让"这条结论会过期"可见;
- 按钮宽度固定,避免文字变化把开关和箭头挤来挤去;
- **不做自动验证**:验证是真实 API 调用(LLM 走一次真润色、ASR 传一段静音音频),
  打开设置就全部验一遍等于按卡片数烧额度,还容易撞进限流。

顺带把 mock 的 reorderChannels 改成真重排:原来是空操作,浏览器预览里松手后顺序被
listChannels 拉回原样,看着像"拖拽坏了",会误导下一个人。

另:补上此前遗漏的 rustfmt(自己编辑范围内的行)。

验证:cargo test --lib persistence::credentials(31 passed)、commands::(101 passed)、
tsc 0;浏览器侧用精确的 pointer 事件序列验了拖动跟手、松手持久化、设置面板不被误关。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channels): 迁移时优先把「填过凭据」的渠道排第一,别让空卡片顶到最前

`active` 指向一个已不存在的 entry 是真实会发生的:前端 prefs 里的 activeAsrProvider
与凭据库里的 active.asr 是两份数据,历史上可能不同步。原来遇到这种情况纯按字母序挑一张
排第一,完全不看那张有没有填过 key —— 结果很容易把一张空卡排到最前,用户升级后打开就
看到「未配置」,而他真正配好的那张其实还在列表下面躺着。

排序优先级改为:原 active → 填过凭据的 → 字母序(兜底,保证幂等)。

同时补一个边界测试钉死底线:即使 active 指向缺失 entry,迁移也**绝不碰任何凭据** ——
所有 key 原样留在各自 entry 里,用户把想用的那张拖回第一位就能恢复。

验证:cargo test --lib persistence::credentials(33 passed,跑前已备份 preferences.json
且跑后 md5 未变)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(channels): 写明重试尚未实现,并区分代码里已有的连接层重连与渠道故障转移

排查时在代码里搜到 retry 容易误以为渠道重试做了。补两条既有行为的说明:
net.rs::send_with_retry 只对连接层失败重连同一个 endpoint(拿到任何 HTTP 响应即返回、
超时不重试),永远不会换卡片;润色失败已经会回落插入 ASR 原文,所以「全渠道失败 →
出原文」这条决策天然满足,P2 要做的是在回落前多试几张卡片。

同时写明 P0 的定位:多渠道现在的价值是存档与手动切换,排第二的卡片不会被自动用上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channels): 修复渠道化 Spec 缺陷——禁用语义/order 冲突/默认值回填/LocalAsr 激活

- sync_active_channels 全部禁用时清空 active,运行时不再使用已禁用渠道凭据
- create_channel 落盘前 compact_orders,消除新卡与禁用项 order 冲突
- 换供应商时仅空槽写入 preset 默认 endpoint/model(不覆盖用户已填值)
- LocalAsr 激活前确保本地引擎渠道卡存在、启用并置顶
- 更新 provider-channels-plan 状态与待确认项

* fix(channels): 修复 Omni 路由与草稿生命周期

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
* feat(macos): read cursor context from the host app (module only, not wired up)

Adds `host_document/` — the one place that touches "the document the user is
currently writing in". Nothing calls it yet from the product path; the only
consumer is a debug command. Wiring it into LLM polish is the next step, and
that is where the (default-off) user-facing switch will live.

Why: ASR only gets hotwords and polish only gets the QA selection, so the doc
the user is writing is invisible to both. Chinese homophones (接口/借口,
大鱼/大禹) are indistinguishable acoustically but trivial in context.

- window.rs: 80/20 cursor window, pure and char-based. Unused budget on one
  side flows to the other, so a cursor at the top of a document still gets a
  full window. Slicing by bytes would split CJK chars.
- mod.rs: safety gate. Secure Event Input, AXSecureTextField role/subrole, and
  a hardcoded bundle-prefix blocklist (password managers, keychain, terminals).
  Gate inputs are a plain struct so the decision is unit-tested without AX —
  getting this wrong means shipping a password to an LLM.
- macos.rs: AX read. Sets AXUIElementSetMessagingTimeout(200ms) — the existing
  AX code in selection.rs and lib.rs sets none and inherits the ~6s default,
  which means a 6s freeze against a hung app. Runs under spawn_blocking with an
  outer tokio timeout, never on a tokio worker. Large documents go through
  AXStringForRange instead of copying the whole AXValue across processes.
- AX indices are UTF-16 code units while the window algorithm is char-based;
  the conversion is explicit and tested against surrogate pairs.

Also folds coordinator::capsule_focus's near-verbatim copy of the frontmost-app
lookup into selection.rs, which now exposes structured
`current_front_app_parts() -> (name, bundle_id)`. The old display-string-only
form was unusable for the bundle blocklist.

Non-macOS returns Unsupported: Windows has no UIAutomation code and TSF is only
live at commit time; Linux fcitx5 SurroundingText is unsupported by most
clients.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 02aa66ac3d303df25334f05e50e08339036539e9)

* feat(polish): feed the cursor's surrounding text to LLM polish (default off)

Wires host_document into the polish path behind `cursorContextEnabled`,
which defaults to false and must stay that way: turning it on means every
dictation reads the foreground app's text and ships a slice of it to the
user's LLM provider. That is data the user never handed us, so it is theirs
to opt into.

- `<cursor_context>` envelope through the existing
  `sanitize_for_xml_envelope`, injected in `compose_polish_prompts` — the one
  funnel both the OpenAI-compatible and Gemini paths go through.
  `assemble_polish_system_prompt` takes the same argument so the settings
  preview cannot drift from what actually gets sent.
- The envelope carries a cursor marker, because "context" without knowing
  where the cursor sits doesn't distinguish finished text from the stub the
  user is mid-way through — and those two are worth very different amounts.
  Forged markers in the document are stripped before the real one goes in.
- The injection-defense clause for `<cursor_context>` is a SEPARATE string
  appended only when context is actually present. Folding it into
  `polish_injection_defense()` would have changed the prompt for every user
  who has this switched off; off must mean the feature does not exist, down
  to the byte. There is a test for exactly that.
- `sanitize_for_xml_envelope` now also neutralizes `< /tag>` (whitespace
  before the slash). It already handled `</ tag >` and `< tag >`; the gap let
  a forged boundary through. Not valid XML, but an LLM may not agree, and
  this envelope carries text from other people's documents.

Read point is `end_session`, next to where front_app is read: focus is still
on the target app there (the capsule is a non-activating panel) and polish is
about to fire. Switch off means host_document is never called — not one AX
message. Any read failure degrades to no context; never to a lost word.

The toggle lives under Privacy, not under polish settings: its real cost is
not tokens, it is that text from another app leaves the machine. macOS only —
a switch that cannot change the outcome is worse than no switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit f787a314075f2109b168dd3d66df06791667c7a8)

* feat(macos): detect when the user hand-corrects text we just inserted

Perception only — this logs the edit and produces no rules. Getting the
sensing right is the whole job here; letting it touch the user's dictionary
is the next step.

- `diff.rs`: minimal-edit extraction, pure and char-based. Longest common
  prefix, then longest common suffix on what's left; the middle is what the
  user actually touched. Whole-text diffs teach nothing — "大禹 → 大鱼" can
  become a rule, "this paragraph → that paragraph" cannot.
  Six rejection rules, each guarding against a class of false positive that
  would silently corrupt every future dictation. Pure insertion is rejected
  (a rule that inserts unconditionally anywhere is the worst of them);
  pure deletion is kept, since it is specific and cannot fire everywhere.
- `edit_is_within_typed_text`: the observer watches the whole control, so a
  user editing their own earlier text in the same field also fires it. That
  edit has nothing to do with this dictation. This predicate is the only line
  between "learn our own mistakes" and "learn whatever moves", so it is a
  pure function with tests rather than an inline `contains`.
- AXObserver on a dedicated thread, shape copied from `device_watch.rs`
  (thread → register → `run_in_mode(1s)` + exit flag → unregister → warn on
  failure), including why it does not use `CFRunLoopRun()` with a cross-thread
  stop. Uses `core_foundation`'s runloop wrapper rather than re-declaring
  `CFRunLoopGetCurrent`/`AddSource`, which `hotkey.rs` already declares —
  duplicate externs only work by luck.
- Teardown has four independent guarantees, because a leaked observer means
  holding another app's AX reference and waking on its every keystroke:
  `EditWatcher` disarms on drop, the next dictation drops it, a 60s cap, and
  the thread kills itself when the front app changes.

Baseline is `finalize_polished_text`'s return value — under streaming that is
`typed_text`, what actually reached the screen, not the full LLM output. Using
the full output would read every interrupted session as "the user deleted a
large chunk".

Adds `DictationSession.asr_transcript`: `raw_transcript` holds the text
*after* local correction rules ran (dictation.rs rewrites `raw.text` in
place). Telling "ASR misheard" from "the LLM broke it" needs the version from
before that. Only written when the rules actually changed something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 06f49faede8f4c47eae6be155feddb65da1b9c01)

* feat: turn detected hand-corrections into dictionary entries

Closes the loop: an edit the user makes by hand becomes a correction rule
plus an ASR hotword. Those two do not substitute for each other — the rule
guarantees this word is right next time, the hotword raises the odds we hear
it right in the first place.

**No pinyin dependency.** The plan called for one to power a "same reading →
collect silently" tier, but the reference spec this was drawn from
(§19.6) does not use phonetics at all — its six boundaries are purely
textual, and the one place it does mention pinyin (IME cloud candidates) is
explicitly listed as not worth copying. So:

- Tier 1 (silent, tagged `learned`): cross-script only — one side pure CJK,
  the other pure ASCII letters (扣德克斯 → Codex). Nobody swaps a Chinese
  word for an English one to change their tone; that is us mishearing a
  loanword.
- Tier 2 (confirm): everything else, including Chinese homophones. 大禹→大鱼
  and 明天→后天 are textually identical in shape, so the text alone cannot
  separate a correction from a change of mind. Ask.

Two problems found while building this, both fixed:

- The minimal diff of a Chinese homophone correction is usually a SINGLE
  character (大禹→大鱼 strips to 禹→鱼), and a one-char rule fires everywhere
  — 禹州 would become 鱼州. So a rule widens outward through the stored
  context until it is at least 2 chars, yielding 大禹→大鱼, which is what the
  user meant. Tier is decided before widening, or the CJK context glued onto
  an ASCII pattern would hide every cross-script case.
- Pure deletions never become rules. As a global replace, "delete 的" means
  every 的 in every future dictation disappears. The risk is nowhere near
  the reward. Detection still logs them.

Suggestions queue in memory rather than firing a toast: at that moment the
user is typing in another app, and stealing focus is the rudest thing we
could do. They surface in the vocabulary page, which is also where `learned`
rules carry a badge, a filter, and a bulk delete — being able to see and undo
what was collected is the precondition for collecting anything at all.

Dedup is by pattern and includes manual rules, so auto-collection can never
duplicate or relabel something the user wrote themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a603d0f8080a258449cffac20bb94e52b1fe0a3d)

* feat(settings): add a cursor-context probe to the debug tools

Milestone 1 shipped `debug_read_cursor_context` with no way to reach it. A
Tauri command with no caller is not a debug tool — the whole point was being
able to see, per app, what we actually read. Only noticed after installing
the build and looking for it.

Settings → Advanced → Debug tools now has a probe button. The 5s countdown is
the part that makes it usable: at the moment you click, the frontmost app is
OpenLess itself, so a probe with no delay only ever reads our own settings
window. The countdown gives you time to switch to Notes / VS Code / WeChat and
click into a text field.

Shows status, reason, elapsed ms, the front app, and the windowed text with
the cursor marked — enough to tell "the safety gate blocked this" from "AX
doesn't support this control", which is exactly the question the verification
gate needs answered per app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 853f01ac145d248ad6da26ac651caa384ebeed35)

* docs: add the cursor-context install test plan

Written for a dogfooding pass: what to test, in what order, and what the
answer should look like. The AX-coverage table at the top is the one that
matters — which apps we can actually read is the biggest unknown in this
feature, and the probe makes it answerable without dictating a word.

Records what I verified on the installed build (Notes reads at 11ms with the
cursor marker in the right place) so you are not re-running what is already
settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 17083a476fbdd20d3d71c33b97861b5aedd25222)

* fix(macos): anchor the edit-watch baseline after the insertion lands

Two real bugs, both found only by installing the build and using it.

**The baseline was read before our own text arrived.** `inserter.insert()`
returning means the Cmd+V event was posted, not that the target app has put
the text in the document — that takes tens to hundreds of ms. We read the
baseline in that gap, so it captured the document as it was *before* the
insertion. The first comparison then saw our own 25 inserted characters as
the difference, called it a pure insertion, and dropped it. The word the user
actually corrected was buried under that and never got looked at.

Log from the failing run: `cursor context read OK: 44 chars` at 19:18:43,
`edit watch armed` at 19:18:52, first notification `baseline=44, current=69`.

The baseline now anchors when the insertion is observed to have landed —
either our text shows up in the document, or 1.5s passes (apps that reshape
what we typed, via smart quotes or autocorrect, would otherwise never match
and the watcher would wait forever, failing silently).

**Submitting a chat box was learned as a correction.** Pressing Return in
Claude Desktop clears the input and shows a placeholder, which is structurally
a "replace this whole sentence with that one" edit. `MAX_EDIT_CHARS` did not
catch it — the sentence was 25 characters. It was suggested as a rule, so
saying that sentence again would have replaced it with "Type / for commands".

Rejected now by sentence boundary: an edit whose source or target contains a
newline or CJK punctuation or `?!;` is not someone fixing a word. Tried a
proportion guard first (edit vs document size) and threw it away — real false
positives and legitimate corrections overlap on that axis, and it killed
several valid cases.

The boundary check deliberately ignores the ASCII period: Node.js, co.uk and
v1.2 all contain one, and technical names are exactly what this feature exists
to learn.

Also adds the diagnostics that made this findable: a notification count on
disarm (0 vs >0 separates "the observer never fired" from "it fired and
something downstream ate it"), and a log line on every early return in the
callback. The first debugging round produced nothing because that whole path
was dark — and because the logs were at `debug` level, which this app does not
record.

Registers `AXSelectedTextChanged` alongside `AXValueChanged`. Not every text
control emits the latter, and a user fixing a word always moves the caret, so
it is a second evidence path for the same event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 80e1691467a319331efd419a876507c9522ccee8)

* fix(macos): only judge an edit once the user has stopped typing

There was no notion of "the user is done" at all — every notification was
judged immediately. That survives a one-character fix by luck, and falls apart
on anything longer.

Changing 扣德克斯 to Codex is delete-four-chars, then C, o, d, e, x. Six
notifications, and the intermediate states — 扣德克斯→C, →Co, →Cod — are each
a structurally valid CROSS-SCRIPT edit, which is the tier that gets collected
silently without asking. One correction would have dropped four pieces of
garbage into the user's dictionary. The dedup set does not help: it stops the
same pair repeating, not a sequence of different wrong pairs.

The callback now only records that something changed and when. The watcher
thread checks once per turn of its runloop and judges only after 1.2s of
quiet, plus once more before disarming (a user who corrects a word and
immediately switches app would otherwise lose it).

1.2s is well above the gap between keystrokes while typing and well below how
long it takes to move on to the next thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 5da1e06ec80d32a0853739ecc953ffe506009f8e)

* fix(macos): end an edit when the caret leaves it, not when a timer expires

The previous commit picked "1.2s of quiet" as the definition of "the user is
done." That is a guess, and it is wrong at both ends: pause to think mid-edit
and it cuts you off; carry straight on to the next sentence and it judges
while you are already somewhere else.

We were already receiving the signal that answers this properly. Both
notifications were registered but treated as interchangeable:

  typing        → text changed  AND caret moved
  clicking away → text unchanged,   caret moved

"Caret moved but the text did not" means the user left this spot — that is
when the edit is final. Semantic, not a timer, and it costs nothing new: the
`AXSelectedTextChanged` subscription was already there, we just never compared
the text to see which kind of notification it was.

So: record throughout, analyse only at a boundary. Boundaries are caret-moved,
app switched, watch ended. The timer stays purely as a backstop for apps that
do not emit caret events, and relaxes 1.2s → 5s — no longer the main judge,
and being slower makes it *less* likely to catch a half-finished edit.

The baseline advances only after a successful report. A rejected comparison
has no verdict yet: delete a word, go copy something from elsewhere, come back
and type the replacement — keeping the old baseline is what lets the whole
edit be computed once you finish, instead of learning the deletion halfway.

Design credit: the user asked what actually defines "done typing" instead of
accepting the debounce, which is the question all of this hangs on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 4d9c96338db9143b4b26268a27a7a4c554b8f5db)

* fix(macos): don't mistake typing's own caret event for the user leaving

Real-machine log: every single keystroke logged "caret moved away; settling".
The user corrected a word and nothing was learned.

The two notifications arrive as a PAIR from one keystroke — AXValueChanged,
then AXSelectedTextChanged milliseconds later. The callback updated last_text
on the first, so the second saw "text unchanged, caret moved" and read it as
a boundary. So every key press settled, every intermediate state was rejected
(they are pure insertions), and each rejection consumed the pending edit. By
the time the user finished typing there was nothing left to judge.

The callback had the notification type in its parameters and ignored it. Now
it uses it: AXValueChanged is always an edit; AXSelectedTextChanged only ends
an edit if at least 300ms have passed since the last text change. Paired
notifications are milliseconds apart, so they fall below the threshold; a
genuine "stop typing, click elsewhere" is far above it.

Erring toward missing a boundary rather than inventing one: a missed boundary
is caught by the 5s backstop, an invented one learns half a word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 347391d0cef20d780d9d50617ba5b4f214944a05)

* refactor: learn vocabulary entries, not correction rules

Writing both was wrong, and the machine proved it. The dictionary held the
hotword `Codex` — "I want this word" — while the learned rule said
`Codex → 扣的爱思` — "replace this word". Same word, opposite meaning. Delete
one and the other stays, so the behaviour was undefined. That is the overlap
the user spotted.

Learned knowledge does not deserve literal-replacement power:

- A correction rule fires on sight. When it is wrong it is silent, global and
  invisible. Real logs from this session: `小鱼 → x`, `都去 → h` — half-typed
  intermediate states that would corrupt every future 小鱼.
- A vocabulary entry is a hint. It goes to ASR to improve recognition, and
  into the polish prompt where the LLM decides WITH CONTEXT whether to apply
  it. Wrong, it merely fails to help.

The dictionary already carried the correcting power through the LLM — the
hotword block says "when the transcript contains a homophone of these, prefer
this spelling". That path has judgement; literal replacement does not. And now
it has the cursor context too.

The cost, stated plainly: no deterministic correction in Raw mode, and ASR may
still mishear. Acceptable — misfiring is silent, not-helping is visible.

Tiering is rebuilt around the new question. It used to ask "is this
replacement safe", so it inspected the source→target mapping and its
direction. It now asks "is this WORD worth remembering", so it only looks at
the target. Direction stops mattering, which is what dissolves the loop:
whether you changed Chinese to English or the reverse, what gets remembered is
the word you ended up with.

- Latin-script word (Codex, Node.js, GPT-5) → collected silently. Changing a
  word to an English spelling is itself the evidence that it is a proper noun.
- Anything else (mostly Han words) → ask. 大鱼 could be a company or could be
  literally "big fish"; 接口 is a term and also an extremely common word. A
  common word in the hotword list makes recognition over-eager for it.
- Not a word at all (empty, crosses a sentence, over 12 chars) → dropped.

`RuleSource` stays on CorrectionRule: nothing writes `learned` any more, but
early builds already wrote some into users' files and the UI must be able to
show and remove them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 5a0135c3209abac4929dfa1ddc120571476c3ecd)

* feat: ask about a new word on a card, where the capsule sits

The queue in the settings page was the wrong place. A suggestion matters at
the moment you just corrected the word — that is when you still remember why.
Buried in a settings page you never think to look, and once it fills up it
starts dropping the oldest, so it was accumulating nothing.

The card appears where the capsule does. That window is already a
nonactivating panel, so it cannot steal the caret while you type in another
app, and it is a position you are already used to watching.

Reuses the capsule window rather than opening another one: multi-monitor
placement, Space attachment (including the macOS 26 bug that pinned the window
to a single desktop) and the nonactivating panel setup were all arrived at the
hard way, and a second window would have to earn them again.

One thing had to change. The capsule is normally fully click-through — it
floats over other apps and must not block what is underneath. A card you can
click needs that off, and a transparent window that is not click-through
blocks the mouse across its TRANSPARENT area too. So while the card is up the
window shrinks to the card's own size, and the blocked region is only the card
itself; both are restored when it goes away.

The card takes an independent event channel rather than a new CapsuleState.
`emit_capsule` is the single exit for session state and carries Esc exclusivity,
Space re-assertion, monitor placement and the Linux fcitx text with it —
a non-session state does not belong in there.

Per the design decision: appears as soon as the caret leaves the edit, gone
after 10s, several edits merge into one card, and nothing is recorded when
dismissed. No rejection list — an invisible list would only leave the user
wondering later why a word stopped being learned. Say it again and it asks
again.

Vocabulary page gets the divider layout: your own entries above, auto-collected
below the line with its own count and a Remove all. No per-chip badge — mixed
into one wall of chips you would have to read every one; a section you take in
at a glance, and "remove all" naturally governs the block beneath it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 5dfad460f5ccc23be8ea9a30382fd1f9db2946fa)

* fix(macos): give the capsule window back after the card closes

Two ways the card left the capsule broken, both reported as "the hotkey
stopped working" — the hotkey was fine, there was just no visible feedback.

`begin_session_as` calls `hide_vocab_suggestion_card` on every dictation. With
no guard it went on to `window.hide()` unconditionally, racing `emit_capsule`'s
show over the same window. Now it returns immediately unless a card is
actually up.

And hiding restored click-through but not the size. The card shrinks the
window to its own 300×108 (a transparent window that is not click-through
blocks the mouse across its transparent area too, so it has to be small), and
without restoring it the next capsule was squeezed into that — effectively
invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 4d4170f935e21ad9d57043b5e0da10a4b70fcf93)

* fix(daily): 把光标上下文系列接到新版 beta 上

daily 重建到 origin/beta 后,几个本地 commit 写作时上游还没有「划词润色」,
把它们放到新 beta 上就缺了三处对接:

- selection_polish 调 polish_text 时少了新增的 cursor_context 参数。传 None:
  选区润色的输入是用户选中的整段文字,本身就是完整上下文,光标前后文是给
  「对着光标口述」用的,这里没有意义。
- 选区润色写历史时缺 asr_transcript 字段(本地新增,存「纠正规则生效前的识别
  文本」)。选区润色不经过 ASR,恒为 None。
- polish.rs 里一个 beta 侧新增的单测调 compose_polish_prompts 时少了
  cursor_context 参数。

仅对接,不改任何行为。cargo test --lib 990 通过,npm test 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 69e21eebaa0a8de024a23e743a03af4b0d4b6fb0)

* chore(macos): quiet the edit-watch diagnostics down to debug

The per-event logging earned its keep — it is what located the baseline bug
and the paired-notification bug, neither of which was findable by reading the
code. But it fires on every keystroke: a day of normal use produced 2341
cursor-context lines in a 5.5MB log.

Dropped to `debug` (this app records `info` and above, so they are silent in
normal use): baseline anchored, caret moved away, settled-with-no-edit,
unreadable AXValue, edit-outside-our-text.

What stays at `info` is one armed/disarmed pair per dictation, and the disarm
line now carries both counts — notifications received and edits reported. Two
numbers are enough to tell whether an app emits usable notifications at all,
which is the per-app coverage data this was supposed to collect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 6d84fa54275d49d21a7b3a6ea9176d3d55db3b6c)

* docs: rewrite the test plan for what the feature actually became

The old plan described correction rules and a Tier1/Tier2 split that no longer
exist — the design changed twice while dogfooding it.

Now covers what shipped: vocabulary entries only, the card at the capsule's
position, and the divider layout. The rejection table names the two false
positives that actually bit us on the machine — pressing Return in a chat box,
and half-typed intermediate states — since those are the cases most worth
re-testing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7667ddd4fc4b58ee828feb8fa1fddcf580c55b3d)

* feat(vocab): 每条建议都要你点勾才入库,卡片挪到右下角

真机跑两天的证据:自动收进词汇表的 5 条只有 1 条是对的。

  Tailscale (was telskill)   ✓ 唯一一条真纠错
  ype       (was ap)         逐字敲 Type 的中间态
  ess       (was ice)        同上
  typeless  (was TypeScript) 用户本来就要打这个词
  " claude" (was cloud)      带前导空格,永远匹配不上

「跨文种的改动一定是专名」这条假设是错的。观察器看到的是编辑过程中的每一帧,
而中间态和一次真纠错在文本上没有任何区别——粗筛拦不住,也不该硬拦。

所以删掉 RuleTier 这一整个分级概念,只留一条路:提成建议 → 卡片 → 你点勾。
LearnedRule 不再带档位,handle_user_edit 也就没有第二条分支了。

顺带修掉那个前导空格:最小差异是按 char 剥前后缀剥出来的,边界上很容易挂着
一个空格,learned_rule 收尾补 trim。

卡片三处改动:

- 位置从「屏幕正下方居中」(胶囊那儿)改到右下角。居中那块正是用户在写字的
  地方,卡片停十秒直接盖住正在编辑的那一行。
- 「好 / 都不用」换成每条一勾一叉。尺寸、配色、SVG path 全部照搬胶囊上那对
  确认/取消按钮——同一个产品里的同一个手势不该长成两个样子。没有批量入口:
  逐条看一眼是这里唯一可靠的判据。
- 配色从写死的深色换成 --ol-capsule-* token(原来在亮色模式下是一块黑的),
  描边从 0.5px solid 换成 1px + 扩散阴影。非整数边框落在半个物理像素里,
  圆角边缘看着就是糊的。

逐条点完会重算窗口高度:显示卡片期间窗口不穿透鼠标,不重算的话已经空掉的
那片透明区域会继续拦住底下的点击。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7c09724253cdac4ca62392564b67e52030546340)

* fix(asr): 别让刚添加的词把常用词挤出 ASR 词表预算

发给 ASR 的词汇偏置 prompt 有 240 字符预算(whisper.rs::PROMPT_CHAR_BUDGET,
按 OpenAI 244 token 上限的 CJK 最坏情况折算),装不下的词条直接跳过。而词条是
按词典顺序填进去的,词典顺序又是「最近添加的在最前」(DictionaryStore::add 用
insert(0),为的是词汇表页面把刚加的词排在上面)。

两个各自都合理的决定撞在一起:预算永远优先喂给最新的词,最老的先掉出去——而最
老的那批恰恰是攒了最多命中的常用词。真机上一份 40 条的词典里,命中 18 次、10 次、
7 次的三个专有名词全部排在预算外,一次都没送到过 ASR。用户在词汇表里看得见它们、
以为在生效,实际上从来没生效。这也解释了为什么「加了热词还是识别不出来」——问题
根本不在识别,在于那个词压根没送出去。

改成按优先级排序再填预算:
- 最近添加的前 5 条保底。刚加的词还没机会攒命中,纯按命中排会让它永远进不去,
  而用户刚加它多半就是因为刚被它坑过。
- 其余按命中次数降序。命中是扫描最终文本累加的,与词条有没有进过 ASR 词表无关,
  所以是个干净的信号。
- 同词异形(claude / Claude)只留命中多的那个写法,位置取最靠前那次。既省预算,
  也免得两种写法一起进词表让模型无所适从。

只改 ASR 侧(听写与 QA 两处构造点)。LLM 热词块没有名额限制,仍走 enabled_phrases,
顺序不变。

另外给预算丢弃加了一行日志。之前是完全静默的,真机上排查花了很久;只在丢弃集合
变化时打,词典不变就不重复刷。

同一份真实词典(40 条)改动前后:
  改动前  Typeless / hermes / win-shukong / 大曹 / NewAPI / songkey / PR 全部在预算外
  改动后  以上全部进入,被挤掉的换成命中 0~1 的冷词

cargo test --lib 908 passed,含 3 个锁住排序规则的新测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 983b6d0cb892ff3667cd764c5eca1bab4f76d80e)

* fix(vocab): 学来的词条追加到词典末尾,不占 ASR 预算的保底席位

两个功能合到一起才暴露出来的相互作用:

- 学习路径走 add_if_absent,原本和手动添加一样 insert(0),插在词典最前面
- ASR 词表预算把「词典最前面的 FRESH_VOCAB_SEATS(5) 条」当保底席位

保底席位的理由是「用户刚手动加这个词,多半是刚被它坑过」。这对手动添加成立,
对着建议卡片点一下勾不成立——门槛低得多,而卡片本来就会建议出半截词(真机上
见过 ap → ype 这种逐字打字的中间态)。

连点五个勾,五个保底席位就全被它们占住,把用户攒了几十次命中的常用词挤出
240 字符预算——那正是 FRESH_VOCAB_SEATS 所在的那个提交要解决的问题本身。

改成追加到末尾。不需要给「学来的」加任何特判,位置本身就把两类区分开了。

排在队尾不等于永远进不了预算:
- 词条进 LLM 热词块没有名额限制,那一侧立刻生效;
- 命中计数扫的是最终文本,与有没有进过 ASR 词表无关,所以这个词一旦真的开始被
  用上就会按命中自己爬进预算。

add_if_absent 只有学习路径这一个调用方,手动添加的 add() 不受影响。词汇表页面
按 note 字段分上下两组,与存储顺序无关,展示不变。

顺带给 DictionaryStore 补了 new_at 测试构造器(与 CorrectionRuleStore 同形),
新增的两个用例因此不会碰用户真实的 dictionary.json。

cargo test --lib 991 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: README 补上「会自己长的词典」,并兑现它已经承诺过的那句话

README 的词典章节里一直写着:

  The app auto-learns candidate corrections such as `Claude`, `ChatGPT`,
  and `OpenLess` from your history and offers them later.
  应用会从你的历史中自动学习候选纠正,并在之后向你推荐。

这句话在 beta 上没有任何实现支撑——搜遍 persistence/dictionary.rs、coordinator
和 Vocab.tsx,没有任何「从历史学词」或「候选建议」的代码。它一直是一句空头支票。

这个分支把它兑现了,所以顺手把描述改成实际做出来的东西:不是「从历史里挖」,
而是「你改掉我刚打出来的词时,当场问你一句」。

三处改动:

- 词典章节:换掉那句空话,写清真实的闭环(手改 → 卡片 → 点勾入库),并说明
  每一条都要用户过目、没有静默入库。新增「光标上下文」小节交代它是这一切的
  前提:只有看得见自己刚打出去的文字,才可能发现用户改了哪个词。同时写明默认
  关闭、仅 macOS、以及永远不读的四类场景。
- 更新亮点:加一条。措辞落在「从转写工具变成认得你的词的输入法」这个转变上,
  而不是罗列功能点。
- 对照表 Typeless 那一行:补上「学到的东西留在本机,且不会在你确认前加进去」。
  这是同类闭源订阅产品给不了的那部分。

只改文档。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: 修 Android 编译,并把 AX 抓取挪出 tokio worker

CI 与机器人审阅回来的三条,全部核实成立。

## 1. Android cargo check 挂了(硬阻塞)

`set_ignore_cursor_events` 在 Android 的 tauri::WebviewWindow 上不存在,卡片
显示/收起各调了一次。仓库里 capsule_focus.rs:645 对同一个 API 早就有处理,
还专门写了注释说明 Android 不可用——照它加 #[cfg(not(mobile))]。

Android 上根本没有胶囊窗口,get_webview_window("capsule") 会先返回 None,
所以只需要让这两句在 mobile 上消失,其余逻辑不必动。

## 2. AX 调用跑在 tokio worker 上

spawn_edit_watcher 原本在调用线程上抓焦点元素、读基线全文、取 pid,注释写的
理由是「趁焦点还没跑」。但调用方 arm_edit_watch 位于 end_session(async)里,
也就是 tokio worker——而这几次 AX 调用每次都可能耗到 AX_MESSAGING_TIMEOUT_SECS,
对着一个 AX 无响应的 app(正是设这个超时要防的那种)能把一个 worker 卡住几百
毫秒。

**这条违反的是本模块自己文档里的硬约束第 2 条**,也是规划文档点名不许犯的。

改成在新线程里做(抽出 grab_focused_element)。代价是「趁焦点还没跑」的窗口从
零变成一次线程启动,几十微秒——比放进 spawn_blocking 好,那个要排 tokio 阻塞池
的队,负载高时反而更晚。

抓取失败时线程直接返回,观察器成为惰性对象;EditWatcher 的 Drop 只是置一个
flag,没有副作用。

## 3. 大文档上每条通知都整份读 AXValue

观察窗口最长 60 秒,用户每敲一个键都可能来一条通知,每条都要整份读 AXValue
再做 O(n) 比对。文档大到一定程度,这就成了「用户改一个词,每次击键都跨进程
拷贝一份文档」——卡顿,甚至把 AX 消息拖超时。同一个 PR 里的 read_document 正是
为了避免这个才做了 AXStringForRange 分支,观察路径却没有对应的保护。

加 EDIT_WATCH_MAX_CHARS = 20_000(与 FULL_TEXT_MAX_UTF16 同量级):超过就不武装,
打一行 info 说明。学不到词可以接受,让用户打字变卡不行。

cargo test --lib 991 passed;cargo check(macOS)干净。Android 本地无法验证
(本机 cargo 由 Homebrew 安装,没有 rustup 加不了 target),交给 CI。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 关掉开关立刻解除观察器,并给「Drop 即停止」钉一个测试

机器人审阅报了一条 5/5 + 隐私标记的「watcher cannot be stopped」,说
spawn_edit_watcher 交出去的停止 flag 从来没人置位,drop Arc 停不掉线程。

**这条不成立。** 停止链路是完整的,只是横跨两个文件:

  watch_for_edits 把 Arc 包成 EditWatcher            mod.rs:385
  impl Drop for EditWatcher → disarm() → store(true) mod.rs:362
  观察循环每次轮转检查 if stop.load(..)              macos.rs:776

解除的调用点也不是显式 disarm(),而是 `*slot = None`(arm_edit_watch 和
begin_session_as 各一处),所以只读 macos.rs 会看不到。它由此推出的三个后果
(线程堆积、重复上报、关开关后继续观察)都建立在这个错误前提上。

补一个测试把这条链钉死——它横跨文件、已经被误读过一次,而且一旦断了症状是
静默的(观察器活到 60 秒硬超时才停,期间继续读、继续上报、还和新武装的并行)。

## 但它指到的地方确实有一个真问题

归因错了,位置对了:**关掉开关时,已经武装的观察器不会被主动解除。**

arm_edit_watch 只在 end_session 跑。用户在观察窗口内把「光标上下文」关掉,
上一次听写留下的观察器会一直活到 60 秒硬超时或前台 app 切换——也就是用户明确
关掉开关之后,我们还在读他正在写的文档,最长一分钟。

功能本身是否还有用不重要:开关关掉的那一刻就该停,这是这个功能敢默认存在的
全部前提。

set_settings 里加一个 true→false 的 diff 分支,调新增的 disarm_edit_watch():
置空 edit_watcher(Drop 即解除),并把还挂着的建议卡片收掉——那些建议是这条
链路的产物,开关关了就不该再让用户看见。

cargo test --lib 992 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 没有光标时别当成光标在开头;词条长度两侧都要量

机器人审阅这轮的两条,都成立。安全标记已撤(No security concerns identified)。

## 1. 负数 caret location 被钳成 0

copy_caret_offset 原本 `range.location.max(0)`。部分 app(尤其 Electron 那类)
在没有插入点、或元素不是文本控件时,AXSelectedTextRange 返回 kCFNotFound(-1)。
钳到 0 等于把「不知道光标在哪」当成「光标在开头」——于是读回文档**开头**那几百
个字,再当作「光标附近」发给 LLM。

这错得静默,而且很可能就是装机测试里那个一直没解释清楚的现象:Electron 类 app
反复出现 `before=0 after=N`,当时记成「这个 app 读不到上文」,实际是读错了地方。
**错的上下文比没有上下文更糟——它看起来是对的。**

改成负数返回 None。read_document 对 None 早有正确降级(走 Unavailable 分支,
这次不发上下文,探针里能看到原因)。抽出纯函数 caret_offset_from_location 以便
单测,并钉一个用例。

## 2. is_vocab_worthy 只量了 target 长度

「把一长串不带标点的话改成 ok」能过关:minimal_edit 的 64 char 闸门放它过去
(没超),句读检查也拦不住(没标点)。于是它成为一条建议——「要记住 ok 这个词
吗」,卡片上那条 pattern 还长到显示不下。那是一次改写,不是一次纠错。

两侧都按 MAX_PHRASE_CHARS 量。一个词被听错,错的写法不会比它长太多。

(机器人说这会「pollute the vocabulary」,这半句不准确:入库的只有 target,
而 target 本来就有上限。真实代价是建议噪声和卡片显示,不是词库污染。)

cargo test --lib 994 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 已解除的观察器不得再上报——迟到一条就能把胶囊弄没

机器人报的这条成立,而且后果比它描述的重。

原文说「stale suggestion card reappears during the new session」。实际不止多一张
卡片:**卡片会把胶囊窗口缩到卡片大小**(show_vocab_suggestion_card 里为了让卡片
可点、又不让透明区域挡住整块屏幕而做的取舍)。一条属于上一轮的建议在新会话进行
中弹出来,等于把正在进行的那次听写的胶囊弄没了——这个坑真机上踩过一次,当时的
表现是「热键像是坏了」。

## 两处直接的口子

run_edit_watch_loop 里:

- 每轮 run_in_mode(1s) 返回后无条件 settle_pending_edit(false)。stop 若在这 1 秒
  内被置位,仍会先判定一次再退出。改成先看 stop 再判定。
- 收尾处无条件 settle_pending_edit(true)。这一刀本来是给「用户改完直接切走 app」
  兜底的,但主动解除也会走到。改成只在非解除退出时才补——超时 / 切走 app / 焦点
  元素消失这三种自然结束没有新会话在跑,那次改动确实还没判定过。

## 但堵 flag 只能缩小窗口,关不死

AX 通知回调跑在 run_in_mode **内部**。stop 置位到循环察觉之间(≤1s),回调走
caret 路径照样能完成一次上报。判据放在线程那边就永远有这段重叠。

所以把判据挪到协调方:Inner 加 edit_watch_generation,每次 arm_edit_watch 和
disarm_edit_watch 都 +1,on_edit 闭包捕获自己武装时的代次,对不上就丢弃。

这样「已解除的观察器不得再上报」不再依赖线程什么时候看见 flag——迟到的上报到达
时代次已经变了,当场失效。

cargo test --lib 994 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 解除观察器收口成一个入口;扩长按 trim 后的长度判

机器人这轮两条,都成立。第一条正中我上一轮修复的漏洞。

## 1. begin_session_as 没推进代次

上一轮加代次守卫时,arm_edit_watch 和 disarm_edit_watch 各自推了代次,唯独
begin_session_as 还是裸的 `*slot = None`——而它恰好是「新会话开始」这条主路径,
也就是上一轮那个 bug 的实际触发路径。等于守卫加了,但没守到该守的门上。

根因是解除动作散在三处、各写各的。收口成 coordinator::disarm_edit_watch(inner),
三条路径都走它:

  arm_edit_watch      武装前先解除上一次
  begin_session_as    新会话开始
  disarm_edit_watch   用户关掉开关

两步缺一不可(丢掉 EditWatcher 置停止 flag + 推进代次),现在只有一处实现,
漏一处这种事不会再发生。

## 2. learned_rule 按 trim 前的长度判扩长

pad_to_min_length 用 edit.source 的原始 char 数当基准,trim 发生在扩长之后。
「大 禹」→「大鱼」的最小差异是 " 禹" → "鱼",带空格数是 2 char,正好够
MIN_PATTERN_CHARS 于是不扩长;trim 之后只剩单字「禹 → 鱼」——正是这个常量存在
的意义所要挡的那种(下次说「禹州」就成了「鱼州」)。

改成按 trim 后的长度算基准,与最终入库的口径一致。

cargo test --lib 995 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(vocab-card): 听写进行中一律不弹卡片——判据下沉到碰窗口前的最后一刻

机器人指出代次检查是 check-then-act:回调读一次代次,然后打日志、跑
handle_user_edit、最后 show_vocab_suggestion_card。这中间 begin_session_as 完全
可能已经推进代次并收起卡片,于是卡片在 hide 之后又被显示出来。成立。

但不再往代次上叠补丁。所有上游判据(观察器代次、pending 是否为空)都是「读一次
再去干活」,而 show 最终要 run_on_main_thread —— 读完到真正碰窗口之间隔着一次
跨线程调度,那一段无论加多少 check 都堵不住。

把判据下沉到主线程闭包里,问一个更朴素、也更真实的不变量:**听写不在 Idle 就不
弹卡片。**

- 这是碰窗口之前的最后一个时点,没有更晚的地方可以被抢跑;
- 它问的是真正要防的事:卡片和录音胶囊共用一个窗口,显示卡片要把窗口缩到卡片
  大小,在听写进行中弹出来就是把那次听写的胶囊弄没了(真机踩过,表现是「热键
  像是坏了」);
- 它覆盖所有调用路径,不只是手改观察器那条——accept/reject 后的 refresh 也走它。

begin_session_as 是先置 phase 再收卡片的,所以只要它开了头,这道闸必然看得见。

vocab_card_visible 的 store(true) 一并挪到闸门之后,两条时序都自洽:
- 卡片已显示 → hide 的 swap 拿到 true → 正常恢复窗口;
- show 还在排队时 hide 先跑 → swap 拿到 false 直接返回、不排恢复;随后 show 的
  闭包看到非 Idle 也退出 → 窗口从没被改过,无需恢复。

代次检查保留:它让上一代的上报连 pending 队列都进不去,是成本更低的早退。两者
分工写在注释里。

cargo test --lib 995 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(cursor-context): 写明 edit_is_within_typed_text 是按内容匹配,不按位置

机器人指出:判据是「这几个字在插入的文本里出现过」,不是「这处改动发生在插入的那
一段里」。同一个词在文档别处也有时,用户改那一处会被误算到我们头上。属实。

没有收紧成位置判定,是权衡后的选择,写进注释免得下一个人以为是疏漏:

- 误算的代价可见且可撤销:每条建议都要用户在卡片上点勾才入库,最多多问一次。
- 收紧的代价不可见:位置判定要在锚定时记下插入偏移再比对,而目标 app 会加工插入
  的文本(智能引号、自动补全、字形转换)——那正是 anchored 那套兜底存在的原因。
  偏移对不上时会静默地不学,用户看不见自己少学了什么。
- 宁可多问一次,不可悄悄漏学。

真机上这种误算多不多,是装机自用才能回答的问题,真出现了再按数据收紧。

同一轮里另一条(asr_transcript / CorrectionRule.source 缺 serde default 会炸旧
文件)不成立:两个字段都已经带 #[serde(default)],RuleSource 那个还有
a_rule_without_a_source_field_deserializes_as_manual 钉着。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 手改观察器也必须过安全闸门——取焦点元素收口成一个入口

机器人报的安全问题成立,而且是这个功能最不能出的那种。

evaluate_gate 原本只装在读取路径(read_around_cursor_blocking)上。手改观察器
自己另开了一条取焦点元素的路(grab_focused_element),一次闸门都不过。后果:

  在终端里听写 → 上下文读取被正确拦住(终端在 bundle 黑名单里)
                → 落字成功后观察器照样武装
                → 读走终端全文当基线,之后每条通知再读一遍
                → 手改的 source/target 以 info 级别进日志
                → 还可能变成一张词条建议卡片

这个功能敢默认存在的全部前提就是「密码框 / Secure Input / 密码管理器 / 终端一律
不读」,而我在第二条路径上把这个承诺漏掉了。**闸门漏一条路径 = 没有闸门。**

没有在观察器里补一份闸门调用了事——那还是「两处各写各的」,下一个人加第三条 AX
路径照样会漏。把「取焦点元素」和「过闸门」焊死成一个入口
focused_element_passing_the_gate,两条路径都从它拿元素,想读宿主 app 的任何东西
都绕不过去。

顺序在入口里保持不变,两段判定不能合并:
1. 先判不需要 AX 的部分(Secure Input、bundle 黑名单)——命中就一条 AX 消息都不发;
2. 拿到元素后补 role/subrole 再判一次——密码框正是靠这个认出来的,而这两个属性
   不拿到元素就读不到。

AXUIElementSetMessagingTimeout 也一并收进入口。

## 顺带修掉同一轮报的性能问题

grab_focused_element 原本先整份拷 AXValue,再由 spawn_edit_watcher 判
EDIT_WATCH_MAX_CHARS。read_document 早就是先问 AXNumberOfCharacters 的,观察路径
没跟上——在一个十万字的文件上,光 marshalling 就够撞上超时,而那种文档我们本来就
不观察,白拷一次纯属浪费。

改成先问长度再决定拷不拷。拷完之后那道检查保留为兜底,给不报
AXNumberOfCharacters 的 app 用。

cargo test --lib 1000 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 改完按回车不该把整句都算成改动

真机翻车,用户当场看出来的:他只把「压根」改成「根本」两个字,改完顺手按了回车,
卡片上却弹出九个字的整句「压根就没有给我提醒 → 根本就没有给我提醒」。他的原话是
「我只改了一个词,这么长怎么要」。

根因在公共后缀的剥离方式:它从末尾往前逐字符比,末尾只要差一个字符,后缀长度立刻
判为 0,于是「改动点到结尾」的整段都成了差异。

  基线   ...压根就没有给我提醒       末尾是「醒」
  当前   ...根本就没有给我提醒\n     末尾是「\n」   ← 第一个字符就不匹配

两个字的改动被撑成九个字。而「改完按回车」是最常见的收尾动作,也就是说这条路径
上的建议长期都是坏的。

比对前对两侧 trim_end。尾部空白的差异本身没有词汇价值(多半就是一次回车),去掉
它既修好了后缀剥离,也顺带让「只按了个回车」在相等判定里直接出局。

新增两个用例:真机那句的最小差异必须是「压根 → 根本」;只按回车不算改动。

## 残留面,写进注释了

这个算法只能表达一处连续差异(前缀 + 后缀两刀剥出中间)。用户同时做两处改动时,
两处之间的字都会被并进同一个 span。trim_end 只治好了「第二处是尾部空白」这一种
——也是最常见的一种。换成尾部标点(改完词又补个句号)仍会撑开。根治要换成 LCS
之类能识别多处改动的算法,是另一件事;在那之前卡片上偶发的超长 pattern 就是这个
来源。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 学不到的改动不许吃掉基线——「删掉重打」这条路修好了

机器人报的 Split-Edit Miss 成立,而且它命中的是**最自然的那个纠错动作**:
删掉错词 → 停顿 → 敲正确的词。

原来的时序:

  删词    settle(光标移开安静 300ms,或 5 秒兜底)→ 纯删除差异被上报
          → 基线推进到「已删词」状态
  敲词    相对新基线只剩一条「空 → 新词」的纯插入
          → minimal_edit 对纯插入一律返回 None

于是只要用户在删词和敲词之间停顿一下(或移动过光标),这次纠正就永远学不进去。

代码和它自己的注释是矛盾的:注释写着「被过滤掉的不推进:用户可能删掉一个词……
再回来把新词打完」,意图完全正确,但真正的过滤发生在**下游** handle_user_edit 里
(is_vocab_worthy 判 target 为空就丢弃),观察器看不到那个决定,照样把一次注定被
丢弃的改动当成「已结论」,顺手吃掉了基线。

让观察器用下游同一个判据:is_vocab_worthy 为假就既不上报也不动基线。用户把新词
敲完时,相对原基线算出来的正是完整的「错词 → 正确词」。

顺带少一类噪声日志——纯删除以前每次都会打一条 user edit detected,而它从来就
不可能变成词条(词条要求 target 非空)。真机日志里这种占了相当一部分。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 回调也要看停止 flag,不能只有循环看

机器人指出:解除信号到达时观察线程可能正卡在 CFRunLoop::run_in_mode 里(最长
1 秒),这一秒内排队的 AX 通知照样派发到 value_changed_shim,而循环末尾那道
`if !stop.load(..)` 覆盖不到回调这条路径。位置属实。

但它推的后果——「一条属于上一轮的建议会在新会话里弹出卡片」——不成立:协调方那边
已经有两道防线,而且都在 handle_user_edit 之前:

- 观察器代次:回调闭包的**第一行**就比对代次,对不上直接 return,连
  handle_user_edit 都进不去,更不会排进 pending_corrections;
- 听写不在 Idle 就不弹卡片:判据下沉在 show_vocab_suggestion_card 的主线程闭包里,
  是碰窗口前的最后一个时点。

不过「循环看 stop、回调不看」本身不自洽,而且不挡的话,一次已经作废的观察还会再
去跨进程读一遍宿主 app 的全文——那既是浪费,也是多读了一次本不该再读的东西。

WatchContext 加 stop 字段,回调开头先看它,**这一刀在读 AXValue 之前**。它不是唯一
防线,是最早、最便宜的一道。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): CF 的 Boolean 是 unsigned char,别用 Rust bool 接

CFStringGetCString / CFNumberGetValue 原本声明成返回 Rust `bool`。CoreFoundation
的 `Boolean` 是 `unsigned char`,不是 C 的 `_Bool`;而 Rust 的 `bool` 要求位模式
**恰好**是 0 或 1,其余一律 UB。拿它接一个 unsigned char,等于赌 CF 永远只返回
0/1——实践中确实如此,但这是赌,不是保证。

同一个文件里 AXValueGetValue 早就是 `u8` 了,这两个是当初照抄 selection.rs 抄进来
的。而本模块开头第三条硬约束写的就是「新代码不得复制既有缺陷,哪怕仓库里的旧 AX
代码就是这么写的」——当时说的是 AX 超时,这里我自己破了同一条例。

改成 `u8` + 与 0 比较,本文件三处 CF/AX 布尔返回值口径统一。

selection.rs 那份保持不动:不在本 PR 范围内,改它要重新验证选区润色整条链路,
风险与收益不匹配。它属于模块文档里点名的那类既有缺陷,值得单独开一个 issue。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 闸门改用元素自己的身份判定,不再信「谁在最前面」

机器人报的竞态成立,而且它指出的其实是一个更根本的设计错误。

bundle_id 由调用方在**取焦点元素之前**采样(current_front_app_parts),而每个 AX
调用都可能阻塞到 AX_MESSAGING_TIMEOUT_SECS。用户在这中间切了 app:

  采样 bundle = 备忘录(放行)
  ...AX 调用阻塞中,用户切到终端...
  AXFocusedUIElement 返回的是终端的元素
  闸门用「备忘录」的身份放行 → 读走终端的文本

终端、密码管理器正是靠 bundle 黑名单拦的,这条路径把它们漏了。

但把它只当竞态修就治标了。真正的问题是**判据本身选错了对象**:闸门要判的是「手里
这个元素属于哪个 app」,而前台 app 只是它的一个近似——两者本来就可能不一致,竞态
只是让这个近似失效得更明显。

改成拿元素自己的 pid 换 bundle(新增 selection::bundle_id_for_pid,
NSRunningApplication.runningApplicationWithProcessIdentifier),在第二道闸门里覆盖
掉第一道用的那个前台 app 值。第一道保留——它不需要任何 AX 调用,命中就一条消息都
不发,作为省事的粗筛仍然有价值,只是不再是最终判据。

Secure Input 也顺手在第二道刷新一次:它是全局状态,同样可能在这几次 AX 调用期间
才被打开。

两条路径(上下文读取、手改观察)都从这一个入口拿元素,所以一起修好。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(cursor-context): 写明「改完词接着往下写」学不到,以及为什么这版不收紧

机器人指出:pending_since 每次文本变化都重置,用户改完词不停手继续写的话,判定
一直往后推;等他终于停下来,比对的是「原基线 vs 最终文本」——改的那个词和之后写
的所有内容被并成同一处差异。要么超长/跨句被拒(纠正白做),要么变成一条被污染的
建议。属实。

这跟用户今天自己抓到的「改完按回车撑成整句」是同一个根:minimal_edit 只能表达
一处连续差异,做两处改动时中间的字必然被卷进来。

**这版不收紧行为**,两个方向都会退化掉更重要的东西:

- 把 pending_since 改成只在为 None 时设置(等于给窗口加 5 秒硬顶),会重新开始抓到
  单个词改到一半的中间态——那正是 EDIT_SETTLE_TIMEOUT 当初从 1 秒放宽到 5 秒要躲开
  的,而「什么时候算打完字」这个判定是花了好几轮真机才调对的;
- 真正的解法是换成能识别多处改动的差异算法(LCS 之类),那是独立一件事,而且必须
  有真机数据才能验证它没把已经调好的判定搞坏。本 PR 已经 20 个 commit,再塞一次
  算法重写只会让它更难评审。

所以只把代价写进 EDIT_SETTLE_TIMEOUT 的文档:这条路径上的建议要么没有、要么偏长,
而每条建议都要用户点勾才入库——代价是漏学或多看一眼,不是静默写错。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 去重挡掉的改动也要推进基线,否则后面的纠正全被带偏

同一处 (source, target) 在一次观察窗口里出现两次是常事——听错的专名在好几句里都
出现,用户逐个改过去。第二次被 reported 去重挡掉时,函数直接 return,基线没推进。

于是基线停在「只改了第一处」的状态,而文档已经改了两处。之后用户再改任何东西,
minimal_edit 都是拿这个陈旧基线去比,算出来的 span 把「已经有结论的那处重复改动」
和「新改动」搅在一起——多半过不了 edit_is_within_typed_text,新的那次纠正就被静默
丢掉了;万一过了,建议本身也是被污染的。

把基线推进挪到去重**之前**。去重管的是「别重复上报」,不是「这处改动没发生」。

判据统一成一句话:**有结论就推进,无论这个结论是不是新的。** 前面两道 return
(不是我们插的文字、注定成不了词条)才是「还没有结论」,那两处保留基线是对的——
后者正是上一轮修「删掉重打」时加的。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cursor-context): 确认不了元素归属就不读——闸门必须失败关闭

上一轮把闸门改成用元素自己的 pid 换 bundle,但只在成功时覆盖:

  if 取到 pid && 查到 bundle {
      gate.bundle_id = Some(owner);   // 成功才覆盖
  }
  // 失败时保持采样时那个前台 app 的值 → 照样放行

于是取不到归属时,闸门退回按「谁在最前面」判定——正是上一轮要修的那个漏洞原样
还在。这是 fail-open,比原来更糟:代码看着已经防住了。

改成失败关闭,返回 Unavailable。

注意**不能简单地把 bundle_id 清成 None**:evaluate_gate 对缺失的元数据是放行的
(missing_metadata_does_not_block_by_itself 钉着这个行为),那只是换了一种
fail-open。所以必须显式返回,不能靠清字段。

这跟那条测试的设计意图并不冲突——它的注释写着「读不到 bundle / role 时……读不到
文档自然会走 Unavailable」。本次改动正是把那个「自然会」在这条路径上变成显式的:
之前它在这里并不成立。

代价是没有 bundle id 的进程读不到上下文。那类进程本来就很少,而「宁可不读」是这个
功能对隐私的基本承诺。

cargo test --lib 1002 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(selection): 合并时把 beta 的按平台 current_front_app 又带了回来,Linux/Windows 重复定义

上一个合并 commit 解 selection.rs 冲突时,我看到 HEAD 侧是空的,就当成「beta 纯
新增」取了 theirs。判断错了:**空的 HEAD 侧也可能意味着「这一侧删掉了它」**。

本分支在里程碑 1 把 current_front_app 重构成了 current_front_app_parts 之上的通用
包装(无 cfg,一份顶所有平台);beta 没有这个重构,那边仍是按平台各写一份。git 看
到的是「我删了平台版本、beta 改了平台版本」,取 theirs 等于把被重构掉的两份又请了
回来:

  1017  pub(crate) fn current_front_app()          无 cfg,所有平台
  1066  #[cfg(windows)] fn current_front_app()     ← 重新带回来的
  1094  #[cfg(not(macos),not(windows))] fn ...     ← 重新带回来的

macOS 上恰好不冲突(那两个 cfg 都不成立),所以本地全绿;Linux 上 1017 与 1094 撞
成 E0428,Windows 会以同样方式撞 1017 与 1066——CI 先报了 Linux,Windows 当时还在
排队。

删掉重新带回来的那两份,保留通用包装。beta 真正新增的 current_front_app_pid(选区
润色 macOS 移植 #926 要用)留下。

核对平台覆盖:current_front_app_parts 三个实现互斥且全覆盖;current_front_app_pid
是 macOS 专属,唯一调用点也在 macOS 分支内。

cargo check 干净、cargo test --lib 1032 passed(macOS)。Linux / Windows 交给 CI。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address PR 917 review findings

---------

Co-authored-by: jisongniu <bigsong038@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
* feat(hotkeys): 风格包直达快捷键——按风格包 ID 绑定自定义全局键(#759)

不做原 issue 的 Alt+1~9 固定槽位(序号随启停位移、macOS 上 Option+数字
会吞特殊字符输入),改为在设置→快捷键里为具体风格包各配一个快捷键:
- prefs 新增 stylePackHotkeys: Vec<{packId, binding}>,默认空、无预设键
- 触发复用 activate_style_pack_by_id:禁用包自动启用、prefs/托盘/前端同步
- 集合式冲突校验:集合内去重 + 与其它全部快捷键互斥,reconcile 兜底(#904 模式)
- 删除风格包时清理孤儿绑定;启动 supervisor + 设置变更全量重建注册
- 设置 UI:风格包下拉(SelectLite)+ 录制器 + 增删行,i18n×5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ux(settings): 快捷键卡片标题「速查」改「设置」——卡片本就可配置,名字要说实话

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hotkeys): make style pack shortcut updates transactional

* fix(hotkeys): ignore unrelated shortcut collisions

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
Ignore .reasonix directory from version control.
* fix(provider): allow custom LLM HTTP hostnames (#929)

- 完全放开 http(s) 端点限制:公网/私网/本地/元数据地址一律放行,
  校验仅保留 URL 格式检查(合法 http(s) URL + 主机名)
- LLM / ASR / Omni 三条路径(连通性测试、模型列表、运行时)共用同一校验
- 运行时 resolve_ark_endpoint 补上同一校验,修复「保存端点绕过验证按钮检查」gap
- 前端对 ark/asr/omni endpoint 的 http:// 输入展示明文风险提示(5 个 locale)

* fix(asr): honor shared endpoint policy for ElevenLabs

Remove the provider-specific HTTPS-only guard so explicitly configured valid HTTP endpoints follow the shared endpoint validation policy.
* fix(asr): 火山 ApiKey 模式改用官方端点,修复 45000010 (#931)

ApiKey 鉴权模式此前使用 wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_async,官方文档(docs/6561/1354869)中不存在 /plan/ 路径,导致服务端鉴权层返回 45000010 AuthenticationError(The API key or AK/SK in the request is missing or invalid)。

- 端点改为与 AppIdToken 模式一致的官方双向流式优化版端点 wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async
- 按官方鉴权表补充 X-Api-Request-Id(复用会话 connect_id)与 X-Api-Sequence(固定 -1)头
- 测试断言两模式端点一致并覆盖新增请求头

* docs(asr): 补充新版控制台 API Key 配置说明,澄清 Key 来源

- docs/volcengine-setup.md 新增「新版控制台(API Key 方式)」章节(官方链接 console.volcengine.com/speech/new/setting/apikeys)
- 设置页文案不再称「方舟 API Key」,改为「新版控制台 API Key」并在提示中给出官方创建入口(en/ja/ko/zh-CN/zh-TW 同步)

* fix(asr): use fresh request IDs for connection retries
* fix(deps): resolve RustSec advisories for issue 815

* ci(rust): verify Rust 1.88 MSRV

* test(linux): stub fcitx in backend harness

* docs: document Rust 1.88 MSRV in Chinese README
* feat(marketplace): add style pack ZIP downloads

* fix(marketplace): reject file URI download targets
* ci: optimize CI caching, concurrency, and Tauri security

Address multiple CI/CD efficiency and security gaps identified during
project optimization analysis. The changes reduce PR build times,
prevent wasteful concurrent runs, tighten the Tauri security boundary,
and eliminate unnecessary submodule fetches on non-macOS jobs.

CI workflow improvements:
- Add swatinem/rust-cache@v2 to both ci.yml jobs (android-check and
  cross-platform), matching the caching already present in
  release-tauri.yml. This converts PR Rust compilation from full
  rebuilds to incremental builds, saving an estimated 60-80% of
  compile time per run.
- Add concurrency groups to all three workflows. PR pushes cancel
  superseded runs to free concurrency slots; tag pushes and manual
  dispatches are isolated by run_id to avoid mutually canceling
  release builds. Previously, rapid PR pushes triggered redundant
  parallel CI runs with no cancellation.
- Unify Node version to 22 across release-tauri.yml and
  android-apk.yml, which were previously pinned to 20. This ensures
  consistent build environments across all workflows.

Submodule and security hardening:
- Conditionally fetch the vendor/qwen-asr submodule only on macOS
  jobs across all three workflows. The submodule is compiled
  exclusively by build.rs on macOS (build_qwen_asr_macos); Linux,
  Windows, and Android builds never reference it. Removing the
  unconditional recursive checkout eliminates a network fetch and
  a submodule-availability failure point on every non-macOS job.
- Set withGlobalTauri to false in tauri.conf.json. The frontend
  exclusively uses @tauri-apps/api imports and never references the
  window.__TAURI__ global, so disabling it removes an unnecessary
  XSS attack surface without functional impact.

Dependency hygiene:
- Move @types/dompurify from dependencies to devDependencies in
  package.json. Type declaration packages are build-time only and
  should not ship in the production bundle.

* fix(ci): address review findings

* fix(ci): isolate manual CI workflow runs

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
* fix(asr): 火山渠道「验证」接入真实 WS 连通检查,修复必报「API Key 为空」

validate_asr_provider 此前没有火山分支,火山渠道点「验证」会掉进 OpenAI
兼容 HTTP 兜底——那条路只认 asr.api_key(火山从不写入的槽位),导致无论
哪种鉴权模式、凭据填得多对,连接检查都报「API Key 为空」。

- 新增 validate_volcengine_asr_provider:按鉴权模式取各自槽位凭据
  (旧版 volcengine.access_key / 新版控制台 volcengine.api_key),
  真连官方 WS 端点 + 1s 静音 + 收尾,与讯飞验证器同构;纯静音会话
  服务端不回 final(NoFinalResult)不算失败——握手成功已证明凭据有效
- 凭据缺失按模式返回哨兵串(volcengineAppIdMissing 等),前端映射为
  多语言文案,直接指出该补哪个输入框(en/ja/ko/zh-CN/zh-TW 同步)
- 握手被拒(401/403)/ 被限流(429)的报错原样透传给验证按钮,
  不再折叠成笼统的「操作失败」
- 补 volcengine_missing_credential_error 单测锁定两种模式的判定

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(asr): 统一火山 Resource ID 空白回退

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
@appergb
appergb merged commit addff04 into main Aug 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants