Commit Graph
62 Commits
Author SHA1 Message Date
Chris233 04719aac37 方->圆 2026-09-10 23:51:30 +08:00
Chris233 cb5438a9d4 feat(docs): 更新 README 和 README.zh.md 中的贡献者信息 2026-09-10 23:51:30 +08:00
sim 0ac0fbd391 merge: 汇入 origin/beta(#1037 远程输入持久 CA、#1040 F13-F20/Mac 听写键、#1020 腾讯云实时语音、#1044 统一模型字段与异步保存隔离)并完成 2.0 UI 适配
冲突解决原则:2.0 重写的界面以本地为准,云端功能语义全部移植——
- ProviderForm 离开收敛接入 SettingsModal / ChannelList / LlmProtocolFields / CredentialField(关闭、切节、换供应商前冲掉防抖写入并等待在途写完成)
- 腾讯云三段式密钥 + 固定模型档分支移植进 2.0 ChannelCredentialFields;OAuth 静态模型走描述符
- Mac 听写键进入 ShortcutRecorder(allowMacDictationKey)并改用 refresh 拉新偏好
- 远程输入手机页新证书信任流程(持久本地 CA + 描述文件/CA 下载 + 复制链接),de/es/fr 补齐全套新文案
- 纯格式分歧文件取云端后 prettier 归一;双方新增内容(i18n、测试)两侧保留
2026-09-10 16:47:30 +08:00
kevinlinandChris233 bf1237c6b1 feat(provider): 增加腾讯云实时语音处理链路 (#1020)
* feat: add Tencent TokenHub and realtime ASR

Rebase onto Core 2.0: Hunyuan realtime ASR and TokenHub polish now live
in openless-core with the shared provider registry, credentials, and
cloud transcription session lifecycle.

* fix: harden Tencent ASR and TokenHub

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
2026-09-09 23:39:11 +08:00
sim 0fa21daaf0 Merge origin/beta into codex/ui-2.0-details(汇入 #1022/#1023/#1041/#1042,保留 2.0 渠道卡片结构并整合 OrcaRouter 目录选择) 2026-09-09 13:14:35 +08:00
sim 1d29f8a6cd feat(app): finalize 2.0 channel settings UI, 8-language alignment and cloud-sync groundwork
- 渠道设置界面:ChannelList/LlmProtocolFields/ProvidersSection 渠道级协议与模型配置
- 8 种界面语言 1711 项文案对齐(de/en/es/fr/ja/ko/zh-CN/zh-TW)
- Core 协议层 llm_protocol 与 provider_service 配套、云同步准备
- linux-egui 后端与 Android updater 配套调整
2026-09-09 13:04:10 +08:00
AnKaifengandChris233 66915405fd feat(providers): add OrcaRouter model catalog, LLM, and ASR support (#1022)
* feat(providers): add OrcaRouter model catalog integration

* fix(providers): harden OrcaRouter catalog integration

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
2026-09-09 00:41:55 +08:00
a3fc7171d2 feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash (#1023)
* feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash

Dictation polish uses OpenAI-compatible chat/completions. For OpenCode Go, change the Base URL to /zen/go/v1.

* fix(macos): 避免 Fn 长按与功能键误触发听写 (#1008)

Auto 和 Toggle 模式只在 Fn 短按松开且未叠加功能层操作时触发听写;长按及亮度、音量等 systemDefined 事件留给 macOS。

保留显式 Hold 模式的按住说话语义,并跳过 Fn 短按释放后的冗余仲裁延迟。

Co-authored-by: Chris233 <h-chris233@outlook.com>

* fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞 (#1036)

* fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞

dashscope 的 AAAA 在部分 Windows 双栈网上 TCP 443 会挂死,connect_async 先试 IPv6 会卡满 5 秒超时,听写进不了 Listening。

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

* fix(asr): preserve websocket ports and retry handshake addresses

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>

* fix(updater): Beta 构建默认跟随 Beta 渠道 (#1041)

* fix(updater): default beta builds to beta channel (#1034)

* fix(updater): persist channel switches atomically

* feat(llm): 支持渠道级 Chat Completions、Responses 和 Messages (#1042)

* feat(llm): add channel request formats and compatibility presets

* fix(llm): harden channel protocol handling

* feat(llm): integrate OpenCode Zen with shared request formats

---------

Co-authored-by: Mark <shawninjuly@gmail.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
Co-authored-by: 浩彬(^-^) <146098469+HKLHaoBin@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 20:54:01 +08:00
sim 69f454bd68 feat(ui): integrate 2.0 interface and agent work
Consolidate the current 2.0 sidebar, overview, settings, vocabulary editing, correction rules page, and first-launch splash with their Core and Tauri adapters. Include the accompanying source and architecture documentation updates.

Validation: npm test (build and 73 discovered test files); cargo test -p openless-core --locked; Core/Linux and Tauri rustfmt checks.
2026-09-08 11:12:05 +08:00
sim 92da4fbf9b docs: 清理已了结的规划过程文档并同步引用
删除已完成/已收口任务的规划与过程记录(2.0 UX 收口、Core 2.0 计划、
PR#1019 复核与桌面复审、平台边界审计、Linux 引导知识快照、
Linux 共享后端阶段计划、供应商渠道计划、Windows sherpa 规划、
PR#711 评审截图、应用内测试计划),保留规范性文档、验收清单、
Linux 交接目录与运营指南,并修复全部引用链接。
2026-09-07 13:29:55 +08:00
Chris233 fc38edd1a3 refactor: consolidate Core 2.0 and restore cross-platform behavior (#1019)
* refactor: split Linux egui host from shared backend

* fix: keep CI contracts portable across runners

* fix(ci): preserve CRLF while removing qwen dependency

* fix(ci): regenerate target lockfile and install ripgrep

* fix(ci): gate desktop-only selection voice module

* ci: limit native build parallelism on cross-platform runners

* fix(host): expose blocking task seam to coordinator

* fix(settings): avoid macOS default hotkey collision

* docs: record cross-platform CI evidence

* ci: expose Linux validation package artifact

* ci: grant reusable Linux package permissions

* refactor: remove legacy coordinator dictation facade

* ci: skip unsupported MSI prerelease builds

* ci: install minisign only for signed Linux releases

* fix: keep test-only coordinator session helper

* refactor: move style preview diagnostics into core

* fix: import shared preferences type from core

* refactor: centralize enabled vocabulary projection

* ci: install ripgrep for Linux package gates

* test: make source contract line-ending agnostic

* fix: keep Linux desktop entry standards compliant

* docs: expose core style diagnostics contract

* ci: resolve Linux package version in app directory

* refactor: centralize ASR vocabulary priority

* fix: use shared vocabulary note constant

* refactor: keep vocabulary priority inside core

* test: retain starting-stop queue coverage

* ci: scope Linux updater URL to the current repository

* docs: close implementation and artifact evidence

* docs: record final CI artifact evidence

* fix(android): align updater plugin versions

* fix(android): pin updater crate exactly

* refactor(core): remove unused type anchor

* docs: record final artifact runs

* fix(remote-input): preserve beta status fields

* fix(tauri): finish beta host merge

* fix(asr): pass scoped operation to native qwen adapter

* fix(macos): bundle mlx metallib as a resource

* test(remote-input): update status wire contract

* feat: share Less Computer voice flow with Linux

* refactor(core): centralize host-independent runtime policy

* refactor(core): complete 2.0 backend consolidation

* fix(ci): prepare Sherpa after cache restore

* fix(macos): route native model paths through Core store

* fix(macos): use ModelStore catalog API

* fix(ci): serialize macOS native test build

* test(ci): preserve macOS cargo diagnostics

* fix(macos): release input source lock before await

* test(core): replace legacy hotkey injection gate

* test(core): move Codex sandbox contract into Core

* fix(core): close final runtime policy gaps

* fix(windows): terminate coding agent process trees

* fix(core): complete local model migration

* fix(credentials): align Core LLM model account

* fix(core): restore 1.x parity across shared backend

* fix(android): complete shared backend stubs

* fix: close cross-platform migration review gaps

* fix: complete migration lifecycles and platform compatibility

* docs: define desktop 2.0 acceptance and Linux handoff

* fix: close desktop parity and session lifecycle gaps

* fix: finish voice lifecycle and desktop feedback parity

* fix: close desktop cancellation and feedback race gaps

* fix: honor proxy settings and reject stale session effects

* fix: dispatch native cleanup on host runtimes and order events

* fix: keep optional recording effects best effort

* fix: close desktop agent model and QA lifecycle gaps

* fix: preserve desktop raw voice and QA turn ownership

* fix: finalize desktop dictation and QA feedback
2026-09-06 18:39:46 +08:00
sim 1933a283c6 chore(release): prepare 1.3.18-Beta.5 2026-08-21 14:24:56 +08:00
Jimmy LeeandChris233 42c1b31754 本地模型增加whisper相关模型的支持; 增加qwen3-asr的rust版本, 相比C版本速度更快 (#940)
* feat: add selectable local ASR backends

* fix(build): 修复本地 ASR 多平台构建与旧渠道兼容

* fix(asr): 修复 MLX 权重跨线程 stream 错误

* fix(ci): exclude MLX Qwen3 from non-macOS jobs

* fix(ci): use app-relative ASR submodule path

* fix(android): skip qwen-asr C backend

* fix(android): guard qwen-asr by target triple

* fix(ci): 修正语法错误

* fix(ci): remove invalid checkout step options

* fix(asr): harden local backend integration

* fix(asr): 处理 PR #940 审查发现(9 项)

- strip: 全局保留 symbols,macOS release profile 单独退回 debuginfo(Homebrew rustc 的 LINKEDIT 问题仅限 macOS)
- 转录超时改为驱逐引擎而非无效 cancel():spawn_blocking 解码任务无法被中止,驱逐后下次会话加载新引擎而非排队
- Turbo/Q5 共享目录模型在 is_downloaded/downloaded_bytes/delete_model 上互相认账
- MLX 临时 WAV 用 RAII guard 兜底清理;HF tree API 改 Link rel=next 游标分页(offset 被服务器静默忽略),带防御上限与 5 个单测
- Intel Mac 渠道下拉不再闪现 MLX 预设;MetalToolchainGuide 默认折叠并标注仅源码构建期要求
- qwen3-asr-rs 子模块 URL 与 gitlink 指向 Open-Less org 镜像,嵌套 mlx-c 同步改指 org(供应链 N-6)
- README/注释补充:所有平台源码构建均需递归初始化子模块(path 依赖跨 target 解析)

* fix: harden local whisper model lifecycle and downloads

* fix(asr): address PR 940 review findings

* fix(ci): add local ASR model stub to backend tests

* fix(linux): bundle and sync fcitx5 selection plugin

* fix(macos): avoid duplicate whisper test module

* fix(history): make detail sibling keys unique

* fix(ci): stub macos whisper model constant

* test(ci): avoid macOS streaming timeout race

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
2026-08-20 20:22:02 +08:00
Chris233 7a66ca625b docs(readme): 移除对 jiangmuran 的赞助商感谢信息 2026-08-20 19:01:46 +08:00
Chris233 b4099362d2 docs(readme): 更新 bigsong 的角色为“贡献者/测试” 2026-08-20 18:54:14 +08:00
Chris233 9bf492e04d docs(readme): 更新开发者信息和链接格式 2026-08-20 18:52:45 +08:00
Chris233 6f4ed5b128 更新 README.md 2026-08-20 16:42:38 +08:00
Chris233 9cc510e86b 更新 README.md 2026-08-20 16:38:53 +08:00
Muran Jiang 09828b7ea6 docs(readme): restore jiangmuran and Cooper (#971) 2026-08-20 13:00:00 +08:00
Chris233 4cd93bd006 fix(deps): resolve memmap2 and rkyv advisories for #815 (#938)
* 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
2026-08-10 21:58:56 +08:00
f016e57952 feat: 让词典自己长——手改一次,它就记住(光标上下文 + 手改学词 + 热词预算优先级) (#917)
* 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>
2026-08-08 13:30:25 +08:00
Chris233 5f9586f739 feat(asr): 接入 ZenMux 语音转写(JSON + base64)作为可选云端 ASR(#837) (#915)
* feat(asr): 接入 ZenMux 语音转写(JSON + base64)作为可选云端 ASR(#837)

- WhisperBatchASR 新增 ZenMuxJson 请求格式:application/json + base64
  input_audio,language 跟随工作语言(ISO 639-1 映射),enable_itn 可配置
- 新增 zenmux 命名预设:默认 https://zenmux.ai/api/v1 + qwen/qwen3-asr-flash,
  复用 Whisper 兼容路由与 30s 分片;模型 slug 自由输入
- 设置页新增 enable_itn 开关(per-provider 高级配置,默认开启)
- 补齐五语言 i18n、前端单测与 README

* fix(asr): ZenMux「测试连接」按 JSON 协议验证 + 词典不生效说明(#837)

- validate_asr_transcription 按 provider 真实请求格式发探针:OpenRouter /
  ZenMux 走 JSON+base64,其余 whisper 兼容厂商走 multipart,避免假阴性
- 设置页对 zenmux 预设增加说明:JSON 协议不发送词典热词(prompt/hotwords),
  词典仍进入润色链路但不参与识别偏置;README 同步标注例外
- coordinator 再导出 whisper_request_format 供验证路径复用
2026-08-04 23:37:09 +08:00
sim e46e94995c docs(readme): remove jiangmuran and Cooper, sync latest stable data 2026-08-04 15:53:57 +08:00
Chris233 5be28b1dbd feat(asr): 支持讯飞实时语音转写(RTASR)提供商(#842) (#894)
- 新增 asr/xfyun.rs RTASR 流式客户端:signa = Base64(HmacSHA1(MD5(appid+ts), apiKey))
  鉴权,1280B/40ms 分块发送,{"end": true} 收尾,seg_id 去重拼接,断连 partial 兜底,
  鉴权/限流/超时错误分类可读。
- 全链路接入:dictation 主流程、QA 面板、重新转录、取消、设置页凭据预检与
  "验证"探活;凭据 xfyun.app_id / xfyun.api_key 存系统凭据库,不落日志。
- 设置页新增「讯飞实时语音转写」选项(AppID + API Key),5 种语言 i18n。
- 文档:docs/xfyun-asr.md + README/README.zh/USAGE 同步。

已知限制:标准版 RTASR 无请求级热词参数(在讯飞控制台配置),语种默认中文普通话。
2026-08-03 21:37:10 +08:00
sim 6b33ba21e0 fix(updater): prepare 1.3.15-Beta.2 after repository transfer 2026-07-19 00:11:34 +08:00
7de0c04ba5 docs: 用协调沉降定律重构 README 能力/增强叙事,凸显开屏即授权完基础设施 (#765)
- 新增「从工具到基础设施 / From tool to infrastructure」专章:把 OpenLess 定位为
  消灭「想法 → 干净文字」之间每一次协调的基础设施——语气沉降成风格包、专有名词
  沉降成词典热词、文字落点沉降成插入协议、模型与凭据沉降进系统保管库,开屏授权
  一次即为默认协议。
- 改写「更新亮点(增强)」与「当前状态(能力)」两节开场,从功能罗列改为「你授权
  一次之后就不必再操心的基础设施层」;其余条目与版本号(v1.3.6)保留不动。
- 中英双 README 同步(README.md + README.zh.md),纯文档改动,不涉及代码/版本门禁。

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:28:36 +08:00
HKLHaoBinandCursor 0fe5fbfdcf feat(android): align release pipeline with desktop updater rules
Add signed release APK builds on v*-tauri tags, minisign manifests per ABI, custom Android in-app updater, and CI cargo check for aarch64-linux-android.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 19:48:07 +08:00
HKLHaoBin 1ea467a001 feat(android): port Android APK runtime onto beta 2026-06-12 18:13:12 +08:00
TRIP 30cf33e2b1 Merge pull request #589 from Open-Less/docs/readme-real-example
docs(readme): 实际效果示例换成客户回邮件用例(中英双语)
2026-06-04 19:33:36 +08:00
吕柏青 5cf015de17 docs(readme): 实际效果示例换成客户回邮件用例(中英双语)
把「一个实际例子 / A concrete example」从未充分测试的 SQL→ChatGPT 演示
换成经过测试的客户回邮件用例:口语输入 → 结构化清理稿,更贴合
OpenLess「只清理你的话、不替你作答」的定位,宣传与实际相符。
收束句由「回车发送」改为「粘贴给 ChatGPT/Claude 续写」,标语保持不变。
2026-06-04 19:31:15 +08:00
吕柏青 ace7fe47ab docs(readme): 顶部加入 Discord 社区入口(badge + 社区行,中英双语) 2026-06-04 13:20:32 +08:00
jmr b0fe9ccb52 Revise README for improved clarity and structure
Updated README.md to enhance clarity and formatting.
2026-06-03 22:20:54 +08:00
吕柏青 08ad6b7979 docs(readme): 突出风格市场 + 流式输出,状态更新到 v1.3.6 2026-06-01 18:00:22 +08:00
H-Chris233 4d9b63aab3 Route qwen-asr through the organization fork
Keep the submodule pinned to the reviewed commit while moving the fetch URL to Open-Less/qwen-asr, then document the review checklist future upgrades must follow.\n\nConstraint: #301 identified the personal upstream submodule URL as a supply-chain risk.\nRejected: Updating the submodule commit together with the URL change | would mix provenance hardening with unreviewed upstream code changes.\nConfidence: high\nScope-risk: narrow\nDirective: Do not point .gitmodules back to antirez/qwen-asr; sync upstream into the organization fork and review the diff first.\nTested: gh repo view Open-Less/qwen-asr; git submodule update --init --recursive openless-all/app/src-tauri/vendor/qwen-asr; git diff --check; npm run build; cargo check --manifest-path src-tauri/Cargo.toml\nNot-tested: macOS INSTALL=0 ./scripts/build-mac.sh C-link verification.
2026-05-12 18:48:03 +08:00
TRIPandbaiqing b7685cadda docs(release): make macOS xattr workaround discoverable (#347)
发现的真因:v1.2.23 Stable 和 v1.2.24-1 Beta 的 macOS DMG 都是 ad-hoc 签名 + 未经 Apple 公证(仓库没配 APPLE_CERTIFICATE 等 secrets)。macOS 14+ Gatekeeper 默认拒绝运行未公证的 app,弹"OpenLess 已损坏"。
build-mac.sh:40-41 注释已经写明这是已知限制:
  "用户免手工 xattr 的根本方案是 Developer ID 签名 + Apple notarization"

之前 README 只在 brew install 段写了 xattr -cr 指令,DMG 直装路径没写。
用户首次装 Beta 不知道要做这一步,看到"已损坏"以为 release 坏了。

本 PR:
- README.md / README.zh.md 的「下载与安装」段,DMG 直装路径加上 xattr -cr 指令 + 一句解释为什么需要(ad-hoc 签名、未公证)
- release-tauri.yml 的 Create release step 加 body 模板,所有未来 release 自动 prepend 一段:macOS xattr 指令 + Beta/Stable 渠道说明。用 append_body: true 让它放在 generated notes 之前

不在本 PR:配 Apple Developer 证书 + secrets——那是根治方案,需要 $99/年 Apple Developer 账号 + 用户决定要不要走那条路。

Co-authored-by: baiqing <lbx12309@icloud.com>
2026-05-08 09:27:16 +08:00
cooper e331784b3b docs: add Cooper's circular avatar image
- Add assets/people/cooper.png (circular avatar)
- Replace GitHub API link with local image file
- Match TRIP and Chris233's avatar style
2026-05-08 00:34:48 +08:00
cooper 620e817939 docs: make Cooper avatar circular
Add border-radius: 50% to Cooper's avatar to match TRIP and Chris233's circular style.
2026-05-08 00:32:29 +08:00
cooper dd96d64672 docs: add website URLs back to developer section
- Add website URLs below each developer's name
- Increase cell width from 110 to 140 to accommodate URLs
- Keep circular avatars and table layout
2026-05-08 00:27:31 +08:00
cooper 712e9564f2 docs: refine developer section 2026-05-08 00:02:23 +08:00
TRIPandbaiqing fd79a051bd feat(updater): manual-download Beta opt-in in Settings → About (#332)
* feat(updater): manual-download Beta opt-in in Settings → About

接入 PR-B-2:客户端「加入 Beta 渠道」开关 + 文档完善。

为什么不走 auto-update 切 endpoint:
  tauri-plugin-updater 2.10 的 Builder 不暴露 endpoints() 运行时 API,
  endpoints 只能从 tauri.conf.json 编译期读,不能跑时切。继续用 plugin
  自带 check 路径意味着 Beta 包会被推到所有用户,违背「Beta 不溢出正式
  版」原则。fork plugin 或自实现 update flow ~500 行高风险,本轮采用
  最稳路径:plugin 永远只读正式版 manifest(旧文件名),Beta 改成手动
  下载——Settings 里展示最新 prerelease 的下载入口,用户点了跳浏览器。

Rust 端:
- types.rs:UpdateChannel enum (Stable | Beta) + UserPreferences::update_channel
  字段;UserPreferencesWire / Default / Deserialize 全套兼容(旧 prefs 反序列化
  默认 Stable,迁移无痛)。
- commands.rs:三个新命令——
  - get_update_channel:读 prefs
  - set_update_channel:写 prefs,复用 persist_settings + emit prefs:changed
  - fetch_latest_beta_release:reqwest 调 GitHub Releases API,过滤
    prerelease=true 且 tag 以 -beta-tauri 结尾的最新一条
- lib.rs:三个命令注册到 invoke_handler

前端:
- ipc.ts:UpdateChannel + LatestBetaRelease 类型 + 三个 invoke 包装
- SettingsModal.tsx:在 AboutMini 末尾加 BetaChannelControl 组件——
  Toggle 切换渠道;切到 beta 时拉一次最新 prerelease 信息,展示
  「最新 Beta:v1.x.x-beta-tauri」+「前往下载」按钮(openExternal 跳
  GitHub release 页面)+ 重新查询按钮。切回 stable 立即清空状态。
- Settings.tsx:把 Toggle 组件 export,让 SettingsModal 复用同一开关样式。
- i18n 五个 locale(zh-CN / zh-TW / en / ja / ko)的 settings.about 都补
  齐 betaChannel* 9 个 key(zh-CN 是 source of truth)。

文档:
- README.md / README.zh.md:把 PR-A 的「opt-in,尚未接入」占位换成
  manual-download 路径的说明 + tag 约定。
- CLAUDE.md:把「Channel distribution (in progress)」整段重写为已接入
  状态,含 tag 约定 / wiring 位置 / 新加的 release verification checklist
  (5 条,发版后一定要走一遍,含 stable/beta 双向 cross-check 与 endpoint
  采样验证)。

cargo check + npm run build 本地都过。本 PR base = chore/branching-workflow
(PR-A),等 PR-A merge 后会自动指向 beta;与 PR-B-1(CI 端)解耦,可独立
review / merge。

* docs(readme): refresh Status / Architecture / Maintainer release checklist

把 README 的产品/发版描述更新到当前真实状态,配合 PR-A / PR-B 系列工作流改造:

Status (v1.2) 章节:
- 把云端 ASR / 本地 ASR / 润色 provider 三类拆开列;本地 ASR 提及内置
  Qwen3-ASR + Windows Foundry Local Whisper 两条线
- 加翻译热键、QA 浮窗、Beta 渠道 opt-in、Homebrew Cask 分发渠道四个要点
- 把「中英双语」改成多语言 UI(实际已支持 zh-CN / zh-TW / en / ja / ko)

Architecture 顶句:
- 由「Auto-updates ride on Tauri updater plugin」改为双渠道描述:Stable
  (v<v>-tauri) 自动推送,Beta (v<v>-beta-tauri) 手动下载

Maintainer release checklist:
- 完全重写为双渠道流程:通用准备(5 处版本号 + 冒烟 + secrets)+
  Beta 流程(在 beta 分支打 v<v>-beta-tauri tag)+ 正式版流程
  (beta→main 后在 main 打 v<v>-tauri tag)
- 把 CI 的 Verify version sync 5-file 校验显式提一次,避免维护者
  漏改 Cargo.lock / package-lock 撞 CI 红灯
- 末尾引用 CLAUDE.md 的 5 步 release verification checklist

Roadmap 章节保持原样(多个项目实际状态我无法在不引入回归的前提下
确认,避免误判)。

* docs: drop reference to non-existent bump-version.sh; describe manual bump

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
2026-05-07 22:00:33 +08:00
TRIPandbaiqing 2fe5f2e0d4 docs: introduce Beta/Stable two-channel branching workflow (#327)
把分支模型从「dev/main」改造为「Beta/正式版」双渠道,渠道语义直接由分支名承载:

- `beta` — Beta 渠道(开发版),默认分支、集成缓冲区,所有 PR 一律打到这里。
  Beta 包不推送给普通用户,只面向主动加入 Beta 渠道的用户。
- `main` — 正式版渠道(Stable),始终可发布。仅由维护者在双端冒烟测试通过后从
  beta 合入;普通用户默认拿到的就是这条线。

文档与 CI 改动:
- README.md / README.zh.md:把「Contributing workflow / 贡献流程」章节
  改写为 Beta/正式版双渠道,含分支流向 ASCII 图、6 条核心规则,
  以及「Beta 不溢出正式版」的隔离要求。
- CLAUDE.md:把 `### Branch & contribution workflow` 改名为
  `### Branch & release-channel workflow`,明确「分支名=渠道名」。
- ci.yml:触发分支从 [main, dev] 改为 [main, beta]。

仓库侧已同步:
- 远端 dev 分支已重命名为 beta(git push origin dev:beta + 删除旧 dev)
- GitHub 仓库默认分支已切到 beta
- release-tauri.yml 用 v*-tauri tag 触发,跟分支无关,发版机制不受影响

Beta 包的 opt-in 分发机制(per-channel updater endpoint + 设置页开关)
将在独立 PR 中接入。在它落地之前,所有 v*-tauri tag 都按正式版对待,
不要从 beta 分支直接打 tag。

Co-authored-by: baiqing <lbx12309@icloud.com>
2026-05-07 21:55:31 +08:00
kiruno 9543a5a86f feat: Add Homebrew Cask install option (#322)
Adds Casks/openless.rb + update-cask.yml workflow + README install docs. Closes #254 #5.

Note: Formula/openless.rb known to fail in Homebrew sandbox (per author); will be removed in follow-up. Cask version will be bumped to current 1.2.23 in same follow-up.
2026-05-07 20:32:35 +08:00
cooper 188efa7e68 docs: add Cooper to developer list 2026-05-07 19:59:38 +08:00
H-Chris233 13ccf85a48 Store credentials in the system vault
Provider credentials should follow the platform credential-store threat model instead of continuing normal plaintext JSON writes. The vault now stores the existing multi-provider credentials payload in the OS credential store, migrates the legacy JSON file only when the vault has no entry, removes the legacy file after a successful write, and keeps raw credential IPC restricted to the main settings window.

Constraint: User explicitly chose Keychain/Credential Manager/Linux keyring for issue #230.

Constraint: Preserve the existing provider schema and active-provider routing with minimal frontend churn.

Rejected: Keep plaintext JSON as the documented product choice | user rejected this direction.

Rejected: Split every credential field into separate vault entries | larger migration surface and risks losing active provider state.

Confidence: medium

Scope-risk: moderate

Directive: Security-model and storage-strategy choices must be confirmed with the user before implementation.

Tested: cargo check --manifest-path src-tauri/Cargo.toml; npm run -s build; git diff --check; grep for stale plaintext/Keychain fallback wording.

Not-tested: Manual migration on macOS Keychain, Windows Credential Manager, or Linux desktop keyring.

Related: https://github.com/appergb/openless/issues/230
2026-05-06 14:39:40 +08:00
whoa bfc718323b docs(readme): document macOS-only qwen-asr submodule init step 2026-05-06 06:29:38 +08:00
H-Chris233 2861a30cbe Add end-of-file acknowledgements for sponsors, developers, and LinuxDo community
README updates now place a unified acknowledgement section near the end (before License) in both English and Chinese, explicitly thanking sponsors, developers/contributors, and the LinuxDo community with a neutral non-endorsement disclaimer.

Constraint: Keep existing sponsor/developer showcase blocks intact while adding final acknowledgement wording

Rejected: Put acknowledgement near top profile blocks | user requested end-of-file placement

Confidence: high

Scope-risk: narrow

Directive: Keep acknowledgement wording neutral and avoid implying official affiliation

Tested: Manual README markdown diff review

Not-tested: Render preview in GitHub UI
2026-05-04 20:26:11 +08:00
Chris233 d08f649f15 docs(readme): add Chris233 to Developers section 2026-05-03 17:55:00 +08:00
baiqing bba13b1b42 docs(readme): 增加官方网站 openless.top 链接
在中英 README 顶部加官方网站链接,并在描述段落中补一句官网地址,方便读者直达。
2026-05-03 16:37:51 +08:00
baiqing cd41267483 docs(readme): jiangmuran 移到赞助商栏
上一个 PR 把 jiangmuran 错放到了开发者栏。修正:

- 赞助商栏(Sponsors):悠雾云数据 + jiangmuran,圆形头像 48×48
  与悠雾云 logo 高度(48)对齐
- 开发者栏(Developers):仅 TRIP

英文 sponsor → Sponsors(多于一个)。
2026-05-03 15:53:24 +08:00
baiqing 64b9132553 docs(readme): 加开发者栏目(圆形头像)+ 资源整理
- 新增"开发者 / Developers"栏(QQ 群、赞助商之下):
  - TRIP — https://tripmc.top/
  - jiangmuran — https://jiangmuran.com/
- 头像本地化到 assets/people/,每张 200×200 圆形透明 PNG
  (ffmpeg geq alpha mask 抗锯齿;GitHub markdown sanitizer 不支持
  border-radius 内联样式,预处理图片是唯一稳定路径)
- 赞助商栏(悠雾云数据)保留不动
2026-05-03 15:48:50 +08:00