chore(release): merge beta for Stable 1.3.0
This commit is contained in:
commit
ea1b18ee08
52 files changed
+7781
-518
No files matched your search
@@ -300,8 +300,10 @@ jobs:
|
||||
throw "Required WiX object missing: $p — tauri build aborted before candle ran. Check the Build (Windows) step log."
|
||||
}
|
||||
}
|
||||
$light = (Get-ChildItem "$env:LOCALAPPDATA\tauri\WixTools314\light.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).FullName
|
||||
if (-not $light) { throw "WiX light.exe not found in $env:LOCALAPPDATA\tauri\WixTools314" }
|
||||
$light = Get-ChildItem "$env:LOCALAPPDATA\tauri\WixTools*\light.exe" -ErrorAction SilentlyContinue |
|
||||
Sort-Object FullName |
|
||||
Select-Object -Last 1 -ExpandProperty FullName
|
||||
if (-not $light) { throw "WiX light.exe not found under $env:LOCALAPPDATA\tauri\WixTools*" }
|
||||
$version = (Get-Content src-tauri\tauri.conf.json -Raw | ConvertFrom-Json).version
|
||||
$bundleDir = Join-Path $appRoot 'src-tauri\target\release\bundle\msi'
|
||||
New-Item -ItemType Directory -Force -Path $bundleDir | Out-Null
|
||||
|
||||
@@ -25,6 +25,8 @@ promo-openless/
|
||||
promo-openless-v2/
|
||||
docs/old-promo/
|
||||
.worktrees/
|
||||
# 宣传录屏 / 素材根目录(GB 级二进制,绝不入版本库)
|
||||
video-materials/
|
||||
|
||||
# 派生产物(兜底):项目曾出现 promo-openless-v2/node_modules 等遗漏,
|
||||
# 这里全局通配,避免某子目录漏配 .gitignore 时把 build artifact 推进 PR。
|
||||
|
||||
@@ -167,3 +167,27 @@ Windows release 链路修过四颗雷,每一颗的 fix 都是不可合并的
|
||||
2. Register it in `lib.rs` (`mod <name>;`).
|
||||
3. Wire it into `coordinator.rs` and expose any frontend-callable surface via `commands.rs` + `invoke_handler!`.
|
||||
4. Add the matching TS wrapper in `openless-all/app/src/lib/ipc.ts` (with a mock branch for browser dev).
|
||||
|
||||
### Third-party service integrations & library / platform API research
|
||||
|
||||
When implementing features that depend on **anything outside this repo** — external HTTP APIs (ASR providers, polish endpoints, GitHub API), unfamiliar crates / npm packages, platform APIs (Apple Security framework, Win32, CoreFoundation), or any SDK whose surface shifts faster than your training cut-off — do not write integration code from memory. API surfaces drift; model training data is stale by definition. The same workflow below applies whether you are calling an HTTP endpoint, learning a new Rust crate, or wiring a system framework — substitute "endpoint URL" / "function signature" / "feature flag" as appropriate.
|
||||
|
||||
Follow this research-first workflow:
|
||||
|
||||
1. **Analyze before coding.** Identify every external call this feature needs: endpoint URL, HTTP method, authentication mechanism, request body schema, expected response schema, and error codes.
|
||||
2. **Delegate web search to a sub-agent.** Spawn a read-only sub-agent whose sole job is to search for official documentation. The sub-agent runs in parallel — you continue other work instead of blocking on sequential web pages.
|
||||
3. **Filter sub-agent results.** When the sub-agent returns, extract only the information directly relevant to the current implementation. Discard marketing pages, unrelated API versions, or tangential tutorials.
|
||||
4. **Cross-verify one key finding.** Before writing code, validate at least one structural claim (endpoint URL, required header, auth format) with a direct `web_search` or `fetch_url` call. Sub-agents can hallucinate.
|
||||
5. **Implement from verified documentation.** Only write integration code after the above steps. Never guess.
|
||||
|
||||
**Sub-agent search brief:**
|
||||
- Focus each sub-agent on a single external service or protocol — one service, one sub-agent.
|
||||
- Prioritize official documentation domains (e.g., `docs.volcengine.com`, `platform.openai.com/docs`), falling back to the project's GitHub README.
|
||||
- The sub-agent must return **structured** findings: endpoint URL, HTTP method, required headers, request body JSON Schema, response body JSON Schema, and error code meanings.
|
||||
- If the documentation covers multiple API versions, the sub-agent must note which version was referenced.
|
||||
|
||||
**Anti-patterns (do not do these):**
|
||||
- ✗ Writing API integration code from memory without a documentation search.
|
||||
- ✗ Pasting entire web pages into the main agent context — the sub-agent does the filtering.
|
||||
- ✗ Mixing field names or endpoint paths from different API versions.
|
||||
- ✗ Skipping error handling — every external call must degrade gracefully when the service is unavailable.
|
||||
@@ -10,6 +10,10 @@ The active codebase lives at `openless-all/app/` and is **Tauri 2 + Rust backend
|
||||
|
||||
UI must match `openless-all/design_handoff_openless/*.jsx` pixel-for-pixel; the JSX is reference-only, never imported.
|
||||
|
||||
Adjacent docs:
|
||||
- `AGENTS.md` is the parallel of this file for **Codex** sessions; the research-before-coding rules at the bottom of this file delegate to it.
|
||||
- `README.md` / `README.zh.md` (root) are user-facing install + feature guides; `USAGE.md` covers runtime usage. Update them when shipping user-visible features, not for internal refactors.
|
||||
|
||||
## Build, Run, Test
|
||||
|
||||
### Tauri (current — start here)
|
||||
@@ -53,27 +57,48 @@ There is no test runner wired in for the frontend. `src/lib/providerSetup.test.t
|
||||
|
||||
## Architecture
|
||||
|
||||
`coordinator::Coordinator` is the **single owner of session state**. Hotkey edges drive a small phase enum (`Idle → Starting → Listening → Processing`); recorder, ASR, polish, insertion, and history are wired here and nowhere else. Library/module code never calls across modules — they each depend only on shared types.
|
||||
`coordinator::Coordinator` is the **single owner of all session state** — both the dictation phase machine (`Idle → Starting → Listening → Processing → Inserting → Done`) **and** the parallel QA phase machine (`Idle → Recording → Processing`). Hotkey edges drive both. Recorder, ASR, polish, insertion, selection capture, and history are wired here and nowhere else. Leaf modules never call across each other — they each depend only on `types.rs`.
|
||||
|
||||
The coordinator was split into a module: `coordinator.rs` is the public entry; `coordinator/{dictation,qa,resources}.rs` carry per-pipeline logic; `coordinator_state.rs` is the pure (no Tauri / audio / clipboard) state-transition layer that makes phase decisions unit-testable.
|
||||
|
||||
```
|
||||
Rust (openless-all/app/src-tauri/src) Purpose
|
||||
────────────────────────────────────── ────────────────────────────────
|
||||
types.rs Pure value types: DictationSession, PolishMode, HotkeyBinding, errors
|
||||
hotkey.rs Global hotkey monitor (modifier-key edges)
|
||||
recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback
|
||||
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
|
||||
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
|
||||
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
|
||||
persistence.rs History/preferences/vocab JSON + platform credential vault
|
||||
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
|
||||
permissions.rs TCC checks (Accessibility / Microphone)
|
||||
Rust (openless-all/app/src-tauri/src) Purpose
|
||||
────────────────────────────────────────── ────────────────────────────────
|
||||
types.rs Pure value types: sessions, PolishMode, HotkeyBinding, errors, QaChatMessage
|
||||
coordinator.rs Public entry; owns Inner, hotkey wiring, capsule emits
|
||||
coordinator/{dictation,qa,resources}.rs Dictation pipeline / QA pipeline / shared helpers (begin/end/cancel)
|
||||
coordinator_state.rs Pure state transitions — Tauri-free, unit-testable
|
||||
commands.rs + lib.rs + main.rs IPC surface (`invoke_handler!`), tray icon, window plumbing, entry
|
||||
permissions.rs TCC checks (Accessibility / Microphone / AppleEvents)
|
||||
|
||||
— Hotkeys (three parallel monitors) —
|
||||
hotkey.rs Modifier-only hotkey via native CGEventTap (macOS) / rdev (Win/Linux)
|
||||
combo_hotkey.rs Custom-combo dictation hotkey (when user picks combo over modifier-only)
|
||||
qa_hotkey.rs QA toggle hotkey (default Cmd/Ctrl+Shift+;) via `global-hotkey` crate
|
||||
global_hotkey_runtime.rs Shared `global-hotkey` Carbon/Win event runtime (combo + QA share it)
|
||||
shortcut_binding.rs Shared parse/validate of user-configurable bindings
|
||||
|
||||
— Audio / ASR / LLM —
|
||||
recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback
|
||||
audio_mute.rs System-output mute guard while recording (RAII)
|
||||
asr/{mod,frame,volcengine,whisper}.rs + asr/local/* ASR providers: Volcengine streaming WS, Whisper HTTP, Bailian, local Foundry
|
||||
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / Codex OAuth reuse)
|
||||
llm_gemini.rs Native Google Gemini client — NOT OpenAI-compatible (separate auth, thinkingConfig, role:model)
|
||||
correction.rs User-defined correction rules (separate from vocab dictionary)
|
||||
|
||||
— Insertion (two paths) —
|
||||
insertion.rs AX focused-element write → clipboard + paste shortcut → copy-only fallback
|
||||
windows_ime_{ipc,profile,protocol,session}.rs Windows IME-side text injection over IPC (parallel insertion path; activates OpenLess TSF profile and submits text via named pipe)
|
||||
selection.rs Cross-platform selection capture for QA: macOS AX → Cmd/Ctrl+C simulate-copy → Linux PRIMARY (best-effort)
|
||||
|
||||
persistence.rs history.json / preferences.json / dictionary.json + platform credential vault
|
||||
|
||||
Frontend (openless-all/app/src)
|
||||
src/components/Capsule.tsx Capsule view + state enum
|
||||
src/ (React) Main window UI: Overview / History / Vocab / Style / Settings
|
||||
src/i18n/ react-i18next init + zh-CN / en resources
|
||||
src/pages/_atoms.tsx Recoil atoms — global frontend state
|
||||
src/state/HotkeySettingsContext.tsx HotkeySettings React context (capability + binding from backend)
|
||||
src/components/Capsule.tsx Capsule view + state enum
|
||||
src/ (React) Main window UI: Overview / History / Vocab / Style / Settings
|
||||
src/i18n/ react-i18next init + zh-CN / en resources (zh-CN is source of truth)
|
||||
src/pages/_atoms.tsx Recoil atoms — global frontend state
|
||||
src/state/HotkeySettingsContext.tsx HotkeySettings React context (capability + binding from backend)
|
||||
```
|
||||
|
||||
### Dictation pipeline
|
||||
@@ -89,6 +114,32 @@ Invariants:
|
||||
- **`BufferingAudioConsumer`** queues PCM until the WebSocket is ready, then drains. Recorder always pushes to it; ASR is attached after `openSession` resolves.
|
||||
- **Hotkey is toggle-only**, not press-and-hold. The monitor yields one edge per modifier-key keydown; the coordinator interprets odd/even.
|
||||
|
||||
### Q&A pipeline (selection-based ask-the-LLM)
|
||||
|
||||
Parallel state machine, lives in `coordinator/qa.rs` + `qa_hotkey.rs` + `selection.rs`. Default trigger: `Cmd+Shift+;` (macOS) / `Ctrl+Shift+;` (Win/Linux).
|
||||
|
||||
```
|
||||
QA hotkey edge → toggle panel: open → capture front_app, clear messages, show QA window
|
||||
close → cancel session, hide window, sweep capsule
|
||||
Option/dictation edge → routed by panel_visible flag (see below):
|
||||
while panel_visible & dictation Idle → handle_qa_option_edge:
|
||||
QaPhase::Idle → begin_qa_session: capture_selection() → Recorder.start → ASR.openSession
|
||||
QaPhase::Recording → end_qa_session: Recorder.stop → ASR final → LLM (with selection as context) → emit qa:state
|
||||
QaPhase::Processing→ ignored (LLM in flight)
|
||||
otherwise handle_pressed (normal dictation)
|
||||
```
|
||||
|
||||
Invariants & gotchas:
|
||||
- **Hotkey routing.** When the QA panel is visible, the dictation hotkey edge routes to QA — *unless* a dictation session is already mid-flight (`Starting/Listening/Processing/Inserting`), in which case the edge stays with dictation. Otherwise QA's `begin_qa_session` would race for the same mic device (cpal rejects the second `build_input_stream` on macOS/Win, PipeWire opens two streams on Linux — neither is recoverable from the QA panel UI). See audit 3.3.1 in `coordinator/dictation.rs`.
|
||||
- **Capsule sweep on panel open.** Open emits a fresh `CapsuleState::Idle` *only if* dictation is Idle. If dictation is Recording/Polishing/Inserting/Done, the sweep is suppressed so the user's in-flight feedback isn't wiped. See audit 3.3.4.
|
||||
- **Selection capture is a 3-tier fallback** (`selection.rs`): (1) macOS AX `kAXSelectedTextAttribute` direct read, no clipboard touched; (2) macOS/Windows simulate Cmd/Ctrl+C → snapshot + restore original clipboard, 80 ms read window; (3) Linux PRIMARY via `wl-paste` / `xclip` / `xsel`, best-effort. Returns `None` when the user genuinely selected nothing.
|
||||
- **Selection truncation.** Hard cap 4000 chars; over → keep first 2000 + `[…truncated…]` + last 2000. Don't raise this without checking LLM context budgeting — Gemini and Ark have different limits.
|
||||
- **Multi-turn memory.** `QaSessionState.messages` accumulates `user→assistant` pairs across turns within a single panel session; closing the panel clears them.
|
||||
|
||||
### Insertion paths
|
||||
|
||||
`insertion.rs` is the cross-platform default. On Windows there is a **second insertion path** in `windows_ime_{ipc,profile,protocol,session}.rs` that activates a TSF profile (CLSID + GUID baked in `windows_ime_profile.rs`) and submits text over a named-pipe IPC. The coordinator picks one based on user preference / fallback status; both routes return the same `InsertStatus` (`Inserted` / `CopiedFallback`). When changing insertion behavior, decide which path you're touching — they don't share code.
|
||||
|
||||
### Permissions, credentials, on-disk state
|
||||
|
||||
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant.
|
||||
@@ -161,3 +212,55 @@ If any step fails, do not announce the release; investigate `release-tauri.yml`
|
||||
2. Register it in `lib.rs` (`mod <name>;`).
|
||||
3. Wire it into `coordinator.rs` and expose any frontend-callable surface via `commands.rs` + `invoke_handler!`.
|
||||
4. Add the matching TS wrapper in `openless-all/app/src/lib/ipc.ts` (with a mock branch for browser dev).
|
||||
|
||||
## 调研先于编码:派子 agent 查 API / 库 / 平台文档
|
||||
|
||||
**完整规则在 [AGENTS.md `Third-party service integrations & library / platform API research`](AGENTS.md) 段落(line 171-191)。** 这里列的是 Claude Code 入场后用得上的具体工具映射。
|
||||
|
||||
### 触发条件 — 命中任一项都先派子 agent 调研,再下笔
|
||||
|
||||
- 第三方 HTTP API(ASR 厂家 / LLM 端点 / GitHub API / Tauri plugin 服务等)
|
||||
- 不熟的 Rust crate / npm 包:连签名和 feature flag 都不确定时
|
||||
- 平台 API:Apple Security framework / CoreFoundation / Win32 / Carbon / AppKit
|
||||
- 仓库 lock 文件锁着的某版本到底支持什么 — 训练记忆和 `Cargo.lock` / `package-lock.json` 实际版本可能不一致
|
||||
- 任何跟「训练 cutoff 之后才迭代过」相关的接口
|
||||
|
||||
### 不需要派子 agent
|
||||
|
||||
- 仓库代码里已有现成调用 → `rg` / `grep` 找参考即可(仓库即文档)
|
||||
- 通用编程 / 算法 / 自己能推导的语言特性
|
||||
- 单文件 surgical 改动且改动点的 API 已有用例
|
||||
- 查本仓库已有模块(`types.rs` / `coordinator.rs` 等)— 直接 Read
|
||||
|
||||
### 工具优先级
|
||||
|
||||
```text
|
||||
1. Context7 MCP(最高优先 — 主流库覆盖广,version-aware)
|
||||
- mcp__context7__resolve-library-id → 拿 library id
|
||||
- mcp__context7__query-docs → 当前版本的官方文档片段
|
||||
|
||||
2. documentation-lookup skill
|
||||
/skill documentation-lookup —— 包装 Context7,含路由 + 缓存。
|
||||
|
||||
3. Agent 子 agent(subagent_type=general-purpose)
|
||||
场景:Context7 没覆盖(小众 crate / 新 SDK / 非英文文档),
|
||||
或需多源交叉(官方文档 + GitHub README + Stack Overflow)。
|
||||
子 agent 用 WebFetch / WebSearch / Context7 综合,回 200-400 字结构化结果。
|
||||
|
||||
4. 单点兜底:直接 WebFetch 单页文档(只读最权威一篇时)
|
||||
```
|
||||
|
||||
### 子 agent prompt 必备字段
|
||||
|
||||
1. **目标问题**:一句话讲清要解决的具体技术问题(不要"了解一下 X"这种空靶)
|
||||
2. **本仓库现状**:当前 lock 着的版本(`Cargo.lock` / `package-lock.json` 拉一下)+ 现有调用点 `file:line`(若有)
|
||||
3. **必须返回的结构**:函数/端点签名 → 最小可运行示例(≤20 行)→ **版本兼容范围**(vs 训练记忆的核心校验点)→ 已知坑 / 平台差异 / 弃用计划
|
||||
4. **禁令**:不改本仓库代码;不贴文档原文(distill 关键部分,避免上下文撑爆);多个独立服务分别派 agent — 一个服务一个 agent
|
||||
|
||||
### 反例
|
||||
|
||||
- ✗ 凭训练记忆写第三方 API 调用,假定参数签名就这样
|
||||
- ✗ 把整段官方文档 paste 进主上下文
|
||||
- ✗ 先写代码再查文档
|
||||
- ✗ 单子 agent 同时调研 5 个不相关库(每个独立 prompt + 独立上下文)
|
||||
- ✗ 子 agent 返回后跳过 cross-verify 直接写代码 — AGENTS.md 第 4 步要求至少用一次 `WebFetch` 直接命中官方源核对一项关键事实
|
||||
@@ -0,0 +1,700 @@
|
||||
# Audit Validation — 2026-05-10
|
||||
|
||||
Validation of the 21 audit items against the current source tree at
|
||||
`openless-all/app/src-tauri/src/`. Every CONFIRMED finding cites the
|
||||
exact file and line range read from the working copy on 2026-05-10.
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Severity | Status | One-line |
|
||||
|----|----------|--------|----------|
|
||||
| 2.2.1 | — | CONFIRMED | TS `UserPreferences` interface and `mockSettings` both miss `updateChannel` |
|
||||
| 3.1.1 | 严重 | CONFIRMED | `MacHotkeyAdapter` does not override `HotkeyAdapter::shutdown`; `CFRunLoopRun` runs forever |
|
||||
| 3.1.2 | 中 | CONFIRMED | `hotkey_supervisor_loop` is `loop { ... sleep(3s) }` with no exit signal |
|
||||
| 3.1.3 | 中 | CONFIRMED | `start_listener_thread` spawns the listener and drops the `JoinHandle` |
|
||||
| 3.2.1 | 高 | FALSE_POSITIVE | Channel is `std::sync::mpsc::channel()` (unbounded async); `tx.send` does not block |
|
||||
| 3.2.2 | 高 | CONFIRMED | `emit_capsule` calls `window.show()/hide()` from the cpal `process_callback` thread |
|
||||
| 3.2.3 | 中 | CONFIRMED | `inner.inserter.insert(...)` runs sync `arboard`+`enigo` from async `end_session` |
|
||||
| 3.2.4 | 中 | CONFIRMED | `AudioMuteGuard::activate` shells out to `osascript` / `wpctl` / `pactl` synchronously |
|
||||
| 3.2.5 | 低 | CONFIRMED (Linux only) | `probe_input_stream` calls `thread::sleep(120ms)` from the async permission gate |
|
||||
| 3.3.1 | 高 | CONFIRMED | `handle_pressed_edge` routes to QA when `panel_visible=true` regardless of dictation phase |
|
||||
| 3.3.2 | 中 | PARTIAL | Two bridge loops both touch `state` for the same modifier event; non-fatal contention, no integrity bug |
|
||||
| 3.3.3 | 中 | FALSE_POSITIVE | Cancelled doesn't reset coordinator latch, but the OS-side `Shared::trigger_held` already gates auto-repeat |
|
||||
| 3.3.4 | 中 | CONFIRMED | `open_qa_panel` always emits `CapsuleState::Idle`, clobbering any in-flight dictation capsule |
|
||||
| 3.3.5 | 低 | CONFIRMED | `finish_cancel_session_state` skips `focus_target = None` when `phase == Processing` |
|
||||
| 3.3.6 | 低 | FALSE_POSITIVE | `take_current_prepared_windows_ime_session_for_restore` removes the slot on first call; second call is a true no-op |
|
||||
| 3.4.1 | 中 (advisory) | ADVISORY_ONLY | `Inner` carries 16 `Mutex` + 4 `AtomicBool` fields (20 concurrent fields) |
|
||||
| 3.4.2 | 中 (advisory) | ADVISORY_ONLY | 66 of 67 `Ordering` usages in coordinator/hotkey are `SeqCst` |
|
||||
| 3.4.3 | 低 (advisory) | ADVISORY_ONLY | ~102 `unsafe`/`unsafe fn`/`unsafe impl`/`unsafe extern` sites; many lack SAFETY comments |
|
||||
| 3.4.4 | 低 | CONFIRMED | `start_dispatcher` in `global_hotkey_runtime.rs` is `loop {}` with no exit |
|
||||
| 20 (NEW) | — | FALSE_POSITIVE | `read_or_default` already falls back to `UserPreferences::default()` on decode failure; `expect()` only fires on filesystem errors |
|
||||
| 2.3.3 | — | CONFIRMED (no action) | All four backend events (`capsule:state`, `qa:state`, `qa:level`, `vocab:updated`) have matching frontend listeners |
|
||||
|
||||
**Tally**: 11 CONFIRMED · 4 FALSE_POSITIVE · 1 PARTIAL · 3 ADVISORY_ONLY · 1 CONFIRMED-no-action · 1 CONFIRMED-Linux-only
|
||||
|
||||
## Recommended PR groupings
|
||||
|
||||
Group by file to minimize merge conflict risk. Suggested order:
|
||||
|
||||
1. **PR A — `hotkey.rs` lifecycle** (3.1.1, 3.1.3): add `MacHotkeyAdapter::shutdown` (post a synthetic `CFRunLoopStop`/`CFRunLoopWakeUp` from `Drop`) and store the listener `JoinHandle` so panics surface. Same file, same review.
|
||||
2. **PR B — TS type alignment** (2.2.1): add `updateChannel: UpdateChannel` to `src/lib/types.ts` and `mockSettings` in `src/lib/ipc.ts`. One file pair, trivial.
|
||||
3. **PR C — coordinator hotkey supervisor exits** (3.1.2, 3.4.4): add an `AtomicBool` shutdown flag to `hotkey_supervisor_loop` and `global_hotkey_runtime::start_dispatcher`. Same module concern, no overlap with PR A.
|
||||
4. **PR D — async hygiene** (3.2.3, 3.2.4, 3.2.5): wrap `inserter.insert`, `AudioMuteGuard::activate`, and `probe_input_stream` in `tokio::task::spawn_blocking`. Touches `coordinator/dictation.rs`, `coordinator/resources.rs`, and `coordinator.rs` — coordinate with PR E to avoid shared-line conflicts.
|
||||
5. **PR E — QA / dictation routing race** (3.3.1, 3.3.4): make `handle_pressed_edge` consult dictation phase before routing to QA, and skip the `Idle` capsule clobber in `open_qa_panel` when dictation is active. Same file (`coordinator/qa.rs` + `coordinator/dictation.rs`).
|
||||
6. **PR F — capsule UI thread marshaling** (3.2.2): bounce `window.show()/hide()` through `app.run_on_main_thread`; emit-only path can stay (Tauri marshals events internally). Touches `coordinator.rs::emit_capsule`. Independent of PR E.
|
||||
7. **PR G — focus_target leak fix** (3.3.5): in `finish_cancel_session_state`, also clear `focus_target` when the cancelled phase is `Processing`. Pure `coordinator_state.rs` edit.
|
||||
|
||||
Advisory items (3.4.1 / 3.4.2 / 3.4.3) need no PR; they are tracked here for future hardening.
|
||||
|
||||
## Detail per item
|
||||
|
||||
### 2.2.1 — `updateChannel` missing in TS types
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/types.rs:216-219`, `openless-all/app/src/lib/types.ts:118-177`, `openless-all/app/src/lib/ipc.ts:46-79`
|
||||
|
||||
**Evidence (Rust source of truth)**:
|
||||
```rust
|
||||
// types.rs:216-219
|
||||
/// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多
|
||||
/// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。
|
||||
#[serde(default)]
|
||||
pub update_channel: UpdateChannel,
|
||||
```
|
||||
|
||||
**Evidence (TS gap)**: `UserPreferences` ends at `startMinimized: boolean;` (line 176). No `updateChannel` field. `mockSettings` (ipc.ts:46-79) ends at `startMinimized: false,`. No `updateChannel` key.
|
||||
|
||||
**Notes**: Channel state today is read/written via separate `getUpdateChannel` / `setUpdateChannel` IPC commands (`ipc.ts:170-176`), so `getSettings()` still works — the TS shape is just lying about what the Rust backend actually serializes. Setting via `setSettings(prefs)` round-trips through Rust's `UserPreferencesWire` which `#[serde(default)]`-fills the field, so currently no data corruption, but the type is incorrect and any consumer that destructures `UserPreferences` will silently miss the field. Trivial fix.
|
||||
|
||||
---
|
||||
|
||||
### 3.1.1 — `MacHotkeyAdapter::shutdown` empty [严重]
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/hotkey.rs:111` (default), `:301-325` (`MacHotkeyAdapter` impl), `:419-458` (`run_listen_loop`), contrast `:769-776` (Windows shutdown).
|
||||
|
||||
**Evidence (default)**:
|
||||
```rust
|
||||
// hotkey.rs:102-112
|
||||
pub trait HotkeyAdapter: Send + Sync {
|
||||
fn kind(&self) -> HotkeyAdapterKind;
|
||||
fn update_binding(&self, binding: HotkeyBinding);
|
||||
fn update_modifier_shortcuts(...);
|
||||
fn reset_held_state(&self);
|
||||
fn shutdown(&self) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence (mac adapter impl is silent on `shutdown`)**:
|
||||
```rust
|
||||
// hotkey.rs:305-325
|
||||
impl HotkeyAdapter for MacHotkeyAdapter {
|
||||
fn kind(&self) -> HotkeyAdapterKind { ... }
|
||||
fn update_binding(&self, binding: HotkeyBinding) { ... }
|
||||
fn update_modifier_shortcuts(...) { ... }
|
||||
fn reset_held_state(&self) { reset_shared_held_state(&self.shared); }
|
||||
// <-- no shutdown override
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence (no exit path)**:
|
||||
```rust
|
||||
// hotkey.rs:454-457
|
||||
log::info!("[hotkey] CGEventTap 已启动");
|
||||
let _ = status_tx.send(Ok(()));
|
||||
CFRunLoopRun();
|
||||
// CFRunLoopRun never returns absent CFRunLoopStop; the listener thread leaks.
|
||||
```
|
||||
|
||||
**Notes**: `Drop for HotkeyMonitor` does call `self.adapter.shutdown()` (line 170-174), but Mac falls through to the empty default. On every preference-driven monitor swap the old `CFRunLoop` thread + tap leak. Visible on macOS as a steady leak of background threads on long-running sessions that change hotkey bindings.
|
||||
|
||||
**Fix sketch**: Store the `CfRunLoopRef` returned by `CFRunLoopGetCurrent()` (currently captured in `run_listen_loop` only) in the `MacHotkeyAdapter`, plus the `CfMachPortRef`. On `shutdown`, call `CGEventTapEnable(tap, false)` then `CFRunLoopStop(rl)`. Both are FFI-safe to call from any thread. Mirror the Windows pattern (`PostThreadMessageW(thread_id, WM_QUIT)`). Storing those refs needs `CallbackContext` exposed to the adapter, easiest via shared `parking_lot::Mutex`.
|
||||
|
||||
---
|
||||
|
||||
### 3.1.2 — `hotkey_supervisor_loop` no-exit
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator.rs:766-833`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator.rs:766-833
|
||||
fn hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
let mut attempts: u32 = 0;
|
||||
let capability = HotkeyMonitor::capability();
|
||||
loop {
|
||||
let prefs = inner.prefs.get();
|
||||
if inner.hotkey.lock().is_some() { return; }
|
||||
// ... try start, on failure:
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: The only successful exit is when the hotkey is already installed (line 772-774). On error the supervisor keeps spinning. There is no `AtomicBool` / `Sender<()>` shutdown signal exposed for app shutdown. Same pattern repeats in `qa_hotkey_supervisor_loop`, `combo_hotkey_supervisor_loop`, `translation_hotkey_supervisor_loop`, `action_hotkey_supervisor_loop`. Worth a single shared shutdown flag.
|
||||
|
||||
---
|
||||
|
||||
### 3.1.3 — `start_listener_thread` drops `JoinHandle`
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/hotkey.rs:196-229`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// hotkey.rs:218-228
|
||||
let (status_tx, status_rx) = mpsc::channel::<Result<T, HotkeyInstallError>>();
|
||||
std::thread::Builder::new()
|
||||
.name(thread_name.into())
|
||||
.spawn(move || run_listen_loop(thread_shared, tx, status_tx))
|
||||
.map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?;
|
||||
|
||||
match status_rx.recv_timeout(Duration::from_secs(3)) { ... }
|
||||
```
|
||||
|
||||
**Notes**: The `Result<JoinHandle, _>` from `spawn(...)` is `?`'d for the spawn error, but the `JoinHandle` itself is silently dropped (the spawn return value isn't bound). `ListenerThread<T>` only stores `shared` and a single `startup` value. If the listener panics (e.g. `parking_lot::RwLock` poisoning, FFI bug), there is no path for the supervisor to learn about it — the channel just stops receiving. Storing the handle and using `JoinHandle::is_finished()` (Rust 1.61+) or pairing with a "thread alive" `AtomicBool` would let the supervisor restart the listener on panic.
|
||||
|
||||
---
|
||||
|
||||
### 3.2.1 — Blocking `tx.send` in event-tap callback
|
||||
**Status**: FALSE_POSITIVE
|
||||
**Files**: `openless-all/app/src-tauri/src/hotkey.rs:183-187`, `:218`, `coordinator.rs:650`, `:781`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// hotkey.rs:183-187
|
||||
fn send_or_log(tx: &Sender<HotkeyEvent>, evt: HotkeyEvent) {
|
||||
if let Err(e) = tx.send(evt) {
|
||||
log::warn!("[hotkey] 事件发送失败: {e}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// coordinator.rs:650 (and :781)
|
||||
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
|
||||
```
|
||||
|
||||
**Notes**: `std::sync::mpsc::channel()` is the **unbounded asynchronous** variant — `Sender::send` only blocks-and-fails when the receiver has been dropped, in which case it returns `Err(SendError)` immediately. There is no "rendezvous" backpressure. The only way `tx.send` would block long enough to trip `kCGEventTapDisabledByTimeout` is if std mpsc internally allocated under contention, which would be milliseconds at worst, not the seconds-scale macOS uses for the tap-disabled timeout. The audit conflated `mpsc::channel()` with `mpsc::sync_channel(0)` (rendezvous). No fix needed.
|
||||
|
||||
---
|
||||
|
||||
### 3.2.2 — `emit_capsule` runs `show()/hide()` on cpal callback thread
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator/dictation.rs:336-365`, `coordinator.rs:3617-3660`, `recorder.rs:458-490`
|
||||
|
||||
**Evidence (call site is the audio callback)**:
|
||||
```rust
|
||||
// coordinator/dictation.rs:336-365
|
||||
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
|
||||
// ...phase guard, throttle to ~30 Hz...
|
||||
emit_capsule(
|
||||
&inner_for_level,
|
||||
CapsuleState::Recording,
|
||||
level,
|
||||
elapsed,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
});
|
||||
// ...
|
||||
match Recorder::start(microphone_device_name, consumer, level_handler) { ... }
|
||||
```
|
||||
|
||||
**Evidence (callback thread is cpal's `process_callback`)**:
|
||||
```rust
|
||||
// recorder.rs:458-482 (process_callback)
|
||||
fn process_callback(...) {
|
||||
// ...resampling, RMS computation...
|
||||
level_handler(level); // synchronously invoked from cpal audio thread
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence (`emit_capsule` directly touches the window)**:
|
||||
```rust
|
||||
// coordinator.rs:3637-3656
|
||||
if let Some(window) = app.get_webview_window("capsule") {
|
||||
let visible = !matches!(state, CapsuleState::Idle);
|
||||
maybe_position_capsule_bottom_center(inner, &window, payload.translation);
|
||||
if show_capsule && visible {
|
||||
if !show_capsule_window_no_activate(&app, &window) {
|
||||
let _ = window.show();
|
||||
}
|
||||
// ...
|
||||
} else {
|
||||
hide_capsule_window_if_present();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
let _ = app.emit_to("capsule", "capsule:state", payload);
|
||||
```
|
||||
|
||||
**Notes**: `app.emit_to` is fine — Tauri's event bus is thread-safe and marshals to the JS runtime internally. The risk is `window.show() / window.hide()` and the position helper, all of which call NSWindow / HWND APIs that expect the main thread. On macOS this can stall the audio callback (NSWindow ops grab the AppKit run loop), risking `kAudioUnitErr_TooManyFramesToProcess` if the callback misses its deadline. The throttle to ~30 Hz mitigates frequency but doesn't change the per-call risk. Worth bouncing the window-touching half through `app.run_on_main_thread`.
|
||||
|
||||
**Fix sketch**: Split `emit_capsule` into `emit_capsule_event` (just `app.emit_to`, safe everywhere) and `apply_capsule_window_state` (called inside `app.run_on_main_thread` or only from already-main paths). The level-handler path only needs the event; window show/hide already happens in begin/end/cancel which run on the tokio runtime where `run_on_main_thread` is cheap.
|
||||
|
||||
---
|
||||
|
||||
### 3.2.3 — Sync inserter from async `end_session`
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator/dictation.rs:900-913`, `insertion.rs:42-56`, `:74-95`, `:136-149`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator/dictation.rs:900-913
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
inner.inserter.insert(&polished, restore_clipboard)
|
||||
}
|
||||
// ...
|
||||
} else if allow_non_tsf_insertion_fallback {
|
||||
inner.inserter.copy_fallback(&polished)
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// insertion.rs:42-46 (non-macOS impl)
|
||||
pub fn insert(&self, text: &str, restore_clipboard_after_paste: bool) -> InsertStatus {
|
||||
if text.is_empty() { return InsertStatus::CopiedFallback; }
|
||||
insert_with_clipboard_restore(text, restore_clipboard_after_paste)
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: `end_session` is `async`. On Linux (and on macOS too — `simulate_paste()` is FFI-light but still sync), `insert` calls `arboard::Clipboard::new()` which can block on X11/wayland for tens of ms, then `enigo` keystroke synthesis which is also sync. Blocking the tokio worker for 50–200 ms isn't catastrophic but contributes to latency under load. macOS path uses `CGEventPost` via FFI — fast, non-blocking in practice; mostly a Linux/Windows concern.
|
||||
|
||||
**Fix sketch**: Wrap the platform-specific `inserter.insert` / `inserter.copy_fallback` in `tokio::task::spawn_blocking(move || ...).await.unwrap_or(InsertStatus::Failed)`. `TextInserter` is `Sync`, so the move only needs `&self` cloned via `Arc` (already in `Inner`).
|
||||
|
||||
---
|
||||
|
||||
### 3.2.4 — `AudioMuteGuard` shells out synchronously
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/audio_mute.rs:127-152` (macOS), `:202-249` (Linux), `coordinator/resources.rs:112-131`, `coordinator/dictation.rs:369`
|
||||
|
||||
**Evidence (macOS `osascript`)**:
|
||||
```rust
|
||||
// audio_mute.rs:143-151
|
||||
let output = Command::new("osascript")
|
||||
.args(["-e", script])
|
||||
.output()
|
||||
.map_err(|e| format!("set output mute failed: {e}"))?;
|
||||
```
|
||||
|
||||
**Evidence (Linux `wpctl`/`pactl` — same shape)**:
|
||||
```rust
|
||||
// audio_mute.rs:215-223
|
||||
let output = Command::new("wpctl")
|
||||
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", value])
|
||||
.output()
|
||||
.map_err(|e| format!("wpctl set-mute failed: {e}"))?;
|
||||
```
|
||||
|
||||
**Evidence (called from async `begin_session`)**:
|
||||
```rust
|
||||
// coordinator/dictation.rs:369
|
||||
acquire_recording_mute(inner, "dictation");
|
||||
match Recorder::start(microphone_device_name, consumer, level_handler) { ... }
|
||||
```
|
||||
|
||||
```rust
|
||||
// coordinator/resources.rs:117-127
|
||||
if mute.holders == 0 {
|
||||
match crate::audio_mute::AudioMuteGuard::activate() {
|
||||
Ok(guard) => { mute.guard = Some(guard); ... }
|
||||
Err(err) => { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: `osascript` typically takes 100–300 ms to spawn + execute on macOS (AppleScript runtime startup). On a hot-key press → begin_session, this delays the recording start by exactly that amount, on the tokio worker thread. Windows path uses native COM (`IAudioEndpointVolume::SetMute`) which is fast and OK. Linux `wpctl`/`pactl` is similar to macOS osascript in cost.
|
||||
|
||||
**Fix sketch**: Wrap `AudioMuteGuard::activate()` in `tokio::task::spawn_blocking`. Since `acquire_recording_mute` itself is sync and called from `begin_session` (async), the cleanest path is making `acquire_recording_mute` async and `.await`-ing the spawn_blocking. Drop path (`PlatformMuteGuard::restore`) also runs `osascript` and is currently called from `Drop` in `release_recording_mute`; moving that to a detached `tokio::spawn_blocking` is sufficient (release path doesn't need to await).
|
||||
|
||||
---
|
||||
|
||||
### 3.2.5 — `thread::sleep(120ms)` in async permission probe
|
||||
**Status**: CONFIRMED (Linux/non-macOS path only)
|
||||
**Files**: `openless-all/app/src-tauri/src/permissions.rs:323-357`, `coordinator/dictation.rs:137`, `coordinator.rs:1732-1763`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// permissions.rs:343-356
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => build_probe!(f32),
|
||||
// ...
|
||||
}?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
drop(stream);
|
||||
Ok(())
|
||||
```
|
||||
|
||||
**Evidence (called from async)**:
|
||||
```rust
|
||||
// coordinator/dictation.rs:137
|
||||
if let Err(message) = ensure_microphone_permission(inner) { ... }
|
||||
```
|
||||
|
||||
```rust
|
||||
// coordinator.rs:1732-1763
|
||||
fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")] { ... return Ok(()); } // Windows skips probe
|
||||
let status = permissions::check_microphone();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: On macOS `check_microphone` uses `AVAudioApplication` / `AVCaptureDevice` and never reaches `probe_input_stream` (that helper is in the `cfg(not(target_os = "macos"))` module). So this 120 ms blocking sleep only fires on **Linux** (and any other non-macOS, non-Windows path) when probing mic permission. On Linux dictation, every `begin_session` blocks the tokio worker for 120 ms before the recorder even starts.
|
||||
|
||||
**Fix sketch**: `tokio::time::sleep(Duration::from_millis(120)).await` is the correct call but requires turning `probe_input_stream` async. Alternatively keep it sync and wrap the whole `check_microphone()` non-macOS path in `tokio::task::spawn_blocking`. The latter is mechanically simpler.
|
||||
|
||||
---
|
||||
|
||||
### 3.3.1 — Pressed edge routes to QA without checking dictation phase
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator/dictation.rs:11-22`, `coordinator/qa.rs:64-77`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator/dictation.rs:11-22
|
||||
pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>) {
|
||||
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
|
||||
if !was_held {
|
||||
// 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。
|
||||
let panel_visible = inner.qa_state.lock().panel_visible;
|
||||
if panel_visible {
|
||||
handle_qa_option_edge(inner).await;
|
||||
} else {
|
||||
handle_pressed(inner).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence (QA path doesn't stop dictation)**:
|
||||
```rust
|
||||
// coordinator/qa.rs:64-77
|
||||
pub(super) async fn handle_qa_option_edge(inner: &Arc<Inner>) {
|
||||
let phase = inner.qa_state.lock().phase;
|
||||
log::info!("[coord] QA option edge (phase={phase:?})");
|
||||
match phase {
|
||||
QaPhase::Idle => { let _ = begin_qa_session(inner).await; }
|
||||
QaPhase::Recording => { let _ = end_qa_session(inner).await; }
|
||||
QaPhase::Processing => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: `panel_visible` flips true via `open_qa_panel`, which is triggered by the QA hotkey (`Cmd+Shift+;` by default). If the user opens the QA panel mid-dictation (dictation `phase = Listening`, mic open, ASR session live), the next dictation-hotkey press routes into `begin_qa_session`. `begin_qa_session` will call `Recorder::start` again on the same mic device. cpal will reject the second `build_input_stream` on most platforms, but on Linux/PipeWire it sometimes succeeds and you end up with two concurrent capture streams. Even where it fails, the dictation session's recorder is still running and the user has no UX path to stop it from the QA panel.
|
||||
|
||||
**Fix sketch**: In `handle_pressed_edge`, check `inner.state.lock().phase`. If `Listening` or `Starting`, route to `handle_pressed` (which is the dictation toggle path) regardless of `panel_visible`, and either close the QA panel or refuse to open it while dictation is active. Decision belongs to product, but the *technical* race is real.
|
||||
|
||||
---
|
||||
|
||||
### 3.3.2 — Dual TranslationModifier handlers
|
||||
**Status**: PARTIAL
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator.rs:1130-1139`, `:1376-1384`, `:1402-1410`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator.rs:1130-1139 (translation_hotkey_bridge_loop, runs on its own thread)
|
||||
fn translation_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
|
||||
while let Ok(evt) = rx.recv() {
|
||||
if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; }
|
||||
if matches!(evt, ComboHotkeyEvent::Pressed) {
|
||||
mark_translation_modifier_seen(&inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coordinator.rs:1402-1410 (hotkey_bridge_loop, separate thread)
|
||||
HotkeyEvent::TranslationModifierPressed => {
|
||||
let translation_hotkey = inner_cloned.prefs.get().translation_hotkey;
|
||||
if is_builtin_translation_shift(&translation_hotkey)
|
||||
|| crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey)
|
||||
.is_some()
|
||||
{
|
||||
mark_translation_modifier_seen(&inner_cloned);
|
||||
}
|
||||
}
|
||||
|
||||
// coordinator.rs:1376-1384
|
||||
fn mark_translation_modifier_seen(inner: &Arc<Inner>) {
|
||||
let phase = inner.state.lock().phase;
|
||||
if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
|
||||
inner.translation_modifier_seen.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: Both bridge loops run on independent `std::thread`s and both ultimately call `mark_translation_modifier_seen`, which locks `inner.state`. They never run *racing on integrity* — they both write the same `AtomicBool::store(true)`, idempotent. The audit's framing of "Both lock `inner.state` independently" is technically true but not a bug — `state` is a `Mutex`, only one acquires at a time, and both write the same flag. Worst case is one log-line of `[coord] translation modifier seen during ...` printed twice for one Shift press. Not worth a code change unless 3.3.1's fix touches the same code.
|
||||
|
||||
---
|
||||
|
||||
### 3.3.3 — Cancelled doesn't reset `hotkey_trigger_held`
|
||||
**Status**: FALSE_POSITIVE
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator.rs:1399-1401`, `hotkey.rs:530-538`, `coordinator/dictation.rs:11-22`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator.rs:1399-1401
|
||||
HotkeyEvent::Cancelled => {
|
||||
cancel_session(&inner_cloned);
|
||||
}
|
||||
```
|
||||
|
||||
**Why it doesn't actually wedge**: `HotkeyEvent::Cancelled` is emitted by the OS-side hotkey monitor only when the user presses **Esc** (`hotkey.rs:565-570` for macOS, `:867-871` for Windows), not when the dictation trigger key is released. The dictation trigger's "is currently held" state lives in `Shared::trigger_held` inside the platform monitor (`hotkey.rs:117`). That atomic gates re-emission of Pressed via the `is_active && !was_held` check (`hotkey.rs:530-538`). So even if `Inner::hotkey_trigger_held` stays `true` in the coordinator after Esc, the next Pressed edge from the OS will only fire when the user releases and re-presses the trigger key — and the OS path will set `was_held=false` again before sending Pressed. The coordinator's `hotkey_trigger_held` swap on the next Pressed will return `false` (because `handle_released_edge` has run between the previous press and this one, OR the user never released, in which case no new Pressed is queued).
|
||||
|
||||
The audit confused two layers: the OS-edge dedupe in `hotkey.rs::Shared::trigger_held` (which is the gating thing) and the coordinator's `Inner::hotkey_trigger_held` (which is just a bookkeeping latch tied to Pressed/Released edges that already came in). Cancelled doesn't change either's correctness.
|
||||
|
||||
**Notes**: There's a *cosmetic* asymmetry — after Esc, `Inner::hotkey_trigger_held=true` until the user releases the trigger. If the user keeps holding past the Esc, `Released` fires later and resets it. Defensive cleanup would be to also reset on Cancelled, but it doesn't fix any user-visible bug.
|
||||
|
||||
---
|
||||
|
||||
### 3.3.4 — `open_qa_panel` clobbers Done capsule
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator/qa.rs:79-104`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator/qa.rs:79-104
|
||||
pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.panel_visible = true;
|
||||
state.phase = QaPhase::Idle;
|
||||
// ...
|
||||
}
|
||||
// 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars
|
||||
// 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: The comment shows the design intent is *intentional* — sweep stale Done state from a previous dictation. But it sweeps *any* in-flight capsule too. If the user opens the QA panel within the ~1.5 s `CAPSULE_AUTO_HIDE_DELAY_MS` window after dictation finishes, they lose the "已粘贴 N 字" toast. More importantly, if dictation is still in `Polishing` or `Inserting` phase (LLM hasn't returned yet), opening QA hides the polish-progress capsule mid-flight. The user sees their dictation "vanish" until insertion completes.
|
||||
|
||||
**Fix sketch**: Before calling `emit_capsule(Idle, ...)`, check `inner.state.lock().phase`. Only sweep if dictation is in `Idle`. If dictation is mid-flight, leave the capsule visible — QA panel doesn't need the capsule cleared to function. Pairs cleanly with the 3.3.1 fix (same source files).
|
||||
|
||||
---
|
||||
|
||||
### 3.3.5 — `focus_target` leaks on Processing-phase cancel
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator_state.rs:155-173`, `:347-374` (test that already proves the gap)
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator_state.rs:168-173
|
||||
pub(crate) fn finish_cancel_session_state(state: &mut SessionState, decision: CancelDecision) {
|
||||
if decision.phase != SessionPhase::Processing {
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Evidence (existing test acknowledges the gap)**:
|
||||
```rust
|
||||
// coordinator_state.rs:370-372
|
||||
if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) {
|
||||
assert!(state.focus_target.is_none(), "initial={initial:?}");
|
||||
}
|
||||
// Note: no assertion that Processing-phase cancel clears focus_target.
|
||||
```
|
||||
|
||||
**Notes**: When cancel hits `Processing`, `finish_cancel_session_state` deliberately keeps `phase=Processing` (the comment says "交给 end_session 自己收尾"), but it also keeps `focus_target` populated. `end_session` does eventually reset state via the `proceed_to_insert=false` branch (`coordinator/dictation.rs:862-878`) which sets `phase=Idle` but doesn't touch `focus_target`. Net result: stale `focus_target` (a `Vec<PreparedWindowsImeSessionSlot>` index, basically a plain `usize`) lives on into the next `begin_session_state`, which overwrites it (`coordinator_state.rs:80`). So the leak is bounded — next session clobbers it. Real risk is only between cancel and next begin, where `restore_focus_target_if_possible(focus_target)` could pick up a stale value if anyone reads it. Code review didn't surface a reader on that interval, so this is a low-impact correctness gap, not a user-facing bug.
|
||||
|
||||
**Fix sketch**: In `finish_cancel_session_state`, set `state.focus_target = None` unconditionally (before the phase check). The Processing branch's existing semantic — "let `end_session` collapse to Idle" — doesn't depend on `focus_target` staying set.
|
||||
|
||||
---
|
||||
|
||||
### 3.3.6 — Double-restore of prepared Windows IME session
|
||||
**Status**: FALSE_POSITIVE
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator.rs:1594-1633`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// coordinator.rs:1594-1602
|
||||
fn take_matching_prepared_windows_ime_session(
|
||||
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
|
||||
session_id: SessionId,
|
||||
) -> Option<PreparedWindowsImeSession> {
|
||||
let index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.session_id == session_id)?;
|
||||
Some(slots.remove(index).prepared)
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// coordinator.rs:1620-1633
|
||||
fn restore_prepared_windows_ime_session(inner: &Arc<Inner>, session_id: SessionId) {
|
||||
let state = inner.state.lock();
|
||||
let prepared = {
|
||||
let mut slot = inner.prepared_windows_ime_session.lock();
|
||||
take_current_prepared_windows_ime_session_for_restore(
|
||||
&mut slot, session_id, state.session_id,
|
||||
)
|
||||
};
|
||||
if let Some(prepared) = prepared { inner.windows_ime.restore_session(prepared); }
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: First call to `restore_prepared_windows_ime_session` for a given `session_id` does `slots.remove(index)` regardless of the freshness check on `current_session_id`. The slot is gone after that. Second call's `slots.iter().position(...)` returns `None`, the `?` short-circuits, the function silently no-ops. So even if `cancel_session → end_session` (or vice versa) both invoke `restore_prepared_windows_ime_session` with the same `session_id`, the IME state is restored at most once. The audit's worry is unfounded.
|
||||
|
||||
---
|
||||
|
||||
### 3.4.1 — Inner has many lock fields
|
||||
**Status**: ADVISORY_ONLY
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator.rs:91-141`
|
||||
|
||||
**Inventory**: 16 `Mutex<...>` + 4 `AtomicBool` (excluding `Arc<...>` indirection counts) plus an `Arc<Mutex<Vec<...>>>` for the windows IME slot vector. Specifically:
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `app` | `Mutex<Option<AppHandle>>` |
|
||||
| `state` | `Mutex<SessionState>` |
|
||||
| `asr` | `Mutex<Option<SessionResource<ActiveAsr>>>` |
|
||||
| `recorder` | `Mutex<Option<SessionResource<Recorder>>>` |
|
||||
| `recording_mute` | `Mutex<SharedRecordingMuteState>` |
|
||||
| `hotkey` | `Mutex<Option<HotkeyMonitor>>` |
|
||||
| `hotkey_status` | `Mutex<HotkeyStatus>` |
|
||||
| `hotkey_trigger_held` | `AtomicBool` |
|
||||
| `shortcut_recording_active` | `AtomicBool` |
|
||||
| `combo_hotkey` | `Mutex<Option<ComboHotkeyMonitor>>` |
|
||||
| `translation_hotkey` | `Mutex<Option<ComboHotkeyMonitor>>` |
|
||||
| `switch_style_hotkey` | `Mutex<Option<ComboHotkeyMonitor>>` |
|
||||
| `open_app_hotkey` | `Mutex<Option<ComboHotkeyMonitor>>` |
|
||||
| `translation_modifier_seen` | `AtomicBool` |
|
||||
| `qa_hotkey` | `Mutex<Option<QaHotkeyMonitor>>` |
|
||||
| `qa_state` | `Mutex<QaSessionState>` |
|
||||
| `capsule_layout` | `Mutex<Option<CapsuleLayoutState>>` |
|
||||
| `qa_asr` | `Mutex<Option<Arc<VolcengineStreamingASR>>>` |
|
||||
| `qa_recorder` | `Mutex<Option<Recorder>>` |
|
||||
| `qa_stream_cancelled` | `Arc<AtomicBool>` (one of two AtomicBools-in-Arc) |
|
||||
| `prepared_windows_ime_session` (windows-only) | `Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>` |
|
||||
|
||||
No deadlock pattern was found in the read paths — most call sites take one lock at a time. `mark_translation_modifier_seen` and `cancel_session` both touch `inner.state`, but in disjoint critical sections. Documenting only.
|
||||
|
||||
---
|
||||
|
||||
### 3.4.2 — Heavy `Ordering::SeqCst` use
|
||||
**Status**: ADVISORY_ONLY
|
||||
**Files**: `coordinator.rs`, `coordinator/*.rs`, `hotkey.rs`
|
||||
|
||||
**Evidence**: `grep -rn "Ordering::SeqCst" coordinator.rs coordinator/ hotkey.rs | wc -l` → 66. Total `Ordering::*` uses in those files: 67 (one `Relaxed` in `recorder.rs::process_callback`, 66 SeqCst).
|
||||
|
||||
**Notes**: Most uses are simple set/load on independent `AtomicBool`s where `Ordering::Relaxed` would suffice. A few that gate cross-thread visibility (`hotkey_trigger_held` swap in `handle_pressed_edge` synchronizing with the audio thread reading session state) might justify Acquire/Release. `SeqCst` is correct everywhere — just over-strong. Not a bug.
|
||||
|
||||
---
|
||||
|
||||
### 3.4.3 — Many `unsafe` blocks, audit SAFETY comments
|
||||
**Status**: ADVISORY_ONLY
|
||||
**Files**: cross-tree (predominantly `hotkey.rs`, `insertion.rs`, `windows_ime_*.rs`, `permissions.rs`)
|
||||
|
||||
**Evidence**: `grep -rn "unsafe " src/ --include="*.rs" | grep -E "unsafe \{|unsafe fn|unsafe impl|unsafe extern"` → 102 sites.
|
||||
|
||||
**Notes**: Almost all are platform FFI (CoreFoundation/CoreGraphics on macOS, win32 on Windows, msg_send! on macOS objc2). Sample inspected (`insertion.rs::send_text` near line 340 and `post_cmd_v` near line 420) — function-level invariants are documented at module level, but inline `// SAFETY:` comments are sparse. Same for `hotkey.rs::run_listen_loop` which leaks `Box::into_raw` for FFI context — a `// SAFETY: ctx is dropped only inside the listener after CFRunLoopRun returns; reentrancy guarded by ...` comment would help. Documentation-grade improvement, no soundness bug detected.
|
||||
|
||||
---
|
||||
|
||||
### 3.4.4 — Global hotkey dispatcher loop has no exit
|
||||
**Status**: CONFIRMED
|
||||
**Files**: `openless-all/app/src-tauri/src/global_hotkey_runtime.rs:94-107`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
// global_hotkey_runtime.rs:94-107
|
||||
fn start_dispatcher(runtime: Arc<GlobalHotkeyRuntime>) {
|
||||
std::thread::Builder::new()
|
||||
.name("openless-global-hotkey-dispatch".into())
|
||||
.spawn(move || {
|
||||
let receiver = GlobalHotKeyEvent::receiver();
|
||||
loop {
|
||||
match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => runtime.dispatch(event),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn global hotkey dispatcher");
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**: `GlobalHotKeyEvent::receiver()` is process-global from the upstream `global-hotkey` crate. The 250 ms timeout means the loop wakes regularly but never checks an exit flag. On app shutdown the thread leaks; harmless for a single-instance app but trips `tokio::test` and any future `Drop`-based teardown (e.g. integration tests that spin coordinator up/down).
|
||||
|
||||
**Fix sketch**: Pair with `Inner` shutdown flag added in 3.1.2 fix, or use a `parking_lot::RwLock<Option<...>>` "dispatcher alive" gate inside `GlobalHotkeyRuntime` that the loop reads each iteration. Same shape as the Windows hotkey thread's `WM_QUIT` plumbing.
|
||||
|
||||
---
|
||||
|
||||
### 20 (NEW) — `PreferencesStore::new().expect(...)` panics on bad prefs
|
||||
**Status**: FALSE_POSITIVE
|
||||
**Files**: `openless-all/app/src-tauri/src/coordinator.rs:169`, `:210`, `persistence.rs:790-811`, `:146-156`, `types.rs:368-422`
|
||||
|
||||
**Evidence (deserialization fallback already exists)**:
|
||||
```rust
|
||||
// persistence.rs:790-811
|
||||
impl PreferencesStore {
|
||||
pub fn new() -> Result<Self> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
let path = dir.join(PREFERENCES_FILE);
|
||||
let prefs = if path.exists() {
|
||||
read_or_default::<UserPreferences>(&path).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"[prefs] load {} failed, using defaults: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
UserPreferences::default()
|
||||
})
|
||||
} else {
|
||||
UserPreferences::default()
|
||||
};
|
||||
Ok(Self { path, state: Mutex::new(prefs), })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// persistence.rs:146-156 (read_or_default)
|
||||
fn read_or_default<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Result<T> {
|
||||
if !path.exists() { return Ok(T::default()); }
|
||||
let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?;
|
||||
if bytes.is_empty() { return Ok(T::default()); }
|
||||
serde_json::from_slice::<T>(&bytes)
|
||||
.with_context(|| format!("decode failed: {}", path.display()))
|
||||
}
|
||||
```
|
||||
|
||||
**Why the audit was wrong**: The custom `Deserialize for UserPreferences` (types.rs:368-422) does call `default_dictation_hotkey_from_legacy(...).map_err(serde::de::Error::custom)?` which can return a serde error for `trigger == Custom` without `customComboHotkey`. That error bubbles through `serde_json::from_slice::<UserPreferences>` to `read_or_default`, which propagates it as `Result::Err`. But `PreferencesStore::new` then catches it at `.unwrap_or_else(|e| { log::warn!(...); UserPreferences::default() })`. So the `expect("preferences store init")` at coordinator.rs:169 only fires if `data_dir()?` or `ensure_dir(&dir)?` fails — i.e. the OS-level Application Support directory cannot be created/accessed, which is a legitimate fail-fast condition (no preferences-file storage, no point continuing).
|
||||
|
||||
The audit conflated "deserialization fails" with "PreferencesStore::new returns Err". In the current code those are different.
|
||||
|
||||
**Notes**: No fix needed. The "bad prefs file" case is already handled silently (log + default). If you want belt-and-braces against panic on the truly-impossible filesystem failure, you can add a final `.unwrap_or_else` that logs and returns a fully-default in-memory store, but that's defensive coding for a case where the user's machine is so broken that Application Support is unwritable.
|
||||
|
||||
---
|
||||
|
||||
### 2.3.3 — Event-name alignment between backend emit and frontend listen
|
||||
**Status**: CONFIRMED (no action)
|
||||
**Files**: `coordinator.rs:3659`, `coordinator/qa.rs:94-101`, `coordinator/dictation.rs:929-931`, `src/components/Capsule.tsx:293`, `src/pages/QaPanel.tsx:55,116`, `src/pages/Vocab.tsx:51`
|
||||
|
||||
**Notes**: Backend emits `capsule:state`, `qa:state`, `qa:level`, `vocab:updated`. Frontend listens to all four under matching names (Capsule, QaPanel, Vocab respectively). No mismatch. As stated in the audit prompt — already verified, retained here for completeness.
|
||||
|
||||
## Files referenced
|
||||
|
||||
- `openless-all/app/src-tauri/src/types.rs` (lines 57, 200-525, especially 216-219, 277-325, 368-422)
|
||||
- `openless-all/app/src-tauri/src/hotkey.rs` (lines 1-250, 280-572, 698-870, 1126-1190)
|
||||
- `openless-all/app/src-tauri/src/coordinator.rs` (lines 91-141, 156-313, 640-833, 837-940, 1130-1139, 1376-1419, 1582-1636, 3617-3700)
|
||||
- `openless-all/app/src-tauri/src/coordinator/dictation.rs` (lines 1-160, 320-410, 810-1050)
|
||||
- `openless-all/app/src-tauri/src/coordinator/qa.rs` (full file, 1-124)
|
||||
- `openless-all/app/src-tauri/src/coordinator/resources.rs` (lines 1-160)
|
||||
- `openless-all/app/src-tauri/src/coordinator_state.rs` (full file, 1-485)
|
||||
- `openless-all/app/src-tauri/src/global_hotkey_runtime.rs` (full file, 1-107)
|
||||
- `openless-all/app/src-tauri/src/audio_mute.rs` (lines 1-263)
|
||||
- `openless-all/app/src-tauri/src/permissions.rs` (lines 200-360)
|
||||
- `openless-all/app/src-tauri/src/insertion.rs` (lines 1-150, 300-450)
|
||||
- `openless-all/app/src-tauri/src/persistence.rs` (lines 146-156, 785-811)
|
||||
- `openless-all/app/src-tauri/src/recorder.rs` (lines 28-490)
|
||||
- `openless-all/app/src-tauri/src/commands.rs` (lines 975-1000)
|
||||
- `openless-all/app/src/lib/types.ts` (lines 118-275)
|
||||
- `openless-all/app/src/lib/ipc.ts` (lines 40-176)
|
||||
- `openless-all/app/src/components/Capsule.tsx` (line 293)
|
||||
- `openless-all/app/src/pages/QaPanel.tsx` (lines 55, 116-120)
|
||||
- `openless-all/app/src/pages/Vocab.tsx` (line 51)
|
||||
@@ -0,0 +1,159 @@
|
||||
# OpenLess beta — Logic Review (commit 400097ad)
|
||||
|
||||
Audit branch: `origin/beta` @ `400097ad` (= "Merge PR #391 fix/audit-async-hygiene").
|
||||
Sources: `openless-all/app/src-tauri/src/{lib.rs, coordinator.rs, coordinator/{dictation,qa,resources}.rs, coordinator_state.rs, hotkey.rs, audio_mute.rs}`, `openless-all/app/src/lib/{ipc.ts,types.ts}`.
|
||||
|
||||
> Caller's premise correction: the prompt asserts "PR #389 (emit_capsule main thread) is pending merge but the same change is already cherry-picked-equivalent on the current code." This is **false** at `400097ad`. Commits `faf02ad4` and `84ee3d96` exist on a side branch but are NOT ancestors of `400097ad` (verified with `git merge-base --is-ancestor`). The audio-thread → AppKit/Win32 SIGTRAP risk that PR #389 was written to fix is therefore **still live on this beta**. See P4.
|
||||
|
||||
## Summary
|
||||
|
||||
| Path | Verdict | Issues found |
|
||||
|------|---------|--------------|
|
||||
| P1 startup | OK | All 6 listeners paired (start in `setup`/`Ready`, stop in `Exit`); tray watcher signaled. |
|
||||
| P2 press/release | OK with 1 ⚠️ | Routing + dedup correct; `acquire_recording_mute` correctly awaited in dictation start. |
|
||||
| P3 end-of-session | 1 🚩 | `cancel_session` during Processing leaves `focus_target` set until next `begin_session` overwrites it. PR #387 contract is incomplete for the Processing branch. |
|
||||
| P4 capsule UI emit | 1 🚩 (CRITICAL) | `emit_capsule` calls `window.show/hide` + `show_capsule_window_no_activate` directly from the cpal audio callback at ~30 Hz on `400097ad`. PR #389 is **not** merged here. |
|
||||
| P5 shutdown | 1 🚩 | `acquire_recording_mute` at QA path (`coordinator.rs:2313`) is missing `.await` — return value is a dropped Future. PR #391 hygiene fix is incomplete. Compiler emits `unused_must_use`. |
|
||||
|
||||
Net: **3 real bugs (🚩)**, 1 cross-PR composition concern, plus minor smells. Two of the three are direct consequences of merge-incomplete state of PR #389/#391. The Processing-cancel `focus_target` leak is a code-path PR #387 missed.
|
||||
|
||||
## Findings (per path)
|
||||
|
||||
### P1 — Startup
|
||||
|
||||
- OK `lib.rs:316-358` — `RunEvent::Exit` stops all 6 hotkey listeners + signals tray watcher; matches the 6 starts at `lib.rs:226` (dictation, in `setup`) and `lib.rs:320-325` (QA / combo / translation / switch_style / open_app, in `RunEvent::Ready`).
|
||||
- OK `coordinator.rs:344-358, 371-373, 383-385, 395-397, 407-409, 1313-1347` — every `stop_*_listener` for `global-hotkey`-backed monitors marshals the `Drop` to `app.run_on_main_thread`, matching the issue #169 contract for Carbon `RemoveEventHotKey`. `take_combo_hotkey_on_main_thread` / `take_translation_hotkey_on_main_thread` / `take_action_hotkey_on_main_thread` are the helpers; `stop_qa_hotkey_listener` inlines the same pattern.
|
||||
- OK `coordinator.rs:330-332` + `hotkey.rs:344-355` — dictation `stop_hotkey_listener` is a plain `inner.hotkey.lock().take()`, but the Drop chain is `HotkeyMonitor::drop` → `MacHotkeyAdapter::shutdown` → `CGEventTapEnable(false)` + `CFRunLoopStop(rl)`. PR #388 fix in place; the comment at `hotkey.rs:311-314` correctly notes both APIs are documented thread-safe, so the lack of main-thread marshalling here is intentional.
|
||||
- ℹ️ `coordinator.rs:316-318` + `global_hotkey_runtime.rs:60-63` — `request_shutdown` is `#[allow(dead_code)]` and never set in production; supervisor loops poll it but nothing flips it (matches PR #392's "passive infrastructure" promise).
|
||||
- ℹ️ `global_hotkey_runtime.rs:19, 41-55` — `GlobalHotKeyManager` lives in `OnceCell<Arc<...>>` and is therefore never Dropped in production. On macOS this means the Carbon event handler is reaped only at process exit. Acceptable but worth noting if anyone tries to add hot-restart later.
|
||||
|
||||
### P2 — Press/release
|
||||
|
||||
- OK `coordinator/dictation.rs:11-28` — `handle_pressed_edge` swap-dedups via `inner.hotkey_trigger_held.swap(true, SeqCst)`; routing checks `panel_visible && !dictation_active` (PR #390 fix). The `dictation_active = !matches!(phase, SessionPhase::Idle)` snapshot is the correct guard: it lets a hotkey press during an in-flight dictation flow fall through to `handle_pressed`, even when the QA panel happens to be visible.
|
||||
- OK `coordinator/dictation.rs:53-67` — `handle_released_edge` symmetric: `panel_visible && !dictation_active → return`. If dictation_active was true when pressed (so it routed to `handle_pressed`), released will also bypass the QA short-circuit and reach `handle_released`. No mismatched-edge leak.
|
||||
- OK `coordinator/dictation.rs:30-51, 69-85` — Hold/Toggle phase matrix correct: `(Toggle, Idle) → begin`, `(Toggle, Listening) → end`, `(Toggle, Starting) → request_stop_during_starting`, `(Hold, Idle) → begin`, `(Hold, Listening released) → end`, `(Hold, Starting released) → request_stop_during_starting`. Other combinations no-op.
|
||||
- OK `coordinator/dictation.rs:87-96` + `coordinator_state.rs:87-93` — `request_stop_during_starting_state` only flips `pending_stop` when phase is exactly `Starting`; `finish_starting_session_state` consumes the bit at the Listening transition (`coordinator_state.rs:118-124`) and triggers immediate `end_session` via `BeginOutcome::PendingStop` at `dictation.rs:587-590`.
|
||||
- OK `coordinator/dictation.rs:451` — `acquire_recording_mute(inner, "dictation").await;` is properly awaited (PR #391 fix applied to dictation path).
|
||||
- ⚠️ `coordinator/dictation.rs:418-447` (level_handler) — runs on the cpal audio callback thread and calls `emit_capsule` synchronously. See P4 for the concrete bug; flagged here because P2's recorder-start path is the producer.
|
||||
|
||||
### P3 — End-of-session pipeline
|
||||
|
||||
- OK `coordinator/dictation.rs:595-602` + `coordinator_state.rs:178-184` — `start_processing_if_listening` only transitions `Listening → Processing`; if phase is anything else (`Idle`, `Starting`, `Inserting`, already-Processing), `end_session` returns Ok(()) immediately. Guards against stale pending_stop or duplicate IPC.
|
||||
- OK `coordinator/dictation.rs:607-620` — recorder + ASR are taken with session-id matching (`take_recorder_for_session` / `take_asr_for_session`), so a stale callback that lands after a session has been re-bumped won't pick up the wrong recorder.
|
||||
- OK `coordinator/dictation.rs:984-1005` — atomic Inserting transition: same `state.lock()` checks `cancelled` and flips phase to `Inserting`. Once `Inserting`, `cancel_session` rejects (`coordinator_state.rs:155-159` — `Idle | Inserting` → `None`). This is the audit HIGH #2 contract.
|
||||
- OK `coordinator/dictation.rs:1007-1031` — `paste_shortcut = prefs.paste_shortcut` flows into `inner.inserter.insert(&polished, restore_clipboard, paste_shortcut)` for non-Windows and into `insert_with_windows_ime_first(..., paste_shortcut, ime_target)` for Windows. PR #377 wiring confirmed; corresponding signature in `coordinator.rs:1673-1680, 1731-1745` and `insertion.rs:43-89`.
|
||||
- OK `coordinator/dictation.rs:1122-1126` — happy-path end_session clears `state.focus_target = None` before scheduling capsule idle.
|
||||
|
||||
- 🚩 `coordinator/dictation.rs:843-849` + `coordinator/dictation.rs:1153-1178` + `coordinator_state.rs:171-176` — **`focus_target` is not cleared when cancel hits during `Processing`.**
|
||||
- `cancel_session` in the `Processing` branch (`dictation.rs:1171-1173`) deliberately does NOT call `finish_cancel_session_state`, leaving phase + focus_target as-is so `end_session` can finish unwinding.
|
||||
- `end_session`'s "ASR-finished, cancelled" exit (`dictation.rs:845-849`) restores Windows IME, sets `phase = Idle`, returns Ok(()) — but never touches `focus_target`.
|
||||
- PR #387 (`ce82fcd9`) was framed as "clear `focus_target` on cancel regardless of phase", but the only code path that gained the unconditional clear is `finish_cancel_session_state` at `coordinator_state.rs:172`, which the Processing branch skips.
|
||||
- Concrete consequence: between cancel-mid-Processing and the next `begin_session`, the cached AX `focus_target` (a stale `usize` slot) is reachable by anyone reading `state.focus_target` (logs, debug dumps, future readers). It's overwritten by `begin_session_state` at `coordinator_state.rs:80`, so user-visible insertion uses the right value. Severity: minor leak / contract violation rather than user-visible breakage. Tests at `coordinator_state.rs:362-385` only validate the cancel happy paths via `finish_cancel_session_state`, so the regression slipped past PR #387's guard test.
|
||||
|
||||
- ⚠️ `coordinator/dictation.rs:1175` — even when cancel fires during `Processing`, the user immediately sees `CapsuleState::Cancelled`, but `end_session` may still be inside the ASR await for several seconds. The phase is still `Processing` until `end_session` reaches the `state.cancelled` check, so a fast retry-press will be quietly dropped (`begin_session_state` requires `Idle`). Not a bug per se — matches the design comment at `dictation.rs:1169-1174` — but worth a UX note.
|
||||
|
||||
- ⚠️ `coordinator/dictation.rs:1153-1163` — `cancel_session` swallows the result of `begin_cancel_session_state` for `Inserting`, only logging "cancel ignored". Acceptable, but there's no UI signal back to the user that their Esc didn't take. Minor.
|
||||
|
||||
### P4 — Capsule UI emission
|
||||
|
||||
- 🚩🚩 **`coordinator.rs:3684-3727`** — **`emit_capsule` does NOT marshal `window.show/hide` to the main thread on `400097ad`.** Verbatim from current source:
|
||||
|
||||
```rust
|
||||
fn emit_capsule(...) {
|
||||
...
|
||||
if let Some(window) = app.get_webview_window("capsule") {
|
||||
...
|
||||
let visible = !matches!(state, CapsuleState::Idle);
|
||||
maybe_position_capsule_bottom_center(inner, &window, payload.translation);
|
||||
if show_capsule && visible {
|
||||
if !show_capsule_window_no_activate(&app, &window) {
|
||||
let _ = window.show();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::restore_main_window_key_if_active(&app);
|
||||
} else {
|
||||
hide_capsule_window_if_present();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
let _ = app.emit_to("capsule", "capsule:state", payload);
|
||||
}
|
||||
```
|
||||
|
||||
This is the *pre-PR-#389* shape. PR #389's fix (`faf02ad4`, then `84ee3d96`) wraps the `if let Some(window)` block in `app.run_on_main_thread(move || { ... })`. Verified that neither commit is an ancestor of `400097ad`:
|
||||
|
||||
```
|
||||
$ git merge-base --is-ancestor faf02ad4 400097ad ; echo $? → 1 (NOT ancestor)
|
||||
$ git merge-base --is-ancestor 84ee3d96 400097ad ; echo $? → 1 (NOT ancestor)
|
||||
```
|
||||
|
||||
Reproduction reasoning: `coordinator/dictation.rs:418-447` builds `level_handler` as `Arc<dyn Fn(f32) + Send + Sync>` and hands it to `Recorder::start`. cpal calls it from the audio process callback thread; the handler then calls `emit_capsule(...)` (line 439-446). On macOS, `WebviewWindow::show()` / `hide()` and the `show_capsule_window_no_activate` (which calls `NSWindow.orderFrontRegardless`) hit AppKit assertions (`dispatch_assert_queue_fail` → SIGTRAP) when invoked off the main thread. The 33 ms throttle at `dictation.rs:417, 426-432` only limits frequency — every individual call is at the same thread-safety risk.
|
||||
|
||||
The same risk applies to QA's level_handler at `coordinator.rs:2282-2309`, which also calls `emit_capsule` directly from the cpal callback (line 2301-2308).
|
||||
|
||||
Severity: high. SIGTRAP would crash the app on long recordings; less catastrophic outcomes are stuttering audio (the audio callback misses its deadline waiting for AppKit) and `kAudioUnitErr_TooManyFramesToProcess`. PR #389 needs to land or be cherry-picked before this beta is shipped.
|
||||
|
||||
- OK `coordinator.rs:3726` — `app.emit_to("capsule", "capsule:state", payload)` stays on the calling thread; Tauri's event bus is internally thread-safe. No change needed regardless of PR #389.
|
||||
- OK `coordinator.rs:3739-3765` — `maybe_position_capsule_bottom_center` is the OS-level call inside the `if let Some(window)` block; it would be moved into the same `run_on_main_thread` closure once PR #389 lands.
|
||||
|
||||
### P5 — App shutdown
|
||||
|
||||
- OK `lib.rs:347-355` (RunEvent::Exit) — calls `stop_hotkey_listener` (Mac CGEventTap path, safe to invoke any thread per `hotkey.rs:344-355`), plus the 5 `global-hotkey`-backed `stop_*_listener`s that all marshal via `app.run_on_main_thread` (`coordinator.rs:344-358, 1313-1347`).
|
||||
- OK `lib.rs:348` — `TRAY_MICROPHONE_WATCHER_STOPPING.store(true, Relaxed)` correctly signals the watcher loop spawned at `lib.rs:540-548`.
|
||||
- ⚠️ `coordinator.rs:344-358, 1313-1347` — `app.run_on_main_thread` is fire-and-forget; the queued `Drop` may not run before the process exits. In practice this is fine (process exit reaps everything), but if Tauri's main-loop teardown beats the queued closure, Carbon `RemoveEventHotKey` is skipped. Same model as the pre-existing PR #169 fix for `qa_hotkey`, so flagging as an inherited limitation, not a regression.
|
||||
|
||||
- 🚩 **`coordinator.rs:2313` — `acquire_recording_mute(inner, "qa");` is missing `.await`.**
|
||||
- PR #391 (`6171df61`) made `acquire_recording_mute` `async fn` (`coordinator/resources.rs:122`) and updated the dictation call site to `.await` (`coordinator/dictation.rs:451`). The QA call site was missed.
|
||||
- Effect: the function returns an `impl Future` that is dropped on the next line. `spawn_blocking` is never scheduled, so `mute.holders` doesn't increment, the system audio mute is never engaged for QA, and the `[audio-mute] acquired by qa` log is never written. The corresponding `release_recording_mute(inner, "qa")` calls (e.g. `resources.rs:194`, `coordinator.rs:2324`) decrement holders that were never incremented (early `return` at `resources.rs:174` because `holders == 0`).
|
||||
- Compiler confirms it: `cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml` emits
|
||||
|
||||
```
|
||||
warning: unused implementer of `futures_util::Future` that must be used
|
||||
--> src/coordinator.rs:2313:5
|
||||
2313 | acquire_recording_mute(inner, "qa");
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
= note: futures do nothing unless you `.await` or poll them
|
||||
```
|
||||
|
||||
- User-visible consequence: when the user opted into "Mute system output during recording" and triggers QA via Option, system audio is NOT muted (e.g. a YouTube playback continues). Dictation behaves correctly. Fix: insert `.await` on the call.
|
||||
|
||||
- OK `coordinator/resources.rs:184-188` — `release_recording_mute` falls back to synchronous `work()` when no tokio handle is present, so the recorder error monitor (a plain `std::thread::spawn`) can release safely. Drop of `AudioMuteGuard` shells out to `osascript` / `wpctl`, blocking on a std thread is OK.
|
||||
|
||||
## Cross-PR composition risks
|
||||
|
||||
1. **PR #391 + #389 incomplete merge.** The current `400097ad` contains PR #391 (which added the async hygiene the rest of the audit fixes assume) but is missing PR #389. Net result: the audio-thread → AppKit risk PR #389 fixes is still present *and* the QA codepath has a half-applied PR #391 (missing `.await` at `coordinator.rs:2313`). Both must land before this commit becomes a release candidate.
|
||||
2. **PR #387 + Processing branch interaction.** PR #387 introduced `state.focus_target = None` inside `finish_cancel_session_state`, but `cancel_session` deliberately skips that helper for `Processing` (`dictation.rs:1171-1173`) so the scheduled `end_session` can drive its own teardown. `end_session`'s cancelled-after-ASR exit (`dictation.rs:845-849`) was not updated to clear `focus_target`. The contract "clear focus_target on cancel regardless of phase" is therefore violated for cancel-during-Processing. Fix can be either (a) clear `focus_target` in the cancel-after-ASR branch of `end_session`, or (b) move the clear into `cancel_session` even for Processing (does not interfere with `end_session`'s own writes).
|
||||
3. **PR #390 + multi-bridge `hotkey_trigger_held`.** The dedup atomic is process-global. With both `hotkey` and `combo_hotkey` monitors running, the legacy modifier-only adapter and the custom-combo adapter share `inner.hotkey_trigger_held`. Currently exclusive (custom-combo only runs when trigger == Custom — see `coordinator.rs:413-426`), so no cross-contamination, but anything that allows them to coexist would corrupt the dedup. ℹ️ informational.
|
||||
4. **PR #392 (passive flag) is dormant**, as the prompt indicates. No interaction risk; calling out only that supervisor loops gracefully ignore `shutdown=false` so future RunEvent::Exit hookup is safe to land in a follow-up PR.
|
||||
|
||||
## Manual-verification checklist for the user
|
||||
|
||||
After cherry-picking PR #389 + fixing the missing `.await` in P5, verify on a running build:
|
||||
|
||||
**P4 — capsule main-thread (PR #389 confirmation)**
|
||||
- [ ] macOS arm64 build, dev profile, run a 3-minute continuous toggle dictation. App must not crash with SIGTRAP / `dispatch_assert_queue_fail`. Tail `~/Library/Logs/OpenLess/openless.log` while recording.
|
||||
- [ ] On macOS, capsule still appears once the first PCM frame is captured (50–200 ms after Recorder::start) and disappears 1.5 s after Done/Cancelled/Error.
|
||||
- [ ] On Windows, no SendMessage deadlock against the GUI thread during recording start/stop (capsule transitions complete within ~50 ms of phase changes).
|
||||
|
||||
**P5 — QA mute fix**
|
||||
- [ ] Set `prefs.muteDuringRecording = true` (Settings → Recording).
|
||||
- [ ] Play YouTube in Safari/Chrome.
|
||||
- [ ] Open QA panel via `Cmd+Shift+;`. Press Right Option to start QA recording. Audio playback **must** mute. Log line `[audio-mute] acquired by qa; holders=1` must appear in `openless.log`. (Without the fix, no log line + audio keeps playing.)
|
||||
- [ ] Release Right Option. Audio playback resumes; log line `[audio-mute] released by qa; holders=0` + `system output mute restored after recording` must appear.
|
||||
|
||||
**P3 — focus_target on Processing-cancel (PR #387 completeness)**
|
||||
- [ ] Start dictation, speak briefly, release hotkey to enter Processing. While ASR is awaiting result (within ~1 s window), press Esc to trigger `cancel_dictation`. End_session bails at `cancelled` check.
|
||||
- [ ] Inspect debug logs / state dump (or add a temporary log at `dictation.rs:849`): `state.focus_target` should be `None`. With current `400097ad` it remains `Some(...)`.
|
||||
- [ ] Start a fresh dictation in a different window. Insertion should still target the new window — this works today via `begin_session_state` overwrite (`coordinator_state.rs:80`), so the bug is silent until something else reads stale `focus_target` between the cancel and the next begin.
|
||||
|
||||
**P2 — pressed/released routing (PR #390 confirmation)**
|
||||
- [ ] Open QA panel (`Cmd+Shift+;`). Confirm Option starts QA recording.
|
||||
- [ ] Close QA panel. Start a normal dictation (Option). While dictation is running, open QA panel via `Cmd+Shift+;` (panel becomes visible while dictation_active=true). Press and release Option once — dictation must end normally (insert text), QA must NOT capture this Option edge.
|
||||
- [ ] Hold mode: Set HotkeyMode::Hold. Hold Option for 2 s, release. End_session must trigger on release (not on press).
|
||||
- [ ] Toggle mode: Set HotkeyMode::Toggle. Tap Option twice rapidly during Starting phase (within the 50–200 ms cpal init window) — verify `request_stop_during_starting` queues then end_session fires on the Listening transition (search log for `applying pending_stop edge → end_session immediately`).
|
||||
|
||||
**P1 — listener teardown sanity**
|
||||
- [ ] Quit the app via tray menu. `openless.log` should show ordered `stop_*_listener` calls; no panic / SIGTRAP at exit.
|
||||
- [ ] Relaunch and grant Accessibility (after prior reset). Confirm `[hotkey] CGEventTap 已启动` returns within 3 s; first hotkey press still works without app restart.
|
||||
|
||||
If any of the P4/P5 checks fail, this beta is **not** build-quality.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.2.24-5",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.2.24-5",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.2.24-6",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -93,9 +93,16 @@ function Find-VsDevCmd {
|
||||
}
|
||||
|
||||
function Find-WixTool($Name) {
|
||||
$tauriWixTool = Join-Path $env:LOCALAPPDATA "tauri\WixTools314\$Name"
|
||||
if (Test-Path $tauriWixTool) {
|
||||
return (Resolve-Path $tauriWixTool).Path
|
||||
$tauriWixRoot = Join-Path $env:LOCALAPPDATA "tauri"
|
||||
if (Test-Path $tauriWixRoot) {
|
||||
$tauriWixTools = Get-ChildItem -LiteralPath $tauriWixRoot -Directory -Filter "WixTools*" -ErrorAction SilentlyContinue |
|
||||
Sort-Object @{ Expression = { if ($_.Name -match '^WixTools(\d+)$') { [int]$Matches[1] } else { -1 } }; Descending = $true }, @{ Expression = "Name"; Descending = $true }
|
||||
foreach ($toolDir in $tauriWixTools) {
|
||||
$tauriWixTool = Join-Path $toolDir.FullName $Name
|
||||
if (Test-Path $tauriWixTool) {
|
||||
return (Resolve-Path $tauriWixTool).Path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
@@ -103,7 +110,7 @@ function Find-WixTool($Name) {
|
||||
return $cmd.Source
|
||||
}
|
||||
|
||||
throw "$Name not found. Run the Tauri MSI build once so the WiX tools are installed."
|
||||
throw "$Name not found. Run the Tauri MSI build once so a WiX tools directory is installed under $tauriWixRoot."
|
||||
}
|
||||
|
||||
function Get-PackageVersion {
|
||||
|
||||
@@ -65,6 +65,10 @@ for (const fragment of requiredFragments) {
|
||||
assert.match(script, /\[switch\]\$SkipRustInstall/, "script should support opting out of Rust installation");
|
||||
assert.match(script, /\[switch\]\$SkipNpmCi/, "script should support reusing existing node_modules");
|
||||
assert.match(script, /\[switch\]\$CleanArtifacts/, "script should support cleaning the output directory");
|
||||
assert.doesNotMatch(script, /WixTools314/, "MSVC packaging must not hard-code a single Tauri WiX tools version");
|
||||
assert.doesNotMatch(ciWorkflow, /WixTools314/, "CI MSI repair must not hard-code a single Tauri WiX tools version");
|
||||
assert.match(script, /-Filter "WixTools\*"/, "MSVC packaging should discover Tauri WiX tools by WixTools* glob");
|
||||
assert.match(ciWorkflow, /WixTools\*\\light\.exe/, "CI MSI repair should discover Tauri WiX tools by WixTools* glob");
|
||||
|
||||
assert.match(imeBuild, /\[string\]\$OutputDirectory/, "IME build should support a package-specific output directory");
|
||||
assert.match(imeBuild, /\[string\]\$IntermediateDirectory/, "IME build should support a package-specific intermediate directory");
|
||||
@@ -120,11 +124,10 @@ assert.match(wixFragment, /UnregisterOpenLessImeX86/, "MSI should unregister x86
|
||||
assert.match(nsisHook, /NSIS_HOOK_PREINSTALL/, "NSIS should copy IME DLLs before install completes");
|
||||
assert.match(nsisHook, /NSIS_HOOK_POSTINSTALL/, "NSIS should register IME DLLs after files are installed");
|
||||
assert.match(nsisHook, /NSIS_HOOK_PREUNINSTALL/, "NSIS should unregister IME DLLs before uninstall removes them");
|
||||
assert.match(nsisHook, /\$%OPENLESS_IME_DLL_X64%/, "NSIS should consume the CI-built x64 IME DLL");
|
||||
assert.match(nsisHook, /\$%OPENLESS_IME_DLL_X86%/, "NSIS should consume the CI-built x86 IME DLL");
|
||||
assert.match(nsisHook, /SetOutPath "\$INSTDIR\\windows-ime\\x64"/, "NSIS should install the x64 IME DLL beside the app");
|
||||
assert.match(nsisHook, /SetOutPath "\$INSTDIR\\windows-ime\\x86"/, "NSIS should install the x86 IME DLL beside the app");
|
||||
assert.match(nsisHook, /File \/oname=OpenLessIme\.dll/, "NSIS should embed OpenLessIme.dll in the installer");
|
||||
assert.match(nsisHook, /OPENLESS_IME_STAGE_AND_REPLACE "x64" "OPENLESS_IME_DLL_X64"/, "NSIS should consume the CI-built x64 IME DLL");
|
||||
assert.match(nsisHook, /OPENLESS_IME_STAGE_AND_REPLACE "x86" "OPENLESS_IME_DLL_X86"/, "NSIS should consume the CI-built x86 IME DLL");
|
||||
assert.match(nsisHook, /SetOutPath "\$INSTDIR\\windows-ime\\\$\{PLATFORM_DIR\}"/, "NSIS should install the IME DLL beside the app by platform");
|
||||
assert.match(nsisHook, /File \/oname=OpenLessIme\.dll\.new "\$%\$\{ENV_VAR\}%"/, "NSIS should embed OpenLessIme.dll in the installer");
|
||||
assert.match(nsisHook, /Sysnative\\regsvr32\.exe/, "NSIS should use 64-bit regsvr32 for the x64 IME");
|
||||
assert.match(nsisHook, /SysWOW64\\regsvr32\.exe/, "NSIS should use 32-bit regsvr32 for the x86 IME");
|
||||
assert.match(nsisHook, /System32\\regsvr32\.exe[\s\S]*windows-ime\\x86\\OpenLessIme\.dll/, "NSIS should use System32 regsvr32 for the x86 IME on 32-bit Windows");
|
||||
|
||||
Generated
+1
-1
@@ -3751,7 +3751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.2.24-6"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.2.24-6"
|
||||
version = "1.3.0"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
@@ -29,7 +29,7 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
|
||||
futures-util = "0.3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream", "system-proxy"] }
|
||||
zip = "2"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"core:event:default",
|
||||
"shell:allow-open",
|
||||
"updater:default",
|
||||
"autostart:default"
|
||||
"autostart:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
//! Alibaba Cloud Bailian / DashScope realtime ASR client.
|
||||
//!
|
||||
//! Uses the classic DashScope realtime recognition WebSocket protocol
|
||||
//! (`/api-ws/v1/inference`) because it accepts raw 16 kHz mono PCM frames and
|
||||
//! matches OpenLess' recorder output directly. The Qwen OpenAI Realtime line is
|
||||
//! a different protocol and is intentionally left for a follow-up provider.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use parking_lot::Mutex as ParkingMutex;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::header::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{AudioConsumer, RawTranscript};
|
||||
|
||||
pub const PROVIDER_ID: &str = "bailian";
|
||||
pub const DEFAULT_ENDPOINT: &str = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/";
|
||||
pub const DEFAULT_MODEL: &str = "fun-asr-realtime";
|
||||
|
||||
/// 100 ms of 16 kHz / 16-bit / mono PCM.
|
||||
pub const TARGET_AUDIO_CHUNK_BYTES: usize = 3_200;
|
||||
const BYTES_PER_MS: u64 = 32;
|
||||
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
type WsSink = futures_util::stream::SplitSink<WsStream, Message>;
|
||||
type SharedWriter = Arc<AsyncMutex<Option<WsSink>>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BailianCredentials {
|
||||
pub api_key: String,
|
||||
pub endpoint: String,
|
||||
pub model: String,
|
||||
pub vocabulary_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BailianCredentials {
|
||||
pub fn normalized_endpoint(&self) -> String {
|
||||
if self.endpoint.trim().is_empty() {
|
||||
return DEFAULT_ENDPOINT.to_string();
|
||||
}
|
||||
self.endpoint.trim().to_string()
|
||||
}
|
||||
|
||||
pub fn normalized_model(&self) -> String {
|
||||
let model = self.model.trim();
|
||||
if model.is_empty() {
|
||||
DEFAULT_MODEL.to_string()
|
||||
} else {
|
||||
model.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BailianASRError {
|
||||
#[error("credentials missing")]
|
||||
CredentialsMissing,
|
||||
#[error("connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
#[error("send failed: {0}")]
|
||||
SendFailed(String),
|
||||
#[error("task failed: {0}")]
|
||||
TaskFailed(String),
|
||||
#[error("no final result")]
|
||||
NoFinalResult,
|
||||
#[error("final result timed out")]
|
||||
FinalResultTimeout,
|
||||
}
|
||||
|
||||
enum SendItem {
|
||||
Audio(Vec<u8>),
|
||||
Finish(oneshot::Sender<Result<(), BailianASRError>>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SyncState {
|
||||
task_id: String,
|
||||
pending_audio: Vec<u8>,
|
||||
audio_scratch: Vec<u8>,
|
||||
bytes_received: u64,
|
||||
task_started: bool,
|
||||
task_finished: bool,
|
||||
runtime: Option<Handle>,
|
||||
start: Option<Instant>,
|
||||
final_tx: Option<oneshot::Sender<Result<RawTranscript, BailianASRError>>>,
|
||||
send_tx: Option<mpsc::UnboundedSender<SendItem>>,
|
||||
final_segments: Vec<String>,
|
||||
last_result_text: String,
|
||||
}
|
||||
|
||||
pub struct BailianRealtimeASR {
|
||||
credentials: BailianCredentials,
|
||||
state: ParkingMutex<SyncState>,
|
||||
writer: SharedWriter,
|
||||
final_rx: ParkingMutex<Option<oneshot::Receiver<Result<RawTranscript, BailianASRError>>>>,
|
||||
task_started: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl BailianRealtimeASR {
|
||||
pub fn new(credentials: BailianCredentials) -> Self {
|
||||
Self {
|
||||
credentials,
|
||||
state: ParkingMutex::new(SyncState::default()),
|
||||
writer: Arc::new(AsyncMutex::new(None)),
|
||||
final_rx: ParkingMutex::new(None),
|
||||
task_started: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_session(self: &Arc<Self>) -> Result<(), BailianASRError> {
|
||||
if self.credentials.api_key.trim().is_empty() {
|
||||
return Err(BailianASRError::CredentialsMissing);
|
||||
}
|
||||
|
||||
let task_id = Uuid::new_v4().simple().to_string();
|
||||
let endpoint = self.credentials.normalized_endpoint();
|
||||
let mut request = endpoint
|
||||
.into_client_request()
|
||||
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?;
|
||||
request.headers_mut().insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("bearer {}", self.credentials.api_key.trim()))
|
||||
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?,
|
||||
);
|
||||
|
||||
let (ws, _resp) = connect_async(request)
|
||||
.await
|
||||
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?;
|
||||
let (write, read) = ws.split();
|
||||
*self.writer.lock().await = Some(write);
|
||||
|
||||
let (final_tx, final_rx) = oneshot::channel();
|
||||
let (send_tx, mut send_rx) = mpsc::unbounded_channel::<SendItem>();
|
||||
{
|
||||
let mut st = self.state.lock();
|
||||
*st = SyncState::default();
|
||||
st.task_id = task_id.clone();
|
||||
st.runtime = Some(Handle::current());
|
||||
st.start = Some(Instant::now());
|
||||
st.final_tx = Some(final_tx);
|
||||
st.send_tx = Some(send_tx);
|
||||
}
|
||||
*self.final_rx.lock() = Some(final_rx);
|
||||
|
||||
let writer_for_worker = Arc::clone(&self.writer);
|
||||
let task_id_for_worker = task_id.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(item) = send_rx.recv().await {
|
||||
match item {
|
||||
SendItem::Audio(chunk) => {
|
||||
if let Err(e) = send_binary(&writer_for_worker, chunk).await {
|
||||
log::error!("[bailian-asr] audio frame send failed: {e}");
|
||||
}
|
||||
}
|
||||
SendItem::Finish(done) => {
|
||||
let result =
|
||||
send_text(&writer_for_worker, finish_task_message(&task_id_for_worker))
|
||||
.await
|
||||
.map_err(|e| BailianASRError::SendFailed(e.to_string()));
|
||||
let _ = done.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
send_text(
|
||||
&self.writer,
|
||||
run_task_message(
|
||||
&task_id,
|
||||
&self.credentials.normalized_model(),
|
||||
self.credentials.vocabulary_id.as_deref(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let weak_self = Arc::downgrade(self);
|
||||
tokio::spawn(async move {
|
||||
let mut read = read;
|
||||
while let Some(msg) = read.next().await {
|
||||
let Some(this) = weak_self.upgrade() else {
|
||||
break;
|
||||
};
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if !this.handle_text_message(&text) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
this.finish_with_partial_or_error(BailianASRError::NoFinalResult);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("[bailian-asr] receive loop error: {e}");
|
||||
this.finish_with_partial_or_error(BailianASRError::ConnectionFailed(
|
||||
e.to_string(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_last_frame(&self) -> Result<(), BailianASRError> {
|
||||
let started = self.task_started.notified();
|
||||
tokio::pin!(started);
|
||||
started.as_mut().enable();
|
||||
let ready = {
|
||||
let st = self.state.lock();
|
||||
st.task_started || st.task_finished
|
||||
};
|
||||
if !ready {
|
||||
tokio::time::timeout(Duration::from_secs(5), started)
|
||||
.await
|
||||
.map_err(|_| BailianASRError::FinalResultTimeout)?;
|
||||
}
|
||||
let (send_tx, tail_chunks) = {
|
||||
let mut st = self.state.lock();
|
||||
let send_tx = st.send_tx.clone();
|
||||
if !st.pending_audio.is_empty() {
|
||||
let pending = std::mem::take(&mut st.pending_audio);
|
||||
st.audio_scratch.extend_from_slice(&pending);
|
||||
}
|
||||
let tail = if st.audio_scratch.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![std::mem::take(&mut st.audio_scratch)]
|
||||
};
|
||||
(send_tx, tail)
|
||||
};
|
||||
let Some(send_tx) = send_tx else {
|
||||
return Ok(());
|
||||
};
|
||||
for chunk in tail_chunks {
|
||||
let _ = send_tx.send(SendItem::Audio(chunk));
|
||||
}
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
send_tx
|
||||
.send(SendItem::Finish(done_tx))
|
||||
.map_err(|_| BailianASRError::SendFailed("send worker closed".to_string()))?;
|
||||
done_rx
|
||||
.await
|
||||
.map_err(|_| BailianASRError::SendFailed("finish ack dropped".to_string()))?
|
||||
}
|
||||
|
||||
pub async fn await_final_result(&self) -> Result<RawTranscript, BailianASRError> {
|
||||
let rx = self.final_rx.lock().take();
|
||||
let Some(rx) = rx else {
|
||||
return Err(BailianASRError::NoFinalResult);
|
||||
};
|
||||
tokio::time::timeout(FINAL_RESULT_TIMEOUT, rx)
|
||||
.await
|
||||
.map_err(|_| BailianASRError::FinalResultTimeout)?
|
||||
.map_err(|_| BailianASRError::NoFinalResult)?
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
let mut st = self.state.lock();
|
||||
st.pending_audio.clear();
|
||||
st.audio_scratch.clear();
|
||||
st.send_tx.take();
|
||||
st.final_tx.take();
|
||||
st.task_finished = true;
|
||||
drop(st);
|
||||
let writer = Arc::clone(&self.writer);
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let _ = close_writer(&writer).await;
|
||||
});
|
||||
} else {
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(rt) = tokio::runtime::Runtime::new() {
|
||||
rt.block_on(async move {
|
||||
let _ = close_writer(&writer).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_text_message(&self, text: &str) -> bool {
|
||||
let value: Value = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("[bailian-asr] invalid json event: {e}");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
let event = value
|
||||
.get("header")
|
||||
.and_then(|h| h.get("event"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match event {
|
||||
"task-started" => {
|
||||
self.mark_task_started();
|
||||
true
|
||||
}
|
||||
"result-generated" => {
|
||||
self.record_result(&value);
|
||||
true
|
||||
}
|
||||
"task-finished" => {
|
||||
self.finish_success();
|
||||
false
|
||||
}
|
||||
"task-failed" => {
|
||||
let message = value
|
||||
.get("header")
|
||||
.and_then(|h| h.get("error_message"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("task failed")
|
||||
.to_string();
|
||||
self.finish_error(BailianASRError::TaskFailed(message));
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_task_started(&self) {
|
||||
let (send_tx, chunks) = {
|
||||
let mut st = self.state.lock();
|
||||
st.task_started = true;
|
||||
if !st.pending_audio.is_empty() {
|
||||
let pending = std::mem::take(&mut st.pending_audio);
|
||||
st.audio_scratch.extend_from_slice(&pending);
|
||||
}
|
||||
let send_tx = st.send_tx.clone();
|
||||
let chunks = drain_audio_chunks(&mut st.audio_scratch);
|
||||
(send_tx, chunks)
|
||||
};
|
||||
if let Some(tx) = send_tx {
|
||||
for chunk in chunks {
|
||||
let _ = tx.send(SendItem::Audio(chunk));
|
||||
}
|
||||
}
|
||||
self.task_started.notify_waiters();
|
||||
}
|
||||
|
||||
fn record_result(&self, value: &Value) {
|
||||
let sentence = value
|
||||
.get("payload")
|
||||
.and_then(|p| p.get("output"))
|
||||
.and_then(|o| o.get("sentence"));
|
||||
let Some(sentence) = sentence else {
|
||||
return;
|
||||
};
|
||||
let Some(text) = sentence.get("text").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let is_sentence_final = sentence.get("end_time").is_some();
|
||||
let mut st = self.state.lock();
|
||||
st.last_result_text = trimmed.to_string();
|
||||
if is_sentence_final && st.final_segments.last().map(|s| s.as_str()) != Some(trimmed) {
|
||||
st.final_segments.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_success(&self) {
|
||||
let (tx, text, duration_ms) = {
|
||||
let mut st = self.state.lock();
|
||||
if st.task_finished {
|
||||
return;
|
||||
}
|
||||
st.task_finished = true;
|
||||
st.send_tx.take();
|
||||
let text = if st.final_segments.is_empty() {
|
||||
st.last_result_text.clone()
|
||||
} else {
|
||||
st.final_segments.join("")
|
||||
};
|
||||
let duration_ms = if st.bytes_received > 0 {
|
||||
st.bytes_received / BYTES_PER_MS
|
||||
} else {
|
||||
st.start
|
||||
.map(|start| start.elapsed().as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
(st.final_tx.take(), text, duration_ms)
|
||||
};
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(Ok(RawTranscript { text, duration_ms }));
|
||||
}
|
||||
self.close_on_runtime();
|
||||
}
|
||||
|
||||
fn finish_with_partial_or_error(&self, error: BailianASRError) {
|
||||
let has_partial = {
|
||||
let st = self.state.lock();
|
||||
!st.last_result_text.trim().is_empty() || !st.final_segments.is_empty()
|
||||
};
|
||||
if has_partial {
|
||||
// 与 Volcengine 保持一致:连接异常但已有 partial 时优先兜底返回,避免丢失用户已识别出的内容。
|
||||
self.finish_success();
|
||||
} else {
|
||||
self.finish_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_error(&self, error: BailianASRError) {
|
||||
let tx = {
|
||||
let mut st = self.state.lock();
|
||||
if st.task_finished {
|
||||
return;
|
||||
}
|
||||
st.task_finished = true;
|
||||
st.send_tx.take();
|
||||
st.final_tx.take()
|
||||
};
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(Err(error));
|
||||
}
|
||||
self.close_on_runtime();
|
||||
}
|
||||
|
||||
fn close_on_runtime(&self) {
|
||||
let writer = Arc::clone(&self.writer);
|
||||
if let Some(handle) = self.state.lock().runtime.clone() {
|
||||
handle.spawn(async move {
|
||||
let _ = close_writer(&writer).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioConsumer for BailianRealtimeASR {
|
||||
fn consume_pcm_chunk(&self, pcm: &[u8]) {
|
||||
if pcm.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (send_tx, chunks) = {
|
||||
let mut st = self.state.lock();
|
||||
st.bytes_received = st.bytes_received.saturating_add(pcm.len() as u64);
|
||||
if !st.task_started {
|
||||
st.pending_audio.extend_from_slice(pcm);
|
||||
return;
|
||||
}
|
||||
st.audio_scratch.extend_from_slice(pcm);
|
||||
let chunks = drain_audio_chunks(&mut st.audio_scratch);
|
||||
(st.send_tx.clone(), chunks)
|
||||
};
|
||||
if let Some(tx) = send_tx {
|
||||
for chunk in chunks {
|
||||
let _ = tx.send(SendItem::Audio(chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_audio_chunks(buffer: &mut Vec<u8>) -> Vec<Vec<u8>> {
|
||||
let mut chunks = Vec::new();
|
||||
while buffer.len() >= TARGET_AUDIO_CHUNK_BYTES {
|
||||
chunks.push(buffer.drain(..TARGET_AUDIO_CHUNK_BYTES).collect());
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
fn run_task_message(task_id: &str, model: &str, vocabulary_id: Option<&str>) -> String {
|
||||
let mut parameters = json!({
|
||||
"sample_rate": 16000,
|
||||
"format": "pcm"
|
||||
});
|
||||
if let Some(vocabulary_id) = vocabulary_id.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
parameters["vocabulary_id"] = Value::String(vocabulary_id.to_string());
|
||||
}
|
||||
|
||||
json!({
|
||||
"header": {
|
||||
"action": "run-task",
|
||||
"task_id": task_id,
|
||||
"streaming": "duplex"
|
||||
},
|
||||
"payload": {
|
||||
"task_group": "audio",
|
||||
"task": "asr",
|
||||
"function": "recognition",
|
||||
"model": model,
|
||||
"parameters": parameters,
|
||||
"input": {}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn finish_task_message(task_id: &str) -> String {
|
||||
json!({
|
||||
"header": {
|
||||
"action": "finish-task",
|
||||
"task_id": task_id,
|
||||
"streaming": "duplex"
|
||||
},
|
||||
"payload": { "input": {} }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn send_text(writer: &SharedWriter, text: String) -> Result<(), BailianASRError> {
|
||||
let mut guard = writer.lock().await;
|
||||
let Some(ws) = guard.as_mut() else {
|
||||
return Err(BailianASRError::ConnectionFailed(
|
||||
"websocket writer not available".to_string(),
|
||||
));
|
||||
};
|
||||
ws.send(Message::Text(text))
|
||||
.await
|
||||
.map_err(|e| BailianASRError::SendFailed(e.to_string()))
|
||||
}
|
||||
|
||||
async fn send_binary(writer: &SharedWriter, data: Vec<u8>) -> Result<(), BailianASRError> {
|
||||
let mut guard = writer.lock().await;
|
||||
let Some(ws) = guard.as_mut() else {
|
||||
return Err(BailianASRError::ConnectionFailed(
|
||||
"websocket writer not available".to_string(),
|
||||
));
|
||||
};
|
||||
ws.send(Message::Binary(data))
|
||||
.await
|
||||
.map_err(|e| BailianASRError::SendFailed(e.to_string()))
|
||||
}
|
||||
|
||||
async fn close_writer(writer: &SharedWriter) -> Result<(), BailianASRError> {
|
||||
let mut guard = writer.lock().await;
|
||||
if let Some(mut ws) = guard.take() {
|
||||
let _ = ws.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credentials_apply_default_endpoint_and_model() {
|
||||
let creds = BailianCredentials {
|
||||
api_key: "sk-test".to_string(),
|
||||
endpoint: "".to_string(),
|
||||
model: "".to_string(),
|
||||
vocabulary_id: None,
|
||||
};
|
||||
assert_eq!(creds.normalized_endpoint(), DEFAULT_ENDPOINT);
|
||||
assert_eq!(creds.normalized_model(), DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_task_message_uses_pcm_16k() {
|
||||
let value: Value =
|
||||
serde_json::from_str(&run_task_message("abc", DEFAULT_MODEL, None)).unwrap();
|
||||
assert_eq!(value["header"]["action"], "run-task");
|
||||
assert_eq!(value["payload"]["model"], DEFAULT_MODEL);
|
||||
assert_eq!(value["payload"]["parameters"]["sample_rate"], 16000);
|
||||
assert_eq!(value["payload"]["parameters"]["format"], "pcm");
|
||||
assert!(value["payload"]["parameters"]["vocabulary_id"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_task_message_includes_optional_vocabulary_id() {
|
||||
let value: Value =
|
||||
serde_json::from_str(&run_task_message("abc", DEFAULT_MODEL, Some(" vocab-123 ")))
|
||||
.unwrap();
|
||||
assert_eq!(value["payload"]["parameters"]["vocabulary_id"], "vocab-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_audio_chunks_keeps_tail_buffered() {
|
||||
let mut buffer = vec![1u8; TARGET_AUDIO_CHUNK_BYTES * 2 + 17];
|
||||
let chunks = drain_audio_chunks(&mut buffer);
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[0].len(), TARGET_AUDIO_CHUNK_BYTES);
|
||||
assert_eq!(chunks[1].len(), TARGET_AUDIO_CHUNK_BYTES);
|
||||
assert_eq!(buffer.len(), 17);
|
||||
}
|
||||
}
|
||||
@@ -508,7 +508,9 @@ pub fn partial_actual_size(partial: &Path) -> u64 {
|
||||
let mut seen: HashSet<usize> = HashSet::new();
|
||||
let mut total: u64 = 0;
|
||||
for line in content.lines() {
|
||||
let Ok(idx) = line.trim().parse::<usize>() else { continue };
|
||||
let Ok(idx) = line.trim().parse::<usize>() else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(idx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
//! `frame.rs` (binary frame codec) and the session lifecycle in
|
||||
//! `volcengine.rs`.
|
||||
|
||||
pub mod bailian;
|
||||
mod frame;
|
||||
pub mod local;
|
||||
pub mod volcengine;
|
||||
pub mod wav;
|
||||
pub mod whisper;
|
||||
|
||||
pub use bailian::{BailianCredentials, BailianRealtimeASR};
|
||||
pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR};
|
||||
pub use whisper::WhisperBatchASR;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ pub struct VolcengineCredentials {
|
||||
|
||||
impl VolcengineCredentials {
|
||||
pub fn default_resource_id() -> &'static str {
|
||||
"volc.seedasr.sauc.duration"
|
||||
"volc.bigasr.sauc.duration"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +719,7 @@ mod tests {
|
||||
fn default_resource_id_is_sauc_duration() {
|
||||
assert_eq!(
|
||||
VolcengineCredentials::default_resource_id(),
|
||||
"volc.seedasr.sauc.duration"
|
||||
"volc.bigasr.sauc.duration"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,7 @@ pub struct WhisperBatchASR {
|
||||
}
|
||||
|
||||
impl WhisperBatchASR {
|
||||
pub fn new(
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
prompt: Option<String>,
|
||||
) -> Self {
|
||||
pub fn new(api_key: String, base_url: String, model: String, prompt: Option<String>) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
base_url,
|
||||
@@ -205,7 +200,10 @@ mod tests {
|
||||
#[test]
|
||||
fn build_prompt_single_phrase() {
|
||||
let phrases = vec!["梁山泊".to_string()];
|
||||
assert_eq!(build_prompt_from_phrases(&phrases), Some("梁山泊.".to_string()));
|
||||
assert_eq!(
|
||||
build_prompt_from_phrases(&phrases),
|
||||
Some("梁山泊.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Tauri command surface — every IPC entry the React UI invokes lives here.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
@@ -15,13 +14,19 @@ use crate::asr::local::foundry::{
|
||||
use crate::asr::local::FoundryLocalRuntime;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::permissions::{self, PermissionStatus};
|
||||
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
|
||||
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
|
||||
use crate::persistence::{
|
||||
CredentialAccount, CredentialsSnapshot, CredentialsVault, PreferencesStore,
|
||||
};
|
||||
use crate::polish::{
|
||||
http_client_builder, CodexOAuthConfig, CodexOAuthCredentials, CodexOAuthLLMProvider, LLMError,
|
||||
OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL,
|
||||
CODEX_OAUTH_PROVIDER_ID,
|
||||
};
|
||||
use crate::recorder::{AudioConsumer, Recorder};
|
||||
use crate::types::{
|
||||
ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry,
|
||||
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding,
|
||||
UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
|
||||
ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession,
|
||||
DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode,
|
||||
ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
|
||||
};
|
||||
|
||||
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||
@@ -160,7 +165,10 @@ pub fn set_settings(
|
||||
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
|
||||
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
|
||||
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
|
||||
sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name);
|
||||
sync_tray_microphone_selection(
|
||||
&tray_state.lock(),
|
||||
&prefs_for_main.microphone_device_name,
|
||||
);
|
||||
}
|
||||
});
|
||||
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
|
||||
@@ -246,7 +254,10 @@ pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, St
|
||||
/// 用 `/releases/tag/` 这个唯一锚点抓 tag。
|
||||
fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
|
||||
for entry in body.split("<entry>").skip(1) {
|
||||
let entry_body = entry.split_once("</entry>").map(|(b, _)| b).unwrap_or(entry);
|
||||
let entry_body = entry
|
||||
.split_once("</entry>")
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(entry);
|
||||
let needle = "/releases/tag/";
|
||||
let tag_start = match entry_body.find(needle) {
|
||||
Some(i) => i + needle.len(),
|
||||
@@ -260,11 +271,9 @@ fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
|
||||
if !tag_name.ends_with("-beta-tauri") {
|
||||
continue;
|
||||
}
|
||||
let html_url = format!(
|
||||
"https://github.com/appergb/openless/releases/tag/{tag_name}"
|
||||
);
|
||||
let published_at = extract_between(entry_body, "<updated>", "</updated>")
|
||||
.unwrap_or_default();
|
||||
let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}");
|
||||
let published_at =
|
||||
extract_between(entry_body, "<updated>", "</updated>").unwrap_or_default();
|
||||
return Some(LatestBetaRelease {
|
||||
tag_name,
|
||||
html_url,
|
||||
@@ -381,10 +390,16 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo
|
||||
// 本地 ASR 不依赖云端凭据。
|
||||
return true;
|
||||
}
|
||||
if provider == crate::asr::bailian::PROVIDER_ID {
|
||||
return configured(&snap.asr_api_key);
|
||||
}
|
||||
configured(&snap.asr_endpoint) && configured(&snap.asr_model)
|
||||
}
|
||||
|
||||
fn llm_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool {
|
||||
if provider == CODEX_OAUTH_PROVIDER_ID {
|
||||
return CodexOAuthCredentials::load_default().is_ok();
|
||||
}
|
||||
let endpoint = snap.ark_endpoint.as_deref().unwrap_or_default();
|
||||
let endpoint_and_model = configured(&snap.ark_endpoint) && configured(&snap.ark_model_id);
|
||||
if endpoint_and_model
|
||||
@@ -403,6 +418,9 @@ fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> {
|
||||
"deepseek" => Some("https://api.deepseek.com/v1"),
|
||||
"siliconflow" => Some("https://api.siliconflow.cn/v1"),
|
||||
"openai" => Some("https://api.openai.com/v1"),
|
||||
// 谷歌 Gemini 原生 API(v1beta)。后端 llm_gemini.rs 会拼成
|
||||
// `{baseUrl}/models/{model}:generateContent`,认证用 x-goog-api-key 头。
|
||||
"gemini" => Some("https://generativelanguage.googleapis.com/v1beta"),
|
||||
"mimo" => Some("https://api.xiaomimimo.com/v1"),
|
||||
"cometapi" => Some("https://api.cometapi.com/v1"),
|
||||
"openrouterFree" => Some("https://openrouter.ai/api/v1"),
|
||||
@@ -539,6 +557,21 @@ pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheck
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
|
||||
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID {
|
||||
return Ok(ProviderModelsResult {
|
||||
models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()],
|
||||
});
|
||||
}
|
||||
if kind == "llm" && CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
|
||||
return Ok(ProviderModelsResult {
|
||||
models: vec![
|
||||
CODEX_DEFAULT_MODEL.to_string(),
|
||||
"gpt-5.3-codex".to_string(),
|
||||
"gpt-5.4".to_string(),
|
||||
"gpt-5.5".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
let config = read_openai_provider_config(&kind)?;
|
||||
fetch_provider_models(&config)
|
||||
.await
|
||||
@@ -580,18 +613,55 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
|
||||
}
|
||||
|
||||
async fn validate_llm_provider() -> Result<(), String> {
|
||||
let llm_thinking_enabled = PreferencesStore::new()
|
||||
.map_err(|e| e.to_string())?
|
||||
.get()
|
||||
.llm_thinking_enabled;
|
||||
if CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
|
||||
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string());
|
||||
let provider = CodexOAuthLLMProvider::new(
|
||||
CodexOAuthConfig::new(model).with_thinking_enabled(llm_thinking_enabled),
|
||||
);
|
||||
return provider
|
||||
.polish(
|
||||
"验证连接",
|
||||
PolishMode::Raw,
|
||||
&[],
|
||||
&[],
|
||||
ChineseScriptPreference::Auto,
|
||||
OutputLanguagePreference::Auto,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| match e {
|
||||
LLMError::InvalidResponse { status, .. } => {
|
||||
format!("providerHttpStatus:{status}")
|
||||
}
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let config = read_openai_provider_config("llm")?;
|
||||
let active_llm = CredentialsVault::get_active_llm();
|
||||
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| "llmModelMissing".to_string())?;
|
||||
let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new(
|
||||
"ark",
|
||||
"Doubao Ark",
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
model,
|
||||
));
|
||||
let provider = OpenAICompatibleLLMProvider::new(
|
||||
OpenAICompatibleConfig::new(
|
||||
active_llm.clone(),
|
||||
active_llm,
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
model,
|
||||
)
|
||||
.with_thinking_enabled(llm_thinking_enabled),
|
||||
);
|
||||
provider
|
||||
.polish(
|
||||
"验证连接",
|
||||
@@ -619,6 +689,10 @@ async fn validate_asr_provider() -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if active_asr == crate::asr::bailian::PROVIDER_ID {
|
||||
return validate_bailian_asr_provider().await;
|
||||
}
|
||||
|
||||
let config = read_openai_provider_config("asr")?;
|
||||
let model = CredentialsVault::get(CredentialAccount::AsrModel)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -627,6 +701,44 @@ async fn validate_asr_provider() -> Result<(), String> {
|
||||
validate_asr_transcription(&config, model.trim()).await
|
||||
}
|
||||
|
||||
async fn validate_bailian_asr_provider() -> Result<(), String> {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default();
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("API Key 为空".to_string());
|
||||
}
|
||||
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
|
||||
let model = CredentialsVault::get(CredentialAccount::AsrModel)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
|
||||
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
let asr = std::sync::Arc::new(crate::asr::BailianRealtimeASR::new(
|
||||
crate::asr::BailianCredentials {
|
||||
api_key,
|
||||
endpoint,
|
||||
model,
|
||||
vocabulary_id,
|
||||
},
|
||||
));
|
||||
asr.open_session().await.map_err(|e| e.to_string())?;
|
||||
crate::asr::AudioConsumer::consume_pcm_chunk(
|
||||
&*asr,
|
||||
&vec![0u8; crate::asr::bailian::TARGET_AUDIO_CHUNK_BYTES],
|
||||
);
|
||||
asr.send_last_frame().await.map_err(|e| e.to_string())?;
|
||||
asr.await_final_result()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
|
||||
provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider)
|
||||
}
|
||||
@@ -654,8 +766,7 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", wav_part)
|
||||
.text("model", model.to_string());
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
let client = http_client_builder(&url, 20)
|
||||
.build()
|
||||
.map_err(|_| "providerClientInitFailed".to_string())?;
|
||||
let response = client
|
||||
@@ -753,14 +864,20 @@ fn encode_wav_16k_mono_silence(duration_ms: u32) -> Vec<u8> {
|
||||
|
||||
async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, String> {
|
||||
let url = models_url(&config.base_url);
|
||||
log::info!("[provider-check] GET {url}");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
let is_gemini = is_gemini_base_url(&config.base_url);
|
||||
log::info!("[provider-check] GET {url} (gemini={is_gemini})");
|
||||
let client = http_client_builder(&config.base_url, 15)
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client 初始化失败: {e}"))?;
|
||||
let mut request = client.get(&url);
|
||||
if !config.api_key.trim().is_empty() {
|
||||
request = request.header("Authorization", format!("Bearer {}", config.api_key));
|
||||
// 谷歌原生 generativelanguage.googleapis.com 不识别 Bearer Authorization,
|
||||
// 必须用 x-goog-api-key 头。其它 OpenAI 兼容 provider 仍走 Bearer。
|
||||
if is_gemini {
|
||||
request = request.header("x-goog-api-key", config.api_key.as_str());
|
||||
} else {
|
||||
request = request.header("Authorization", format!("Bearer {}", config.api_key));
|
||||
}
|
||||
}
|
||||
let response = request.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
@@ -777,7 +894,15 @@ async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, S
|
||||
if !status.is_success() {
|
||||
return Err(format!("providerHttpStatus:{}", status.as_u16()));
|
||||
}
|
||||
parse_model_ids(&body)
|
||||
if is_gemini {
|
||||
parse_gemini_model_ids(&body)
|
||||
} else {
|
||||
parse_model_ids(&body)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gemini_base_url(base_url: &str) -> bool {
|
||||
base_url.contains("generativelanguage.googleapis.com")
|
||||
}
|
||||
|
||||
fn models_url(base_url: &str) -> String {
|
||||
@@ -810,6 +935,51 @@ fn parse_model_ids(body: &str) -> Result<Vec<String>, String> {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 谷歌 v1beta/models 响应形状:`{models: [{name: "models/gemini-2.5-flash",
|
||||
/// supportedGenerationMethods: ["generateContent", ...], ...}, ...]}`。
|
||||
/// 与 OpenAI `{data: [{id: "..."}]}` 不兼容,所以单独解析;name 字段去掉
|
||||
/// "models/" 前缀后即是 ProviderTools「拉取模型」按钮可直接写入 ark.model_id
|
||||
/// 的字符串。
|
||||
///
|
||||
/// 过滤:只保留声明支持 `generateContent` 的模型——Google 的 model list 同时
|
||||
/// 暴露 embedding (`gemini-embedding-2`)、TTS、image 等不支持
|
||||
/// generateContent 的家族;用户选中那种 ID 后 polish 必失败(PR #398 pr_agent
|
||||
/// 漏洞反馈)。`supportedGenerationMethods` 字段缺失时保守保留——某些 preview
|
||||
/// 模型可能未暴露这个字段,宁误显示也不要把新模型挡在外面。
|
||||
fn parse_gemini_model_ids(body: &str) -> Result<Vec<String>, String> {
|
||||
let json: Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?;
|
||||
let models = json
|
||||
.get("models")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| "Gemini 模型列表缺少 models 数组".to_string())?;
|
||||
let mut ids = models
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
match item
|
||||
.get("supportedGenerationMethods")
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
Some(methods) => methods
|
||||
.iter()
|
||||
.any(|m| m.as_str() == Some("generateContent")),
|
||||
None => true, // 字段缺失:保守包含
|
||||
}
|
||||
})
|
||||
.filter_map(|item| item.get("name").and_then(|n| n.as_str()))
|
||||
.map(|name| {
|
||||
name.strip_prefix("models/")
|
||||
.unwrap_or(name)
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_account(s: &str) -> Result<CredentialAccount, String> {
|
||||
match s {
|
||||
"volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey),
|
||||
@@ -821,6 +991,7 @@ fn parse_account(s: &str) -> Result<CredentialAccount, String> {
|
||||
"asr.api_key" => Ok(CredentialAccount::AsrApiKey),
|
||||
"asr.endpoint" => Ok(CredentialAccount::AsrEndpoint),
|
||||
"asr.model" => Ok(CredentialAccount::AsrModel),
|
||||
"asr.vocabulary_id" => Ok(CredentialAccount::AsrVocabularyId),
|
||||
_ => Err(format!("unknown account: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -875,6 +1046,43 @@ pub fn set_vocab_enabled(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_correction_rules(coord: CoordinatorState<'_>) -> Result<Vec<CorrectionRule>, String> {
|
||||
coord.correction_rules().list().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_correction_rule(
|
||||
coord: CoordinatorState<'_>,
|
||||
pattern: String,
|
||||
replacement: String,
|
||||
) -> Result<CorrectionRule, String> {
|
||||
coord
|
||||
.correction_rules()
|
||||
.add(pattern, replacement)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> {
|
||||
coord
|
||||
.correction_rules()
|
||||
.remove(&id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_correction_rule_enabled(
|
||||
coord: CoordinatorState<'_>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
coord
|
||||
.correction_rules()
|
||||
.set_enabled(&id, enabled)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
|
||||
crate::persistence::list_vocab_presets().map_err(|e| e.to_string())
|
||||
@@ -940,7 +1148,10 @@ pub fn set_default_polish_mode(
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.default_mode = mode;
|
||||
coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?;
|
||||
coord
|
||||
.prefs()
|
||||
.set(prefs.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem,
|
||||
// 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。
|
||||
let app_for_main = app.clone();
|
||||
@@ -1743,10 +1954,11 @@ mod tests {
|
||||
use super::{
|
||||
active_asr_is_keyless_for_validation, active_foundry_model_from_prefs,
|
||||
asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models,
|
||||
llm_configured_for_provider, local_asr_release_plan_for_provider, models_url,
|
||||
normalize_foundry_language_hint, parse_latest_beta_from_atom, parse_model_ids,
|
||||
persist_settings, release_foundry_runtime_if_inactive, validate_foundry_model_alias,
|
||||
ProviderConfig, SettingsWriter,
|
||||
is_gemini_base_url, llm_configured_for_provider, local_asr_release_plan_for_provider,
|
||||
models_url, normalize_foundry_language_hint, parse_gemini_model_ids,
|
||||
parse_latest_beta_from_atom, parse_model_ids, persist_settings,
|
||||
release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig,
|
||||
SettingsWriter,
|
||||
};
|
||||
use crate::persistence::CredentialsSnapshot;
|
||||
use crate::types::{
|
||||
@@ -1784,6 +1996,10 @@ mod tests {
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!asr_configured_for_provider("whisper", &whisper_key_only));
|
||||
assert!(asr_configured_for_provider(
|
||||
crate::asr::bailian::PROVIDER_ID,
|
||||
&whisper_key_only
|
||||
));
|
||||
|
||||
let whisper_keyless_ready = CredentialsSnapshot {
|
||||
asr_endpoint: Some("https://api.openai.com/v1".into()),
|
||||
@@ -1794,6 +2010,10 @@ mod tests {
|
||||
"whisper",
|
||||
&whisper_keyless_ready
|
||||
));
|
||||
assert!(!asr_configured_for_provider(
|
||||
crate::asr::bailian::PROVIDER_ID,
|
||||
&whisper_keyless_ready
|
||||
));
|
||||
|
||||
assert!(asr_configured_for_provider(
|
||||
crate::asr::local::PROVIDER_ID,
|
||||
@@ -1916,14 +2136,20 @@ mod tests {
|
||||
};
|
||||
assert!(llm_configured_for_provider("custom", &keyless_ready));
|
||||
assert!(llm_configured_for_provider("self-hosted", &keyless_ready));
|
||||
assert!(llm_configured_for_provider("openrouterFree", &keyless_ready));
|
||||
assert!(llm_configured_for_provider(
|
||||
"openrouterFree",
|
||||
&keyless_ready
|
||||
));
|
||||
|
||||
let hosted_keyless = CredentialsSnapshot {
|
||||
ark_endpoint: Some("https://openrouter.ai/api/v1".into()),
|
||||
ark_model_id: Some("qwen/qwen3-coder:free".into()),
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!llm_configured_for_provider("openrouterFree", &hosted_keyless));
|
||||
assert!(!llm_configured_for_provider(
|
||||
"openrouterFree",
|
||||
&hosted_keyless
|
||||
));
|
||||
|
||||
let hosted_ready = CredentialsSnapshot {
|
||||
ark_api_key: Some("key".into()),
|
||||
@@ -1938,13 +2164,19 @@ mod tests {
|
||||
ark_model_id: Some("qwen".into()),
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!llm_configured_for_provider("custom", &key_without_endpoint));
|
||||
assert!(!llm_configured_for_provider(
|
||||
"custom",
|
||||
&key_without_endpoint
|
||||
));
|
||||
|
||||
let endpoint_without_model = CredentialsSnapshot {
|
||||
ark_endpoint: Some("http://localhost:11434/v1".into()),
|
||||
..snapshot()
|
||||
};
|
||||
assert!(!llm_configured_for_provider("custom", &endpoint_without_model));
|
||||
assert!(!llm_configured_for_provider(
|
||||
"custom",
|
||||
&endpoint_without_model
|
||||
));
|
||||
}
|
||||
|
||||
impl SettingsWriter for FakeSettingsWriter {
|
||||
@@ -2014,6 +2246,67 @@ mod tests {
|
||||
assert_eq!(models, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gemini_model_ids_strips_models_prefix_and_dedups() {
|
||||
// Google v1beta/models 真实响应的子集——name 字段带 `models/` 前缀,
|
||||
// ProviderTools 选中后写入 ark.model_id 时不能带这个前缀(generateContent
|
||||
// URL 拼接已经会加 `models/`,不去前缀就会变成 `models/models/...`)。
|
||||
// 字段缺失时保守保留(视为支持 generateContent)。
|
||||
let body = r#"{"models":[
|
||||
{"name":"models/gemini-2.5-pro"},
|
||||
{"name":"models/gemini-2.5-flash"},
|
||||
{"name":"models/gemini-2.5-flash"},
|
||||
{"name":"models/gemini-3-flash-preview"}
|
||||
]}"#;
|
||||
let ids = parse_gemini_model_ids(body).unwrap();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"gemini-2.5-flash".to_string(),
|
||||
"gemini-2.5-pro".to_string(),
|
||||
"gemini-3-flash-preview".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gemini_model_ids_filters_out_non_generate_content_families() {
|
||||
// 真实 Google v1beta/models 响应里同时有 generateContent / embedContent /
|
||||
// generateMessage 等多种家族。用户选中 embedding/TTS/image 模型写入
|
||||
// ark.model_id → polish 必败。这里是 PR #398 pr_agent advisory 的回归用例:
|
||||
// 只把 supportedGenerationMethods 里含 generateContent 的过滤出来。
|
||||
let body = r#"{"models":[
|
||||
{"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent","streamGenerateContent","countTokens"]},
|
||||
{"name":"models/gemini-embedding-2","supportedGenerationMethods":["embedContent"]},
|
||||
{"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent","countTextTokens"]},
|
||||
{"name":"models/gemini-2.5-pro-preview-tts","supportedGenerationMethods":["generateContent"]},
|
||||
{"name":"models/gemini-2.5-flash-image","supportedGenerationMethods":["predict"]}
|
||||
]}"#;
|
||||
let ids = parse_gemini_model_ids(body).unwrap();
|
||||
// 只剩两条声明 generateContent 的;embedding 与 image (predict-only) 必须被过滤。
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"gemini-2.5-flash".to_string(),
|
||||
"gemini-2.5-pro-preview-tts".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_gemini_base_url_matches_official_domain() {
|
||||
assert!(is_gemini_base_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta"
|
||||
));
|
||||
assert!(is_gemini_base_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/"
|
||||
));
|
||||
assert!(!is_gemini_base_url("https://api.openai.com/v1"));
|
||||
assert!(!is_gemini_base_url(
|
||||
"https://ark.cn-beijing.volces.com/api/v3"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_refreshes_both_hotkey_pipelines() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
|
||||
@@ -19,8 +19,8 @@ use uuid::Uuid;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr};
|
||||
use crate::asr::{
|
||||
DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR,
|
||||
WhisperBatchASR,
|
||||
BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript,
|
||||
VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR,
|
||||
};
|
||||
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor};
|
||||
use crate::coordinator_state::{
|
||||
@@ -32,13 +32,20 @@ use crate::coordinator_state::{
|
||||
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
|
||||
use crate::insertion::TextInserter;
|
||||
use crate::persistence::{
|
||||
CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore,
|
||||
CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore,
|
||||
PreferencesStore,
|
||||
};
|
||||
|
||||
use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
|
||||
use crate::llm_gemini::{GeminiConfig, GeminiProvider};
|
||||
use crate::polish::{
|
||||
ActiveLLMProvider, CodexOAuthConfig, CodexOAuthLLMProvider, OpenAICompatibleConfig,
|
||||
OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID,
|
||||
};
|
||||
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
|
||||
use crate::recorder::{Recorder, RecorderError};
|
||||
use crate::selection::capture_selection;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::types::PasteShortcut;
|
||||
use crate::types::{
|
||||
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
|
||||
HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode,
|
||||
@@ -69,6 +76,7 @@ use resources::{
|
||||
enum ActiveAsr {
|
||||
Volcengine(Arc<VolcengineStreamingASR>),
|
||||
Whisper(Arc<WhisperBatchASR>),
|
||||
Bailian(Arc<BailianRealtimeASR>),
|
||||
#[cfg(target_os = "windows")]
|
||||
FoundryLocalWhisper(Arc<FoundryLocalWhisperAsr>),
|
||||
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
|
||||
@@ -93,6 +101,7 @@ struct Inner {
|
||||
history: HistoryStore,
|
||||
prefs: PreferencesStore,
|
||||
vocab: DictionaryStore,
|
||||
correction_rules: CorrectionRuleStore,
|
||||
inserter: TextInserter,
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_ime: WindowsImeSessionController,
|
||||
@@ -110,6 +119,11 @@ struct Inner {
|
||||
hotkey: Mutex<Option<HotkeyMonitor>>,
|
||||
hotkey_status: Mutex<HotkeyStatus>,
|
||||
hotkey_trigger_held: AtomicBool,
|
||||
/// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接
|
||||
/// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。
|
||||
/// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防
|
||||
/// press-release-press 三连过快。
|
||||
last_hotkey_dispatch_at: Mutex<Option<std::time::Instant>>,
|
||||
shortcut_recording_active: AtomicBool,
|
||||
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
|
||||
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
|
||||
@@ -138,6 +152,11 @@ struct Inner {
|
||||
/// polish::chat_completion_history_streaming 的 loop 每帧检查,true 时 break loop
|
||||
/// 避免取消后 LLM 仍 drain HTTP body 烧 token。详见 issue #161。
|
||||
qa_stream_cancelled: Arc<AtomicBool>,
|
||||
/// Coordinator 退出信号。各 hotkey supervisor loop 在每轮重试 sleep 之前会检查
|
||||
/// 此 flag;为 true 时 loop 立刻 return。生产场景里 process exit 一并 reap 所有
|
||||
/// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条
|
||||
/// 显式退出路径。审计 3.1.2。
|
||||
shutdown: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -168,6 +187,7 @@ impl Coordinator {
|
||||
});
|
||||
let prefs = PreferencesStore::new().expect("preferences store init");
|
||||
let vocab = DictionaryStore::new().expect("dictionary store init");
|
||||
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
@@ -175,6 +195,7 @@ impl Coordinator {
|
||||
history,
|
||||
prefs,
|
||||
vocab,
|
||||
correction_rules,
|
||||
inserter: TextInserter::new(),
|
||||
state: Mutex::new(SessionState::default()),
|
||||
asr: Mutex::new(None),
|
||||
@@ -183,6 +204,7 @@ impl Coordinator {
|
||||
hotkey: Mutex::new(None),
|
||||
hotkey_status: Mutex::new(HotkeyStatus::default()),
|
||||
hotkey_trigger_held: AtomicBool::new(false),
|
||||
last_hotkey_dispatch_at: Mutex::new(None),
|
||||
shortcut_recording_active: AtomicBool::new(false),
|
||||
combo_hotkey: Mutex::new(None),
|
||||
translation_hotkey: Mutex::new(None),
|
||||
@@ -196,6 +218,7 @@ impl Coordinator {
|
||||
qa_recorder: Mutex::new(None),
|
||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
||||
shutdown: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -209,6 +232,7 @@ impl Coordinator {
|
||||
});
|
||||
let prefs = PreferencesStore::new().expect("preferences store init");
|
||||
let vocab = DictionaryStore::new().expect("dictionary store init");
|
||||
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
@@ -216,6 +240,7 @@ impl Coordinator {
|
||||
history,
|
||||
prefs,
|
||||
vocab,
|
||||
correction_rules,
|
||||
inserter: TextInserter::new(),
|
||||
windows_ime: WindowsImeSessionController::new(),
|
||||
prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -226,6 +251,7 @@ impl Coordinator {
|
||||
hotkey: Mutex::new(None),
|
||||
hotkey_status: Mutex::new(HotkeyStatus::default()),
|
||||
hotkey_trigger_held: AtomicBool::new(false),
|
||||
last_hotkey_dispatch_at: Mutex::new(None),
|
||||
shortcut_recording_active: AtomicBool::new(false),
|
||||
combo_hotkey: Mutex::new(None),
|
||||
translation_hotkey: Mutex::new(None),
|
||||
@@ -240,6 +266,7 @@ impl Coordinator {
|
||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
||||
foundry_local_runtime,
|
||||
shutdown: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -298,6 +325,15 @@ impl Coordinator {
|
||||
*self.inner.app.lock() = Some(handle);
|
||||
}
|
||||
|
||||
/// 让所有 hotkey supervisor loop(dictation / qa / combo / translation /
|
||||
/// switch_style / open_app)在下一轮 sleep / poll 后退出。生产场景下进程退出
|
||||
/// 一并 reap 所有线程,但 integration test 和未来 RunEvent::Exit 钩子需要
|
||||
/// 显式退出路径。审计 3.1.2。
|
||||
#[allow(dead_code)]
|
||||
pub fn request_shutdown(&self) {
|
||||
self.inner.shutdown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn start_hotkey_listener(&self) {
|
||||
// 起一个守护线程,反复尝试安装 hotkey hook。Accessibility 一被授予就立即生效,
|
||||
// 用户不需要手动重启 OpenLess。
|
||||
@@ -623,6 +659,9 @@ impl Coordinator {
|
||||
pub fn vocab(&self) -> &DictionaryStore {
|
||||
&self.inner.vocab
|
||||
}
|
||||
pub fn correction_rules(&self) -> &CorrectionRuleStore {
|
||||
&self.inner.correction_rules
|
||||
}
|
||||
|
||||
pub fn update_hotkey_binding(&self) {
|
||||
let prefs = self.inner.prefs.get();
|
||||
@@ -741,6 +780,7 @@ impl Coordinator {
|
||||
let working_languages = prefs.working_languages;
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
// repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app;
|
||||
// 当下用户调起的 app 才是相关上下文(如果可拿)。
|
||||
let front_app = capture_frontmost_app();
|
||||
@@ -753,6 +793,7 @@ impl Coordinator {
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&[],
|
||||
)
|
||||
@@ -767,6 +808,9 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
let mut attempts: u32 = 0;
|
||||
let capability = HotkeyMonitor::capability();
|
||||
loop {
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let prefs = inner.prefs.get();
|
||||
|
||||
if inner.hotkey.lock().is_some() {
|
||||
@@ -837,6 +881,9 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
fn qa_hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
// 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。
|
||||
let binding = match inner.prefs.get().qa_hotkey.clone() {
|
||||
Some(b) => b,
|
||||
@@ -944,6 +991,9 @@ fn qa_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
|
||||
fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
// 读当前 prefs
|
||||
let prefs = inner.prefs.get();
|
||||
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
|
||||
@@ -1042,6 +1092,9 @@ fn combo_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEve
|
||||
fn translation_hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let binding = inner.prefs.get().translation_hotkey;
|
||||
if is_builtin_translation_shift(&binding)
|
||||
|| crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some()
|
||||
@@ -1141,6 +1194,9 @@ fn translation_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHot
|
||||
fn action_hotkey_supervisor_loop(inner: Arc<Inner>, kind: ActionHotkeyKind) {
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let binding = action_hotkey_binding(&inner, kind);
|
||||
if is_modifier_only_shortcut(&binding) {
|
||||
take_action_hotkey_on_main_thread(&inner, kind);
|
||||
@@ -1541,7 +1597,7 @@ fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, c
|
||||
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
|
||||
(key == "Alt" || key == "AltGraph") && code == "AltRight"
|
||||
}
|
||||
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight",
|
||||
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft",
|
||||
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
|
||||
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
|
||||
// Custom 走 global-hotkey crate,不走 window hotkey fallback
|
||||
@@ -1642,6 +1698,7 @@ async fn insert_with_windows_ime_first(
|
||||
polished: &str,
|
||||
restore_clipboard: bool,
|
||||
allow_non_tsf_insertion_fallback: bool,
|
||||
paste_shortcut: PasteShortcut,
|
||||
ime_target: Option<ImeSubmitTarget>,
|
||||
) -> InsertStatus {
|
||||
let prepared = {
|
||||
@@ -1654,7 +1711,7 @@ async fn insert_with_windows_ime_first(
|
||||
allow_non_tsf_insertion_fallback,
|
||||
InsertStatus::Failed,
|
||||
) {
|
||||
return insert_via_non_tsf_fallback(inner, polished, restore_clipboard);
|
||||
return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut);
|
||||
}
|
||||
log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert");
|
||||
return InsertStatus::Failed;
|
||||
@@ -1679,7 +1736,7 @@ async fn insert_with_windows_ime_first(
|
||||
if ime_status == InsertStatus::Inserted {
|
||||
ime_status
|
||||
} else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) {
|
||||
insert_via_non_tsf_fallback(inner, polished, restore_clipboard)
|
||||
insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut)
|
||||
} else {
|
||||
log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled");
|
||||
InsertStatus::Failed
|
||||
@@ -1699,6 +1756,7 @@ fn insert_via_non_tsf_fallback(
|
||||
inner: &Arc<Inner>,
|
||||
polished: &str,
|
||||
restore_clipboard: bool,
|
||||
paste_shortcut: PasteShortcut,
|
||||
) -> InsertStatus {
|
||||
if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted {
|
||||
log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput");
|
||||
@@ -1706,7 +1764,7 @@ fn insert_via_non_tsf_fallback(
|
||||
} else {
|
||||
inner
|
||||
.inserter
|
||||
.insert_via_clipboard_fallback(polished, restore_clipboard)
|
||||
.insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1785,7 +1843,7 @@ fn ensure_asr_credentials() -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_whisper_compatible_provider(&active_asr) {
|
||||
if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -1918,6 +1976,10 @@ fn is_whisper_compatible_provider(id: &str) -> bool {
|
||||
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
|
||||
}
|
||||
|
||||
fn is_bailian_provider(id: &str) -> bool {
|
||||
id == crate::asr::bailian::PROVIDER_ID
|
||||
}
|
||||
|
||||
fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
@@ -1953,6 +2015,105 @@ fn ensure_qa_volcengine_credentials() -> Result<(), String> {
|
||||
|
||||
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
|
||||
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
|
||||
/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
|
||||
/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
|
||||
pub enum StreamingPolishOutcome {
|
||||
/// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写
|
||||
/// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符
|
||||
/// 已经通过键盘事件落到光标处。
|
||||
Streamed(String),
|
||||
/// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex
|
||||
/// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的
|
||||
/// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。
|
||||
UnsupportedFallback,
|
||||
/// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当
|
||||
/// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
|
||||
/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
|
||||
///
|
||||
/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
|
||||
/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
|
||||
/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
|
||||
pub async fn polish_or_passthrough_streaming<F, C>(
|
||||
raw: &RawTranscript,
|
||||
mode: PolishMode,
|
||||
hotwords: &[String],
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
prior_turns: &[(String, String)],
|
||||
on_delta: F,
|
||||
should_cancel: C,
|
||||
) -> StreamingPolishOutcome
|
||||
where
|
||||
F: Fn(&str) + Send + Sync,
|
||||
C: Fn() -> bool + Send + Sync,
|
||||
{
|
||||
if mode == PolishMode::Raw {
|
||||
log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot");
|
||||
return StreamingPolishOutcome::UnsupportedFallback;
|
||||
}
|
||||
let active_llm = CredentialsVault::get_active_llm();
|
||||
if active_llm == "gemini" {
|
||||
log::info!(
|
||||
"[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot"
|
||||
);
|
||||
return StreamingPolishOutcome::UnsupportedFallback;
|
||||
}
|
||||
let provider = match build_active_llm_provider(llm_thinking_enabled) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("[coord] streaming polish: build provider failed: {e}");
|
||||
return StreamingPolishOutcome::Failed(e.to_string());
|
||||
}
|
||||
};
|
||||
if !provider.supports_streaming_polish() {
|
||||
log::info!(
|
||||
"[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot"
|
||||
);
|
||||
return StreamingPolishOutcome::UnsupportedFallback;
|
||||
}
|
||||
log::info!(
|
||||
"[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}",
|
||||
mode,
|
||||
raw.text.chars().count(),
|
||||
prior_turns.len()
|
||||
);
|
||||
match provider
|
||||
.polish_streaming(
|
||||
&raw.text,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
front_app,
|
||||
prior_turns,
|
||||
on_delta,
|
||||
should_cancel,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
log::info!(
|
||||
"[coord] streaming polish OK: final_chars={}",
|
||||
text.chars().count()
|
||||
);
|
||||
StreamingPolishOutcome::Streamed(text)
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = e.to_string();
|
||||
log::error!("[coord] streaming polish FAILED: {reason}");
|
||||
StreamingPolishOutcome::Failed(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn polish_or_passthrough(
|
||||
raw: &RawTranscript,
|
||||
mode: PolishMode,
|
||||
@@ -1960,6 +2121,7 @@ async fn polish_or_passthrough(
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
prior_turns: &[(String, String)],
|
||||
) -> (String, Option<String>) {
|
||||
@@ -1973,6 +2135,7 @@ async fn polish_or_passthrough(
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app,
|
||||
prior_turns,
|
||||
)
|
||||
@@ -1994,21 +2157,34 @@ async fn polish_text(
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
prior_turns: &[(String, String)],
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
|
||||
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "deepseek-v3-2".to_string());
|
||||
let endpoint = resolve_ark_endpoint(&api_key)?;
|
||||
let base_url = endpoint
|
||||
.trim_end_matches("/chat/completions")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
// 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生
|
||||
// generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI
|
||||
// 兼容协议,并在该路径里按 provider/channel 下发对应的思考开关。
|
||||
let active_llm = CredentialsVault::get_active_llm();
|
||||
if active_llm == "gemini" {
|
||||
let (api_key, model, base_url) = read_gemini_credentials()?;
|
||||
let provider = GeminiProvider::new(
|
||||
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
|
||||
);
|
||||
return Ok(provider
|
||||
.polish(
|
||||
raw,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
front_app,
|
||||
prior_turns,
|
||||
)
|
||||
.await?);
|
||||
}
|
||||
|
||||
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
|
||||
let provider = OpenAICompatibleLLMProvider::new(config);
|
||||
let provider = build_active_llm_provider(llm_thinking_enabled)?;
|
||||
Ok(provider
|
||||
.polish(
|
||||
raw,
|
||||
@@ -2030,6 +2206,7 @@ async fn translate_or_passthrough(
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
) -> (String, Option<String>) {
|
||||
match translate_text(
|
||||
@@ -2038,6 +2215,7 @@ async fn translate_or_passthrough(
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app,
|
||||
)
|
||||
.await
|
||||
@@ -2057,20 +2235,29 @@ async fn translate_text(
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
|
||||
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "deepseek-v3-2".to_string());
|
||||
let endpoint = resolve_ark_endpoint(&api_key)?;
|
||||
let base_url = endpoint
|
||||
.trim_end_matches("/chat/completions")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。
|
||||
let active_llm = CredentialsVault::get_active_llm();
|
||||
if active_llm == "gemini" {
|
||||
let (api_key, model, base_url) = read_gemini_credentials()?;
|
||||
let provider = GeminiProvider::new(
|
||||
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
|
||||
);
|
||||
return Ok(provider
|
||||
.translate_to(
|
||||
raw,
|
||||
target_language,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
front_app,
|
||||
)
|
||||
.await?);
|
||||
}
|
||||
|
||||
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
|
||||
let provider = OpenAICompatibleLLMProvider::new(config);
|
||||
let provider = build_active_llm_provider(llm_thinking_enabled)?;
|
||||
Ok(provider
|
||||
.translate_to(
|
||||
raw,
|
||||
@@ -2100,6 +2287,33 @@ fn read_whisper_credentials() -> (String, String, String) {
|
||||
(api_key, base_url, model)
|
||||
}
|
||||
|
||||
fn read_bailian_credentials() -> BailianCredentials {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
|
||||
let model = CredentialsVault::get(CredentialAccount::AsrModel)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
|
||||
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
BailianCredentials {
|
||||
api_key,
|
||||
endpoint,
|
||||
model,
|
||||
vocabulary_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_volc_credentials() -> VolcengineCredentials {
|
||||
let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey)
|
||||
.ok()
|
||||
@@ -2243,7 +2457,7 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
|
||||
let microphone_device_name = selected_microphone_device_name(inner);
|
||||
stop_microphone_preview_monitor(inner, "QA recorder");
|
||||
acquire_recording_mute(inner, "qa");
|
||||
acquire_recording_mute(inner, "qa").await;
|
||||
match Recorder::start(microphone_device_name, consumer, level_handler) {
|
||||
Ok((rec, runtime_errors)) => {
|
||||
*inner.qa_recorder.lock() = Some(rec);
|
||||
@@ -2405,6 +2619,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
let (messages_for_llm, front_app) = {
|
||||
let st = inner.qa_state.lock();
|
||||
(st.messages.clone(), st.front_app.clone())
|
||||
@@ -2445,6 +2660,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
on_delta,
|
||||
should_cancel,
|
||||
@@ -2596,6 +2812,7 @@ async fn answer_chat_dispatch<F, C>(
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
on_delta: F,
|
||||
should_cancel: C,
|
||||
@@ -2604,17 +2821,28 @@ where
|
||||
F: Fn(&str) + Send + Sync,
|
||||
C: Fn() -> bool + Send + Sync,
|
||||
{
|
||||
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
|
||||
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "deepseek-v3-2".to_string());
|
||||
let endpoint = resolve_ark_endpoint(&api_key)?;
|
||||
let base_url = endpoint
|
||||
.trim_end_matches("/chat/completions")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
|
||||
let provider = OpenAICompatibleLLMProvider::new(config);
|
||||
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑,
|
||||
// QA 流式回答走 Gemini 原生 :streamGenerateContent?alt=sse。
|
||||
let active_llm = CredentialsVault::get_active_llm();
|
||||
if active_llm == "gemini" {
|
||||
let (api_key, model, base_url) = read_gemini_credentials()?;
|
||||
let provider = GeminiProvider::new(
|
||||
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
|
||||
);
|
||||
return Ok(provider
|
||||
.answer_chat_streaming(
|
||||
messages,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
front_app,
|
||||
on_delta,
|
||||
should_cancel,
|
||||
)
|
||||
.await?);
|
||||
}
|
||||
|
||||
let provider = build_active_llm_provider(llm_thinking_enabled)?;
|
||||
Ok(provider
|
||||
.answer_chat_streaming(
|
||||
messages,
|
||||
@@ -2628,6 +2856,55 @@ where
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider
|
||||
/// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读,
|
||||
/// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`,
|
||||
/// 模型默认 `gemini-2.5-flash`。Settings.tsx::onLlmProviderChange 在用户切到 gemini
|
||||
/// 时会强制把 endpoint/model 覆盖为这两个默认值,所以 99% 情况下槽里读出来就是
|
||||
/// 这两个;这里的 `unwrap_or_else` 是给极端情况兜底(如旧版本切换 bug 留下的脏数据)。
|
||||
///
|
||||
/// base_url 末尾去掉 `/`,让 `llm_gemini::generate_content_url` 拼接稳定。
|
||||
/// 不去 `/chat/completions` 后缀——OpenAI 兼容路径才会有那个后缀,原生 Gemini 不会。
|
||||
fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
|
||||
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "gemini-2.5-flash".to_string());
|
||||
let base_url = CredentialsVault::get(CredentialAccount::ArkEndpoint)?
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
|
||||
if api_key.trim().is_empty() {
|
||||
anyhow::bail!("API Key 为空");
|
||||
}
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
Ok((api_key, model, base_url))
|
||||
}
|
||||
|
||||
fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result<ActiveLLMProvider> {
|
||||
let active = CredentialsVault::get_active_llm();
|
||||
let model =
|
||||
CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty());
|
||||
if active == CODEX_OAUTH_PROVIDER_ID {
|
||||
let config =
|
||||
CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string()))
|
||||
.with_thinking_enabled(llm_thinking_enabled);
|
||||
return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config)));
|
||||
}
|
||||
|
||||
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
|
||||
let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string());
|
||||
let endpoint = resolve_ark_endpoint(&api_key)?;
|
||||
let base_url = endpoint
|
||||
.trim_end_matches("/chat/completions")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model)
|
||||
.with_thinking_enabled(llm_thinking_enabled);
|
||||
Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new(
|
||||
config,
|
||||
)))
|
||||
}
|
||||
|
||||
fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result<String> {
|
||||
let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty());
|
||||
resolve_ark_endpoint_with_policy(api_key, endpoint)
|
||||
@@ -2727,8 +3004,8 @@ mod tests {
|
||||
(HotkeyTrigger::RightOption, "Alt", "AltRight"),
|
||||
(HotkeyTrigger::RightAlt, "AltGraph", "AltRight"),
|
||||
(HotkeyTrigger::RightCommand, "Meta", "MetaRight"),
|
||||
(HotkeyTrigger::LeftOption, "Alt", "AltLeft"),
|
||||
// Mirrors Windows trigger_to_vk_code aliases.
|
||||
(HotkeyTrigger::LeftOption, "Alt", "AltRight"),
|
||||
(HotkeyTrigger::Fn, "Control", "ControlRight"),
|
||||
];
|
||||
for (trigger, key, code) in cases {
|
||||
@@ -2746,7 +3023,7 @@ mod tests {
|
||||
assert!(!window_key_matches_trigger(
|
||||
HotkeyTrigger::LeftOption,
|
||||
"Alt",
|
||||
"AltLeft"
|
||||
"AltRight"
|
||||
));
|
||||
assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn"));
|
||||
}
|
||||
@@ -3624,37 +3901,55 @@ fn emit_capsule(
|
||||
) {
|
||||
let app_opt = inner.app.lock().clone();
|
||||
let Some(app) = app_opt else { return };
|
||||
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
|
||||
let payload = CapsulePayload {
|
||||
state,
|
||||
level,
|
||||
elapsed_ms,
|
||||
message,
|
||||
inserted_chars,
|
||||
translation: inner.translation_modifier_seen.load(Ordering::SeqCst),
|
||||
translation,
|
||||
};
|
||||
|
||||
let show_capsule = inner.prefs.get().show_capsule;
|
||||
if let Some(window) = app.get_webview_window("capsule") {
|
||||
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
|
||||
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
|
||||
// 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。
|
||||
let visible = !matches!(state, CapsuleState::Idle);
|
||||
|
||||
// emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该
|
||||
// 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP
|
||||
// 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程;
|
||||
// app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。
|
||||
//
|
||||
// show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置,
|
||||
// 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref
|
||||
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
|
||||
let inner_for_main = Arc::clone(inner);
|
||||
let app_for_main = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
let Some(window) = app_for_main.get_webview_window("capsule") else {
|
||||
return;
|
||||
};
|
||||
let show_capsule = inner_for_main.prefs.get().show_capsule;
|
||||
// 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast
|
||||
// (schedule_capsule_idle 之后会回 Idle 隐藏)。
|
||||
// Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的
|
||||
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
|
||||
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
|
||||
let visible = !matches!(state, CapsuleState::Idle);
|
||||
maybe_position_capsule_bottom_center(inner, &window, payload.translation);
|
||||
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
|
||||
if show_capsule && visible {
|
||||
if !show_capsule_window_no_activate(&app, &window) {
|
||||
if !show_capsule_window_no_activate(&app_for_main, &window) {
|
||||
let _ = window.show();
|
||||
}
|
||||
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点。
|
||||
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::restore_main_window_key_if_active(&app);
|
||||
crate::restore_main_window_key_if_active(&app_for_main);
|
||||
} else {
|
||||
hide_capsule_window_if_present();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app.emit_to("capsule", "capsule:state", payload);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,307 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::coordinator_state::request_stop_during_starting_state;
|
||||
use crate::correction::apply_correction_rules;
|
||||
use crate::types::HotkeyMode;
|
||||
|
||||
use super::qa::handle_qa_option_edge;
|
||||
use super::resources::*;
|
||||
use super::*;
|
||||
|
||||
/// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 ——
|
||||
/// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。
|
||||
const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250);
|
||||
|
||||
/// 跑流式润色路径(opt-in,跨平台)。
|
||||
///
|
||||
/// 平台差异:
|
||||
/// - **macOS**:`switch_to_ascii` 切到 ABC 输入源(规避 CJK / 日文 IME 拦截 Unicode 事件),
|
||||
/// session 结束 `restore_input_source` 切回。`type_unicode_chunk` 走 CGEvent FFI。
|
||||
/// - **Windows**:`switch_to_ascii` 是 no-op(SendInput Unicode 绕过 TSF);
|
||||
/// `type_unicode_chunk` 走 `SendInput(KEYEVENTF_UNICODE)`。
|
||||
/// - **Linux(实验)**:`switch_to_ascii` 是 no-op;`type_unicode_chunk` 走 enigo
|
||||
/// `Keyboard::text`。X11 / XTest 稳定,Wayland 看 compositor 给不给 libei 权限。
|
||||
///
|
||||
/// 通用流程:
|
||||
/// 1. `switch_to_ascii`(macOS)/ no-op(其他);失败则降级回一次性 `polish_or_passthrough`。
|
||||
/// 2. 起一个 `spawn_blocking` 后台任务,从 mpsc 收 SSE delta,逐 delta 调
|
||||
/// `type_unicode_chunk` 模拟键盘事件落到光标处。串行有序,无竞态。
|
||||
/// 3. 调 `polish_or_passthrough_streaming`,`on_delta` 把 chunk 塞进 mpsc。
|
||||
/// 4. 流结束 / 失败 / 取消 → drop mpsc 发送端 → typer 任务 drain 完剩余 delta 退出 →
|
||||
/// `restore_input_source` 恢复用户原输入源(macOS 才有意义,其他平台 no-op)。
|
||||
/// 5. 返回 `(polished, polish_error, already_streamed)`:
|
||||
/// - 成功:`(text, None, true)` — 字符已经在屏幕上,调用方应当跳过 `inserter.insert`
|
||||
/// - 失败:`(raw_text, Some(reason), false)` — 流式过程出错,调用方走 raw 一次性兜底
|
||||
/// - 不支持:`run_streaming_polish` 内部直接调 `polish_or_passthrough` 透明降级
|
||||
///
|
||||
/// **不在流式路径里做**:`apply_chinese_script_preference` / `apply_correction_rules`
|
||||
/// 这两步在 v1 跳过 —— 字符已经一边流一边落出去了,不好回退。需要的话只能关 toggle 走
|
||||
/// 一次性路径。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_streaming_polish(
|
||||
inner: &Arc<Inner>,
|
||||
raw: &RawTranscript,
|
||||
mode: PolishMode,
|
||||
hotwords: &[String],
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: crate::types::ChineseScriptPreference,
|
||||
output_language_preference: crate::types::OutputLanguagePreference,
|
||||
llm_thinking_enabled: bool,
|
||||
front_app: Option<&str>,
|
||||
prior_turns: &[(String, String)],
|
||||
) -> (String, Option<String>, bool) {
|
||||
log::info!(
|
||||
"[coord] streaming_insert path ENTER (raw_chars={})",
|
||||
raw.text.chars().count()
|
||||
);
|
||||
|
||||
let app = inner.app.lock().clone();
|
||||
let Some(app) = app else {
|
||||
log::warn!("[coord] streaming_insert: no AppHandle in Inner; fall back to one-shot");
|
||||
let (p, e) = polish_or_passthrough(
|
||||
raw,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app,
|
||||
prior_turns,
|
||||
)
|
||||
.await;
|
||||
return (p, e, false);
|
||||
};
|
||||
|
||||
// 1. 切到 ABC 输入源。失败则降级 —— 流式路径上 CJK IME 拦截不是可恢复错误。
|
||||
log::info!("[coord] streaming_insert: switching input source to ABC");
|
||||
let prev_ime = match crate::unicode_keystroke::switch_to_ascii(&app).await {
|
||||
Ok(prev) => {
|
||||
log::info!(
|
||||
"[coord] streaming_insert: switched to ABC (had_previous={})",
|
||||
prev.is_some()
|
||||
);
|
||||
prev
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[coord] streaming_insert: switch_to_ascii failed: {e}; fall back to one-shot"
|
||||
);
|
||||
let (p, err) = polish_or_passthrough(
|
||||
raw,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app,
|
||||
prior_turns,
|
||||
)
|
||||
.await;
|
||||
return (p, err, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 起 typer 后台任务:从 mpsc 收 delta,串行调 type_unicode_chunk。
|
||||
// 同时累积 typed_text:屏幕上真正落字的内容,用于(a)SSE 中途失败时让 history
|
||||
// 与用户实际看到的内容一致;(b)pr-agent #412 反馈 \"saved output diverges
|
||||
// from what the user actually sees\"。
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let typer_handle = tokio::task::spawn_blocking(move || {
|
||||
let mut rx = rx;
|
||||
let mut typed_text = String::new();
|
||||
let mut first_failure: Option<String> = None;
|
||||
while let Some(delta) = rx.blocking_recv() {
|
||||
if first_failure.is_some() {
|
||||
// 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
|
||||
// 把 mpsc drain 完,避免发送端阻塞。
|
||||
continue;
|
||||
}
|
||||
match crate::unicode_keystroke::type_unicode_chunk(&delta) {
|
||||
Ok(()) => {
|
||||
typed_text.push_str(&delta);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \
|
||||
dropping remaining deltas",
|
||||
typed_text.chars().count()
|
||||
);
|
||||
first_failure = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
(typed_text, first_failure)
|
||||
});
|
||||
|
||||
// 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。
|
||||
let inner_for_cancel = Arc::clone(inner);
|
||||
let should_cancel = move || inner_for_cancel.state.lock().cancelled;
|
||||
let outcome = super::polish_or_passthrough_streaming(
|
||||
raw,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app,
|
||||
prior_turns,
|
||||
move |delta: &str| {
|
||||
let _ = tx.send(delta.to_string());
|
||||
},
|
||||
should_cancel,
|
||||
)
|
||||
.await;
|
||||
// tx 已经被 move 进 on_delta 闭包;闭包随 polish_or_passthrough_streaming 返回
|
||||
// 而 drop,typer 那侧 blocking_recv 拿到 None 自然退出。
|
||||
|
||||
// 4. 等 typer 把缓冲 drain 完,拿到实际落字的全文 + 第一条失败原因。
|
||||
let (typed_text, typer_failure) = typer_handle.await.unwrap_or_else(|e| {
|
||||
log::error!("[coord] streaming_insert: typer task join failed: {e}");
|
||||
(String::new(), Some(format!("typer join: {e}")))
|
||||
});
|
||||
let typed_chars = typed_text.chars().count();
|
||||
log::info!("[coord] streaming_insert: typer drained, typed {typed_chars} chars");
|
||||
|
||||
// 5. 无论流是否成功,都恢复用户原输入源。
|
||||
log::info!("[coord] streaming_insert: restoring input source");
|
||||
if let Err(e) = crate::unicode_keystroke::restore_input_source(&app, prev_ime).await {
|
||||
log::warn!("[coord] streaming_insert: restore_input_source failed: {e}");
|
||||
} else {
|
||||
log::info!("[coord] streaming_insert: input source restored");
|
||||
}
|
||||
|
||||
// 6. 把 outcome 翻译成 (polished, polish_error, already_streamed)。
|
||||
match outcome {
|
||||
super::StreamingPolishOutcome::Streamed(text) => {
|
||||
log::info!(
|
||||
"[coord] streaming_insert SUCCESS: polished_chars={} typed_chars={} typer_err={:?}",
|
||||
text.chars().count(),
|
||||
typed_chars,
|
||||
typer_failure
|
||||
);
|
||||
// 边界 case:polish 成功但 typer 在第一字就失败(最常见:session 开始时
|
||||
// 已处于 Secure Input;或 SendInput / enigo 拒绝)。屏幕上一字未见,
|
||||
// already_streamed=true 会让上层跳过 inserter,最终用户看不到任何内容。
|
||||
// 这里显式回退到一次性兜底,让正常 inserter 路径写出 polish 结果。
|
||||
// pr-agent #412 反馈 \"Missing fallback\"。
|
||||
if typed_chars == 0 {
|
||||
if let Some(reason) = typer_failure {
|
||||
log::warn!(
|
||||
"[coord] streaming_insert: zero chars typed despite polish success ({reason}); falling back to one-shot inserter"
|
||||
);
|
||||
return (text, Some(reason), false);
|
||||
}
|
||||
}
|
||||
// 先确定 final_text —— typer 中途失败时屏幕只有 typed_text 这一段,
|
||||
// history 记完整 polish 反而会让用户复盘困惑。让 history / clipboard /
|
||||
// 后续逻辑统统用 final_text,三处保持一致。
|
||||
// pr-agent #412 反馈 \"Clipboard Mismatch\":之前先写 text 到剪贴板再
|
||||
// 决定 typer 是否中途失败,导致 Cmd+V 粘出用户屏幕上没见过的内容。
|
||||
let (final_text, polish_err) = match typer_failure {
|
||||
Some(e) => (
|
||||
typed_text,
|
||||
Some(format!("typing partially failed: {e}")),
|
||||
),
|
||||
None => (text, None),
|
||||
};
|
||||
// 把 final_text 写回剪贴板(默认 on,可关)。一次性路径天然走剪贴板,
|
||||
// 开关默认对齐一次性行为,让 Cmd+V 重复粘贴可用。
|
||||
if inner.prefs.get().streaming_insert_save_clipboard {
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut cb) => match cb.set_text(final_text.clone()) {
|
||||
Ok(()) => log::info!(
|
||||
"[coord] streaming_insert: final text written to clipboard ({} chars)",
|
||||
final_text.chars().count()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"[coord] streaming_insert: clipboard set_text failed: {e}"
|
||||
),
|
||||
},
|
||||
Err(e) => log::warn!(
|
||||
"[coord] streaming_insert: clipboard handle init failed: {e}"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
"[coord] streaming_insert: clipboard save skipped (pref off)"
|
||||
);
|
||||
}
|
||||
(final_text, polish_err, true)
|
||||
}
|
||||
super::StreamingPolishOutcome::UnsupportedFallback => {
|
||||
log::info!("[coord] streaming_insert: dispatch reported unsupported, fall back to one-shot");
|
||||
let (p, e) = polish_or_passthrough(
|
||||
raw,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app,
|
||||
prior_turns,
|
||||
)
|
||||
.await;
|
||||
(p, e, false)
|
||||
}
|
||||
super::StreamingPolishOutcome::Failed(reason) => {
|
||||
log::warn!(
|
||||
"[coord] streaming_insert FAILED: {reason}; typed {typed_chars} chars before failure"
|
||||
);
|
||||
// 流式失败但已经流了一部分 chars:用户屏幕上有半截 polish。history 应当
|
||||
// 跟屏幕一致 —— 记 typed_text 而不是 raw.text,否则保存内容跟用户看见的
|
||||
// 内容会分叉(pr-agent #412 \"Wrong final text\" 反馈)。
|
||||
// 一字都没流时 typed_text 是空串,回到 raw 一次性兜底。
|
||||
if typed_chars > 0 {
|
||||
(
|
||||
typed_text,
|
||||
Some(format!(
|
||||
"streaming polish failed mid-stream after {typed_chars} chars: {reason}"
|
||||
)),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
(raw.text.clone(), Some(reason), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>) {
|
||||
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
|
||||
if !was_held {
|
||||
// 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。
|
||||
// 与 `hotkey_trigger_held` 互补:held 防 press-without-release,本检查防
|
||||
// press-release-press 三连过快。每个有效边沿都会更新时间戳。
|
||||
let now = std::time::Instant::now();
|
||||
let too_soon = {
|
||||
let mut last = inner.last_hotkey_dispatch_at.lock();
|
||||
let drop = matches!(*last, Some(t) if now.duration_since(t) < HOTKEY_DEBOUNCE);
|
||||
if !drop {
|
||||
*last = Some(now);
|
||||
}
|
||||
drop
|
||||
};
|
||||
if too_soon {
|
||||
log::info!(
|
||||
"[coord] hotkey pressed edge debounced (< {} ms since last dispatch)",
|
||||
HOTKEY_DEBOUNCE.as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。
|
||||
// 例外:dictation session 已经在跑(Starting / Listening / Processing / Inserting),
|
||||
// 即使 QA 浮窗被打开了,这条边沿也必须先走 dictation。否则 begin_qa_session 会
|
||||
// 第二次抢同一个麦克风 device —— 在 Linux/PipeWire 上甚至会成功打开两路捕获,
|
||||
// dictation 的 recorder 没人停;在 macOS/Windows 上 cpal 会拒绝第二次 build_input_stream
|
||||
// 但 dictation session 仍在跑、用户找不到从 QA 面板停掉它的入口。审计 3.3.1。
|
||||
let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle);
|
||||
let panel_visible = inner.qa_state.lock().panel_visible;
|
||||
if panel_visible {
|
||||
if panel_visible && !dictation_active {
|
||||
handle_qa_option_edge(inner).await;
|
||||
} else {
|
||||
handle_pressed(inner).await;
|
||||
@@ -48,8 +337,12 @@ pub(super) async fn handle_released_edge(inner: &Arc<Inner>) {
|
||||
let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst);
|
||||
if was_held {
|
||||
// QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。
|
||||
// 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到
|
||||
// dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时
|
||||
// end_session 不会触发,dictation 永远停不下来。审计 3.3.1。
|
||||
let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle);
|
||||
let panel_visible = inner.qa_state.lock().panel_visible;
|
||||
if panel_visible {
|
||||
if panel_visible && !dictation_active {
|
||||
return;
|
||||
}
|
||||
handle_released(inner).await;
|
||||
@@ -216,7 +509,79 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_whisper_compatible_provider(&active_asr) {
|
||||
if is_bailian_provider(&active_asr) {
|
||||
let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials()));
|
||||
let bridge = Arc::new(DeferredAsrBridge::new());
|
||||
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
|
||||
store_asr_for_session(
|
||||
inner,
|
||||
current_session_id,
|
||||
ActiveAsr::Bailian(Arc::clone(&asr)),
|
||||
);
|
||||
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
|
||||
|
||||
if let Err(e) = asr.open_session().await {
|
||||
log::error!("[coord] open Bailian ASR session failed: {e}");
|
||||
match startup_race_status_for_starting(inner, current_session_id) {
|
||||
StartupRaceStatus::StaleContinuation => {
|
||||
log::info!(
|
||||
"[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring"
|
||||
);
|
||||
asr.cancel();
|
||||
discard_startup_resources_for_session(inner, current_session_id);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
StartupRaceStatus::CancelRaced => {
|
||||
asr.cancel();
|
||||
discard_startup_resources_for_session(inner, current_session_id);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
StartupRaceStatus::ActiveStarting => {
|
||||
asr.cancel();
|
||||
}
|
||||
}
|
||||
discard_startup_resources_for_session(inner, current_session_id);
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
0,
|
||||
Some(format!("ASR 连接失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
match startup_race_status_for_starting(inner, current_session_id) {
|
||||
StartupRaceStatus::ActiveStarting => {}
|
||||
StartupRaceStatus::CancelRaced => {
|
||||
log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin");
|
||||
asr.cancel();
|
||||
discard_startup_resources_for_session(inner, current_session_id);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
StartupRaceStatus::StaleContinuation => {
|
||||
log::info!(
|
||||
"[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring"
|
||||
);
|
||||
asr.cancel();
|
||||
discard_startup_resources_for_session(inner, current_session_id);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
|
||||
let flushed_bytes = bridge.attach(target);
|
||||
log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes");
|
||||
finish_starting_session(inner, current_session_id).await;
|
||||
} else if is_whisper_compatible_provider(&active_asr) {
|
||||
let (api_key, base_url, model) = read_whisper_credentials();
|
||||
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
|
||||
// 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には
|
||||
@@ -252,7 +617,7 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
current_session_id,
|
||||
ActiveAsr::Volcengine(Arc::clone(&asr)),
|
||||
);
|
||||
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?;
|
||||
start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?;
|
||||
|
||||
if let Err(e) = asr.open_session().await {
|
||||
log::error!("[coord] open ASR session failed: {e}");
|
||||
@@ -321,7 +686,7 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn start_recorder_for_starting(
|
||||
pub(super) async fn start_recorder_for_starting(
|
||||
inner: &Arc<Inner>,
|
||||
session_id: SessionId,
|
||||
active_asr: &str,
|
||||
@@ -366,7 +731,7 @@ pub(super) fn start_recorder_for_starting(
|
||||
|
||||
let microphone_device_name = selected_microphone_device_name(inner);
|
||||
stop_microphone_preview_monitor(inner, "dictation recorder");
|
||||
acquire_recording_mute(inner, "dictation");
|
||||
acquire_recording_mute(inner, "dictation").await;
|
||||
match Recorder::start(microphone_device_name, consumer, level_handler) {
|
||||
Ok((rec, runtime_errors)) => {
|
||||
store_recorder_for_session(inner, session_id, rec);
|
||||
@@ -473,7 +838,7 @@ pub(super) async fn start_recorder_and_enter_listening(
|
||||
active_asr: &str,
|
||||
consumer: Arc<dyn crate::recorder::AudioConsumer>,
|
||||
) -> Result<(), String> {
|
||||
start_recorder_for_starting(inner, session_id, active_asr, consumer)?;
|
||||
start_recorder_for_starting(inner, session_id, active_asr, consumer).await?;
|
||||
finish_starting_session(inner, session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -627,6 +992,50 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
ActiveAsr::Bailian(asr) => {
|
||||
debug_assert!(uses_global_timeout);
|
||||
if let Err(e) = asr.send_last_frame().await {
|
||||
log::error!("[coord] Bailian send last frame failed: {e}");
|
||||
}
|
||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
log::error!("[coord] Bailian await final failed: {e}");
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some(format!("识别失败: {e}")),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"[coord] Bailian 全局超时 {} 秒",
|
||||
COORDINATOR_GLOBAL_TIMEOUT_SECS
|
||||
);
|
||||
asr.cancel();
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("识别超时".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
return Err("bailian global timeout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => {
|
||||
debug_assert!(!uses_global_timeout);
|
||||
@@ -719,7 +1128,15 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
if inner.state.lock().cancelled {
|
||||
log::info!("[coord] cancel detected after ASR — discarding transcript");
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
// PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。
|
||||
// cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让
|
||||
// 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review
|
||||
// 2026-05-10 P3 (🚩) 把这条补完。
|
||||
{
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -763,7 +1180,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
CapsuleState::Error,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("ASR returned empty transcript".to_string()),
|
||||
Some("没有识别到语音".to_string()),
|
||||
None,
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
@@ -772,6 +1189,25 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
return Err("ASR returned empty transcript".to_string());
|
||||
}
|
||||
|
||||
let correction_rules = match inner.correction_rules.list() {
|
||||
Ok(rules) => rules,
|
||||
Err(e) => {
|
||||
log::warn!("[coord] load correction rules failed: {e}; continue without correction");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if !correction_rules.is_empty() {
|
||||
let corrected = apply_correction_rules(&raw.text, &correction_rules);
|
||||
if corrected != raw.text {
|
||||
log::info!(
|
||||
"[coord] correction rules adjusted raw transcript ({} → {} chars)",
|
||||
raw.text.chars().count(),
|
||||
corrected.chars().count()
|
||||
);
|
||||
raw.text = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
@@ -780,6 +1216,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
let chinese_script_preference = prefs.chinese_script_preference;
|
||||
let output_language_preference = prefs.output_language_preference;
|
||||
let llm_thinking_enabled = prefs.llm_thinking_enabled;
|
||||
let front_app = inner.state.lock().front_app.clone();
|
||||
let translation_target = prefs.translation_target_language.trim().to_string();
|
||||
let translation_active =
|
||||
@@ -811,34 +1248,60 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (polished, polish_error) = if translation_active {
|
||||
// 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。
|
||||
// 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。
|
||||
let streaming_eligible =
|
||||
prefs.streaming_insert && !translation_active && mode != PolishMode::Raw;
|
||||
log::info!(
|
||||
"[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}"
|
||||
);
|
||||
|
||||
let (polished, polish_error, already_streamed) = if translation_active {
|
||||
log::info!(
|
||||
"[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}",
|
||||
translation_target,
|
||||
working_languages,
|
||||
front_app
|
||||
);
|
||||
translate_or_passthrough(
|
||||
let (p, e) = translate_or_passthrough(
|
||||
&raw,
|
||||
&translation_target,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
polish_or_passthrough(
|
||||
.await;
|
||||
(p, e, false)
|
||||
} else if streaming_eligible {
|
||||
run_streaming_polish(
|
||||
inner,
|
||||
&raw,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let (p, e) = polish_or_passthrough(
|
||||
&raw,
|
||||
mode,
|
||||
&hotword_strs,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
llm_thinking_enabled,
|
||||
front_app.as_deref(),
|
||||
&prior_turns,
|
||||
)
|
||||
.await;
|
||||
(p, e, false)
|
||||
};
|
||||
|
||||
// 仅在“ASR 直出文本”场景做强制简繁收敛,避免误伤成功的翻译/常规 LLM 输出:
|
||||
@@ -854,14 +1317,31 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
} else {
|
||||
polished
|
||||
};
|
||||
let polished = if correction_rules.is_empty() {
|
||||
polished
|
||||
} else {
|
||||
let corrected = apply_correction_rules(&polished, &correction_rules);
|
||||
if corrected != polished {
|
||||
log::info!(
|
||||
"[coord] correction rules adjusted final text ({} → {} chars)",
|
||||
polished.chars().count(),
|
||||
corrected.chars().count()
|
||||
);
|
||||
}
|
||||
corrected
|
||||
};
|
||||
|
||||
// 原子化最后一次 cancel 检查 + 转 Inserting:
|
||||
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
|
||||
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
|
||||
// 之前 check 与 inserter.insert 之间有窗口期。
|
||||
//
|
||||
// 流式路径例外:`already_streamed = true` 表示字符已经一边流一边落到光标了,
|
||||
// 撤销不掉。即使 cancel 旗在中途被立起来,也只能尊重「已经发生」的事实,进入
|
||||
// Inserting 状态完成 history / vocab 等收尾工作。
|
||||
let proceed_to_insert = {
|
||||
let mut state = inner.state.lock();
|
||||
if state.cancelled {
|
||||
if state.cancelled && !already_streamed {
|
||||
state.phase = SessionPhase::Idle;
|
||||
false
|
||||
} else {
|
||||
@@ -883,7 +1363,16 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
let prefs = inner.prefs.get();
|
||||
let restore_clipboard = prefs.restore_clipboard_after_paste;
|
||||
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
|
||||
let status = if focus_ready_for_paste {
|
||||
let paste_shortcut = prefs.paste_shortcut;
|
||||
// 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。
|
||||
let status = if already_streamed {
|
||||
log::info!(
|
||||
"[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})",
|
||||
polished.chars().count(),
|
||||
polish_error
|
||||
);
|
||||
InsertStatus::Inserted
|
||||
} else if focus_ready_for_paste {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ime_target = capture_ime_submit_target();
|
||||
@@ -893,13 +1382,16 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
&polished,
|
||||
restore_clipboard,
|
||||
allow_non_tsf_insertion_fallback,
|
||||
paste_shortcut,
|
||||
ime_target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
inner.inserter.insert(&polished, restore_clipboard)
|
||||
inner
|
||||
.inserter
|
||||
.insert(&polished, restore_clipboard, paste_shortcut)
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::coordinator_state::{initial_session_id, SessionId};
|
||||
use crate::coordinator_state::{initial_session_id, SessionId, SessionPhase};
|
||||
use crate::selection::SelectionContext;
|
||||
use crate::types::CapsuleState;
|
||||
|
||||
@@ -86,9 +86,16 @@ pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
|
||||
state.selection = None;
|
||||
state.front_app = capture_frontmost_app();
|
||||
}
|
||||
// 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars
|
||||
// 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
// 主听写 phase 是 Idle 才需要 sweep capsule —— 这里的语义是清掉「上一次 dictation
|
||||
// Done 状态残留」的 message / insertedChars,让 QA 自己的 capsule 状态从干净起跑
|
||||
// (否则 capsule UI 会出现 "已粘贴这个 0" 之类把上一次 inserted_chars 错误复用的
|
||||
// 显示)。但如果 dictation 当前正处于 Recording / Polishing / Inserting / Done toast
|
||||
// 显示中,强行 emit Idle 会把用户没看完的反馈抹掉、或者把 Polishing 中的进度条
|
||||
// 卡死。审计 3.3.4。
|
||||
let dictation_idle = matches!(inner.state.lock().phase, SessionPhase::Idle);
|
||||
if dictation_idle {
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
}
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
crate::show_qa_window(&app, "idle");
|
||||
let _ = app.emit_to(
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) {
|
||||
match asr {
|
||||
ActiveAsr::Volcengine(v) => v.cancel(),
|
||||
ActiveAsr::Whisper(w) => w.cancel(),
|
||||
ActiveAsr::Bailian(b) => b.cancel(),
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -109,37 +110,81 @@ pub(super) fn stop_microphone_preview_monitor(inner: &Arc<Inner>, owner: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn acquire_recording_mute(inner: &Arc<Inner>, owner: &str) {
|
||||
/// Acquire system-output mute for the duration of a recording session.
|
||||
///
|
||||
/// `AudioMuteGuard::activate()` on macOS shells out to `osascript` (~100–300 ms)
|
||||
/// and on Linux to `wpctl`/`pactl` (similar). When called from the async
|
||||
/// `begin_session` path that blocks the tokio worker thread for the entire
|
||||
/// duration, delaying the recorder start by exactly that much. Wrap the
|
||||
/// activate + bookkeeping in `spawn_blocking` so the tokio worker is freed
|
||||
/// while the shell-out runs. Parking-lot `Mutex` guards never cross an await
|
||||
/// (they live entirely inside the blocking task). Audit 3.2.4.
|
||||
pub(super) async fn acquire_recording_mute(inner: &Arc<Inner>, owner: &'static str) {
|
||||
if !inner.prefs.get().mute_during_recording {
|
||||
return;
|
||||
}
|
||||
let mut mute = inner.recording_mute.lock();
|
||||
if mute.holders == 0 {
|
||||
match crate::audio_mute::AudioMuteGuard::activate() {
|
||||
Ok(guard) => {
|
||||
mute.guard = Some(guard);
|
||||
log::info!("[audio-mute] system output muted for recording");
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[audio-mute] failed to mute output for {owner}: {err}");
|
||||
return;
|
||||
let inner = Arc::clone(inner);
|
||||
let join_result = tokio::task::spawn_blocking(move || {
|
||||
let mut mute = inner.recording_mute.lock();
|
||||
if mute.holders == 0 {
|
||||
match crate::audio_mute::AudioMuteGuard::activate() {
|
||||
Ok(guard) => {
|
||||
mute.guard = Some(guard);
|
||||
log::info!("[audio-mute] system output muted for recording");
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[audio-mute] failed to mute output for {owner}: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
mute.holders = mute.holders.saturating_add(1);
|
||||
log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders);
|
||||
})
|
||||
.await;
|
||||
// 显式记录 spawn_blocking 任务的 panic(之前是 `let _ = .await` 静默吞掉)。
|
||||
// holders/guard 状态本身在 panic 路径下仍然一致 —— 因为 panic 只能发生在
|
||||
// activate() 抛 / lock 抛,前者会让 holders 不增 + guard 仍 None,后者根本
|
||||
// 进不到 mutate 阶段;但用户碰到 system audio 在录音时漏出系统声却找不到
|
||||
// 任何 [audio-mute] 日志,没法 debug。pr_agent feedback on PR #391。
|
||||
if let Err(join_err) = join_result {
|
||||
log::error!(
|
||||
"[audio-mute] acquire task panicked for {owner}: {join_err}; mute did not activate"
|
||||
);
|
||||
}
|
||||
mute.holders = mute.holders.saturating_add(1);
|
||||
log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders);
|
||||
}
|
||||
|
||||
pub(super) fn release_recording_mute(inner: &Arc<Inner>, owner: &str) {
|
||||
let mut mute = inner.recording_mute.lock();
|
||||
if mute.holders == 0 {
|
||||
return;
|
||||
}
|
||||
mute.holders -= 1;
|
||||
log::info!("[audio-mute] released by {owner}; holders={}", mute.holders);
|
||||
if mute.holders == 0 {
|
||||
mute.guard.take();
|
||||
log::info!("[audio-mute] system output mute restored after recording");
|
||||
/// Release the recording-mute guard. The Drop impl on `AudioMuteGuard` shells
|
||||
/// out to `osascript` / `wpctl` again, so when holders reaches 0 we hand the
|
||||
/// drop off to a blocking task to keep the tokio worker free. Audit 3.2.4.
|
||||
///
|
||||
/// Fire-and-forget (no await): callers — `cancel_session`, `end_session`,
|
||||
/// recorder error monitor — don't need the mute restoration to complete
|
||||
/// before they continue. The user has already stopped recording; system audio
|
||||
/// recovery happening 100 ms later is fine.
|
||||
///
|
||||
/// `release_recording_mute` is also called from non-tokio threads (the recorder
|
||||
/// error monitor uses `std::thread::spawn`), so fall back to a synchronous
|
||||
/// run when there's no current tokio handle — running synchronously on a std
|
||||
/// thread blocks nothing.
|
||||
pub(super) fn release_recording_mute(inner: &Arc<Inner>, owner: &'static str) {
|
||||
let inner = Arc::clone(inner);
|
||||
let work = move || {
|
||||
let mut mute = inner.recording_mute.lock();
|
||||
if mute.holders == 0 {
|
||||
return;
|
||||
}
|
||||
mute.holders -= 1;
|
||||
log::info!("[audio-mute] released by {owner}; holders={}", mute.holders);
|
||||
if mute.holders == 0 {
|
||||
mute.guard.take();
|
||||
log::info!("[audio-mute] system output mute restored after recording");
|
||||
}
|
||||
};
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn_blocking(work);
|
||||
} else {
|
||||
work();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,11 +164,14 @@ pub(crate) fn begin_cancel_session_state(state: &mut SessionState) -> Option<Can
|
||||
})
|
||||
}
|
||||
|
||||
/// cancel_session 外部资源清理后的锁内收尾。Processing 交给 end_session 自己收尾。
|
||||
/// cancel_session 外部资源清理后的锁内收尾。Processing 阶段把 phase 留给 end_session
|
||||
/// 自己收尾(防止与 polish/insert 路径竞争),但 focus_target 是当前 session 的窗口
|
||||
/// 资源句柄,cancel 之后无论处于哪个 phase 都应当释放,避免下一个 session 之前的
|
||||
/// 空档期被旧值污染。详见 audit 3.3.5。
|
||||
pub(crate) fn finish_cancel_session_state(state: &mut SessionState, decision: CancelDecision) {
|
||||
state.focus_target = None;
|
||||
if decision.phase != SessionPhase::Processing {
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,8 +370,19 @@ mod tests {
|
||||
|
||||
assert_eq!(state.phase, expected_phase, "initial={initial:?}");
|
||||
assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}");
|
||||
if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) {
|
||||
assert!(state.focus_target.is_none(), "initial={initial:?}");
|
||||
// 任何被 begin_cancel_session_state 接受的 phase(即非 Idle/Inserting)
|
||||
// 都应当清掉 focus_target,包括 Processing —— 这是 audit 3.3.5 的回归卡。
|
||||
if expected_cancelled {
|
||||
assert!(
|
||||
state.focus_target.is_none(),
|
||||
"focus_target should clear after cancel, initial={initial:?}"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
state.focus_target,
|
||||
Some(1),
|
||||
"rejected cancel must not touch focus_target, initial={initial:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! 用户自定义纠正规则。
|
||||
//!
|
||||
//! 规则独立于词汇表:词汇表负责 ASR/LLM 热词提示,纠正规则负责在听写流水线里
|
||||
//! 做确定性的文本替换。当前只支持一个保守通配符 `{num}`,避免把任意正则暴露给
|
||||
//! 用户造成误替换。
|
||||
|
||||
use crate::types::CorrectionRule;
|
||||
|
||||
const NUM_TOKEN: &str = "{num}";
|
||||
|
||||
pub fn apply_correction_rules(text: &str, rules: &[CorrectionRule]) -> String {
|
||||
let mut current = text.to_string();
|
||||
for rule in rules {
|
||||
if !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
let pattern = rule.pattern.trim();
|
||||
if pattern.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current = apply_rule(¤t, pattern, &rule.replacement);
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn apply_rule(text: &str, pattern: &str, replacement: &str) -> String {
|
||||
let token_count = pattern.matches(NUM_TOKEN).count();
|
||||
if token_count == 0 {
|
||||
if replacement.contains(NUM_TOKEN) {
|
||||
return text.to_string();
|
||||
}
|
||||
return text.replace(pattern, replacement);
|
||||
}
|
||||
if token_count != 1 {
|
||||
return text.to_string();
|
||||
}
|
||||
apply_num_rule(text, pattern, replacement)
|
||||
}
|
||||
|
||||
fn apply_num_rule(text: &str, pattern: &str, replacement: &str) -> String {
|
||||
let Some((prefix, suffix)) = pattern.split_once(NUM_TOKEN) else {
|
||||
return text.to_string();
|
||||
};
|
||||
if prefix.is_empty() && suffix.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let mut output = String::with_capacity(text.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < text.len() {
|
||||
let Some((match_start, token_start)) = next_prefix_match(text, cursor, prefix) else {
|
||||
break;
|
||||
};
|
||||
let Some(token_end) = consume_number_token(text, token_start) else {
|
||||
output.push_str(&text[cursor..next_char_boundary(text, match_start)]);
|
||||
cursor = next_char_boundary(text, match_start);
|
||||
continue;
|
||||
};
|
||||
let after_number = &text[token_end..];
|
||||
if !after_number.starts_with(suffix) {
|
||||
output.push_str(&text[cursor..next_char_boundary(text, match_start)]);
|
||||
cursor = next_char_boundary(text, match_start);
|
||||
continue;
|
||||
}
|
||||
|
||||
let match_end = token_end + suffix.len();
|
||||
output.push_str(&text[cursor..match_start]);
|
||||
output.push_str(&replacement.replace(NUM_TOKEN, &text[token_start..token_end]));
|
||||
cursor = match_end;
|
||||
}
|
||||
output.push_str(&text[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn next_prefix_match(text: &str, cursor: usize, prefix: &str) -> Option<(usize, usize)> {
|
||||
if prefix.is_empty() {
|
||||
let match_start = next_number_start(text, cursor)?;
|
||||
return Some((match_start, match_start));
|
||||
}
|
||||
let relative = text[cursor..].find(prefix)?;
|
||||
let match_start = cursor + relative;
|
||||
Some((match_start, match_start + prefix.len()))
|
||||
}
|
||||
|
||||
fn next_number_start(text: &str, cursor: usize) -> Option<usize> {
|
||||
text[cursor..]
|
||||
.char_indices()
|
||||
.find_map(|(offset, ch)| is_number_char(ch).then_some(cursor + offset))
|
||||
}
|
||||
|
||||
fn consume_number_token(text: &str, start: usize) -> Option<usize> {
|
||||
let mut end = start;
|
||||
let mut consumed = false;
|
||||
for (offset, ch) in text[start..].char_indices() {
|
||||
if !is_number_char(ch) {
|
||||
break;
|
||||
}
|
||||
consumed = true;
|
||||
end = start + offset + ch.len_utf8();
|
||||
}
|
||||
consumed.then_some(end)
|
||||
}
|
||||
|
||||
fn is_number_char(ch: char) -> bool {
|
||||
ch.is_ascii_digit()
|
||||
|| matches!(
|
||||
ch,
|
||||
'零' | '〇'
|
||||
| '一'
|
||||
| '二'
|
||||
| '两'
|
||||
| '兩'
|
||||
| '三'
|
||||
| '四'
|
||||
| '五'
|
||||
| '六'
|
||||
| '七'
|
||||
| '八'
|
||||
| '九'
|
||||
| '十'
|
||||
| '百'
|
||||
| '千'
|
||||
| '万'
|
||||
| '萬'
|
||||
| '亿'
|
||||
| '億'
|
||||
| '几'
|
||||
| '幾'
|
||||
)
|
||||
}
|
||||
|
||||
fn next_char_boundary(text: &str, start: usize) -> usize {
|
||||
text[start..]
|
||||
.chars()
|
||||
.next()
|
||||
.map(|ch| start + ch.len_utf8())
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(pattern: &str, replacement: &str) -> CorrectionRule {
|
||||
CorrectionRule {
|
||||
id: "rule".into(),
|
||||
pattern: pattern.into(),
|
||||
replacement: replacement.into(),
|
||||
enabled: true,
|
||||
created_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_literal_replacement() {
|
||||
let rules = vec![rule("几粒", "几例")];
|
||||
assert_eq!(
|
||||
apply_correction_rules("这里有几粒样品", &rules),
|
||||
"这里有几例样品"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_num_wildcard_for_arabic_digits() {
|
||||
let rules = vec![rule("{num}粒", "{num}例")];
|
||||
assert_eq!(
|
||||
apply_correction_rules("2粒样品和10粒对照", &rules),
|
||||
"2例样品和10例对照"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_num_wildcard_for_chinese_numbers() {
|
||||
let rules = vec![rule("{num}粒", "{num}例")];
|
||||
assert_eq!(
|
||||
apply_correction_rules("两粒样品和幾粒对照", &rules),
|
||||
"两例样品和幾例对照"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_rules_are_ignored() {
|
||||
let mut disabled = rule("{num}粒", "{num}例");
|
||||
disabled.enabled = false;
|
||||
assert_eq!(apply_correction_rules("10粒样品", &[disabled]), "10粒样品");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rules_are_inert() {
|
||||
let rules = vec![
|
||||
rule("{num}到{num}粒", "{num}例"),
|
||||
rule("几粒", "{num}例"),
|
||||
rule("{num}", "{num}例"),
|
||||
];
|
||||
assert_eq!(apply_correction_rules("几粒和10粒", &rules), "几粒和10粒");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_rules_sequentially() {
|
||||
let rules = vec![rule("{num}粒", "{num}例"), rule("样本", "样品")];
|
||||
assert_eq!(apply_correction_rules("10粒样本", &rules), "10例样品");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! and one dispatcher instead of racing on `GlobalHotKeyEvent::receiver()`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -20,6 +21,10 @@ static RUNTIME: OnceCell<Arc<GlobalHotkeyRuntime>> = OnceCell::new();
|
||||
pub struct GlobalHotkeyRuntime {
|
||||
manager: GlobalHotKeyManager,
|
||||
routes: Mutex<HashMap<u32, Sender<GlobalHotKeyEvent>>>,
|
||||
/// 用于 dispatcher loop 的退出信号。process-singleton 在生产路径里不会被
|
||||
/// drop,但 integration test / future RunEvent::Exit 钩子可以调用
|
||||
/// `request_shutdown` 让 dispatcher 退出。审计 3.4.4。
|
||||
shutdown: AtomicBool,
|
||||
}
|
||||
|
||||
// global-hotkey 0.6 does not mark its manager Send/Sync on all platforms even
|
||||
@@ -41,6 +46,7 @@ impl GlobalHotkeyRuntime {
|
||||
let runtime = Arc::new(Self {
|
||||
manager,
|
||||
routes: Mutex::new(HashMap::new()),
|
||||
shutdown: AtomicBool::new(false),
|
||||
});
|
||||
start_dispatcher(Arc::clone(&runtime));
|
||||
Ok(runtime)
|
||||
@@ -48,6 +54,14 @@ impl GlobalHotkeyRuntime {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Signal the dispatcher loop to exit at its next 250 ms tick. Idempotent.
|
||||
/// Currently called only from tests; production app shutdown lets the OS
|
||||
/// reap the process. Audit 3.4.4.
|
||||
#[allow(dead_code)]
|
||||
pub fn request_shutdown(&self) {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
self: &Arc<Self>,
|
||||
hotkey: HotKey,
|
||||
@@ -97,6 +111,9 @@ fn start_dispatcher(runtime: Arc<GlobalHotkeyRuntime>) {
|
||||
.spawn(move || {
|
||||
let receiver = GlobalHotKeyEvent::receiver();
|
||||
loop {
|
||||
if runtime.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => runtime.dispatch(event),
|
||||
Err(_) => continue,
|
||||
|
||||
@@ -292,14 +292,32 @@ mod platform {
|
||||
"hotkey hook 启动超时",
|
||||
run_listen_loop,
|
||||
)?;
|
||||
listener.startup;
|
||||
Ok(Box::new(MacHotkeyAdapter {
|
||||
shared: listener.shared,
|
||||
handles: listener.startup,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Refs needed to stop the Mac CFRunLoop / CGEventTap from outside the listener
|
||||
/// thread. Filled in by `run_listen_loop` once the tap is created and the runloop
|
||||
/// reference is captured; consumed by `MacHotkeyAdapter::shutdown` when the
|
||||
/// monitor is dropped (so a binding swap or app shutdown doesn't leak the
|
||||
/// listener thread + tap). Cf. audit 3.1.1.
|
||||
struct MacShutdownHandles {
|
||||
tap: std::sync::Mutex<Option<CfMachPortRef>>,
|
||||
runloop: std::sync::Mutex<Option<CfRunLoopRef>>,
|
||||
}
|
||||
|
||||
// SAFETY: CfMachPortRef / CfRunLoopRef are CoreFoundation handles; the only
|
||||
// operations we perform on them across threads are CGEventTapEnable and
|
||||
// CFRunLoopStop, both of which Apple documents as safe to call from any
|
||||
// thread.
|
||||
unsafe impl Send for MacShutdownHandles {}
|
||||
unsafe impl Sync for MacShutdownHandles {}
|
||||
|
||||
struct MacHotkeyAdapter {
|
||||
shared: Arc<Shared>,
|
||||
handles: Arc<MacShutdownHandles>,
|
||||
}
|
||||
|
||||
impl HotkeyAdapter for MacHotkeyAdapter {
|
||||
@@ -322,6 +340,19 @@ mod platform {
|
||||
fn reset_held_state(&self) {
|
||||
reset_shared_held_state(&self.shared);
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
// 顺序:先 disable tap 让 OS 不再向我们派发事件,然后 stop runloop
|
||||
// 让 listener 线程从 CFRunLoopRun() 返回退出。take() 保证幂等。
|
||||
let tap = self.handles.tap.lock().ok().and_then(|mut g| g.take());
|
||||
if let Some(tap) = tap {
|
||||
unsafe { CGEventTapEnable(tap, false) };
|
||||
}
|
||||
let runloop = self.handles.runloop.lock().ok().and_then(|mut g| g.take());
|
||||
if let Some(rl) = runloop {
|
||||
unsafe { CFRunLoopStop(rl) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Raw CG/CF FFI ──────────────────────────────────────────────────────
|
||||
@@ -404,24 +435,35 @@ mod platform {
|
||||
fn CFRunLoopGetCurrent() -> CfRunLoopRef;
|
||||
fn CFRunLoopAddSource(rl: CfRunLoopRef, source: CfRunLoopSourceRef, mode: CfStringRef);
|
||||
fn CFRunLoopRun();
|
||||
fn CFRunLoopStop(rl: CfRunLoopRef);
|
||||
static kCFRunLoopCommonModes: CfStringRef;
|
||||
}
|
||||
|
||||
struct CallbackContext {
|
||||
shared: Arc<Shared>,
|
||||
tx: Sender<HotkeyEvent>,
|
||||
tap: std::sync::Mutex<Option<CfMachPortRef>>,
|
||||
/// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on
|
||||
/// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。
|
||||
handles: Arc<MacShutdownHandles>,
|
||||
}
|
||||
|
||||
unsafe impl Send for CallbackContext {}
|
||||
unsafe impl Sync for CallbackContext {}
|
||||
|
||||
fn run_listen_loop(shared: Arc<Shared>, tx: Sender<HotkeyEvent>, status_tx: StartupTx<()>) {
|
||||
fn run_listen_loop(
|
||||
shared: Arc<Shared>,
|
||||
tx: Sender<HotkeyEvent>,
|
||||
status_tx: StartupTx<Arc<MacShutdownHandles>>,
|
||||
) {
|
||||
let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN);
|
||||
let handles = Arc::new(MacShutdownHandles {
|
||||
tap: std::sync::Mutex::new(None),
|
||||
runloop: std::sync::Mutex::new(None),
|
||||
});
|
||||
let context = Box::into_raw(Box::new(CallbackContext {
|
||||
shared,
|
||||
tx,
|
||||
tap: std::sync::Mutex::new(None),
|
||||
handles: Arc::clone(&handles),
|
||||
}));
|
||||
|
||||
unsafe {
|
||||
@@ -444,16 +486,20 @@ mod platform {
|
||||
)));
|
||||
return;
|
||||
}
|
||||
*(*context).tap.lock().unwrap() = Some(tap);
|
||||
*handles.tap.lock().unwrap() = Some(tap);
|
||||
|
||||
let source = CFMachPortCreateRunLoopSource(std::ptr::null(), tap, 0);
|
||||
let runloop = CFRunLoopGetCurrent();
|
||||
*handles.runloop.lock().unwrap() = Some(runloop);
|
||||
CFRunLoopAddSource(runloop, source, kCFRunLoopCommonModes);
|
||||
CGEventTapEnable(tap, true);
|
||||
|
||||
log::info!("[hotkey] CGEventTap 已启动");
|
||||
let _ = status_tx.send(Ok(()));
|
||||
let _ = status_tx.send(Ok(handles));
|
||||
// CFRunLoopRun 阻塞直到 CFRunLoopStop 被调用(由 MacHotkeyAdapter::shutdown
|
||||
// 触发)。返回后 listener 线程清理 context 并自然退出。
|
||||
CFRunLoopRun();
|
||||
let _ = Box::from_raw(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +516,7 @@ mod platform {
|
||||
|
||||
match event_type {
|
||||
TAP_DISABLED_BY_TIMEOUT | TAP_DISABLED_BY_USER_INPUT => {
|
||||
if let Some(tap) = *ctx.tap.lock().unwrap() {
|
||||
if let Some(tap) = *ctx.handles.tap.lock().unwrap() {
|
||||
unsafe { CGEventTapEnable(tap, true) };
|
||||
}
|
||||
return event;
|
||||
@@ -622,7 +668,10 @@ mod platform {
|
||||
CallbackContext {
|
||||
shared,
|
||||
tx,
|
||||
tap: std::sync::Mutex::new(None),
|
||||
handles: Arc::new(MacShutdownHandles {
|
||||
tap: std::sync::Mutex::new(None),
|
||||
runloop: std::sync::Mutex::new(None),
|
||||
}),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
@@ -716,6 +765,7 @@ mod platform {
|
||||
const VK_RSHIFT: u32 = 0xA1;
|
||||
const VK_LCONTROL: u32 = 0xA2;
|
||||
const VK_RCONTROL: u32 = 0xA3;
|
||||
const VK_LMENU: u32 = 0xA4;
|
||||
const VK_RMENU: u32 = 0xA5;
|
||||
const VK_RWIN: u32 = 0x5C;
|
||||
const LLKHF_INJECTED: u32 = 0x0000_0010;
|
||||
@@ -966,18 +1016,15 @@ mod platform {
|
||||
}
|
||||
|
||||
fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 {
|
||||
// Windows only gives us a small set of modifier virtual keys that can be
|
||||
// used as reliable modifier-only global triggers, so the cross-platform
|
||||
// trigger list intentionally collapses a few aliases onto the same
|
||||
// physical Windows key:
|
||||
// - LeftOption reuses RightAlt / VK_RMENU
|
||||
// - Fn reuses RightControl / VK_RCONTROL
|
||||
// Windows 低层 hook 能区分左右 Alt,LeftOption / RightOption 必须保留物理侧。
|
||||
// 其他少量跨平台别名仍按 Windows 可用物理键折叠:
|
||||
// - Fn 复用 RightControl / VK_RCONTROL
|
||||
match trigger {
|
||||
HotkeyTrigger::RightControl => VK_RCONTROL,
|
||||
HotkeyTrigger::LeftControl => VK_LCONTROL,
|
||||
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU,
|
||||
HotkeyTrigger::RightCommand => VK_RWIN,
|
||||
HotkeyTrigger::LeftOption => VK_RMENU,
|
||||
HotkeyTrigger::LeftOption => VK_LMENU,
|
||||
HotkeyTrigger::Fn => VK_RCONTROL,
|
||||
HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"),
|
||||
}
|
||||
@@ -1072,7 +1119,7 @@ mod platform {
|
||||
|
||||
dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN);
|
||||
dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN);
|
||||
dispatch_keyboard_event(&ctx, VK_RMENU, WM_KEYDOWN);
|
||||
dispatch_keyboard_event(&ctx, VK_LMENU, WM_KEYDOWN);
|
||||
dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN);
|
||||
dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN);
|
||||
dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYUP);
|
||||
@@ -1088,6 +1135,48 @@ mod platform {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_option_triggers_keep_left_and_right_alt_separate() {
|
||||
let left_shared = shared(HotkeyTrigger::LeftOption);
|
||||
let (left_ctx, left_rx) = callback_context(left_shared);
|
||||
|
||||
assert!(!dispatch_keyboard_event(&left_ctx, VK_RMENU, WM_KEYDOWN));
|
||||
assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYDOWN));
|
||||
assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYUP));
|
||||
assert_eq!(
|
||||
drain(&left_rx),
|
||||
vec![HotkeyEvent::Pressed, HotkeyEvent::Released]
|
||||
);
|
||||
|
||||
let right_option_shared = shared(HotkeyTrigger::RightOption);
|
||||
let (right_option_ctx, right_option_rx) = callback_context(right_option_shared);
|
||||
assert!(!dispatch_keyboard_event(
|
||||
&right_option_ctx,
|
||||
VK_LMENU,
|
||||
WM_KEYDOWN
|
||||
));
|
||||
assert!(dispatch_keyboard_event(
|
||||
&right_option_ctx,
|
||||
VK_RMENU,
|
||||
WM_KEYDOWN
|
||||
));
|
||||
assert_eq!(drain(&right_option_rx), vec![HotkeyEvent::Pressed]);
|
||||
|
||||
let right_alt_shared = shared(HotkeyTrigger::RightAlt);
|
||||
let (right_alt_ctx, right_alt_rx) = callback_context(right_alt_shared);
|
||||
assert!(!dispatch_keyboard_event(
|
||||
&right_alt_ctx,
|
||||
VK_LMENU,
|
||||
WM_KEYDOWN
|
||||
));
|
||||
assert!(dispatch_keyboard_event(
|
||||
&right_alt_ctx,
|
||||
VK_RMENU,
|
||||
WM_KEYDOWN
|
||||
));
|
||||
assert_eq!(drain(&right_alt_rx), vec![HotkeyEvent::Pressed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use once_cell::sync::Lazy;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::types::InsertStatus;
|
||||
use crate::types::{InsertStatus, PasteShortcut};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750);
|
||||
@@ -38,12 +38,19 @@ impl TextInserter {
|
||||
/// Insert `text` at the current cursor position.
|
||||
/// `restore_clipboard_after_paste` 仅在 Windows/Linux 路径下决定 paste 之后是否恢复
|
||||
/// 用户原剪贴板。macOS 走 AX 直写,参数被忽略。详见 issue #111。
|
||||
/// `paste_shortcut` 决定 Windows/Linux 上模拟按下的粘贴快捷键。详见 issue #360:
|
||||
/// kitty/alacritty 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉。
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn insert(&self, text: &str, restore_clipboard_after_paste: bool) -> InsertStatus {
|
||||
pub fn insert(
|
||||
&self,
|
||||
text: &str,
|
||||
restore_clipboard_after_paste: bool,
|
||||
paste_shortcut: PasteShortcut,
|
||||
) -> InsertStatus {
|
||||
if text.is_empty() {
|
||||
return InsertStatus::CopiedFallback;
|
||||
}
|
||||
insert_with_clipboard_restore(text, restore_clipboard_after_paste)
|
||||
insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -51,8 +58,9 @@ impl TextInserter {
|
||||
&self,
|
||||
text: &str,
|
||||
restore_clipboard_after_paste: bool,
|
||||
paste_shortcut: PasteShortcut,
|
||||
) -> InsertStatus {
|
||||
self.insert(text, restore_clipboard_after_paste)
|
||||
self.insert(text, restore_clipboard_after_paste, paste_shortcut)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -70,8 +78,15 @@ impl TextInserter {
|
||||
}
|
||||
|
||||
/// Insert `text` at the current cursor position.
|
||||
/// macOS 走 AX 直写 / Cmd+V:`_restore_clipboard_after_paste` 与 `_paste_shortcut`
|
||||
/// 仅为跨平台调用方对齐签名而存在,本路径不读它们。
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus {
|
||||
pub fn insert(
|
||||
&self,
|
||||
text: &str,
|
||||
_restore_clipboard_after_paste: bool,
|
||||
_paste_shortcut: PasteShortcut,
|
||||
) -> InsertStatus {
|
||||
if text.is_empty() {
|
||||
return InsertStatus::CopiedFallback;
|
||||
}
|
||||
@@ -171,7 +186,11 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result<ClipboardRestorePla
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool) -> InsertStatus {
|
||||
fn insert_with_clipboard_restore(
|
||||
text: &str,
|
||||
restore_clipboard_after_paste: bool,
|
||||
paste_shortcut: PasteShortcut,
|
||||
) -> InsertStatus {
|
||||
let restore_plan = match copy_to_clipboard_with_restore_plan(text) {
|
||||
Ok(plan) => plan,
|
||||
Err(err) => {
|
||||
@@ -180,7 +199,7 @@ fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = simulate_paste() {
|
||||
if let Err(err) = simulate_paste(paste_shortcut) {
|
||||
log::warn!("[insertion] simulated paste failed: {}", err);
|
||||
return InsertStatus::CopiedFallback;
|
||||
}
|
||||
@@ -302,21 +321,57 @@ fn simulate_paste() -> Result<(), String> {
|
||||
macos::post_cmd_v()
|
||||
}
|
||||
|
||||
/// 把用户配置的 PasteShortcut 拆成 `(modifiers, primary)`。modifier 顺序决定 enigo
|
||||
/// 按下/释放顺序,跟物理键盘一致:先 Ctrl 再 Shift 再主键,释放反向。
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn simulate_paste() -> Result<(), String> {
|
||||
use enigo::{Direction, Enigo, Key, Keyboard, Settings};
|
||||
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
|
||||
let modifier = Key::Control;
|
||||
enigo
|
||||
.key(modifier, Direction::Press)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let press_v = enigo.key(Key::Unicode('v'), Direction::Click);
|
||||
let release_modifier = enigo.key(modifier, Direction::Release);
|
||||
if let Err(e) = release_modifier {
|
||||
return Err(e.to_string());
|
||||
fn paste_keys(shortcut: PasteShortcut) -> (Vec<enigo::Key>, enigo::Key) {
|
||||
use enigo::Key;
|
||||
match shortcut {
|
||||
PasteShortcut::CtrlV => (vec![Key::Control], Key::Unicode('v')),
|
||||
PasteShortcut::CtrlShiftV => (vec![Key::Control, Key::Shift], Key::Unicode('v')),
|
||||
PasteShortcut::ShiftInsert => (vec![Key::Shift], Key::Insert),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> {
|
||||
use enigo::{Direction, Enigo, Keyboard, Settings};
|
||||
let (modifiers, primary) = paste_keys(shortcut);
|
||||
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
|
||||
|
||||
// 跟原版 simulate_paste 保持同一行为:按下 modifier → 点击主键 → 反向释放 modifier。
|
||||
// 任何中途失败都尽量把已经按下的 modifier 反向释放回来,避免卡键。`pressed`
|
||||
// 记录已经成功按下的 modifier 数;用切片 `modifiers[..pressed]` 控制释放范围
|
||||
// —— 切片自带 DoubleEndedIterator,可以放心 `.rev()`。
|
||||
let mut pressed = 0usize;
|
||||
let mut first_err: Option<String> = None;
|
||||
|
||||
for modifier in &modifiers {
|
||||
if let Err(e) = enigo.key(*modifier, Direction::Press) {
|
||||
first_err = Some(e.to_string());
|
||||
break;
|
||||
}
|
||||
pressed += 1;
|
||||
}
|
||||
|
||||
if first_err.is_none() {
|
||||
if let Err(e) = enigo.key(primary, Direction::Click) {
|
||||
first_err = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for modifier in modifiers[..pressed].iter().rev() {
|
||||
if let Err(e) = enigo.key(*modifier, Direction::Release) {
|
||||
if first_err.is_none() {
|
||||
first_err = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match first_err {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
}
|
||||
press_v.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -474,15 +529,42 @@ mod tests {
|
||||
assert!(!should_restore_clipboard(None, "dictated text"));
|
||||
}
|
||||
|
||||
/// issue #360: 用户配置的快捷键必须真的映射到对应按键,否则 Settings UI
|
||||
/// 改了也没用。这里只检查 modifier 数量 + 主键,不依赖 enigo 内部 PartialEq。
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn paste_keys_match_configured_shortcut() {
|
||||
use enigo::Key;
|
||||
|
||||
let (mods, primary) = paste_keys(PasteShortcut::CtrlV);
|
||||
assert_eq!(mods.len(), 1);
|
||||
assert!(matches!(mods[0], Key::Control));
|
||||
assert!(matches!(primary, Key::Unicode('v')));
|
||||
|
||||
let (mods, primary) = paste_keys(PasteShortcut::CtrlShiftV);
|
||||
assert_eq!(mods.len(), 2);
|
||||
assert!(matches!(mods[0], Key::Control));
|
||||
assert!(matches!(mods[1], Key::Shift));
|
||||
assert!(matches!(primary, Key::Unicode('v')));
|
||||
|
||||
let (mods, primary) = paste_keys(PasteShortcut::ShiftInsert);
|
||||
assert_eq!(mods.len(), 1);
|
||||
assert!(matches!(mods[0], Key::Shift));
|
||||
assert!(matches!(primary, Key::Insert));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_insertions_never_touch_clipboard_or_paste_path() {
|
||||
let inserter = TextInserter::new();
|
||||
|
||||
assert_eq!(inserter.insert("", true), InsertStatus::CopiedFallback);
|
||||
assert_eq!(
|
||||
inserter.insert("", true, PasteShortcut::CtrlV),
|
||||
InsertStatus::CopiedFallback
|
||||
);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
assert_eq!(
|
||||
inserter.insert_via_clipboard_fallback("", true),
|
||||
inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV),
|
||||
InsertStatus::CopiedFallback
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ mod combo_hotkey;
|
||||
mod commands;
|
||||
mod coordinator;
|
||||
mod coordinator_state;
|
||||
mod correction;
|
||||
mod global_hotkey_runtime;
|
||||
mod hotkey;
|
||||
mod insertion;
|
||||
mod llm_gemini;
|
||||
mod permissions;
|
||||
mod persistence;
|
||||
mod polish;
|
||||
@@ -27,6 +29,7 @@ mod recorder;
|
||||
mod selection;
|
||||
mod shortcut_binding;
|
||||
mod types;
|
||||
mod unicode_keystroke;
|
||||
mod windows_ime_ipc;
|
||||
mod windows_ime_profile;
|
||||
mod windows_ime_protocol;
|
||||
@@ -157,8 +160,8 @@ pub fn run() {
|
||||
// 登录都被主窗口打扰。OPENLESS_SHOW_MAIN_ON_START=1 仍保留
|
||||
// 老的强制 show 路径(手动 dispatch 测试 / dev 用),优先级高
|
||||
// 于 prefs。
|
||||
let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref()
|
||||
== Some("1");
|
||||
let force_show =
|
||||
std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1");
|
||||
let suppress_show = !force_show && coordinator.prefs().get().start_minimized;
|
||||
if suppress_show {
|
||||
log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘");
|
||||
@@ -254,6 +257,10 @@ pub fn run() {
|
||||
commands::add_vocab,
|
||||
commands::remove_vocab,
|
||||
commands::set_vocab_enabled,
|
||||
commands::list_correction_rules,
|
||||
commands::add_correction_rule,
|
||||
commands::remove_correction_rule,
|
||||
commands::set_correction_rule_enabled,
|
||||
commands::list_vocab_presets,
|
||||
commands::save_vocab_presets,
|
||||
commands::start_dictation,
|
||||
@@ -1197,8 +1204,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tray_style_menu_id_parsing_accepts_only_style_items() {
|
||||
assert_eq!(parse_tray_polish_mode_id("style-raw"), Some(PolishMode::Raw));
|
||||
assert_eq!(parse_tray_polish_mode_id("style-light"), Some(PolishMode::Light));
|
||||
assert_eq!(
|
||||
parse_tray_polish_mode_id("style-raw"),
|
||||
Some(PolishMode::Raw)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_tray_polish_mode_id("style-light"),
|
||||
Some(PolishMode::Light)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_tray_polish_mode_id("style-structured"),
|
||||
Some(PolishMode::Structured)
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
//! 谷歌 Gemini 原生 generateContent / streamGenerateContent 客户端。
|
||||
//!
|
||||
//! 为什么不复用 `polish.rs::OpenAICompatibleLLMProvider`:
|
||||
//! 1. **思考模式控制**——Gemini 原生 `thinkingConfig` 比 OpenAI 兼容 shim
|
||||
//! 的 provider 私有字段更直接;OpenLess 只做渠道级开关,不维护单模型适配表。
|
||||
//! 2. **认证机制**——原生用 `x-goog-api-key` header(Bearer 不被识别),
|
||||
//! OpenAICompatibleLLMProvider 写死了 Bearer Authorization。
|
||||
//! 3. **请求/响应 shape**——原生 `contents` 走 `role: user|model`,没有
|
||||
//! chat completions 的 system role;要走 `systemInstruction` 字段。
|
||||
//!
|
||||
//! prompt 装配 (system_prompt / user_prompt / qa system_prompt) 复用
|
||||
//! `polish.rs::compose_*` pub(crate) 装配函数,避免两路 LLM 客户端漂移。
|
||||
//! `clean_polish_output` 也复用——polish 提示词禁的"以下是整理后的内容"
|
||||
//! 前缀只有走它才能在原生路径上同样剥离。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::polish::{
|
||||
clean_polish_output, compose_polish_prompts, compose_qa_system_prompt,
|
||||
compose_translate_prompts, safe_str_slice, LLMError,
|
||||
};
|
||||
use crate::types::{ChineseScriptPreference, OutputLanguagePreference, PolishMode, QaChatMessage};
|
||||
|
||||
const DEFAULT_TEMPERATURE: f32 = 0.3;
|
||||
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
const BODY_PREVIEW_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeminiConfig {
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
/// e.g. `https://generativelanguage.googleapis.com/v1beta`。允许末尾带 `/`。
|
||||
/// 后端拼成 `{base_url}/models/{model}:generateContent`。
|
||||
pub base_url: String,
|
||||
pub temperature: f32,
|
||||
pub request_timeout_secs: u64,
|
||||
/// true = 不下发关闭思考的 thinkingConfig,让模型按自身默认思考;
|
||||
/// false = 下发 Gemini 原生渠道级最低思考配置。
|
||||
pub thinking_enabled: bool,
|
||||
}
|
||||
|
||||
impl GeminiConfig {
|
||||
pub fn new(
|
||||
api_key: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_key: api_key.into(),
|
||||
model: model.into(),
|
||||
base_url: base_url.into(),
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS,
|
||||
thinking_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_thinking_enabled(mut self, enabled: bool) -> Self {
|
||||
self.thinking_enabled = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GeminiProvider {
|
||||
config: GeminiConfig,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GeminiProvider {
|
||||
pub fn new(config: GeminiConfig) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(config.request_timeout_secs))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { config, client }
|
||||
}
|
||||
|
||||
pub async fn polish(
|
||||
&self,
|
||||
raw_text: &str,
|
||||
mode: PolishMode,
|
||||
hotwords: &[String],
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
front_app: Option<&str>,
|
||||
prior_turns: &[(String, String)],
|
||||
) -> Result<String, LLMError> {
|
||||
let (system_prompt, user_prompt) = compose_polish_prompts(
|
||||
raw_text,
|
||||
mode,
|
||||
hotwords,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
front_app,
|
||||
!prior_turns.is_empty(),
|
||||
);
|
||||
|
||||
let contents = build_polish_history_contents(prior_turns, &user_prompt);
|
||||
let body = self.build_generate_body(&system_prompt, contents);
|
||||
let url = generate_content_url(&self.config.base_url, &self.config.model);
|
||||
|
||||
log::info!(
|
||||
"[llm] POST {} provider=gemini model={} prior_turns={}",
|
||||
url,
|
||||
self.config.model,
|
||||
prior_turns.len()
|
||||
);
|
||||
|
||||
let body_text = self.send_unary(&url, &body).await?;
|
||||
let raw = extract_assistant_content(&body_text)?;
|
||||
Ok(clean_polish_output(&raw))
|
||||
}
|
||||
|
||||
pub async fn translate_to(
|
||||
&self,
|
||||
raw_text: &str,
|
||||
target_language: &str,
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
_output_language_preference: OutputLanguagePreference,
|
||||
front_app: Option<&str>,
|
||||
) -> Result<String, LLMError> {
|
||||
let (system_prompt, user_prompt) = compose_translate_prompts(
|
||||
raw_text,
|
||||
target_language,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
front_app,
|
||||
);
|
||||
|
||||
let contents = vec![user_content(&user_prompt)];
|
||||
let body = self.build_generate_body(&system_prompt, contents);
|
||||
let url = generate_content_url(&self.config.base_url, &self.config.model);
|
||||
|
||||
log::info!(
|
||||
"[llm] POST {} provider=gemini model={} translate=true",
|
||||
url,
|
||||
self.config.model
|
||||
);
|
||||
|
||||
let body_text = self.send_unary(&url, &body).await?;
|
||||
let raw = extract_assistant_content(&body_text)?;
|
||||
Ok(clean_polish_output(&raw))
|
||||
}
|
||||
|
||||
/// 划词语音问答的流式回答。Gemini 原生 SSE: `:streamGenerateContent?alt=sse`,
|
||||
/// 每个 `data: {...}` 帧里 `candidates[0].content.parts[0].text` 是 delta;
|
||||
/// 流结束没有 `[DONE]` sentinel,stream 自然终止。
|
||||
pub async fn answer_chat_streaming<F, C>(
|
||||
&self,
|
||||
messages: &[QaChatMessage],
|
||||
working_languages: &[String],
|
||||
chinese_script_preference: ChineseScriptPreference,
|
||||
output_language_preference: OutputLanguagePreference,
|
||||
front_app: Option<&str>,
|
||||
on_delta: F,
|
||||
should_cancel: C,
|
||||
) -> Result<String, LLMError>
|
||||
where
|
||||
F: Fn(&str) + Send + Sync,
|
||||
C: Fn() -> bool + Send + Sync,
|
||||
{
|
||||
let system_prompt = compose_qa_system_prompt(
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
output_language_preference,
|
||||
front_app,
|
||||
);
|
||||
|
||||
let contents = qa_messages_to_contents(messages);
|
||||
let body = self.build_generate_body(&system_prompt, contents);
|
||||
let url = stream_generate_content_url(&self.config.base_url, &self.config.model);
|
||||
|
||||
log::info!(
|
||||
"[llm] POST {} provider=gemini model={} chat_turns={} stream=true",
|
||||
url,
|
||||
self.config.model,
|
||||
messages.len()
|
||||
);
|
||||
|
||||
self.send_streaming(&url, &body, on_delta, should_cancel)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `generationConfig` 注入:温度 + 渠道级 thinkingConfig。
|
||||
fn build_generate_body(&self, system_prompt: &str, contents: Vec<Value>) -> Value {
|
||||
let mut generation_config = json!({ "temperature": self.config.temperature });
|
||||
if !self.config.thinking_enabled {
|
||||
generation_config["thinkingConfig"] = disabled_thinking_config();
|
||||
}
|
||||
json!({
|
||||
"systemInstruction": system_instruction(system_prompt),
|
||||
"contents": contents,
|
||||
"generationConfig": generation_config,
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_unary(&self, url: &str, body: &Value) -> Result<String, LLMError> {
|
||||
let mut request = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json");
|
||||
if !self.config.api_key.trim().is_empty() {
|
||||
request = request.header("x-goog-api-key", self.config.api_key.as_str());
|
||||
}
|
||||
let request = request.json(body);
|
||||
|
||||
let response = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if e.is_timeout() {
|
||||
return Err(LLMError::Timeout);
|
||||
}
|
||||
return Err(LLMError::Network(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let body_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| LLMError::Network(e.to_string()))?;
|
||||
|
||||
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
|
||||
let preview = safe_str_slice(&body_text, preview_end);
|
||||
log::info!("[llm] HTTP {} body={}", status.as_u16(), preview);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(LLMError::InvalidResponse {
|
||||
status: status.as_u16(),
|
||||
body: preview.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(body_text)
|
||||
}
|
||||
|
||||
async fn send_streaming<F, C>(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &Value,
|
||||
on_delta: F,
|
||||
should_cancel: C,
|
||||
) -> Result<String, LLMError>
|
||||
where
|
||||
F: Fn(&str) + Send + Sync,
|
||||
C: Fn() -> bool + Send + Sync,
|
||||
{
|
||||
let mut request = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream");
|
||||
if !self.config.api_key.trim().is_empty() {
|
||||
request = request.header("x-goog-api-key", self.config.api_key.as_str());
|
||||
}
|
||||
let request = request.json(body);
|
||||
|
||||
let response = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if e.is_timeout() {
|
||||
return Err(LLMError::Timeout);
|
||||
}
|
||||
return Err(LLMError::Network(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| LLMError::Network(e.to_string()))?;
|
||||
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
|
||||
let preview = safe_str_slice(&body_text, preview_end);
|
||||
log::error!("[llm] HTTP {} body={}", status.as_u16(), preview);
|
||||
return Err(LLMError::InvalidResponse {
|
||||
status: status.as_u16(),
|
||||
body: preview.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut response = response;
|
||||
// 字节级缓冲——`reqwest::chunk()` 可能在多字节 UTF-8 字符(CJK / emoji)
|
||||
// 中间切开,对每个 chunk 独立 from_utf8 会把合法的 SSE 流当成
|
||||
// "non-utf8 SSE chunk" 直接 fail(PR #398 pr_agent 实测漏洞)。
|
||||
// SSE 帧分隔符 `\n\n` 两字节都是 ASCII (0x0A),永远不会落在多字节字符中部,
|
||||
// 所以按字节定位完整 event、再对完整 event 做 from_utf8 永远安全。
|
||||
let mut byte_buffer: Vec<u8> = Vec::new();
|
||||
let mut full_text = String::new();
|
||||
loop {
|
||||
// 与 polish.rs streaming 同款取消旗标——用户取消 / 关浮窗时立即 break,
|
||||
// 不再 drain HTTP body 烧 quota。
|
||||
if should_cancel() {
|
||||
log::info!("[llm] gemini stream cancelled by caller; breaking SSE loop");
|
||||
break;
|
||||
}
|
||||
let chunk_opt = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| LLMError::Network(e.to_string()))?;
|
||||
let Some(chunk) = chunk_opt else { break };
|
||||
byte_buffer.extend_from_slice(&chunk);
|
||||
|
||||
for event in drain_complete_sse_events(&mut byte_buffer) {
|
||||
for line in event.lines() {
|
||||
let Some(payload) = line
|
||||
.strip_prefix("data: ")
|
||||
.or_else(|| line.strip_prefix("data:"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let payload = payload.trim();
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: Value = match serde_json::from_str(payload) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[llm] gemini SSE parse skip: {e}; payload preview: {}",
|
||||
safe_str_slice(payload, 80)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Gemini SSE: candidates[0].content.parts[*].text
|
||||
if let Some(parts) = v["candidates"][0]["content"]["parts"].as_array() {
|
||||
for part in parts {
|
||||
if let Some(delta) = part["text"].as_str() {
|
||||
if !delta.is_empty() {
|
||||
full_text.push_str(delta);
|
||||
on_delta(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[llm] HTTP 200 gemini stream done; total chars={}",
|
||||
full_text.chars().count()
|
||||
);
|
||||
|
||||
if full_text.is_empty() {
|
||||
return Err(LLMError::InvalidResponse {
|
||||
status: 200,
|
||||
body: "empty stream".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(full_text)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────── 内部辅助 ───────────────────────
|
||||
|
||||
fn user_content(text: &str) -> Value {
|
||||
json!({ "role": "user", "parts": [{ "text": text }] })
|
||||
}
|
||||
|
||||
fn model_content(text: &str) -> Value {
|
||||
json!({ "role": "model", "parts": [{ "text": text }] })
|
||||
}
|
||||
|
||||
fn system_instruction(system_prompt: &str) -> Value {
|
||||
json!({ "parts": [{ "text": system_prompt }] })
|
||||
}
|
||||
|
||||
/// 从字节缓冲里取出所有以 SSE 帧分隔符(`\n\n` 或 `\r\n\r\n`)分隔的完整
|
||||
/// event;剩余不完整字节留在 buffer 里等下一次 chunk 拼接。
|
||||
///
|
||||
/// 不变量:两种分隔符的所有字节都是 ASCII(0x0A / 0x0D),永远不会出现在
|
||||
/// UTF-8 多字节字符的中部位置,所以
|
||||
/// 1. 按字节查找分隔符 100% 安全;
|
||||
/// 2. 对完整 event 字节区间 (event_start..delim_start) 做 from_utf8 永远不会因
|
||||
/// chunk 边界把多字节字符切开而失败;
|
||||
/// 3. CRLF 与 LF 不会在同一位置都匹配(\r\n\r\n 内部不含 \n\n),按"最早出现"
|
||||
/// 选取分隔符不会歧义。
|
||||
///
|
||||
/// 这是 PR #398 pr_agent 指出的两个 SSE 漏洞的合修:
|
||||
/// (a) 原代码对每个网络 chunk 独立 from_utf8,遇到 CJK / emoji 跨 chunk 切分时
|
||||
/// 直接报错让流挂掉;
|
||||
/// (b) 原代码只识别 `\n\n`,碰到走 CRLF 风格的服务器流(个别 HTTP/2 中间层、
|
||||
/// CDN 会做行尾标准化)会以为流是空的——文档没强制 LF only,必须兼容。
|
||||
fn drain_complete_sse_events(buffer: &mut Vec<u8>) -> Vec<String> {
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let crlf = buffer.windows(4).position(|w| w == b"\r\n\r\n");
|
||||
let lf = buffer.windows(2).position(|w| w == b"\n\n");
|
||||
let (end, delim_len) = match (crlf, lf) {
|
||||
(Some(c), Some(l)) => {
|
||||
if c <= l {
|
||||
(c, 4)
|
||||
} else {
|
||||
(l, 2)
|
||||
}
|
||||
}
|
||||
(Some(c), None) => (c, 4),
|
||||
(None, Some(l)) => (l, 2),
|
||||
(None, None) => break,
|
||||
};
|
||||
let event_str = match std::str::from_utf8(&buffer[..end]) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
// 完整 event 自身 UTF-8 不合法(极少见,可能是上游异常):丢弃此 event 不让流挂掉。
|
||||
log::warn!("[llm] gemini SSE event has invalid UTF-8 (skipping): {e}");
|
||||
buffer.drain(..end + delim_len);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
events.push(event_str);
|
||||
buffer.drain(..end + delim_len);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 多轮 polish 的 contents 序列。
|
||||
/// 输入约定:`prior_turns` 与 polish.rs 一致(最新在前 newest-first),
|
||||
/// chat 时间序为 oldest-first,所以这里 `iter().rev()` 反转。
|
||||
fn build_polish_history_contents(
|
||||
prior_turns: &[(String, String)],
|
||||
user_prompt: &str,
|
||||
) -> Vec<Value> {
|
||||
let mut contents: Vec<Value> = Vec::with_capacity(prior_turns.len() * 2 + 1);
|
||||
for (raw, polished) in prior_turns.iter().rev() {
|
||||
contents.push(user_content(&crate::polish::prompts::user_prompt(raw)));
|
||||
contents.push(model_content(polished));
|
||||
}
|
||||
contents.push(user_content(user_prompt));
|
||||
contents
|
||||
}
|
||||
|
||||
/// QA chat messages → Gemini contents:assistant role 重命名为 model。
|
||||
/// QaChatMessage.role 在 polish.rs OpenAI 路径里是 `"user" | "assistant"`;
|
||||
/// 这里把 `assistant` 翻成 Gemini 的 `model`,其它原样保留。
|
||||
fn qa_messages_to_contents(messages: &[QaChatMessage]) -> Vec<Value> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let role = if m.role == "assistant" {
|
||||
"model"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
json!({ "role": role, "parts": [{ "text": m.content }] })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Gemini 原生通道的关闭/最低思考请求。
|
||||
///
|
||||
/// OpenLess 不维护 Gemini 单模型适配表;开启时不下发 thinkingConfig,关闭时
|
||||
/// 使用官方 thinkingConfig 中可表达“关闭思考”的 `thinkingBudget = 0`。若某个
|
||||
/// 具体模型不支持该字段或不能完全关闭思考,交由 Gemini API 自身处理。
|
||||
fn disabled_thinking_config() -> Value {
|
||||
json!({ "thinkingBudget": 0 })
|
||||
}
|
||||
|
||||
fn generate_content_url(base_url: &str, model: &str) -> String {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
format!("{trimmed}/models/{model}:generateContent")
|
||||
}
|
||||
|
||||
fn stream_generate_content_url(base_url: &str, model: &str) -> String {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
format!("{trimmed}/models/{model}:streamGenerateContent?alt=sse")
|
||||
}
|
||||
|
||||
fn extract_assistant_content(body: &str) -> Result<String, LLMError> {
|
||||
let json: Value = serde_json::from_str(body)
|
||||
.map_err(|e| LLMError::ParseError(format!("not valid JSON: {}", e)))?;
|
||||
let candidates = json
|
||||
.get("candidates")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| LLMError::ParseError("missing candidates array".into()))?;
|
||||
let first = candidates
|
||||
.first()
|
||||
.ok_or_else(|| LLMError::ParseError("candidates array is empty".into()))?;
|
||||
let parts = first
|
||||
.get("content")
|
||||
.and_then(|c| c.get("parts"))
|
||||
.and_then(|p| p.as_array())
|
||||
.ok_or_else(|| LLMError::ParseError("missing content.parts".into()))?;
|
||||
// 把所有 part.text 拼起来。开启思考时模型可能产出多段;逐段拼接避免
|
||||
// future-proof 单 part vs 多 part 的差异坑到。
|
||||
let mut buf = String::new();
|
||||
for part in parts {
|
||||
if let Some(t) = part.get("text").and_then(|v| v.as_str()) {
|
||||
buf.push_str(t);
|
||||
}
|
||||
}
|
||||
if buf.is_empty() {
|
||||
return Err(LLMError::ParseError(
|
||||
"candidates[0].content.parts[*].text 为空".into(),
|
||||
));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disabled_thinking_config_uses_channel_level_budget_zero() {
|
||||
assert_eq!(disabled_thinking_config(), json!({ "thinkingBudget": 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_content_url_handles_trailing_slash_in_base_url() {
|
||||
let a = generate_content_url("https://x/v1beta", "gemini-2.5-flash");
|
||||
let b = generate_content_url("https://x/v1beta/", "gemini-2.5-flash");
|
||||
assert_eq!(
|
||||
a,
|
||||
"https://x/v1beta/models/gemini-2.5-flash:generateContent"
|
||||
);
|
||||
assert_eq!(
|
||||
b,
|
||||
"https://x/v1beta/models/gemini-2.5-flash:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_generate_content_url_appends_alt_sse() {
|
||||
let a = stream_generate_content_url("https://x/v1beta", "gemini-2.5-flash");
|
||||
assert_eq!(
|
||||
a,
|
||||
"https://x/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_assistant_content_concatenates_multiple_parts() {
|
||||
let body = r#"{"candidates":[{"content":{"parts":[{"text":"hello "},{"text":"world"}]}}]}"#;
|
||||
assert_eq!(extract_assistant_content(body).unwrap(), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_assistant_content_empty_array_errors() {
|
||||
let body = r#"{"candidates":[]}"#;
|
||||
assert!(extract_assistant_content(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_polish_history_contents_orders_oldest_to_newest_and_uses_model_role() {
|
||||
// prior_turns 入参约定 newest-first(与 polish.rs::build_polish_history_messages
|
||||
// 同源约定);这里反转为 chat 时间序 oldest-first 喂给 Gemini。
|
||||
// assistant role 的 polished 历史必须挂在 Gemini 的 `model` role 上。
|
||||
let prior = vec![
|
||||
("raw-newest".into(), "polished-newest".into()),
|
||||
("raw-mid".into(), "polished-mid".into()),
|
||||
("raw-oldest".into(), "polished-oldest".into()),
|
||||
];
|
||||
let contents = build_polish_history_contents(&prior, "USER_NOW");
|
||||
// 3×(user/model) + 1 当前 user = 7
|
||||
assert_eq!(contents.len(), 7);
|
||||
assert_eq!(contents[0]["role"], "user");
|
||||
assert!(contents[0]["parts"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("raw-oldest"));
|
||||
assert_eq!(contents[1]["role"], "model");
|
||||
assert_eq!(contents[1]["parts"][0]["text"], "polished-oldest");
|
||||
assert_eq!(contents[5]["role"], "model");
|
||||
assert_eq!(contents[5]["parts"][0]["text"], "polished-newest");
|
||||
assert_eq!(contents[6]["role"], "user");
|
||||
assert_eq!(contents[6]["parts"][0]["text"], "USER_NOW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qa_messages_assistant_role_is_remapped_to_model() {
|
||||
let messages = vec![
|
||||
QaChatMessage {
|
||||
role: "user".into(),
|
||||
content: "选区是什么意思".into(),
|
||||
},
|
||||
QaChatMessage {
|
||||
role: "assistant".into(),
|
||||
content: "这是一段示例文本".into(),
|
||||
},
|
||||
QaChatMessage {
|
||||
role: "user".into(),
|
||||
content: "继续问".into(),
|
||||
},
|
||||
];
|
||||
let contents = qa_messages_to_contents(&messages);
|
||||
assert_eq!(contents[0]["role"], "user");
|
||||
assert_eq!(contents[1]["role"], "model");
|
||||
assert_eq!(contents[2]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generate_body_disabled_includes_channel_level_thinking_budget_zero() {
|
||||
let cfg = GeminiConfig::new("k", "any-gemini-model", "https://x/v1beta");
|
||||
let provider = GeminiProvider::new(cfg);
|
||||
let body = provider.build_generate_body("SYS", vec![user_content("hi")]);
|
||||
assert_eq!(
|
||||
body["generationConfig"]["thinkingConfig"],
|
||||
json!({ "thinkingBudget": 0 })
|
||||
);
|
||||
assert_eq!(body["systemInstruction"]["parts"][0]["text"], "SYS");
|
||||
assert_eq!(body["contents"][0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generate_body_thinking_enabled_omits_thinking_config() {
|
||||
let cfg = GeminiConfig::new("k", "gemini-2.5-flash", "https://x/v1beta")
|
||||
.with_thinking_enabled(true);
|
||||
let provider = GeminiProvider::new(cfg);
|
||||
let body = provider.build_generate_body("SYS", vec![user_content("hi")]);
|
||||
assert!(
|
||||
body["generationConfig"].get("thinkingConfig").is_none(),
|
||||
"开启思考模式时不下发关闭思考的 thinkingConfig"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_complete_sse_events_splits_full_event_at_delimiter() {
|
||||
let mut buf = b"data: {\"a\":1}\n\ndata: {\"b\":2}\n\ndata: incompl".to_vec();
|
||||
let events = drain_complete_sse_events(&mut buf);
|
||||
assert_eq!(events, vec!["data: {\"a\":1}", "data: {\"b\":2}"]);
|
||||
// 不完整的最后一段保留在 buffer 里等下次 chunk 拼接
|
||||
assert_eq!(buf, b"data: incompl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_complete_sse_events_handles_multibyte_split_across_chunks() {
|
||||
// 回归 PR #398 pr_agent UTF-8 SSE 漏洞:
|
||||
// "你好" 的 UTF-8 字节是 e4 bd a0 e5 a5 bd(共 6 字节)。
|
||||
// 模拟 reqwest::chunk() 把这段切在 e4 bd 后(即第一个汉字的 1/3 处),
|
||||
// 旧代码立刻 from_utf8(&chunk) 报错让整条流挂掉;新代码累积字节直到拿到
|
||||
// 完整 event (\n\n) 才解码,应当无损。
|
||||
let event_bytes = b"data: {\"text\":\"\xe4\xbd\xa0\xe5\xa5\xbd\"}\n\n";
|
||||
let cut = 17; // 切在 e4 bd 之后、a0 之前——多字节字符内部
|
||||
assert!(cut < event_bytes.len() && event_bytes[cut] == 0xa0);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&event_bytes[..cut]);
|
||||
let events_round_1 = drain_complete_sse_events(&mut buf);
|
||||
assert!(
|
||||
events_round_1.is_empty(),
|
||||
"尚未收到 \\n\\n,不能产生 event;同时 buffer 不应因半截多字节字符报错"
|
||||
);
|
||||
|
||||
buf.extend_from_slice(&event_bytes[cut..]);
|
||||
let events_round_2 = drain_complete_sse_events(&mut buf);
|
||||
assert_eq!(events_round_2.len(), 1, "拼齐后应产生 1 个完整 event");
|
||||
assert!(
|
||||
events_round_2[0].contains("你好"),
|
||||
"中文必须在拼齐后完好解出;旧实现这里会丢字"
|
||||
);
|
||||
assert!(buf.is_empty(), "处理完后 buffer 应清空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_complete_sse_events_handles_crlf_delimiter() {
|
||||
// 回归 PR #398 pr_agent advisory:部分服务器/CDN 用 \r\n\r\n 分隔 SSE 帧,
|
||||
// 旧实现只认 \n\n 会把整条流当空流。新实现按字节同时查 \r\n\r\n 与 \n\n,
|
||||
// 取最早位置。Rust str::lines() 在 event 内自动剥 \r,所以 line 处理无需改。
|
||||
let mut buf = b"data: {\"a\":1}\r\n\r\ndata: {\"b\":2}\r\n\r\n".to_vec();
|
||||
let events = drain_complete_sse_events(&mut buf);
|
||||
assert_eq!(events, vec!["data: {\"a\":1}", "data: {\"b\":2}"]);
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_complete_sse_events_picks_earliest_delimiter_when_mixed() {
|
||||
// 同一 buffer 里既有 LF 风格也有 CRLF 风格——按出现顺序处理,不漏 event。
|
||||
let mut buf = b"data: lf-event\n\ndata: crlf-event\r\n\r\nrest".to_vec();
|
||||
let events = drain_complete_sse_events(&mut buf);
|
||||
assert_eq!(events, vec!["data: lf-event", "data: crlf-event"]);
|
||||
assert_eq!(buf, b"rest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_complete_sse_events_skips_invalid_utf8_event_without_failing_stream() {
|
||||
// 极端情况:完整 event 自身字节序列就 UTF-8 不合法(上游脏数据)。
|
||||
// 旧实现会 ? 直接 fail 让流挂掉;新实现降级为 warn + skip。
|
||||
let mut buf: Vec<u8> = b"data: ok\n\n".to_vec();
|
||||
buf.extend_from_slice(&[0xff, 0xfe, b'\n', b'\n']); // 不合法 event
|
||||
buf.extend_from_slice(b"data: ok2\n\n");
|
||||
let events = drain_complete_sse_events(&mut buf);
|
||||
assert_eq!(events, vec!["data: ok", "data: ok2"]);
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore};
|
||||
use crate::types::{
|
||||
CorrectionRule, DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore,
|
||||
};
|
||||
|
||||
const HISTORY_CAP: usize = 200;
|
||||
const HISTORY_FILE: &str = "history.json";
|
||||
@@ -29,6 +31,8 @@ const PREFERENCES_FILE: &str = "preferences.json";
|
||||
/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名,
|
||||
/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。
|
||||
const VOCAB_FILE: &str = "dictionary.json";
|
||||
const CORRECTION_RULES_FILE: &str = "correction-rules.json";
|
||||
const CORRECTION_NUM_TOKEN: &str = "{num}";
|
||||
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
|
||||
|
||||
/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。
|
||||
@@ -47,6 +51,41 @@ fn credentials_lock() -> &'static Mutex<()> {
|
||||
CREDENTIALS_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// Process-wide credentials cache.
|
||||
///
|
||||
/// Without this cache every `CredentialsVault::get_*` / `snapshot` call hits
|
||||
/// `load_credentials()` → `load_keyring_credentials()` which reads the
|
||||
/// manifest entry plus every chunk entry from the OS keyring. On macOS each
|
||||
/// distinct keychain entry has its own ACL — so an ad-hoc-signed binary (or
|
||||
/// any binary whose ACL grants haven't been set up yet) prompts on every read
|
||||
/// of every entry. A single dictation cycle reads credentials 5–10 times,
|
||||
/// times (1 manifest + N chunks) entries → tens of "OpenLess wants to use
|
||||
/// the keychain" prompts per recording.
|
||||
///
|
||||
/// With this cache the first read populates `Some(CredsRoot)` and every
|
||||
/// subsequent read in the same process is silent. `save_credentials` keeps
|
||||
/// the cache in sync after writes so Settings → Recording credential edits
|
||||
/// take effect immediately.
|
||||
///
|
||||
/// Cross-process changes (e.g. user edits via `security` CLI, or another
|
||||
/// instance of the app — single-instance is enforced but defense in depth)
|
||||
/// will be invisible until the next process launch. Acceptable trade-off
|
||||
/// per the credential vault contract: the keyring is owned by this app.
|
||||
static CREDENTIALS_CACHE: OnceLock<Mutex<Option<CredsRoot>>> = OnceLock::new();
|
||||
|
||||
fn credentials_cache() -> &'static Mutex<Option<CredsRoot>> {
|
||||
CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
fn store_credentials_cache(root: &CredsRoot) {
|
||||
*credentials_cache().lock() = Some(root.clone());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reset_credentials_cache_for_tests() {
|
||||
*credentials_cache().lock() = None;
|
||||
}
|
||||
|
||||
// ───────────────────────── path helpers ─────────────────────────
|
||||
|
||||
fn data_dir() -> Result<PathBuf> {
|
||||
@@ -166,7 +205,7 @@ fn read_or_default<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Resul
|
||||
// "version": 1,
|
||||
// "active": { "asr": "<id>", "llm": "<id>" },
|
||||
// "providers": {
|
||||
// "asr": { "<id>": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model" } },
|
||||
// "asr": { "<id>": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model", "vocabularyId" } },
|
||||
// "llm": { "<id>": { "displayName", "apiKey", "baseURL", "model", "temperature", "extraHeaders" } }
|
||||
// }
|
||||
// }
|
||||
@@ -244,6 +283,8 @@ struct CredsAsrEntry {
|
||||
accessKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resourceId: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vocabularyId: Option<String>,
|
||||
}
|
||||
|
||||
impl CredsAsrEntry {
|
||||
@@ -254,6 +295,7 @@ impl CredsAsrEntry {
|
||||
&& self.appKey.as_deref().unwrap_or("").is_empty()
|
||||
&& self.accessKey.as_deref().unwrap_or("").is_empty()
|
||||
&& self.resourceId.as_deref().unwrap_or("").is_empty()
|
||||
&& self.vocabularyId.as_deref().unwrap_or("").is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,19 +572,35 @@ fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
|
||||
}
|
||||
|
||||
fn load_credentials() -> CredsRoot {
|
||||
if let Some(cached) = credentials_cache().lock().as_ref().cloned() {
|
||||
return cached;
|
||||
}
|
||||
match load_keyring_credentials() {
|
||||
Ok(Some(root)) => {
|
||||
// 不在这里调 remove_legacy_keyring_credentials() —— 它内部对 9 个
|
||||
// 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个
|
||||
// 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain
|
||||
// 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经
|
||||
// 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑,
|
||||
// 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext
|
||||
// JSON)不需要 ACL,可继续 best-effort 删除。
|
||||
remove_legacy_credentials_file_best_effort();
|
||||
store_credentials_cache(&root);
|
||||
root
|
||||
}
|
||||
Ok(None) => {
|
||||
// 没有现成 chunked manifest —— 走 migrate(如果有 legacy 则写入并返回写后的 root)。
|
||||
// migrate_legacy_sources 内部 save_credentials 已经会刷 cache,这里再补一次
|
||||
// 是为了「无 legacy 也无 manifest」走默认 root 的路径也能进 cache。
|
||||
let root = migrate_legacy_sources();
|
||||
store_credentials_cache(&root);
|
||||
root
|
||||
}
|
||||
Ok(None) => migrate_legacy_sources(),
|
||||
Err(e) => {
|
||||
// **不缓存 keyring 错误路径下的 fallback**。Keychain 可能只是临时不可读
|
||||
// (用户尚未在第一次弹窗里点同意 / DataProtection 错误 / login keychain
|
||||
// 还没 unlock);如果在这里把 legacy fallback 写进 cache,等用户授权后
|
||||
// 我们就再也不会重读 keyring,整个进程生命周期里都拿 stale 数据。下次
|
||||
// 调用让它再尝试一次 keyring。pr_agent feedback on PR #394。
|
||||
log::warn!("[vault] system credential read failed: {e}");
|
||||
load_legacy_sources_without_migration()
|
||||
}
|
||||
@@ -550,14 +608,26 @@ fn load_credentials() -> CredsRoot {
|
||||
}
|
||||
|
||||
fn load_credentials_for_update() -> Result<CredsRoot> {
|
||||
if let Some(cached) = credentials_cache().lock().as_ref().cloned() {
|
||||
return Ok(cached);
|
||||
}
|
||||
match load_keyring_credentials() {
|
||||
Ok(Some(root)) => {
|
||||
// 同 load_credentials:不再每次 update 都尝试 delete legacy keyring
|
||||
// entries,避免反复触发 macOS Keychain ACL 弹窗。
|
||||
remove_legacy_credentials_file_best_effort();
|
||||
store_credentials_cache(&root);
|
||||
Ok(root)
|
||||
}
|
||||
Ok(None) => migrate_legacy_sources_for_update(),
|
||||
Ok(None) => {
|
||||
// migrate_legacy_sources_for_update 内部如果实际 migrate 会调
|
||||
// save_credentials,cache 会被刷新;如果只返回 default root(没 legacy),
|
||||
// 我们这里再显式 cache 一次防御性补一下。
|
||||
let root = migrate_legacy_sources_for_update()?;
|
||||
store_credentials_cache(&root);
|
||||
Ok(root)
|
||||
}
|
||||
// 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
@@ -613,6 +683,9 @@ fn save_credentials(root: &CredsRoot) -> Result<()> {
|
||||
}
|
||||
|
||||
remove_legacy_credentials_file_best_effort();
|
||||
// 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。
|
||||
// 见 CREDENTIALS_CACHE 的 doc。
|
||||
store_credentials_cache(&cleaned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -632,6 +705,7 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option<String
|
||||
CredentialAccount::AsrApiKey => asr.and_then(|e| pick(&e.apiKey)),
|
||||
CredentialAccount::AsrEndpoint => asr.and_then(|e| pick(&e.baseURL)),
|
||||
CredentialAccount::AsrModel => asr.and_then(|e| pick(&e.model)),
|
||||
CredentialAccount::AsrVocabularyId => asr.and_then(|e| pick(&e.vocabularyId)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,6 +750,10 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.model = normalized;
|
||||
}
|
||||
CredentialAccount::AsrVocabularyId => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.vocabularyId = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,8 +796,7 @@ impl HistoryStore {
|
||||
// Prepend so the newest session is at index 0, matching the Swift impl.
|
||||
sessions.insert(0, session);
|
||||
if retention_days > 0 {
|
||||
let cutoff =
|
||||
chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days));
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days));
|
||||
sessions.retain(|s| {
|
||||
chrono::DateTime::parse_from_rfc3339(&s.created_at)
|
||||
.map(|t| t.with_timezone(&chrono::Utc) >= cutoff)
|
||||
@@ -967,6 +1044,106 @@ pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
|
||||
atomic_write(&path, &json)
|
||||
}
|
||||
|
||||
// ───────────────────────── CorrectionRuleStore ─────────────────────────
|
||||
|
||||
pub struct CorrectionRuleStore {
|
||||
path: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl CorrectionRuleStore {
|
||||
pub fn new() -> Result<Self> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
Ok(Self {
|
||||
path: dir.join(CORRECTION_RULES_FILE),
|
||||
lock: Mutex::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<CorrectionRule>> {
|
||||
let _guard = self.lock.lock();
|
||||
self.read_locked()
|
||||
}
|
||||
|
||||
pub fn add(&self, pattern: String, replacement: String) -> Result<CorrectionRule> {
|
||||
let pattern = pattern.trim().to_string();
|
||||
let replacement = replacement.trim().to_string();
|
||||
validate_correction_rule_syntax(&pattern, &replacement)?;
|
||||
let _guard = self.lock.lock();
|
||||
let mut rules = self.read_locked()?;
|
||||
let rule = CorrectionRule {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
pattern,
|
||||
replacement,
|
||||
enabled: true,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
rules.insert(0, rule.clone());
|
||||
self.write_locked(&rules)?;
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<()> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut rules = self.read_locked()?;
|
||||
let before = rules.len();
|
||||
rules.retain(|r| r.id != id);
|
||||
if rules.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.write_locked(&rules)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut rules = self.read_locked()?;
|
||||
let mut found = false;
|
||||
for rule in rules.iter_mut() {
|
||||
if rule.id == id {
|
||||
rule.enabled = enabled;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(anyhow!("correction rule {} not found", id));
|
||||
}
|
||||
self.write_locked(&rules)
|
||||
}
|
||||
|
||||
fn read_locked(&self) -> Result<Vec<CorrectionRule>> {
|
||||
read_or_default::<Vec<CorrectionRule>>(&self.path)
|
||||
}
|
||||
|
||||
fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> {
|
||||
let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?;
|
||||
atomic_write(&self.path, &json)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> {
|
||||
if pattern.is_empty() {
|
||||
return Err(anyhow!("correction rule pattern is empty"));
|
||||
}
|
||||
let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count();
|
||||
if pattern_token_count > 1 {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
}
|
||||
if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
}
|
||||
if pattern_token_count == 1 {
|
||||
let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
};
|
||||
if prefix.is_empty() && suffix.is_empty() {
|
||||
return Err(anyhow!("unsupported correction rule syntax"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ───────────────────────── CredentialsVault ─────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -983,6 +1160,8 @@ pub enum CredentialAccount {
|
||||
AsrEndpoint,
|
||||
/// Active ASR provider's model name.
|
||||
AsrModel,
|
||||
/// Active ASR provider's optional hotword vocabulary ID.
|
||||
AsrVocabularyId,
|
||||
}
|
||||
|
||||
impl CredentialAccount {
|
||||
@@ -1000,6 +1179,7 @@ impl CredentialAccount {
|
||||
CredentialAccount::AsrApiKey => "asr.api_key",
|
||||
CredentialAccount::AsrEndpoint => "asr.endpoint",
|
||||
CredentialAccount::AsrModel => "asr.model",
|
||||
CredentialAccount::AsrVocabularyId => "asr.vocabulary_id",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,6 +1194,7 @@ impl CredentialAccount {
|
||||
CredentialAccount::AsrApiKey,
|
||||
CredentialAccount::AsrEndpoint,
|
||||
CredentialAccount::AsrModel,
|
||||
CredentialAccount::AsrVocabularyId,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1107,7 +1288,8 @@ impl CredentialsVault {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS,
|
||||
chunk_json_payload, list_vocab_presets, save_vocab_presets,
|
||||
validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS,
|
||||
};
|
||||
use crate::types::{VocabPreset, VocabPresetStore};
|
||||
use std::fs;
|
||||
@@ -1158,4 +1340,14 @@ mod tests {
|
||||
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_rule_syntax_rejects_silent_noops() {
|
||||
assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok());
|
||||
assert!(validate_correction_rule_syntax("几粒", "几例").is_ok());
|
||||
assert!(validate_correction_rule_syntax("", "几例").is_err());
|
||||
assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err());
|
||||
assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err());
|
||||
assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -45,6 +45,21 @@ pub enum OutputLanguagePreference {
|
||||
Ko,
|
||||
}
|
||||
|
||||
/// 模拟粘贴时实际按下的快捷键。macOS 走 AX 直写 / Cmd+V,本枚举只在
|
||||
/// Windows / Linux 的 simulate_paste 路径生效。详见 issue #360:kitty 等
|
||||
/// Linux 终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩
|
||||
/// 在剪贴板里。默认 `CtrlV` 与历史行为一致;用户在 Settings 里改成
|
||||
/// `CtrlShiftV`(kitty/alacritty/wezterm/gnome-terminal/foot/...)或
|
||||
/// `ShiftInsert`(xterm/urxvt)后,simulate_paste 用对应组合。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PasteShortcut {
|
||||
#[default]
|
||||
CtrlV,
|
||||
CtrlShiftV,
|
||||
ShiftInsert,
|
||||
}
|
||||
|
||||
/// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。
|
||||
/// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json`
|
||||
/// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。
|
||||
@@ -104,6 +119,18 @@ pub struct DictionaryEntry {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CorrectionRule {
|
||||
pub id: String,
|
||||
pub pattern: String,
|
||||
pub replacement: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VocabPreset {
|
||||
@@ -141,10 +168,20 @@ pub struct UserPreferences {
|
||||
pub microphone_device_name: String,
|
||||
pub active_asr_provider: String, // "volcengine" | "apple-speech" | ...
|
||||
pub active_llm_provider: String, // "ark" | "openai" | ...
|
||||
/// LLM 思考模式开关。默认 false 以保持既有「尽量关闭思考」行为;
|
||||
/// Gemini 走原生 thinkingConfig,OpenAI-compatible 路径仅按 provider/channel
|
||||
/// 下发官方渠道级字段,不用 prompt 注入,也不做模型白名单适配。详见 issue #402。
|
||||
#[serde(default)]
|
||||
pub llm_thinking_enabled: bool,
|
||||
/// Windows/Linux 粘贴成功后是否恢复用户原剪贴板。默认 true 跟历史行为一致;
|
||||
/// 关掉就把听写文本留在剪贴板,让 simulate_paste 实际没生效时用户能 Ctrl+V 找回。
|
||||
/// macOS 走 AX 直写,不受这个开关影响。详见 issue #111。
|
||||
pub restore_clipboard_after_paste: bool,
|
||||
/// Windows / Linux 的模拟粘贴键。macOS 走 AX 直写不受影响。详见 issue #360:
|
||||
/// kitty 等 Linux 终端不接受 Ctrl+V,只能配 Ctrl+Shift+V。默认 CtrlV 与历史
|
||||
/// 行为一致,不破坏既有用户。
|
||||
#[serde(default)]
|
||||
pub paste_shortcut: PasteShortcut,
|
||||
/// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。
|
||||
/// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。
|
||||
#[serde(default = "default_true")]
|
||||
@@ -231,6 +268,28 @@ pub struct UserPreferences {
|
||||
/// 用户改用托盘菜单访问主窗口。默认 false 跟历史行为一致。
|
||||
#[serde(default)]
|
||||
pub start_minimized: bool,
|
||||
/// 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
|
||||
/// 的处理时延显著降低(润色 LLM 第一个 token 即开始落字)。
|
||||
///
|
||||
/// 平台原语:
|
||||
/// - macOS:CGEvent Unicode FFI;CJK / 日文 IME 会拦截,session 期间临时切到 ABC
|
||||
/// - Windows:SendInput Unicode(绕过 TSF);不需要切输入法
|
||||
/// - Linux(实验性):enigo `Keyboard::text`;X11 稳定,Wayland 看 compositor
|
||||
///
|
||||
/// 限制:
|
||||
/// - 不再走剪贴板路径,对 secure input 框(密码框 / 1Password)静默拒绝
|
||||
/// - 仅 OpenAI-compatible provider 实装(v1);Gemini / Codex provider 走原一次性
|
||||
/// 插入路径
|
||||
///
|
||||
/// 默认 false 与历史行为一致。
|
||||
#[serde(default)]
|
||||
pub streaming_insert: bool,
|
||||
/// 流式输入成功后是否把最终润色文本写回剪贴板。一次性路径天然走剪贴板,所以
|
||||
/// Cmd+V 可以重复粘贴;流式路径直接合成键盘事件、不动剪贴板,会让用户失去这层
|
||||
/// 兜底。开启后流式成功收尾时把 final text 写到系统剪贴板,跟一次性行为对齐。
|
||||
/// 默认 true(更接近用户习惯)。
|
||||
#[serde(default = "default_true")]
|
||||
pub streaming_insert_save_clipboard: bool,
|
||||
}
|
||||
|
||||
fn default_local_asr_model() -> String {
|
||||
@@ -287,7 +346,11 @@ struct UserPreferencesWire {
|
||||
microphone_device_name: String,
|
||||
active_asr_provider: String,
|
||||
active_llm_provider: String,
|
||||
#[serde(default)]
|
||||
llm_thinking_enabled: bool,
|
||||
restore_clipboard_after_paste: bool,
|
||||
#[serde(default)]
|
||||
paste_shortcut: PasteShortcut,
|
||||
allow_non_tsf_insertion_fallback: bool,
|
||||
working_languages: Vec<String>,
|
||||
translation_target_language: String,
|
||||
@@ -322,6 +385,10 @@ struct UserPreferencesWire {
|
||||
polish_context_window_minutes: u32,
|
||||
#[serde(default)]
|
||||
start_minimized: bool,
|
||||
#[serde(default)]
|
||||
streaming_insert: bool,
|
||||
#[serde(default = "default_true")]
|
||||
streaming_insert_save_clipboard: bool,
|
||||
}
|
||||
|
||||
impl Default for UserPreferencesWire {
|
||||
@@ -338,7 +405,9 @@ impl Default for UserPreferencesWire {
|
||||
microphone_device_name: prefs.microphone_device_name,
|
||||
active_asr_provider: prefs.active_asr_provider,
|
||||
active_llm_provider: prefs.active_llm_provider,
|
||||
llm_thinking_enabled: prefs.llm_thinking_enabled,
|
||||
restore_clipboard_after_paste: prefs.restore_clipboard_after_paste,
|
||||
paste_shortcut: prefs.paste_shortcut,
|
||||
allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback,
|
||||
working_languages: prefs.working_languages,
|
||||
translation_target_language: prefs.translation_target_language,
|
||||
@@ -361,6 +430,8 @@ impl Default for UserPreferencesWire {
|
||||
history_retention_days: prefs.history_retention_days,
|
||||
polish_context_window_minutes: prefs.polish_context_window_minutes,
|
||||
start_minimized: prefs.start_minimized,
|
||||
streaming_insert: prefs.streaming_insert,
|
||||
streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,7 +458,9 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
microphone_device_name: wire.microphone_device_name,
|
||||
active_asr_provider: wire.active_asr_provider,
|
||||
active_llm_provider: wire.active_llm_provider,
|
||||
llm_thinking_enabled: wire.llm_thinking_enabled,
|
||||
restore_clipboard_after_paste: wire.restore_clipboard_after_paste,
|
||||
paste_shortcut: wire.paste_shortcut,
|
||||
allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback,
|
||||
working_languages: wire.working_languages,
|
||||
translation_target_language: wire.translation_target_language,
|
||||
@@ -417,6 +490,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
history_retention_days: wire.history_retention_days,
|
||||
polish_context_window_minutes: wire.polish_context_window_minutes,
|
||||
start_minimized: wire.start_minimized,
|
||||
streaming_insert: wire.streaming_insert,
|
||||
streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -503,7 +578,9 @@ impl Default for UserPreferences {
|
||||
microphone_device_name: String::new(),
|
||||
active_asr_provider: default_active_asr_provider(),
|
||||
active_llm_provider: "ark".into(),
|
||||
llm_thinking_enabled: false,
|
||||
restore_clipboard_after_paste: true,
|
||||
paste_shortcut: PasteShortcut::default(),
|
||||
allow_non_tsf_insertion_fallback: true,
|
||||
working_languages: default_working_languages(),
|
||||
translation_target_language: String::new(),
|
||||
@@ -526,6 +603,8 @@ impl Default for UserPreferences {
|
||||
history_retention_days: default_history_retention_days(),
|
||||
polish_context_window_minutes: default_polish_context_window_minutes(),
|
||||
start_minimized: false,
|
||||
streaming_insert: false,
|
||||
streaming_insert_save_clipboard: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1127,6 +1206,31 @@ mod tests {
|
||||
assert!(prefs.allow_non_tsf_insertion_fallback);
|
||||
}
|
||||
|
||||
/// issue #360: 默认值必须是 CtrlV,跟历史行为一致;老配置文件没有
|
||||
/// pasteShortcut 字段时反序列化也得回到 CtrlV,否则会把现有用户的粘贴
|
||||
/// 行为静默改掉。
|
||||
#[test]
|
||||
fn paste_shortcut_defaults_to_ctrl_v() {
|
||||
let prefs = UserPreferences::default();
|
||||
assert_eq!(prefs.paste_shortcut, PasteShortcut::CtrlV);
|
||||
|
||||
let from_empty: UserPreferences = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_shortcut_round_trips_explicit_values() {
|
||||
for (raw, expected) in [
|
||||
("ctrlV", PasteShortcut::CtrlV),
|
||||
("ctrlShiftV", PasteShortcut::CtrlShiftV),
|
||||
("shiftInsert", PasteShortcut::ShiftInsert),
|
||||
] {
|
||||
let json = format!(r#"{{ "pasteShortcut": "{raw}" }}"#);
|
||||
let prefs: UserPreferences = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(prefs.paste_shortcut, expected, "raw={raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_custom_hotkey_without_custom_binding_is_rejected() {
|
||||
let result = serde_json::from_str::<UserPreferences>(
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! 跨平台 Unicode keystroke 合成(流式输入用)。
|
||||
//!
|
||||
//! 公开 API 三件套:
|
||||
//! - `type_unicode_chunk(text)` —— 阻塞地把一段文字逐 codepoint 当作键盘事件发出去,
|
||||
//! 不动剪贴板。各平台用各自的原语。
|
||||
//! - `switch_to_ascii(app)` —— 仅 macOS 有效;切到 ABC 输入源以绕过 CJK / 日文 IME
|
||||
//! 对 Unicode 字符串事件的拦截。Windows / Linux 上是 no-op。
|
||||
//! - `restore_input_source(app, prev)` —— 配对调用,恢复 macOS 上的原输入源。
|
||||
//!
|
||||
//! ## 平台差异
|
||||
//!
|
||||
//! - **macOS**:手写 CGEvent FFI(与 `insertion.rs::macos` 的 Cmd+V 同源)。
|
||||
//! `CGEventKeyboardSetUnicodeString` 在 CJK / 日文 IME 激活时被拦截 ——
|
||||
//! 必须 `switch_to_ascii` 切到 ABC,session 结束再 `restore_input_source` 切回。
|
||||
//! - **Windows**:`SendInput(KEYEVENTF_UNICODE)` 直接发 UTF-16 scancode。TSF 不拦
|
||||
//! Unicode 事件(与 keyboard layout / IME 解耦),所以不需要切输入法。
|
||||
//! - **Linux**:enigo `Keyboard::text(...)`。X11 走 XTest 稳定;Wayland 看 compositor
|
||||
//! 是否给 libei 权限,stock GNOME-Wayland 经常拒绝,调用方应当容忍失败回落到一次性。
|
||||
//! 不切输入法 —— Linux 的 fcitx / ibus 与 enigo 的交互非常碎,v1 不尝试。
|
||||
//!
|
||||
//! ## 已知坑(macOS)
|
||||
//!
|
||||
//! - Secure Event Input(密码框、1Password 等)下 CGEventPost 静默失败;
|
||||
//! `type_unicode_chunk` 开头先用 `IsSecureEventInputEnabled` 探测,命中即返
|
||||
//! `TypeError::SecureInputActive`。
|
||||
//! - Modifier 状态继承 —— 用户按着 Shift 不清零会被映射成大写,每个事件显式
|
||||
//! `CGEventSetFlags(_, 0)`。
|
||||
//! - Chromium / Electron / Tauri 自身在 keyDown/keyUp 之间无延迟时会丢字,每 codepoint
|
||||
//! sleep 1ms。
|
||||
//!
|
||||
//! ## 线程安全(macOS)
|
||||
//!
|
||||
//! - `type_unicode_chunk`(CGEventPost)任意线程可调,对齐 `insertion.rs::macos::
|
||||
//! simulate_paste` 现状。
|
||||
//! - TIS(`switch_to_ascii` / `restore_input_source`)调度到主线程,规避 macOS 14+
|
||||
//! 对 TSM/TIS 主线程的 `dispatch_assert_queue_fail` SIGTRAP(与
|
||||
//! `feedback_rdev_macos_trap.md` 同款风险类别)。
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TypeError {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("CGEventSourceCreate returned null")]
|
||||
SourceAllocFailed,
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("CGEventCreateKeyboardEvent returned null")]
|
||||
EventAllocFailed,
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("Secure Event Input is enabled — synthetic keystrokes will be silently dropped")]
|
||||
SecureInputActive,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[error("Windows SendInput failed: {0}")]
|
||||
SendInputFailed(String),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error("enigo init failed: {0}")]
|
||||
EnigoInit(String),
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error("enigo text input failed: {0}")]
|
||||
EnigoText(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TisError {
|
||||
#[error("dispatch to main thread failed: {0}")]
|
||||
MainThreadDispatch(String),
|
||||
#[error("TISCopyInputSourceForLanguage(\"en\") returned null — ABC source not installed?")]
|
||||
AbcSourceNotFound,
|
||||
#[error("TISSelectInputSource failed: OSStatus={0}")]
|
||||
SelectFailed(i32),
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// macOS 实现
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_impl {
|
||||
use super::{TisError, TypeError};
|
||||
use std::ffi::c_void;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
const INTER_KEYSTROKE_DELAY: Duration = Duration::from_millis(1);
|
||||
|
||||
/// 之前激活的 input source 引用 token。携带 raw ptr 的 usize 表示,所有解引用都
|
||||
/// 通过 `restore_input_source` 调度到主线程执行;手动 `Send + Sync`。
|
||||
pub struct PreviousInputSource {
|
||||
raw: usize,
|
||||
}
|
||||
unsafe impl Send for PreviousInputSource {}
|
||||
unsafe impl Sync for PreviousInputSource {}
|
||||
|
||||
pub fn type_unicode_chunk(text: &str) -> Result<(), TypeError> {
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if is_secure_input_enabled() {
|
||||
return Err(TypeError::SecureInputActive);
|
||||
}
|
||||
for ch in text.chars() {
|
||||
send_one_codepoint(ch)?;
|
||||
std::thread::sleep(INTER_KEYSTROKE_DELAY);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_one_codepoint(ch: char) -> Result<(), TypeError> {
|
||||
let mut buf = [0u16; 2];
|
||||
let utf16 = ch.encode_utf16(&mut buf);
|
||||
let len = utf16.len();
|
||||
unsafe {
|
||||
let src = CGEventSourceCreate(KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE);
|
||||
if src.is_null() {
|
||||
return Err(TypeError::SourceAllocFailed);
|
||||
}
|
||||
let down = CGEventCreateKeyboardEvent(src, 0, true);
|
||||
let up = CGEventCreateKeyboardEvent(src, 0, false);
|
||||
if down.is_null() || up.is_null() {
|
||||
if !down.is_null() {
|
||||
CFRelease(down as _);
|
||||
}
|
||||
if !up.is_null() {
|
||||
CFRelease(up as _);
|
||||
}
|
||||
CFRelease(src as _);
|
||||
return Err(TypeError::EventAllocFailed);
|
||||
}
|
||||
CGEventSetFlags(down, 0);
|
||||
CGEventSetFlags(up, 0);
|
||||
CGEventKeyboardSetUnicodeString(down, len, utf16.as_ptr());
|
||||
CGEventKeyboardSetUnicodeString(up, len, utf16.as_ptr());
|
||||
CGEventPost(KCG_HID_EVENT_TAP, down);
|
||||
CGEventPost(KCG_HID_EVENT_TAP, up);
|
||||
CFRelease(down as _);
|
||||
CFRelease(up as _);
|
||||
CFRelease(src as _);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_secure_input_enabled() -> bool {
|
||||
unsafe { IsSecureEventInputEnabled() != 0 }
|
||||
}
|
||||
|
||||
pub async fn switch_to_ascii<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
) -> Result<Option<PreviousInputSource>, TisError> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let result = unsafe { switch_to_ascii_on_main() };
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| TisError::MainThreadDispatch(e.to_string()))?;
|
||||
rx.await
|
||||
.map_err(|e| TisError::MainThreadDispatch(e.to_string()))?
|
||||
}
|
||||
|
||||
unsafe fn switch_to_ascii_on_main() -> Result<Option<PreviousInputSource>, TisError> {
|
||||
let prev = TISCopyCurrentKeyboardInputSource();
|
||||
let prev_token = if prev.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(PreviousInputSource { raw: prev as usize })
|
||||
};
|
||||
let lang_bytes = b"en\0";
|
||||
let lang = CFStringCreateWithCString(
|
||||
std::ptr::null(),
|
||||
lang_bytes.as_ptr() as *const i8,
|
||||
K_CF_STRING_ENCODING_ASCII,
|
||||
);
|
||||
if lang.is_null() {
|
||||
if let Some(p) = prev_token {
|
||||
CFRelease(p.raw as *const _);
|
||||
}
|
||||
return Err(TisError::AbcSourceNotFound);
|
||||
}
|
||||
let abc = TISCopyInputSourceForLanguage(lang);
|
||||
CFRelease(lang as _);
|
||||
if abc.is_null() {
|
||||
if let Some(p) = prev_token {
|
||||
CFRelease(p.raw as *const _);
|
||||
}
|
||||
return Err(TisError::AbcSourceNotFound);
|
||||
}
|
||||
let status = TISSelectInputSource(abc);
|
||||
CFRelease(abc as _);
|
||||
if status != 0 {
|
||||
if let Some(p) = prev_token {
|
||||
CFRelease(p.raw as *const _);
|
||||
}
|
||||
return Err(TisError::SelectFailed(status));
|
||||
}
|
||||
Ok(prev_token)
|
||||
}
|
||||
|
||||
pub async fn restore_input_source<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
prev: Option<PreviousInputSource>,
|
||||
) -> Result<(), TisError> {
|
||||
let Some(prev) = prev else {
|
||||
return Ok(());
|
||||
};
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let result = unsafe { restore_input_source_on_main(prev) };
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| TisError::MainThreadDispatch(e.to_string()))?;
|
||||
rx.await
|
||||
.map_err(|e| TisError::MainThreadDispatch(e.to_string()))?
|
||||
}
|
||||
|
||||
unsafe fn restore_input_source_on_main(prev: PreviousInputSource) -> Result<(), TisError> {
|
||||
let raw = prev.raw as *mut c_void;
|
||||
let status = TISSelectInputSource(raw);
|
||||
CFRelease(raw as _);
|
||||
if status != 0 {
|
||||
return Err(TisError::SelectFailed(status));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── FFI ───
|
||||
type CGEventTapLocation = u32;
|
||||
type CGEventSourceStateID = i32;
|
||||
type CGKeyCode = u16;
|
||||
type CGEventFlags = u64;
|
||||
type CFStringEncoding = u32;
|
||||
type CFAllocatorRef = *const c_void;
|
||||
type CFStringRef = *const c_void;
|
||||
type TISInputSourceRef = *mut c_void;
|
||||
|
||||
const KCG_HID_EVENT_TAP: CGEventTapLocation = 0;
|
||||
const KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE: CGEventSourceStateID = 1;
|
||||
const K_CF_STRING_ENCODING_ASCII: CFStringEncoding = 0x0600;
|
||||
|
||||
#[repr(C)]
|
||||
struct OpaqueCGEvent(c_void);
|
||||
type CGEventRef = *mut OpaqueCGEvent;
|
||||
#[repr(C)]
|
||||
struct OpaqueCGEventSource(c_void);
|
||||
type CGEventSourceRef = *mut OpaqueCGEventSource;
|
||||
|
||||
#[link(name = "CoreGraphics", kind = "framework")]
|
||||
extern "C" {
|
||||
fn CGEventSourceCreate(state_id: CGEventSourceStateID) -> CGEventSourceRef;
|
||||
fn CGEventCreateKeyboardEvent(
|
||||
source: CGEventSourceRef,
|
||||
virtual_key: CGKeyCode,
|
||||
key_down: bool,
|
||||
) -> CGEventRef;
|
||||
fn CGEventSetFlags(event: CGEventRef, flags: CGEventFlags);
|
||||
fn CGEventKeyboardSetUnicodeString(
|
||||
event: CGEventRef,
|
||||
string_length: usize,
|
||||
unicode_string: *const u16,
|
||||
);
|
||||
fn CGEventPost(tap: CGEventTapLocation, event: CGEventRef);
|
||||
}
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
extern "C" {
|
||||
fn CFRelease(cf: *const c_void);
|
||||
fn CFStringCreateWithCString(
|
||||
alloc: CFAllocatorRef,
|
||||
c_str: *const i8,
|
||||
encoding: CFStringEncoding,
|
||||
) -> CFStringRef;
|
||||
}
|
||||
|
||||
#[link(name = "Carbon", kind = "framework")]
|
||||
extern "C" {
|
||||
fn IsSecureEventInputEnabled() -> i32;
|
||||
fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef;
|
||||
fn TISCopyInputSourceForLanguage(lang: CFStringRef) -> TISInputSourceRef;
|
||||
fn TISSelectInputSource(source: TISInputSourceRef) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Windows 实现
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_impl {
|
||||
use super::{TisError, TypeError};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
|
||||
KEYEVENTF_UNICODE, VIRTUAL_KEY,
|
||||
};
|
||||
|
||||
/// Windows / Linux 上没有 input source 概念,token 留空。Send/Sync 自动派生。
|
||||
pub struct PreviousInputSource;
|
||||
|
||||
/// 同一个会话内 keyDown/keyUp 之间的微延迟。Windows SendInput Unicode 在大多数
|
||||
/// 应用上不需要延迟,但 Chromium 系(Edge / VSCode)观察到偶尔丢字,保留 1ms
|
||||
/// 兜底跟 macOS 对齐。
|
||||
const INTER_KEYSTROKE_DELAY: Duration = Duration::from_millis(1);
|
||||
|
||||
pub fn type_unicode_chunk(text: &str) -> Result<(), TypeError> {
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for unit in text.encode_utf16() {
|
||||
send_utf16_unit(unit, false)?;
|
||||
send_utf16_unit(unit, true)?;
|
||||
std::thread::sleep(INTER_KEYSTROKE_DELAY);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), TypeError> {
|
||||
let flags = if key_up {
|
||||
KEYEVENTF_UNICODE | KEYEVENTF_KEYUP
|
||||
} else {
|
||||
KEYEVENTF_UNICODE
|
||||
};
|
||||
let input = INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(0),
|
||||
wScan: unit,
|
||||
dwFlags: KEYBD_EVENT_FLAGS(flags.0),
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
let sent = unsafe { SendInput(&[input], std::mem::size_of::<INPUT>() as i32) };
|
||||
if sent == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TypeError::SendInputFailed(
|
||||
std::io::Error::last_os_error().to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows SendInput Unicode 绕过 TSF 与 IME,无需切换输入法。返回 `Ok(None)`,
|
||||
/// `restore_input_source` 也是 no-op。
|
||||
pub async fn switch_to_ascii<R: Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
) -> Result<Option<PreviousInputSource>, TisError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn restore_input_source<R: Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
_prev: Option<PreviousInputSource>,
|
||||
) -> Result<(), TisError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Linux 实现(实验性)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_impl {
|
||||
use super::{TisError, TypeError};
|
||||
use enigo::{Enigo, Keyboard, Settings};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
pub struct PreviousInputSource;
|
||||
|
||||
/// 用 enigo 一次 `text()` 把整段 chunk 发出去。X11 上走 XTest 稳定;Wayland 上
|
||||
/// 看 compositor 是否给 libei 权限,stock GNOME-Wayland 通常拒绝 —— 此处
|
||||
/// 失败返回 `EnigoInit` / `EnigoText`,调用方应回落到一次性。
|
||||
///
|
||||
/// 不处理 fcitx / ibus 输入法切换 —— Linux 输入法栈与 X11 合成事件的交互非常
|
||||
/// 碎片化,v1 实验阶段直接交给用户保证当前输入源是英文键盘。
|
||||
pub fn type_unicode_chunk(text: &str) -> Result<(), TypeError> {
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut enigo =
|
||||
Enigo::new(&Settings::default()).map_err(|e| TypeError::EnigoInit(e.to_string()))?;
|
||||
enigo
|
||||
.text(text)
|
||||
.map_err(|e| TypeError::EnigoText(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn switch_to_ascii<R: Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
) -> Result<Option<PreviousInputSource>, TisError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn restore_input_source<R: Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
_prev: Option<PreviousInputSource>,
|
||||
) -> Result<(), TisError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 公共导出(按 cfg 分发到对应实现)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unused_imports)]
|
||||
pub use macos_impl::{restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(unused_imports)]
|
||||
pub use windows_impl::{restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unused_imports)]
|
||||
pub use linux_impl::{restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.2.24-6",
|
||||
"version": "1.3.0",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -56,26 +56,6 @@ function AudioBars({ level }: AudioBarsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessingDots() {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, width: 20 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
background: 'var(--ol-blue)',
|
||||
opacity: 0.85,
|
||||
animation: `cap-dot 0.9s var(--ol-motion-soft) ${i * 0.3}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CenterTextProps {
|
||||
os: OS;
|
||||
kind: 'default' | 'processing' | 'error';
|
||||
@@ -176,6 +156,20 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
|
||||
const processingLayout = getCapsuleMessageLayout(os, 'processing');
|
||||
const enabled = state === 'recording';
|
||||
|
||||
// "thinking" 扫光速度:进入 transcribing/polishing 的头 2 秒走快速(0.9s/cycle,提示
|
||||
// 「流式刚开始」),之后切回慢速(2.4s)作为稳态。切回 idle / done / 其他 state 也复位
|
||||
// 为 fast,下次进入时从头开始 burst。
|
||||
const [shineFast, setShineFast] = useState(true);
|
||||
useEffect(() => {
|
||||
if (state === 'transcribing' || state === 'polishing') {
|
||||
setShineFast(true);
|
||||
const t = setTimeout(() => setShineFast(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
setShineFast(true);
|
||||
return undefined;
|
||||
}, [state]);
|
||||
|
||||
let center: JSX.Element;
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
@@ -187,24 +181,43 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: os === 'win' ? 'column' : 'row',
|
||||
alignItems: 'center',
|
||||
gap: os === 'win' ? 4 : 6,
|
||||
// 左右 4px 内边距 + 外层 gap 已经让 "thinking" ↔ ✗/✓ 视觉间距落在 ~4-5px。
|
||||
padding: '0 4px',
|
||||
width: '100%',
|
||||
maxWidth: metrics.textWidth,
|
||||
minWidth: 0,
|
||||
justifyContent: 'center',
|
||||
// state 进入动画 —— 用户从 recording 切到 polishing 时多一道淡入提示,
|
||||
// 比纯切换 center 内容更容易被感知。
|
||||
animation: 'cap-state-enter 220ms var(--ol-motion-soft) both',
|
||||
}}
|
||||
>
|
||||
<ProcessingDots />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
// 放大 11 → 14 让 thinking 在胶囊里更显眼。
|
||||
fontSize: 14,
|
||||
// 字重 500:用户反馈 700 太粗。500 是 medium 视觉重量,扫光更稳。
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0.5,
|
||||
// 字号 14 + 字重 500 时小写 g/y/p 等下伸字符在 line-height: 1 下会被
|
||||
// clip。给 line-height 一点冗余 + 上下 1px padding,descender 不再被切。
|
||||
paddingBlock: 1,
|
||||
// 扫光:渐变中段用更饱和的 ol-blue(不靠透明度),尾段保留 ink-3 作收尾,
|
||||
// 整条 stripe 比之前更醒目(用户反馈"扫光不够明显")。
|
||||
color: 'var(--ol-ink-2)',
|
||||
backgroundImage:
|
||||
'linear-gradient(100deg, var(--ol-ink-3) 0%, var(--ol-ink-3) 35%, var(--ol-blue) 50%, var(--ol-ink-3) 65%, var(--ol-ink-3) 100%)',
|
||||
backgroundSize: '220% auto',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
// 进入流式的头 ~2 秒用 0.9s 高速扫光(视觉提示「刚开始」),之后 React 副作用
|
||||
// 切到 2.4s 慢速。duration 变化时浏览器不重启动画,会平滑减速。
|
||||
animation: `cap-shine ${shineFast ? '0.9s' : '2.4s'} linear infinite`,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
lineHeight: processingLayout.allowWrap ? 1.15 : 1,
|
||||
lineHeight: processingLayout.allowWrap ? 1.3 : 1.25,
|
||||
whiteSpace: processingLayout.allowWrap ? 'normal' : 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -242,7 +255,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
gap: 4,
|
||||
padding: '0 8px',
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
@@ -400,9 +413,13 @@ export function Capsule() {
|
||||
from { opacity: 0; transform: translateY(6px) scale(.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes cap-dot {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1.0; transform: scale(1.0); }
|
||||
@keyframes cap-shine {
|
||||
0% { background-position: 200% center; }
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
@keyframes cap-state-enter {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@ import { Vocab } from '../pages/Vocab';
|
||||
import { Style } from '../pages/Style';
|
||||
import { Translation } from '../pages/Translation';
|
||||
import { SelectionAsk } from '../pages/SelectionAsk';
|
||||
import { LocalAsr } from '../pages/LocalAsr';
|
||||
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中
|
||||
// 通过 <LocalAsr embedded /> 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。
|
||||
import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion';
|
||||
import {
|
||||
HOTKEY_MODE_MIGRATION_ACK_KEY,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
|
||||
shouldShowProviderSetupPrompt,
|
||||
} from '../lib/providerSetup';
|
||||
import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings';
|
||||
import { type SettingsSectionId } from '../pages/Settings';
|
||||
import { useAppState, type AppTab } from '../state/useAppState';
|
||||
|
||||
interface NavItem {
|
||||
@@ -45,7 +46,6 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
|
||||
{ id: 'style', icon: 'style', cmp: Style },
|
||||
{ id: 'translation', icon: 'translate', cmp: Translation },
|
||||
{ id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk },
|
||||
{ id: 'localAsr', icon: 'archive', cmp: LocalAsr },
|
||||
];
|
||||
|
||||
interface FloatingShellProps {
|
||||
@@ -128,15 +128,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Settings → ASR 选 local-qwen3 时的"前往模型设置"事件 → 关 modal + 切 tab。
|
||||
useEffect(() => {
|
||||
const onNavigate = () => {
|
||||
setSettingsOpen(false);
|
||||
setCurrentTab('localAsr');
|
||||
};
|
||||
window.addEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate);
|
||||
return () => window.removeEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate);
|
||||
}, [setCurrentTab, setSettingsOpen]);
|
||||
// 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线,
|
||||
// 模型管理 UI 现在通过 Settings → Advanced 的 <LocalAsr embedded /> 渲染,
|
||||
// 用户在 Settings 内即可一站式管理,无需跨页跳转。
|
||||
|
||||
const rememberProviderPrompt = () => {
|
||||
window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1');
|
||||
|
||||
@@ -30,7 +30,7 @@ export const en: typeof zhCN = {
|
||||
durationMinutes: '{{value}}m',
|
||||
},
|
||||
capsule: {
|
||||
thinking: 'Thinking…',
|
||||
thinking: 'thinking',
|
||||
cancelled: 'Cancelled',
|
||||
error: 'Something went wrong',
|
||||
inserted: 'Inserted {{count}}',
|
||||
@@ -171,6 +171,7 @@ export const en: typeof zhCN = {
|
||||
kicker: 'VOCABULARY',
|
||||
title: 'Vocabulary',
|
||||
desc: 'Tell the model about words to expect — new terms, names, jargon. They are passed both as ASR hot words and as model context.',
|
||||
sectionTitle: 'Entries',
|
||||
placeholder: 'Type a word, press Enter or click Add…',
|
||||
tip: 'Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically',
|
||||
loadFailed: 'Load failed: {{err}}',
|
||||
@@ -178,6 +179,17 @@ export const en: typeof zhCN = {
|
||||
tipDisabled: 'Click to disable this entry',
|
||||
tipEnabled: 'Click to enable this entry',
|
||||
removeAria: 'Remove',
|
||||
corrections: {
|
||||
title: 'Correction rules',
|
||||
tip: 'Separate from hot words. Deterministically fixes common ASR mistakes; {num} matches numbers. Example: {num}粒 → {num}例.',
|
||||
patternPlaceholder: 'Mistaken text, e.g. {num}粒',
|
||||
replacementPlaceholder: 'Target text, e.g. {num}例',
|
||||
empty: 'No correction rules yet.',
|
||||
invalid: 'Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.',
|
||||
tipDisabled: 'Click to disable this rule',
|
||||
tipEnabled: 'Click to enable this rule',
|
||||
removeAria: 'Remove correction rule',
|
||||
},
|
||||
presets: {
|
||||
title: 'Scenario presets',
|
||||
tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.',
|
||||
@@ -283,36 +295,43 @@ export const en: typeof zhCN = {
|
||||
shortcuts: 'Shortcuts',
|
||||
permissions: 'Permissions',
|
||||
language: 'Language',
|
||||
advanced: 'Advanced',
|
||||
about: 'About',
|
||||
},
|
||||
recording: {
|
||||
title: 'Recording',
|
||||
desc: 'Define the global recording hotkey and how it triggers.',
|
||||
desc: 'Global recording hotkey and trigger mode.',
|
||||
hotkeyLabel: 'Recording hotkey',
|
||||
hotkeyDescAcc: 'Pressing it captures voice globally. Requires Accessibility permission.',
|
||||
hotkeyDescNoAcc: 'Pressing it captures voice globally. No Accessibility permission required.',
|
||||
hotkeyDescAcc: 'Press to capture voice globally (requires Accessibility permission).',
|
||||
hotkeyDescNoAcc: 'Press to capture voice globally.',
|
||||
modeLabel: 'Trigger mode',
|
||||
modeDesc: 'Toggle = press once to start, again to stop. Push-to-talk = hold to record, release to stop.',
|
||||
modeDesc: 'Toggle = tap once to start, again to stop. Push-to-talk = hold to record.',
|
||||
modeToggle: 'Toggle',
|
||||
modeHold: 'Push-to-talk',
|
||||
migrationNoticeTitle: 'Default recording mode is now Toggle',
|
||||
migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.',
|
||||
migrationNoticeDesc: 'This update changes the default; if you prefer push-to-talk, switch it back here.',
|
||||
microphoneLabel: 'Preferred microphone',
|
||||
microphoneDesc: 'Choose the preferred input device. If it is temporarily unavailable, OpenLess uses the system default microphone and switches back when it reconnects.',
|
||||
microphoneDesc: 'Choose the preferred input device; falls back to system default when unavailable.',
|
||||
microphoneDefault: 'System default microphone',
|
||||
microphoneDefaultDesc: 'Use the system default input device',
|
||||
microphoneSystemDefault: 'system default',
|
||||
microphoneUnavailable: 'unavailable',
|
||||
microphoneLoadError: 'Failed to load microphones: {{message}}',
|
||||
microphoneDialogTitle: 'Microphone',
|
||||
microphoneDialogDesc: 'Choose a microphone that can pick up your voice. If the meter does not move, try another microphone.',
|
||||
microphoneDialogDesc: 'Choose a microphone that can pick up your voice.',
|
||||
microphoneMonitorError: 'Failed to monitor input level: {{message}}',
|
||||
capsuleLabel: 'Recording capsule',
|
||||
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.',
|
||||
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording.',
|
||||
muteDuringRecordingLabel: 'Mute while recording',
|
||||
muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.',
|
||||
muteDuringRecordingDesc: 'Temporarily mute system output during voice input to avoid speaker echo.',
|
||||
insertGroupTitle: 'Insertion & clipboard',
|
||||
restoreClipboardLabel: 'Restore clipboard after insert',
|
||||
restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.',
|
||||
restoreClipboardDesc: 'Restore your original clipboard after a successful paste (Windows / Linux only).',
|
||||
pasteShortcutLabel: 'Simulated paste shortcut',
|
||||
pasteShortcutDesc: 'Which paste combo to simulate when inserting; some terminals need Ctrl+Shift+V (Windows / Linux only).',
|
||||
pasteShortcutCtrlV: 'Ctrl+V (default / most apps)',
|
||||
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)',
|
||||
pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)',
|
||||
comboRecordLabel: 'Record shortcut',
|
||||
comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.',
|
||||
comboRecordBtn: 'Record shortcut',
|
||||
@@ -321,15 +340,17 @@ export const en: typeof zhCN = {
|
||||
comboClear: 'Clear',
|
||||
comboConflict: 'This shortcut combination is not available',
|
||||
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
|
||||
allowNonTsfFallbackDesc: 'Windows only: if direct TSF insertion fails, allow Unicode SendInput, shortcut paste, or WM_PASTE. Turn off to verify that insertion is really coming from TSF.',
|
||||
allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, allow Unicode SendInput / shortcut paste.',
|
||||
historyGroupTitle: 'History & context',
|
||||
historyRetentionLabel: 'History retention (days)',
|
||||
historyRetentionDesc: 'History entries older than this many days are pruned when a new entry is written. 0 = no time-based pruning (still capped at 200). Default 7 days.',
|
||||
historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.',
|
||||
polishContextWindowLabel: 'Polish context window (minutes)',
|
||||
polishContextWindowDesc: 'Feed the LLM successfully-polished transcripts from the last N minutes as multi-turn context, so pronouns and unfinished sentences resolve against prior dictation. 0 = disabled (single-turn polish, legacy behavior). Default 5 minutes; values above 60 rarely help.',
|
||||
polishContextWindowDesc: 'Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.',
|
||||
startupGroupTitle: 'Startup',
|
||||
startMinimizedLabel: 'Start minimized (no main window)',
|
||||
startMinimizedDesc: 'When on, the main window never appears on launch (including login autostart) — OpenLess runs in the menu bar / tray only. Click the tray icon to open the main window when needed. Useful with Windows autostart.',
|
||||
startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.',
|
||||
startupAtBoot: 'Launch at login',
|
||||
startupAtBootDesc: 'Start OpenLess automatically when you sign in. macOS uses a LaunchAgent, Linux writes ~/.config/autostart, Windows writes HKCU\\Run (no admin required). See issue #194.',
|
||||
startupAtBootDesc: 'Start OpenLess automatically when you sign in.',
|
||||
startupAtBootError: 'Failed to toggle launch at login: {{message}}',
|
||||
},
|
||||
providers: {
|
||||
@@ -338,6 +359,7 @@ export const en: typeof zhCN = {
|
||||
providerLabel: 'Provider',
|
||||
llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.',
|
||||
credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.',
|
||||
codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.',
|
||||
asrProviderDesc: 'Switching providers automatically loads the matching credentials.',
|
||||
asrTitle: 'ASR (transcription)',
|
||||
asrDesc: 'Used to turn speech into text in real time.',
|
||||
@@ -346,6 +368,8 @@ export const en: typeof zhCN = {
|
||||
deepseek: 'DeepSeek',
|
||||
siliconflow: 'SiliconFlow',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Google Gemini',
|
||||
codexOAuth: 'Codex OAuth',
|
||||
mimo: 'Xiaomi MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter (free models)',
|
||||
@@ -353,6 +377,7 @@ export const en: typeof zhCN = {
|
||||
codingPlanX: 'CodingPlanX',
|
||||
custom: 'Custom',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrBailian: 'Alibaba Bailian realtime ASR',
|
||||
asrSiliconflow: 'SiliconFlow SenseVoice',
|
||||
asrZhipu: 'Zhipu GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
@@ -364,6 +389,8 @@ export const en: typeof zhCN = {
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
volcengineResourceIdLabel: 'Resource ID',
|
||||
volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.',
|
||||
localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.',
|
||||
localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.',
|
||||
localAsrHint: 'Local Qwen3-ASR runs entirely on this machine. No API key needed — just download the model from HuggingFace.',
|
||||
foundryLocalAsrHint: 'Windows local Whisper runs on this device and does not need an ASR API key. First use downloads Foundry Local runtime components and a Whisper model; LLM polishing still uses your configured LLM provider.',
|
||||
localAsrPerformanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate; each transcription takes **several seconds longer than cloud ASR**, and Chinese / dialect accuracy is **typically lower** than Volcengine or Whisper turbo. Use it for offline, privacy-sensitive, or no-cloud-API scenarios.',
|
||||
@@ -378,6 +405,12 @@ export const en: typeof zhCN = {
|
||||
apiKeyLabel: 'API Key',
|
||||
baseUrlLabel: 'Base URL',
|
||||
modelLabel: 'Model',
|
||||
thinkingModeLabel: 'Thinking',
|
||||
thinkingModeOn: 'On',
|
||||
thinkingModeOff: 'Off',
|
||||
thinkingModeHint: 'Off disables or minimizes thinking with provider-level official parameters. On enables thinking by channel defaults. No prompt injection or per-model adapters.',
|
||||
bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)',
|
||||
bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.',
|
||||
appIdLabel: 'App ID',
|
||||
accessKeyLabel: 'Access Key',
|
||||
resourceIdLabel: 'Resource ID',
|
||||
@@ -450,6 +483,36 @@ export const en: typeof zhCN = {
|
||||
notWindows: 'Only available on Windows.',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
streamingInsertTitle: 'Streaming insertion',
|
||||
streamingInsertTitleLinux: 'Streaming insertion (experimental)',
|
||||
streamingInsertDesc:
|
||||
'When on, polished output streams to the cursor character by character as the LLM SSE arrives — perceived latency drops noticeably. Requires an OpenAI-compatible LLM provider (Ark / DeepSeek / etc.), not Raw / translation mode, and focus that is not a Secure Input field (password / 1Password). When any condition fails the path transparently falls back to one-shot paste — identical to having the toggle off.',
|
||||
streamingInsertLabel: 'Enable streaming insertion',
|
||||
streamingInsertHintMac:
|
||||
'During streaming the input source is temporarily switched to ABC (so CJK / Japanese IMEs cannot intercept the Unicode keystrokes) and restored when the session ends.',
|
||||
streamingInsertHintWindows:
|
||||
'Uses SendInput Unicode to type characters directly, bypassing TSF / IME — no input-method switching needed.',
|
||||
streamingInsertHintLinux:
|
||||
'Experimental: uses enigo + XTest to synthesize keystrokes. Stable on X11; on Wayland it depends on whether the compositor grants libei access — failures fall back automatically to one-shot insertion.',
|
||||
streamingInsertSaveClipboardLabel: 'Also copy to clipboard',
|
||||
streamingInsertSaveClipboardHint: 'After a successful streaming insert, write the final text to the system clipboard so Cmd+V can paste it again. Off = streaming never touches the clipboard.',
|
||||
localAsrTitle: 'Local ASR models (experimental)',
|
||||
localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.',
|
||||
localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.',
|
||||
qwen3Desc: 'Once enabled, the ASR provider will be taken over.',
|
||||
foundryDesc: 'Once enabled, the ASR provider will be taken over.',
|
||||
notSupportedHere: 'Not supported on this platform — no inference module bundled.',
|
||||
enable: 'Enable',
|
||||
alreadyActive: 'Active',
|
||||
disableLocalLabel: 'Disable local ASR',
|
||||
disableLocalDesc: 'Switch back to cloud ASR (defaults to Volcengine bigasr).',
|
||||
disable: 'Disable',
|
||||
platformNotSupported: 'Local ASR model integration is not supported on this platform.',
|
||||
confirmEnableLocalTitle: 'Enable local ASR?',
|
||||
confirmEnableLocalBody: 'Enabling "{{target}}" makes transcription several seconds slower than cloud and typically less accurate. On under-spec hardware it can drop words (output only part of your speech).',
|
||||
confirm: 'Enable',
|
||||
},
|
||||
language: {
|
||||
title: 'Interface language',
|
||||
desc: 'Switch the UI language. Applies to the current session immediately and persists across launches.',
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ja: typeof zhCN = {
|
||||
durationMinutes: '{{value}} 分',
|
||||
},
|
||||
capsule: {
|
||||
thinking: '考えています',
|
||||
thinking: 'thinking',
|
||||
cancelled: 'キャンセルしました',
|
||||
error: 'エラーが発生しました',
|
||||
inserted: '{{count}} 文字を入力しました',
|
||||
@@ -173,6 +173,7 @@ export const ja: typeof zhCN = {
|
||||
kicker: 'VOCABULARY',
|
||||
title: '語彙',
|
||||
desc: '認識前に出現する可能性がある単語をモデルに伝えます——新語や専門用語など。ASR ホットワードと後処理モデルのコンテキスト両方に渡されます。',
|
||||
sectionTitle: '項目',
|
||||
placeholder: '単語を入力し、Enter または追加をクリック…',
|
||||
tip: '日本語と英数の混在対応 · 数字始まりは字面通り認識 · ヒット回数を自動カウント',
|
||||
loadFailed: '読み込み失敗:{{err}}',
|
||||
@@ -180,6 +181,17 @@ export const ja: typeof zhCN = {
|
||||
tipDisabled: 'クリックで無効化',
|
||||
tipEnabled: 'クリックで有効化',
|
||||
removeAria: '削除',
|
||||
corrections: {
|
||||
title: '補正ルール',
|
||||
tip: 'ホットワードとは別に、ASR のよくある誤認識を文字列として補正します。{num} は数字に一致します。例:{num}粒 → {num}例。',
|
||||
patternPlaceholder: '誤認識された表記(例:{num}粒)',
|
||||
replacementPlaceholder: '修正後の表記(例:{num}例)',
|
||||
empty: '補正ルールはまだありません。',
|
||||
invalid: '文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。',
|
||||
tipDisabled: 'クリックしてこのルールを無効化',
|
||||
tipEnabled: 'クリックしてこのルールを有効化',
|
||||
removeAria: '補正ルールを削除',
|
||||
},
|
||||
presets: {
|
||||
title: 'シーンプリセット',
|
||||
tip: '複数選択して一括有効化できます。編集と新規作成にも対応し、将来のインポート/エクスポート用のローカル構造を予約しています。',
|
||||
@@ -285,6 +297,7 @@ export const ja: typeof zhCN = {
|
||||
shortcuts: 'ショートカット',
|
||||
permissions: '権限',
|
||||
language: '言語',
|
||||
advanced: '詳細設定',
|
||||
about: '情報',
|
||||
},
|
||||
recording: {
|
||||
@@ -312,9 +325,15 @@ export const ja: typeof zhCN = {
|
||||
capsuleLabel: '録音カプセル',
|
||||
capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。',
|
||||
muteDuringRecordingLabel: '録音中はミュート',
|
||||
muteDuringRecordingDesc: '音声入力開始時にシステム出力を一時的にミュートし、停止/キャンセル/エラー後に元のミュート状態を復元。スピーカーの音がマイクに入らないようにします。',
|
||||
muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。',
|
||||
insertGroupTitle: '挿入とクリップボード',
|
||||
restoreClipboardLabel: '入力後にクリップボードを復元',
|
||||
restoreClipboardDesc: 'Windows / Linux のみ:ペースト成功後に元のクリップボード内容を復元(既定 ON)。OFF にするとディクテーションテキストがクリップボードに残り、ペーストが効かなかった場合に手動で Ctrl+V できます。詳細は issue #111。',
|
||||
restoreClipboardDesc: 'ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。',
|
||||
pasteShortcutLabel: '貼り付けショートカット',
|
||||
pasteShortcutDesc: '挿入時に模擬するペーストショートカット。一部のターミナルでは Ctrl+Shift+V が必要(Windows / Linux のみ)。',
|
||||
pasteShortcutCtrlV: 'Ctrl+V(既定 / ほとんどのアプリ)',
|
||||
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)',
|
||||
pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)',
|
||||
comboRecordLabel: 'ショートカットを記録',
|
||||
comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。',
|
||||
comboRecordBtn: 'ショートカットを記録',
|
||||
@@ -323,15 +342,17 @@ export const ja: typeof zhCN = {
|
||||
comboClear: 'クリア',
|
||||
comboConflict: 'このショートカットの組み合わせは使用できません',
|
||||
allowNonTsfFallbackLabel: '非 TSF フォールバックを許可',
|
||||
allowNonTsfFallbackDesc: 'Windows のみ:TSF による直接入力が失敗した場合、Unicode SendInput / ショートカットペースト / WM_PASTE への切り替えを許可。OFF にすると TSF 入力が実際に使われているか検証できます。',
|
||||
allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時に Unicode SendInput / ショートカットペーストへの切替を許可。',
|
||||
historyGroupTitle: '履歴とコンテキスト',
|
||||
historyRetentionLabel: '履歴保持期間(日)',
|
||||
historyRetentionDesc: 'この日数を超えた履歴は新しいエントリ書き込み時に削除されます。0 = 時間による削除なし(200 件上限は維持)。デフォルト 7 日。',
|
||||
historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。',
|
||||
polishContextWindowLabel: '会話コンテキスト窓(分)',
|
||||
polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として LLM に渡し、代名詞や未完了の文を前のディクテーションと突き合わせて解釈できるようにします。0 = 無効(シングルターン整文)。デフォルト 5 分。60 分超は実質意味なし。',
|
||||
polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。',
|
||||
startupGroupTitle: '起動',
|
||||
startMinimizedLabel: '起動時にメインウィンドウを表示しない',
|
||||
startMinimizedDesc: 'ON にすると、ログイン時の自動起動を含むすべての起動でメインウィンドウは表示されません。メニューバー / トレイのみで動作し、必要時にトレイアイコンをクリックしてメインウィンドウを開きます。Windows の自動起動と組み合わせて便利。',
|
||||
startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。',
|
||||
startupAtBoot: '起動時に自動起動',
|
||||
startupAtBootDesc: 'ログイン後に OpenLess を自動起動。macOS は LaunchAgent、Linux は ~/.config/autostart、Windows は HKCU\\Run(管理者不要)。詳細は issue #194。',
|
||||
startupAtBootDesc: 'ログイン時に OpenLess を自動起動。',
|
||||
startupAtBootError: '自動起動の切り替えに失敗:{{message}}',
|
||||
},
|
||||
providers: {
|
||||
@@ -340,6 +361,7 @@ export const ja: typeof zhCN = {
|
||||
providerLabel: 'サプライヤー',
|
||||
llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。',
|
||||
credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。',
|
||||
codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。',
|
||||
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
|
||||
asrTitle: 'ASR 音声(転写)',
|
||||
asrDesc: '口述をリアルタイムでテキストに転写。',
|
||||
@@ -348,6 +370,8 @@ export const ja: typeof zhCN = {
|
||||
deepseek: 'DeepSeek',
|
||||
siliconflow: 'SiliconFlow',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Google Gemini',
|
||||
codexOAuth: 'Codex OAuth',
|
||||
mimo: 'Xiaomi MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(無料モデル)',
|
||||
@@ -355,6 +379,7 @@ export const ja: typeof zhCN = {
|
||||
codingPlanX: 'CodingPlanX',
|
||||
custom: 'カスタム',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrBailian: 'Alibaba Bailian リアルタイム ASR',
|
||||
asrSiliconflow: 'SiliconFlow SenseVoice',
|
||||
asrZhipu: 'Zhipu GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
@@ -366,6 +391,8 @@ export const ja: typeof zhCN = {
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
volcengineResourceIdLabel: 'Resource ID',
|
||||
volcengineMappingNote: 'Secret Key は現在不要。Resource ID のデフォルトは volc.bigasr.sauc.duration。',
|
||||
localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。',
|
||||
localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。',
|
||||
localAsrHint: 'ローカル Qwen3-ASR は本機で実行されるため API Key 不要。HuggingFace からモデルをダウンロードすればすぐに利用できます。',
|
||||
foundryLocalAsrHint: 'Windows ローカル Whisper は本機で実行され、ASR API Key は不要です。初回使用時に Foundry Local ランタイムコンポーネントと Whisper モデルをダウンロードします。LLM 整文は引き続き設定済みの LLM プロバイダーを使用します。',
|
||||
localAsrPerformanceWarning: 'ローカル推論は CPU + Apple Silicon Accelerate で動作するため、1 回の転写時間は **クラウド ASR より数秒長くなります**。中国語認識精度や方言/訛り対応も **通常は** Volcengine / Whisper turbo に劣ります。ネットワーク制限下またはプライバシー重視の場合に選択してください。',
|
||||
@@ -380,6 +407,12 @@ export const ja: typeof zhCN = {
|
||||
apiKeyLabel: 'API キー',
|
||||
baseUrlLabel: 'エンドポイント',
|
||||
modelLabel: 'モデル',
|
||||
thinkingModeLabel: '思考',
|
||||
thinkingModeOn: 'オン',
|
||||
thinkingModeOff: 'オフ',
|
||||
thinkingModeHint: 'オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。',
|
||||
bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)',
|
||||
bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。',
|
||||
appIdLabel: 'App ID(アプリケーション ID)',
|
||||
accessKeyLabel: 'Access Key',
|
||||
resourceIdLabel: 'Resource ID',
|
||||
@@ -452,6 +485,36 @@ export const ja: typeof zhCN = {
|
||||
notWindows: 'Windows のみ利用可能。',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
streamingInsertTitle: 'ストリーミング入力',
|
||||
streamingInsertTitleLinux: 'ストリーミング入力(実験的)',
|
||||
streamingInsertDesc:
|
||||
'ON にすると、整形済みテキストが SSE の到着に合わせて 1 文字ずつカーソル位置に挿入され、体感遅延が大幅に短くなります。条件:OpenAI 互換 LLM プロバイダ(Ark / DeepSeek 等)+ Raw / 翻訳モード以外 + フォーカスがパスワード欄など Secure Input 制御でないこと。いずれかの条件を満たさない場合は自動で従来の一括貼り付けに戻り、OFF と同じ挙動になります。',
|
||||
streamingInsertLabel: 'ストリーミング入力を有効化',
|
||||
streamingInsertHintMac:
|
||||
'ストリーミング中は一時的に ABC 入力ソースへ切り替わります(中国語 / 日本語 IME による Unicode 文字傍受を回避)。セッション終了時に自動で元に戻ります。',
|
||||
streamingInsertHintWindows:
|
||||
'SendInput Unicode で文字イベントを直接送信し、TSF / IME を迂回します。入力ソースの切り替えは不要です。',
|
||||
streamingInsertHintLinux:
|
||||
'実験的:enigo + XTest によるキーボードイベント合成。X11 では安定動作、Wayland では compositor が libei を許可するかどうかに依存し、失敗時は自動的に一括挿入にフォールバックします。',
|
||||
streamingInsertSaveClipboardLabel: 'クリップボードにも保存',
|
||||
streamingInsertSaveClipboardHint: 'ストリーミング入力成功後、最終テキストをシステムクリップボードに書き込んで Cmd+V で再貼り付けできるようにします。OFF にすると、ストリーミング処理はクリップボードに一切触れません。',
|
||||
localAsrTitle: 'ローカル ASR モデル(実験的)',
|
||||
localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。',
|
||||
localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。',
|
||||
qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。',
|
||||
foundryDesc: '有効化すると ASR プロバイダーが引き継がれます。',
|
||||
notSupportedHere: 'このプラットフォームでは未対応(推論モジュール未組込)。',
|
||||
enable: '有効化',
|
||||
alreadyActive: '有効',
|
||||
disableLocalLabel: 'ローカル ASR を無効化',
|
||||
disableLocalDesc: 'クラウド ASR(既定は Volcengine bigasr)に戻します。',
|
||||
disable: '無効化',
|
||||
platformNotSupported: 'このプラットフォームではローカル ASR モデル統合に対応していません。',
|
||||
confirmEnableLocalTitle: 'ローカル ASR を有効化しますか?',
|
||||
confirmEnableLocalBody: '「{{target}}」を有効化すると、転写はクラウドより数秒遅くなり、精度も通常は低くなります。スペック不足のマシンでは欠字(音声の一部のみ出力)が起きる場合があります。',
|
||||
confirm: '有効化する',
|
||||
},
|
||||
language: {
|
||||
title: '表示言語',
|
||||
desc: 'UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。',
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ko: typeof zhCN = {
|
||||
durationMinutes: '{{value}}분',
|
||||
},
|
||||
capsule: {
|
||||
thinking: '생각 중',
|
||||
thinking: 'thinking',
|
||||
cancelled: '취소됨',
|
||||
error: '오류 발생',
|
||||
inserted: '{{count}}자 입력됨',
|
||||
@@ -173,6 +173,7 @@ export const ko: typeof zhCN = {
|
||||
kicker: 'VOCABULARY',
|
||||
title: '어휘',
|
||||
desc: '인식 전 등장할 수 있는 단어를 모델에 알려 주세요 — 새 단어, 신조어, 전문 용어. ASR 핫워드와 후처리 모델 컨텍스트 양쪽에 모두 전달됩니다.',
|
||||
sectionTitle: '항목',
|
||||
placeholder: '단어를 입력하고 Enter 또는 추가 클릭…',
|
||||
tip: '한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트',
|
||||
loadFailed: '로드 실패: {{err}}',
|
||||
@@ -180,6 +181,17 @@ export const ko: typeof zhCN = {
|
||||
tipDisabled: '클릭하여 비활성화',
|
||||
tipEnabled: '클릭하여 활성화',
|
||||
removeAria: '삭제',
|
||||
corrections: {
|
||||
title: '교정 규칙',
|
||||
tip: '핫워드와 별도로 ASR의 흔한 오인식을 문자 그대로 교정합니다. {num}은 숫자와 일치합니다. 예: {num}粒 → {num}例.',
|
||||
patternPlaceholder: '오인식 표현, 예: {num}粒',
|
||||
replacementPlaceholder: '대상 표현, 예: {num}例',
|
||||
empty: '아직 교정 규칙이 없습니다.',
|
||||
invalid: '문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.',
|
||||
tipDisabled: '이 규칙 비활성화',
|
||||
tipEnabled: '이 규칙 활성화',
|
||||
removeAria: '교정 규칙 삭제',
|
||||
},
|
||||
presets: {
|
||||
title: '시나리오 프리셋',
|
||||
tip: '여러 개 선택 후 일괄 활성화 가능. 편집과 새로 만들기 지원, 향후 가져오기/내보내기를 위한 로컬 구조가 예약되어 있습니다.',
|
||||
@@ -285,6 +297,7 @@ export const ko: typeof zhCN = {
|
||||
shortcuts: '단축키',
|
||||
permissions: '권한',
|
||||
language: '언어',
|
||||
advanced: '고급',
|
||||
about: '정보',
|
||||
},
|
||||
recording: {
|
||||
@@ -312,9 +325,15 @@ export const ko: typeof zhCN = {
|
||||
capsuleLabel: '녹음 캡슐',
|
||||
capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.',
|
||||
muteDuringRecordingLabel: '녹음 중 음소거',
|
||||
muteDuringRecordingDesc: '음성 입력 시작 시 시스템 출력을 일시적으로 음소거하고, 정지/취소/오류 후 원래 음소거 상태를 복원합니다. 스피커 소리가 마이크에 들어가지 않도록 합니다.',
|
||||
muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.',
|
||||
insertGroupTitle: '삽입 및 클립보드',
|
||||
restoreClipboardLabel: '입력 후 클립보드 복원',
|
||||
restoreClipboardDesc: 'Windows / Linux 만: 붙여넣기 성공 후 원래 클립보드 내용을 복원합니다(기본 ON). OFF 시 받아쓰기 텍스트가 클립보드에 남아 붙여넣기가 실패한 경우 수동으로 Ctrl+V 할 수 있습니다. issue #111 참조.',
|
||||
restoreClipboardDesc: '붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).',
|
||||
pasteShortcutLabel: '붙여넣기 단축키',
|
||||
pasteShortcutDesc: '삽입 시 시뮬레이션할 붙여넣기 단축키. 일부 터미널은 Ctrl+Shift+V 가 필요 (Windows / Linux 만).',
|
||||
pasteShortcutCtrlV: 'Ctrl+V (기본 / 대부분 앱)',
|
||||
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)',
|
||||
pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)',
|
||||
comboRecordLabel: '단축키 녹화',
|
||||
comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.',
|
||||
comboRecordBtn: '단축키 녹화',
|
||||
@@ -323,15 +342,17 @@ export const ko: typeof zhCN = {
|
||||
comboClear: '지우기',
|
||||
comboConflict: '이 단축키 조합은 사용할 수 없습니다',
|
||||
allowNonTsfFallbackLabel: '비 TSF 폴백 허용',
|
||||
allowNonTsfFallbackDesc: 'Windows 만: TSF 직접 입력이 실패할 경우 Unicode SendInput / 단축키 붙여넣기 / WM_PASTE 로 전환을 허용합니다. OFF 시 실제로 TSF 입력이 사용되는지 검증할 수 있습니다.',
|
||||
allowNonTsfFallbackDesc: 'Windows: TSF 입력 실패 시 Unicode SendInput / 단축키 붙여넣기로 전환 허용.',
|
||||
historyGroupTitle: '기록 및 컨텍스트',
|
||||
historyRetentionLabel: '기록 보관 기간(일)',
|
||||
historyRetentionDesc: '이 기간을 초과한 기록은 새 항목을 쓸 때 정리됩니다. 0 = 시간 기반 정리 비활성화(200건 상한은 유지). 기본 7일.',
|
||||
historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.',
|
||||
polishContextWindowLabel: '대화 컨텍스트 윈도(분)',
|
||||
polishContextWindowDesc: '최근 N분간 성공적으로 정리된 전사를 멀티턴 컨텍스트로 LLM 에 전달하여 대명사와 미완성 문장을 이전 받아쓰기와 대조해 해석할 수 있도록 합니다. 0 = 비활성화(단일턴 정리). 기본 5분; 60분 초과는 의미가 거의 없습니다.',
|
||||
polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.',
|
||||
startupGroupTitle: '시작',
|
||||
startMinimizedLabel: '시작 시 메인 창 숨기기',
|
||||
startMinimizedDesc: '켜면 로그인 자동 시작을 포함한 모든 시작 경로에서 메인 창이 나타나지 않습니다. 메뉴 막대 / 트레이에서만 실행되며, 필요할 때 트레이 아이콘을 클릭해 메인 창을 엽니다. Windows 자동 시작과 함께 사용하면 편리합니다.',
|
||||
startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.',
|
||||
startupAtBoot: '부팅 시 자동 시작',
|
||||
startupAtBootDesc: '로그인 후 OpenLess 자동 시작. macOS 는 LaunchAgent, Linux 는 ~/.config/autostart, Windows 는 HKCU\\Run(관리자 불필요). issue #194 참조.',
|
||||
startupAtBootDesc: '로그인 시 OpenLess 자동 시작.',
|
||||
startupAtBootError: '자동 시작 전환 실패: {{message}}',
|
||||
},
|
||||
providers: {
|
||||
@@ -340,6 +361,7 @@ export const ko: typeof zhCN = {
|
||||
providerLabel: '공급자',
|
||||
llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.',
|
||||
credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.',
|
||||
codexOAuthNotice: 'Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.',
|
||||
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
|
||||
asrTitle: 'ASR 음성(전사)',
|
||||
asrDesc: '구술을 실시간으로 텍스트로 전사합니다.',
|
||||
@@ -348,6 +370,8 @@ export const ko: typeof zhCN = {
|
||||
deepseek: 'DeepSeek',
|
||||
siliconflow: 'SiliconFlow',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Google Gemini',
|
||||
codexOAuth: 'Codex OAuth',
|
||||
mimo: 'Xiaomi MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(무료 모델)',
|
||||
@@ -355,6 +379,7 @@ export const ko: typeof zhCN = {
|
||||
codingPlanX: 'CodingPlanX',
|
||||
custom: '사용자 정의',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrBailian: 'Alibaba Bailian 실시간 ASR',
|
||||
asrSiliconflow: 'SiliconFlow SenseVoice',
|
||||
asrZhipu: 'Zhipu GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
@@ -366,6 +391,8 @@ export const ko: typeof zhCN = {
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
volcengineResourceIdLabel: 'Resource ID',
|
||||
volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.bigasr.sauc.duration.',
|
||||
localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.',
|
||||
localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.',
|
||||
localAsrHint: '로컬 Qwen3-ASR 은 본 기기에서 실행되며 API Key 가 필요 없습니다. HuggingFace 에서 모델을 로컬로 다운로드하면 즉시 사용 가능합니다.',
|
||||
foundryLocalAsrHint: 'Windows 로컬 Whisper 는 이 기기에서 실행되며 ASR API Key 가 필요 없습니다. 첫 사용 시 Foundry Local 런타임 구성 요소와 Whisper 모델을 다운로드합니다. LLM 정리는 계속 설정된 LLM 공급자를 사용합니다.',
|
||||
localAsrPerformanceWarning: '로컬 추론은 CPU + Apple Silicon Accelerate 에서 동작하므로, 한 번의 전사 시간이 **클라우드 ASR 보다 몇 초 더 걸립니다**. 중국어 인식 정확도와 방언/억양 대응도 **일반적으로** Volcengine / Whisper turbo 에 미치지 못합니다. 네트워크 제한 또는 프라이버시가 중요한 경우에 사용하세요.',
|
||||
@@ -380,6 +407,12 @@ export const ko: typeof zhCN = {
|
||||
apiKeyLabel: 'API 키',
|
||||
baseUrlLabel: '엔드포인트',
|
||||
modelLabel: '모델',
|
||||
thinkingModeLabel: '사고',
|
||||
thinkingModeOn: '켜짐',
|
||||
thinkingModeOff: '꺼짐',
|
||||
thinkingModeHint: '꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.',
|
||||
bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)',
|
||||
bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.',
|
||||
appIdLabel: 'App ID(애플리케이션 ID)',
|
||||
accessKeyLabel: 'Access Key',
|
||||
resourceIdLabel: 'Resource ID',
|
||||
@@ -452,6 +485,36 @@ export const ko: typeof zhCN = {
|
||||
notWindows: 'Windows 만 사용 가능.',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
streamingInsertTitle: '스트리밍 입력',
|
||||
streamingInsertTitleLinux: '스트리밍 입력 (실험적)',
|
||||
streamingInsertDesc:
|
||||
'활성화하면 정제된 텍스트가 SSE 도착에 맞춰 글자 단위로 커서에 입력되어 체감 지연이 크게 줄어듭니다. 조건: OpenAI 호환 LLM 제공자 (Ark / DeepSeek 등) + Raw / 번역 모드가 아님 + 포커스가 비밀번호 입력란 등 Secure Input 컨트롤이 아님. 조건 중 하나라도 충족되지 않으면 기존 일괄 붙여넣기 경로로 자동 폴백되어, OFF 와 동일한 동작을 합니다.',
|
||||
streamingInsertLabel: '스트리밍 입력 사용',
|
||||
streamingInsertHintMac:
|
||||
'스트리밍 중에는 입력 소스가 일시적으로 ABC 로 전환됩니다 (중국어 / 일본어 IME 의 Unicode 문자 가로채기 방지). 세션 종료 시 자동으로 원래 입력 소스로 복귀합니다.',
|
||||
streamingInsertHintWindows:
|
||||
'SendInput Unicode 로 문자 이벤트를 직접 전송하여 TSF / IME 를 우회합니다. 입력 소스 전환이 필요하지 않습니다.',
|
||||
streamingInsertHintLinux:
|
||||
'실험적: enigo + XTest 로 키보드 이벤트를 합성합니다. X11 에서는 안정적이며, Wayland 에서는 compositor 가 libei 를 허용하는지에 따라 다르고, 실패 시 자동으로 일괄 삽입으로 폴백됩니다.',
|
||||
streamingInsertSaveClipboardLabel: '클립보드에도 저장',
|
||||
streamingInsertSaveClipboardHint: '스트리밍 입력 성공 후 최종 텍스트를 시스템 클립보드에 기록하여 Cmd+V 로 재붙여넣기를 할 수 있도록 합니다. 끄면 스트리밍 동안 클립보드를 일절 건드리지 않습니다.',
|
||||
localAsrTitle: '로컬 ASR 모델 (실험적)',
|
||||
localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.',
|
||||
localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.',
|
||||
qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.',
|
||||
foundryDesc: '활성화하면 ASR 프로바이더가 인수됩니다.',
|
||||
notSupportedHere: '이 플랫폼에서는 미지원 (추론 모듈 미내장).',
|
||||
enable: '활성화',
|
||||
alreadyActive: '활성',
|
||||
disableLocalLabel: '로컬 ASR 비활성화',
|
||||
disableLocalDesc: '클라우드 ASR (기본 Volcengine bigasr) 로 돌아갑니다.',
|
||||
disable: '비활성화',
|
||||
platformNotSupported: '이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.',
|
||||
confirmEnableLocalTitle: '로컬 ASR 을 활성화할까요?',
|
||||
confirmEnableLocalBody: '"{{target}}" 을 활성화하면 전사가 클라우드보다 몇 초 느려지고 정확도도 일반적으로 낮아집니다. 사양이 부족한 기기에서는 글자 누락(음성의 일부만 출력)이 발생할 수 있습니다.',
|
||||
confirm: '활성화',
|
||||
},
|
||||
language: {
|
||||
title: '인터페이스 언어',
|
||||
desc: 'UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.',
|
||||
|
||||
@@ -28,7 +28,7 @@ export const zhCN = {
|
||||
durationMinutes: '{{value}} 分钟',
|
||||
},
|
||||
capsule: {
|
||||
thinking: '正在思考中',
|
||||
thinking: 'thinking',
|
||||
cancelled: '已取消',
|
||||
error: '出错了',
|
||||
inserted: '已插入 {{count}}',
|
||||
@@ -169,6 +169,7 @@ export const zhCN = {
|
||||
kicker: 'VOCABULARY',
|
||||
title: '词汇表',
|
||||
desc: '告诉模型识别前可能出现的词——生词、新词或专业词汇。同时进入 ASR 热词与后期模型上下文。',
|
||||
sectionTitle: '词条',
|
||||
placeholder: '输入词语,按 Enter 或点添加…',
|
||||
tip: '支持中英混合 · 数字开头按字面识别 · 命中次数自动计数',
|
||||
loadFailed: '加载失败:{{err}}',
|
||||
@@ -176,6 +177,17 @@ export const zhCN = {
|
||||
tipDisabled: '点击禁用此词条',
|
||||
tipEnabled: '点击启用此词条',
|
||||
removeAria: '删除',
|
||||
corrections: {
|
||||
title: '纠正规则',
|
||||
tip: '独立于热词,按字面修正 ASR 常见误识别;支持 {num} 通配数字。例如:{num}粒 → {num}例。',
|
||||
patternPlaceholder: '误识别写法,如 {num}粒',
|
||||
replacementPlaceholder: '目标写法,如 {num}例',
|
||||
empty: '还没有纠正规则。',
|
||||
invalid: '仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。',
|
||||
tipDisabled: '点击禁用此规则',
|
||||
tipEnabled: '点击启用此规则',
|
||||
removeAria: '删除纠正规则',
|
||||
},
|
||||
presets: {
|
||||
title: '场景预设',
|
||||
tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。',
|
||||
@@ -281,53 +293,62 @@ export const zhCN = {
|
||||
shortcuts: '快捷键',
|
||||
permissions: '权限',
|
||||
language: '语言',
|
||||
advanced: '高级',
|
||||
about: '关于',
|
||||
},
|
||||
recording: {
|
||||
title: '录音',
|
||||
desc: '定义全局录音的快捷键与触发方式。',
|
||||
desc: '全局录音的快捷键与触发方式。',
|
||||
hotkeyLabel: '录音快捷键',
|
||||
hotkeyDescAcc: '按下即开始捕获语音,全局生效。需要授予辅助功能权限。',
|
||||
hotkeyDescNoAcc: '按下即开始捕获语音,全局生效。无需额外辅助功能授权。',
|
||||
hotkeyDescAcc: '按下开始捕获语音,全局生效(需辅助功能权限)。',
|
||||
hotkeyDescNoAcc: '按下开始捕获语音,全局生效。',
|
||||
modeLabel: '录音方式',
|
||||
modeDesc: '切换式 = 按一次开始、再按一次结束;按住说话 = 按住开始、松开结束。',
|
||||
modeDesc: '切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。',
|
||||
modeToggle: '切换式',
|
||||
modeHold: '按住说话',
|
||||
migrationNoticeTitle: '默认已改为切换式说话',
|
||||
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
|
||||
migrationNoticeDesc: '本次更新调整了默认值,如果习惯按住说话,请在此处切回。',
|
||||
microphoneLabel: '首选麦克风',
|
||||
microphoneDesc: '选择优先使用的输入设备。设备暂时不可用时会使用系统默认麦克风,重新连接后自动切回首选设备。',
|
||||
microphoneDesc: '选择优先输入设备。设备断开时自动切到系统默认。',
|
||||
microphoneDefault: '系统默认麦克风',
|
||||
microphoneDefaultDesc: '使用系统默认输入设备',
|
||||
microphoneSystemDefault: '系统默认',
|
||||
microphoneUnavailable: '不可用',
|
||||
microphoneLoadError: '麦克风列表读取失败:{{message}}',
|
||||
microphoneDialogTitle: '麦克风',
|
||||
microphoneDialogDesc: '选择能捕捉到您声音的麦克风。如果指示条没有移动,请尝试其他麦克风。',
|
||||
microphoneDialogDesc: '选择能捕捉到您声音的麦克风。',
|
||||
microphoneMonitorError: '输入电平监听失败:{{message}}',
|
||||
capsuleLabel: '录音胶囊',
|
||||
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
|
||||
capsuleDesc: '录音 / 转写时显示屏幕底部胶囊。',
|
||||
muteDuringRecordingLabel: '录音时静音',
|
||||
muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。',
|
||||
muteDuringRecordingDesc: '录音期间临时静音系统输出,避免扬声器回音。',
|
||||
insertGroupTitle: '插入与剪贴板',
|
||||
restoreClipboardLabel: '插入后恢复剪贴板',
|
||||
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
|
||||
restoreClipboardDesc: '粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。',
|
||||
pasteShortcutLabel: '模拟粘贴快捷键',
|
||||
pasteShortcutDesc: '插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。',
|
||||
pasteShortcutCtrlV: 'Ctrl+V(默认 / 多数应用)',
|
||||
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)',
|
||||
pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)',
|
||||
comboRecordLabel: '录制快捷键',
|
||||
comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。',
|
||||
comboRecordDesc: '点击后按下想要的快捷键组合(如 ⌘⇧D)。',
|
||||
comboRecordBtn: '录制快捷键',
|
||||
comboRecordHint: '请按下快捷键组合…',
|
||||
comboRecorded: '已录制',
|
||||
comboClear: '清除',
|
||||
comboConflict: '该快捷键组合不可用',
|
||||
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
|
||||
allowNonTsfFallbackDesc: '仅 Windows:TSF 直接上屏失败后,允许改用 Unicode SendInput、快捷键粘贴或 WM_PASTE。关闭后可验证是否真实使用 TSF 输入。',
|
||||
allowNonTsfFallbackDesc: 'Windows:TSF 失败时改用 Unicode SendInput / 快捷键粘贴。',
|
||||
historyGroupTitle: '历史与上下文',
|
||||
historyRetentionLabel: '历史保留天数',
|
||||
historyRetentionDesc: '超过这个天数的历史会在写入新条目时被清理。0 = 不按时间清理(仍受 200 条上限)。默认 7 天。',
|
||||
historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。',
|
||||
polishContextWindowLabel: '对话上下文窗口(分钟)',
|
||||
polishContextWindowDesc: '把最近 N 分钟内已成功润色的转写作为多轮上下文喂给 LLM,让代词与未完整句子能被正确解析。0 = 关闭,单轮独立润色。默认 5 分钟;超过 60 分钟意义不大。',
|
||||
polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。',
|
||||
startupGroupTitle: '启动',
|
||||
startMinimizedLabel: '启动时静默运行',
|
||||
startMinimizedDesc: '打开后所有启动路径都不弹主窗口(包括开机自启),App 仅在菜单栏 / 托盘运行。需要主窗口时点托盘图标打开。Windows 开机自启场景常用。',
|
||||
startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。',
|
||||
startupAtBoot: '开机自启',
|
||||
startupAtBootDesc: '登录后自动启动 OpenLess。macOS 写 LaunchAgent,Linux 写 ~/.config/autostart,Windows 写 HKCU\\Run(不需要管理员)。详见 issue #194。',
|
||||
startupAtBootDesc: '登录系统时自动启动 OpenLess。',
|
||||
startupAtBootError: '开机自启切换失败:{{message}}',
|
||||
},
|
||||
providers: {
|
||||
@@ -336,6 +357,7 @@ export const zhCN = {
|
||||
providerLabel: '供应商',
|
||||
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
|
||||
credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。',
|
||||
codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。',
|
||||
asrProviderDesc: '切换后将自动选用对应凭据。',
|
||||
asrTitle: 'ASR 语音(转写)',
|
||||
asrDesc: '用于将口述实时转写为文本。',
|
||||
@@ -344,6 +366,8 @@ export const zhCN = {
|
||||
deepseek: 'DeepSeek',
|
||||
siliconflow: '硅基流动',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Google Gemini',
|
||||
codexOAuth: 'Codex OAuth',
|
||||
mimo: '小米 MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(免费模型)',
|
||||
@@ -351,6 +375,7 @@ export const zhCN = {
|
||||
codingPlanX: 'CodingPlanX',
|
||||
custom: '自定义',
|
||||
asrVolcengine: '火山引擎 bigasr',
|
||||
asrBailian: '阿里云百炼实时 ASR',
|
||||
asrSiliconflow: '硅基流动 SenseVoice',
|
||||
asrZhipu: '智谱 GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
@@ -362,6 +387,8 @@ export const zhCN = {
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
volcengineResourceIdLabel: 'Resource ID',
|
||||
volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。',
|
||||
localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。',
|
||||
localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。',
|
||||
localAsrHint: '本地 Qwen3-ASR 在本机运行,无需 API Key。模型从 HuggingFace 下载到本地后即可使用。',
|
||||
foundryLocalAsrHint: 'Windows 本地 Whisper 在本机运行,无需 ASR API Key。首次使用会下载 Foundry Local 运行组件和 Whisper 模型;LLM 润色仍按你配置的 LLM 提供商调用。',
|
||||
localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,单次转写时间会**比云端 ASR 长几秒**;中文识别准确率与方言/口音表现也**通常不如**火山引擎 / Whisper turbo。请按需取舍:网络受限或对隐私敏感时再用本地。',
|
||||
@@ -376,6 +403,12 @@ export const zhCN = {
|
||||
apiKeyLabel: 'API 密钥',
|
||||
baseUrlLabel: '接口地址',
|
||||
modelLabel: '模型',
|
||||
thinkingModeLabel: '思考',
|
||||
thinkingModeOn: '开启',
|
||||
thinkingModeOff: '关闭',
|
||||
thinkingModeHint: '关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。',
|
||||
bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)',
|
||||
bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。',
|
||||
appIdLabel: 'App ID(应用 ID)',
|
||||
accessKeyLabel: 'Access Key',
|
||||
resourceIdLabel: '资源 ID',
|
||||
@@ -448,6 +481,36 @@ export const zhCN = {
|
||||
notWindows: '仅 Windows 可用。',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
streamingInsertTitle: '流式输入',
|
||||
streamingInsertTitleLinux: '流式输入(实验)',
|
||||
streamingInsertDesc:
|
||||
'开启后润色 SSE 边到达边逐字落到光标,感知延迟显著降低。需要满足:OpenAI 兼容 LLM 提供商(Ark / DeepSeek 等)+ 非 Raw / 翻译模式 + 焦点不是密码框等 Secure Input 控件。任何条件不满足都自动回落到原一次性粘贴路径,行为与关闭等价。',
|
||||
streamingInsertLabel: '启用流式输入',
|
||||
streamingInsertHintMac:
|
||||
'流式期间临时切换到 ABC 输入源(规避中文 / 日文 IME 拦截 Unicode 字符),session 结束自动切回。',
|
||||
streamingInsertHintWindows:
|
||||
'SendInput Unicode 直接发字符事件,绕过 TSF / IME,不切输入法。',
|
||||
streamingInsertHintLinux:
|
||||
'实验性:enigo + XTest 模拟键盘事件,X11 稳定;Wayland 看 compositor 是否允许 libei,失败自动回落到一次性插入。',
|
||||
streamingInsertSaveClipboardLabel: '同步写入剪贴板',
|
||||
streamingInsertSaveClipboardHint: '流式输入成功后把这次的最终文本写到系统剪贴板,方便 Cmd+V 再次粘贴。关闭后流式过程完全不动剪贴板。',
|
||||
localAsrTitle: '本地 ASR 模型(实验性)',
|
||||
localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。',
|
||||
localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。',
|
||||
qwen3Desc: '启动之后,ASR 提供商将被接管。',
|
||||
foundryDesc: '启动之后,ASR 提供商将被接管。',
|
||||
notSupportedHere: '本平台暂不支持,未集成推理模块。',
|
||||
enable: '启用',
|
||||
alreadyActive: '已启用',
|
||||
disableLocalLabel: '禁用本地 ASR',
|
||||
disableLocalDesc: '切回云端 ASR(默认火山引擎 bigasr)。',
|
||||
disable: '禁用',
|
||||
platformNotSupported: '该平台暂未支持本地 ASR 模型集成。',
|
||||
confirmEnableLocalTitle: '启用本地 ASR?',
|
||||
confirmEnableLocalBody: '启用「{{target}}」后,转写会比云端慢若干秒,准确率更低;如果机器配置不足,可能出现吞字(仅输出部分语音)。',
|
||||
confirm: '确认启用',
|
||||
},
|
||||
language: {
|
||||
title: '界面语言',
|
||||
desc: '切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。',
|
||||
|
||||
@@ -30,7 +30,7 @@ export const zhTW: typeof zhCN = {
|
||||
durationMinutes: '{{value}} 分鐘',
|
||||
},
|
||||
capsule: {
|
||||
thinking: '正在思考中',
|
||||
thinking: 'thinking',
|
||||
cancelled: '已取消',
|
||||
error: '出錯了',
|
||||
inserted: '已插入 {{count}}',
|
||||
@@ -171,6 +171,7 @@ export const zhTW: typeof zhCN = {
|
||||
kicker: 'VOCABULARY',
|
||||
title: '詞彙表',
|
||||
desc: '告訴模型識別前可能出現的詞——生詞、新詞或專業詞彙。同時進入 ASR 熱詞與後期模型上下文。',
|
||||
sectionTitle: '詞條',
|
||||
placeholder: '輸入詞語,按 Enter 或點添加…',
|
||||
tip: '支持中英混合 · 數字開頭按字面識別 · 命中次數自動計數',
|
||||
loadFailed: '加載失敗:{{err}}',
|
||||
@@ -178,6 +179,17 @@ export const zhTW: typeof zhCN = {
|
||||
tipDisabled: '點擊禁用此詞條',
|
||||
tipEnabled: '點擊啓用此詞條',
|
||||
removeAria: '刪除',
|
||||
corrections: {
|
||||
title: '糾正規則',
|
||||
tip: '獨立於熱詞,按字面修正 ASR 常見誤識別;支援 {num} 通配數字。例如:{num}粒 → {num}例。',
|
||||
patternPlaceholder: '誤識別寫法,如 {num}粒',
|
||||
replacementPlaceholder: '目標寫法,如 {num}例',
|
||||
empty: '還沒有糾正規則。',
|
||||
invalid: '僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。',
|
||||
tipDisabled: '點擊停用此規則',
|
||||
tipEnabled: '點擊啟用此規則',
|
||||
removeAria: '刪除糾正規則',
|
||||
},
|
||||
presets: {
|
||||
title: '場景預設',
|
||||
tip: '可多選後批量啓用;支持編輯和新建,已爲後續導入導出預留本地結構。',
|
||||
@@ -283,6 +295,7 @@ export const zhTW: typeof zhCN = {
|
||||
shortcuts: '快捷鍵',
|
||||
permissions: '權限',
|
||||
language: '語言',
|
||||
advanced: '高級',
|
||||
about: '關於',
|
||||
},
|
||||
recording: {
|
||||
@@ -317,19 +330,27 @@ export const zhTW: typeof zhCN = {
|
||||
capsuleLabel: '錄音膠囊',
|
||||
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
|
||||
muteDuringRecordingLabel: '錄音時靜音',
|
||||
muteDuringRecordingDesc: '開始語音輸入時臨時靜音系統輸出,停止、取消或出錯後恢復原來的靜音狀態,避免揚聲器聲音被麥克風收進去。',
|
||||
muteDuringRecordingDesc: '錄音期間臨時靜音系統輸出,避免揚聲器回音。',
|
||||
insertGroupTitle: '插入與剪貼板',
|
||||
restoreClipboardLabel: '插入後恢復剪貼板',
|
||||
restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。',
|
||||
restoreClipboardDesc: '粘貼成功後恢復你原來的剪貼板內容(僅 Windows / Linux)。',
|
||||
pasteShortcutLabel: '模擬粘貼快捷鍵',
|
||||
pasteShortcutDesc: '插入時模擬按下的粘貼鍵,部分終端類應用需要 Ctrl+Shift+V(僅 Windows / Linux)。',
|
||||
pasteShortcutCtrlV: 'Ctrl+V(默認 / 多數應用)',
|
||||
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)',
|
||||
pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)',
|
||||
allowNonTsfFallbackLabel: '允許非 TSF 兜底',
|
||||
allowNonTsfFallbackDesc: '僅 Windows:TSF 直接上屏失敗後,允許改用 Unicode SendInput、快捷鍵粘貼或 WM_PASTE。關閉後可驗證是否真實使用 TSF 輸入。',
|
||||
allowNonTsfFallbackDesc: 'Windows:TSF 失敗時改用 Unicode SendInput / 快捷鍵粘貼。',
|
||||
historyGroupTitle: '歷史與上下文',
|
||||
historyRetentionLabel: '歷史保留天數',
|
||||
historyRetentionDesc: '超過這個天數的歷史會在寫入新條目時被清理。0 = 不按時間清理(仍受 200 條上限)。默認 7 天。',
|
||||
historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。',
|
||||
polishContextWindowLabel: '對話上下文窗口(分鐘)',
|
||||
polishContextWindowDesc: '把最近 N 分鐘內已成功潤色的轉寫作為多輪上下文喂給 LLM,讓代詞與未完整句子能被正確解析。0 = 關閉,單輪獨立潤色。默認 5 分鐘;超過 60 分鐘意義不大。',
|
||||
polishContextWindowDesc: '把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。',
|
||||
startupGroupTitle: '啟動',
|
||||
startMinimizedLabel: '啓動時靜默運行',
|
||||
startMinimizedDesc: '打開後所有啓動路徑都不彈主窗口(包括開機自啓),App 僅在選單欄 / 托盤運行。需要主窗口時點托盤圖示打開。Windows 開機自啓場景常用。',
|
||||
startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。',
|
||||
startupAtBoot: '開機自啓',
|
||||
startupAtBootDesc: '登錄後自動啓動 OpenLess。macOS 寫 LaunchAgent,Linux 寫 ~/.config/autostart,Windows 寫 HKCU\\Run(不需要管理員)。詳見 issue #194。',
|
||||
startupAtBootDesc: '登錄系統時自動啓動 OpenLess。',
|
||||
startupAtBootError: '開機自啓切換失敗:{{message}}',
|
||||
},
|
||||
providers: {
|
||||
@@ -338,6 +359,7 @@ export const zhTW: typeof zhCN = {
|
||||
providerLabel: '供應商',
|
||||
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
|
||||
credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。',
|
||||
codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。',
|
||||
asrProviderDesc: '切換後將自動選用對應憑據。',
|
||||
asrTitle: 'ASR 語音(轉寫)',
|
||||
asrDesc: '用於將口述實時轉寫爲文本。',
|
||||
@@ -346,6 +368,8 @@ export const zhTW: typeof zhCN = {
|
||||
deepseek: 'DeepSeek',
|
||||
siliconflow: '硅基流動',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Google Gemini',
|
||||
codexOAuth: 'Codex OAuth',
|
||||
mimo: '小米 MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(免費模型)',
|
||||
@@ -353,6 +377,7 @@ export const zhTW: typeof zhCN = {
|
||||
codingPlanX: 'CodingPlanX',
|
||||
custom: '自定義',
|
||||
asrVolcengine: '火山引擎 bigasr',
|
||||
asrBailian: '阿里雲百煉即時 ASR',
|
||||
asrSiliconflow: '硅基流動 SenseVoice',
|
||||
asrZhipu: '智譜 GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
@@ -364,6 +389,8 @@ export const zhTW: typeof zhCN = {
|
||||
volcengineAccessKeyLabel: 'Access Token',
|
||||
volcengineResourceIdLabel: 'Resource ID',
|
||||
volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。',
|
||||
localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。',
|
||||
localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。',
|
||||
localAsrHint: '本地 Qwen3-ASR 在本機運行,無需 API Key。模型從 HuggingFace 下載到本地後即可使用。',
|
||||
foundryLocalAsrHint: 'Windows 本地 Whisper 在本機運行,無需 ASR API Key。首次使用會下載 Foundry Local 運行組件和 Whisper 模型;LLM 潤色仍按你配置的 LLM 提供商調用。',
|
||||
localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次轉寫需要加載模型(數秒)**,之後單次轉寫也會比雲端 ASR 慢若干秒;中文識別準確率與方言/口音表現通常不如火山引擎 / Whisper turbo。適用場景:離線 / 隱私敏感 / 不願付費雲 API。',
|
||||
@@ -378,6 +405,12 @@ export const zhTW: typeof zhCN = {
|
||||
apiKeyLabel: 'API 密鑰',
|
||||
baseUrlLabel: '接口地址',
|
||||
modelLabel: '模型',
|
||||
thinkingModeLabel: '思考',
|
||||
thinkingModeOn: '開啟',
|
||||
thinkingModeOff: '關閉',
|
||||
thinkingModeHint: '關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。',
|
||||
bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)',
|
||||
bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。',
|
||||
appIdLabel: 'App ID(應用 ID)',
|
||||
accessKeyLabel: 'Access Key',
|
||||
resourceIdLabel: '資源 ID',
|
||||
@@ -450,6 +483,36 @@ export const zhTW: typeof zhCN = {
|
||||
notWindows: '僅 Windows 可用。',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
streamingInsertTitle: '流式輸入',
|
||||
streamingInsertTitleLinux: '流式輸入(實驗)',
|
||||
streamingInsertDesc:
|
||||
'開啟後潤色 SSE 邊到達邊逐字落到游標,感知延遲顯著降低。需要滿足:OpenAI 相容 LLM 提供商(Ark / DeepSeek 等)+ 非 Raw / 翻譯模式 + 焦點不是密碼框等 Secure Input 控件。任何條件不滿足都自動回落到原一次性貼上路徑,行為與關閉等價。',
|
||||
streamingInsertLabel: '啟用流式輸入',
|
||||
streamingInsertHintMac:
|
||||
'流式期間臨時切換到 ABC 輸入源(規避中文 / 日文 IME 攔截 Unicode 字元),session 結束自動切回。',
|
||||
streamingInsertHintWindows:
|
||||
'SendInput Unicode 直接發字元事件,繞過 TSF / IME,不切輸入法。',
|
||||
streamingInsertHintLinux:
|
||||
'實驗性:enigo + XTest 模擬鍵盤事件,X11 穩定;Wayland 看 compositor 是否允許 libei,失敗自動回落到一次性插入。',
|
||||
streamingInsertSaveClipboardLabel: '同步寫入剪貼簿',
|
||||
streamingInsertSaveClipboardHint: '流式輸入成功後把這次的最終文字寫到系統剪貼簿,方便 Cmd+V 再次貼上。關閉後流式過程完全不動剪貼簿。',
|
||||
localAsrTitle: '本地 ASR 模型(實驗性)',
|
||||
localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。',
|
||||
localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。',
|
||||
qwen3Desc: '啓動之後,ASR 提供商將被接管。',
|
||||
foundryDesc: '啓動之後,ASR 提供商將被接管。',
|
||||
notSupportedHere: '本平臺暫不支持,未集成推理模塊。',
|
||||
enable: '啓用',
|
||||
alreadyActive: '已啓用',
|
||||
disableLocalLabel: '停用本地 ASR',
|
||||
disableLocalDesc: '切回雲端 ASR(默認火山引擎 bigasr)。',
|
||||
disable: '停用',
|
||||
platformNotSupported: '該平臺暫未支持本地 ASR 模型集成。',
|
||||
confirmEnableLocalTitle: '啓用本地 ASR?',
|
||||
confirmEnableLocalBody: '啓用「{{target}}」後,轉寫會比雲端慢若干秒,準確率更低;如果機器配置不足,可能出現吞字(僅輸出部分語音)。',
|
||||
confirm: '確認啓用',
|
||||
},
|
||||
language: {
|
||||
title: '界面語言',
|
||||
desc: '切換 UI 顯示語言。當前會話即時生效,下次啓動自動沿用。',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type {
|
||||
ComboBinding,
|
||||
CorrectionRule,
|
||||
CredentialsStatus,
|
||||
DictationSession,
|
||||
DictionaryEntry,
|
||||
@@ -14,10 +15,12 @@ import type {
|
||||
PolishMode,
|
||||
QaHotkeyBinding,
|
||||
ShortcutBinding,
|
||||
UpdateChannel,
|
||||
UserPreferences,
|
||||
WindowsImeStatus,
|
||||
VocabPresetStore,
|
||||
} from './types';
|
||||
export type { UpdateChannel } from './types';
|
||||
import { OL_DATA } from './mockData';
|
||||
import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel } from './hotkey';
|
||||
|
||||
@@ -54,7 +57,9 @@ const mockSettings: UserPreferences = {
|
||||
microphoneDeviceName: '',
|
||||
activeAsrProvider: 'foundry-local-whisper',
|
||||
activeLlmProvider: 'ark',
|
||||
llmThinkingEnabled: false,
|
||||
restoreClipboardAfterPaste: true,
|
||||
pasteShortcut: 'ctrlV',
|
||||
allowNonTsfInsertionFallback: true,
|
||||
workingLanguages: ['简体中文'],
|
||||
translationTargetLanguage: '',
|
||||
@@ -76,6 +81,9 @@ const mockSettings: UserPreferences = {
|
||||
historyRetentionDays: 7,
|
||||
polishContextWindowMinutes: 5,
|
||||
startMinimized: false,
|
||||
updateChannel: 'stable',
|
||||
streamingInsert: false,
|
||||
streamingInsertSaveClipboard: true,
|
||||
};
|
||||
|
||||
const mockHotkeyCapability: HotkeyCapability = {
|
||||
@@ -147,6 +155,16 @@ const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const mockCorrectionRules: CorrectionRule[] = [
|
||||
{
|
||||
id: 'rule-quantity-classifier',
|
||||
pattern: '{num}粒',
|
||||
replacement: '{num}例',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────
|
||||
export function getSettings(): Promise<UserPreferences> {
|
||||
return invokeOrMock('get_settings', undefined, () => mockSettings);
|
||||
@@ -159,7 +177,8 @@ export function setSettings(prefs: UserPreferences): Promise<void> {
|
||||
// ── Release channel (Beta opt-in) ──────────────────────────────────────
|
||||
// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义;
|
||||
// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。
|
||||
export type UpdateChannel = 'stable' | 'beta';
|
||||
// UpdateChannel 类型搬到 types.ts(UserPreferences.updateChannel 字段使用),
|
||||
// 这里 re-export 保持外部模块(SettingsModal 等)import 路径不变。
|
||||
|
||||
export interface LatestBetaRelease {
|
||||
tagName: string;
|
||||
@@ -269,6 +288,28 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
|
||||
}
|
||||
|
||||
export function listCorrectionRules(): Promise<CorrectionRule[]> {
|
||||
return invokeOrMock('list_correction_rules', undefined, () => mockCorrectionRules);
|
||||
}
|
||||
|
||||
export function addCorrectionRule(pattern: string, replacement: string): Promise<CorrectionRule> {
|
||||
return invokeOrMock('add_correction_rule', { pattern, replacement }, () => ({
|
||||
id: `rule-new-${Date.now()}`,
|
||||
pattern,
|
||||
replacement,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeCorrectionRule(id: string): Promise<void> {
|
||||
return invokeOrMock('remove_correction_rule', { id }, () => undefined);
|
||||
}
|
||||
|
||||
export function setCorrectionRuleEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
return invokeOrMock('set_correction_rule_enabled', { id, enabled }, () => undefined);
|
||||
}
|
||||
|
||||
export function listVocabPresets(): Promise<VocabPresetStore> {
|
||||
return invokeOrMock('list_vocab_presets', undefined, () => ({
|
||||
custom: [],
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {
|
||||
applyStylePreferencesNotification,
|
||||
isStyleMasterEnabled,
|
||||
rollbackDefaultAndEnabledChange,
|
||||
persistStylePreferenceChange,
|
||||
rollbackStyleEnabledChange,
|
||||
rollbackWholeStylePreferences,
|
||||
styleDefaultModePreferences,
|
||||
styleMasterFallbackModes,
|
||||
styleMasterOffPreferences,
|
||||
} from './stylePrefs';
|
||||
import type { UserPreferences } from './types';
|
||||
|
||||
@@ -21,7 +26,9 @@ const previousPrefs: UserPreferences = {
|
||||
microphoneDeviceName: '',
|
||||
activeAsrProvider: 'volcengine',
|
||||
activeLlmProvider: 'ark',
|
||||
llmThinkingEnabled: false,
|
||||
restoreClipboardAfterPaste: true,
|
||||
pasteShortcut: 'ctrlV',
|
||||
allowNonTsfInsertionFallback: true,
|
||||
workingLanguages: ['简体中文'],
|
||||
translationTargetLanguage: '',
|
||||
@@ -43,6 +50,9 @@ const previousPrefs: UserPreferences = {
|
||||
historyRetentionDays: 7,
|
||||
polishContextWindowMinutes: 5,
|
||||
startMinimized: false,
|
||||
updateChannel: 'stable',
|
||||
streamingInsert: false,
|
||||
streamingInsertSaveClipboard: true,
|
||||
};
|
||||
|
||||
const nextPrefs: UserPreferences = {
|
||||
@@ -114,3 +124,53 @@ const notifiedPrefs: UserPreferences = {
|
||||
};
|
||||
const syncedPrefs = applyStylePreferencesNotification(previousPrefs, notifiedPrefs);
|
||||
assert(syncedPrefs === notifiedPrefs, 'prefs notification should replace stale style page prefs');
|
||||
|
||||
const masterOffPrefs = styleMasterOffPreferences(previousPrefs);
|
||||
assert(
|
||||
masterOffPrefs.enabledModes.join(',') === 'raw,light',
|
||||
`master toggle off should persist raw and current default, got ${masterOffPrefs.enabledModes.join(',')}`,
|
||||
);
|
||||
|
||||
const masterFallback = styleMasterFallbackModes('light');
|
||||
assert(
|
||||
masterFallback.join(',') === 'raw,light',
|
||||
`master toggle off should preserve raw and current default, got ${masterFallback.join(',')}`,
|
||||
);
|
||||
assert(
|
||||
isStyleMasterEnabled({ ...previousPrefs, enabledModes: masterFallback }) === false,
|
||||
'master toggle should render off when only raw and default remain enabled',
|
||||
);
|
||||
assert(
|
||||
isStyleMasterEnabled(previousPrefs) === true,
|
||||
'master toggle should render on when extra styles remain enabled',
|
||||
);
|
||||
const rawFallback = styleMasterFallbackModes('raw');
|
||||
assert(
|
||||
rawFallback.join(',') === 'raw',
|
||||
`raw default fallback should not duplicate raw, got ${rawFallback.join(',')}`,
|
||||
);
|
||||
|
||||
|
||||
const defaultAfterMasterOff = styleDefaultModePreferences(
|
||||
{ ...previousPrefs, enabledModes: ['raw', 'light'] },
|
||||
'formal',
|
||||
);
|
||||
assert(
|
||||
defaultAfterMasterOff.defaultMode === 'formal' && defaultAfterMasterOff.enabledModes.join(',') === 'raw,formal',
|
||||
`default change while master is off should refresh fallback modes, got ${defaultAfterMasterOff.defaultMode}/${defaultAfterMasterOff.enabledModes.join(',')}`,
|
||||
);
|
||||
assert(
|
||||
isStyleMasterEnabled(defaultAfterMasterOff) === false,
|
||||
'master toggle should stay off after changing default while off',
|
||||
);
|
||||
|
||||
let rolledBackDefaultAndEnabled: UserPreferences | null = defaultAfterMasterOff;
|
||||
const rollbackDefaultAndEnabled = rollbackDefaultAndEnabledChange(
|
||||
{ ...previousPrefs, enabledModes: ['raw', 'light'] },
|
||||
defaultAfterMasterOff,
|
||||
);
|
||||
rolledBackDefaultAndEnabled = rollbackDefaultAndEnabled(rolledBackDefaultAndEnabled);
|
||||
assert(
|
||||
rolledBackDefaultAndEnabled?.defaultMode === 'light' && rolledBackDefaultAndEnabled.enabledModes.join(',') === 'raw,light',
|
||||
'failed off-state default save should roll back both default mode and enabled modes',
|
||||
);
|
||||
@@ -35,6 +35,23 @@ export function applyStylePreferencesNotification(
|
||||
return incoming;
|
||||
}
|
||||
|
||||
export function styleMasterFallbackModes(defaultMode: PolishMode): PolishMode[] {
|
||||
return defaultMode === 'raw' ? ['raw'] : ['raw', defaultMode];
|
||||
}
|
||||
|
||||
export function isStyleMasterEnabled(prefs: UserPreferences): boolean {
|
||||
return !sameModeSet(prefs.enabledModes, styleMasterFallbackModes(prefs.defaultMode));
|
||||
}
|
||||
|
||||
export function styleMasterOffPreferences(prefs: UserPreferences): UserPreferences {
|
||||
return { ...prefs, enabledModes: styleMasterFallbackModes(prefs.defaultMode) };
|
||||
}
|
||||
|
||||
export function styleDefaultModePreferences(prefs: UserPreferences, mode: PolishMode): UserPreferences {
|
||||
const nextPrefs = { ...prefs, defaultMode: mode };
|
||||
return isStyleMasterEnabled(prefs) ? nextPrefs : styleMasterOffPreferences(nextPrefs);
|
||||
}
|
||||
|
||||
export function rollbackDefaultModeChange(
|
||||
previousPrefs: UserPreferences,
|
||||
nextPrefs: UserPreferences,
|
||||
@@ -71,6 +88,32 @@ export function rollbackWholeStylePreferences(
|
||||
};
|
||||
}
|
||||
|
||||
export function rollbackDefaultAndEnabledChange(
|
||||
previousPrefs: UserPreferences,
|
||||
nextPrefs: UserPreferences,
|
||||
): StylePrefsRollback {
|
||||
return current => {
|
||||
if (
|
||||
!current ||
|
||||
current.defaultMode !== nextPrefs.defaultMode ||
|
||||
!sameModes(current.enabledModes, nextPrefs.enabledModes)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
defaultMode: previousPrefs.defaultMode,
|
||||
enabledModes: previousPrefs.enabledModes,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sameModeSet(left: PolishMode[], right: PolishMode[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
const rightSet = new Set(right);
|
||||
return left.every(mode => rightSet.has(mode));
|
||||
}
|
||||
|
||||
function sameModes(left: PolishMode[], right: PolishMode[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((mode, index) => mode === right[index]);
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface DictionaryEntry {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CorrectionRule {
|
||||
id: string;
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VocabPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -102,6 +110,13 @@ export type QaHotkeyBinding = ShortcutBinding;
|
||||
/** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */
|
||||
export type ComboBinding = ShortcutBinding;
|
||||
|
||||
/** 模拟粘贴时按下的快捷键。仅 Windows/Linux 生效;macOS 走 AX 直写。
|
||||
* - ctrlV : 标准粘贴(默认;大多数编辑器、浏览器、IDE)
|
||||
* - ctrlShiftV : kitty / alacritty / wezterm / gnome-terminal / foot 等终端
|
||||
* - shiftInsert : xterm / urxvt 等老派 X11 终端
|
||||
* 详见 issue #360。 */
|
||||
export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert';
|
||||
|
||||
export type WindowsImeInstallState =
|
||||
| 'installed'
|
||||
| 'notInstalled'
|
||||
@@ -115,6 +130,10 @@ export interface WindowsImeStatus {
|
||||
dllPath: string | null;
|
||||
}
|
||||
|
||||
/** Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多一个
|
||||
* 手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 */
|
||||
export type UpdateChannel = 'stable' | 'beta';
|
||||
|
||||
export interface UserPreferences {
|
||||
hotkey: HotkeyBinding;
|
||||
dictationHotkey: ShortcutBinding;
|
||||
@@ -128,8 +147,14 @@ export interface UserPreferences {
|
||||
microphoneDeviceName: string;
|
||||
activeAsrProvider: string;
|
||||
activeLlmProvider: string;
|
||||
/** LLM 思考模式开关。默认关闭,保持既有尽量关闭思考的行为。详见 issue #402。 */
|
||||
llmThinkingEnabled: boolean;
|
||||
/** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */
|
||||
restoreClipboardAfterPaste: boolean;
|
||||
/** 仅 Windows/Linux:模拟粘贴时按下的快捷键。详见 issue #360:kitty/alacritty
|
||||
* 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。
|
||||
* macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */
|
||||
pasteShortcut: PasteShortcut;
|
||||
/** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */
|
||||
allowNonTsfInsertionFallback: boolean;
|
||||
/** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */
|
||||
@@ -174,6 +199,16 @@ export interface UserPreferences {
|
||||
/** 启动时静默运行(不弹主窗口)。Windows 开机自启场景常用——只想要后台 + 托盘,
|
||||
* 不想被主窗口打扰。开后所有启动路径都不弹窗,从菜单栏 / 托盘进入主窗口。默认 false。 */
|
||||
startMinimized: boolean;
|
||||
/** 自动更新渠道。'stable'(默认)= plugin-updater 仅检查正式版;
|
||||
* 'beta' = Settings → About 出现手动下载 Beta 的入口。 */
|
||||
updateChannel: UpdateChannel;
|
||||
/** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
|
||||
* 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落
|
||||
* 到原一次性插入。默认 false 与历史行为一致。 */
|
||||
streamingInsert: boolean;
|
||||
/** 流式输入成功后是否把最终润色文本写回剪贴板。开启后 Cmd+V 还能重复粘贴该次输出,
|
||||
* 与一次性路径行为对齐。默认 true。 */
|
||||
streamingInsertSaveClipboard: boolean;
|
||||
}
|
||||
|
||||
export interface MicrophoneDevice {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// - 监听 `local-asr-download-progress` 事件实时刷新进度
|
||||
// - Win 端引擎不可用时禁用下载按钮,提示见 issue #256
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isTauri, setActiveAsrProvider } from '../lib/ipc';
|
||||
import {
|
||||
@@ -52,7 +52,13 @@ import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
// Foundry Local Whisper 后端只在 Windows 编译实体(foundry_local_sdk 仅 Windows),
|
||||
// 非 Windows 平台 runtime 是 stub 永远 unavailable。前端这一页对应的卡片、状态拉取、
|
||||
// 事件订阅都必须按 OS 隔离,避免 macOS / Linux 用户看到 Windows 专属的 UI。
|
||||
//
|
||||
// 同理 Qwen3-ASR 后端只在 macOS 编译实体(qwen_engine / cache / local_provider 全是
|
||||
// `#[cfg(target_os = "macos")]`),Qwen3 模型管理 UI 也按 IS_MAC 守严——之前用
|
||||
// `!IS_WINDOWS` 会让假设的 Linux 渲染路径暴露死 UI(pr_agent #403 'Linux regression'
|
||||
// 修法)。
|
||||
const IS_WINDOWS = detectOS() === 'win';
|
||||
const IS_MAC = detectOS() === 'mac';
|
||||
|
||||
interface RemoteSize {
|
||||
totalBytes: number;
|
||||
@@ -61,7 +67,16 @@ interface RemoteSize {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function LocalAsr() {
|
||||
interface LocalAsrProps {
|
||||
/// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced);
|
||||
/// 此时跳过外层 page padding/height、PageHeader 与独立警告 Card —— 这些由
|
||||
/// 宿主 AdvancedSection 决定(包括把警告统一到页面顶部的浮层 popup 上)。
|
||||
/// `embedded=false`(默认)保留原全屏页样式,供 v 旧版本的独立「模型设置」
|
||||
/// 页面入口使用——但当前代码里该入口已删,本分支会一并移除。
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const [settings, setSettings] = useState<LocalAsrSettings | null>(null);
|
||||
@@ -553,20 +568,35 @@ export function LocalAsr() {
|
||||
? t('localAsr.foundryRetryPrepare')
|
||||
: t('localAsr.foundryPrepare');
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 28px 32px', overflowY: 'auto', height: '100%' }}>
|
||||
<PageHeader
|
||||
kicker={t('localAsr.kicker')}
|
||||
title={t('localAsr.title')}
|
||||
desc={t('localAsr.desc')}
|
||||
/>
|
||||
|
||||
{/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 215, 130, 0.18)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
|
||||
⚠️ {t('localAsr.performanceWarning')}
|
||||
// embedded=true 嵌入「高级」设置:跳过外层 page padding/height、PageHeader,
|
||||
// 与独立警告 Card——AdvancedSection 自己负责标题与短警告 + 启用时的浮层 popup,
|
||||
// LocalAsr 只输出实际功能 Cards(Foundry / Qwen3 模型状态 / 模型列表)。
|
||||
const Wrapper = embedded
|
||||
? (props: { children: ReactNode }) => <>{props.children}</>
|
||||
: (props: { children: ReactNode }) => (
|
||||
<div style={{ padding: '20px 28px 32px', overflowY: 'auto', height: '100%' }}>
|
||||
{props.children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{!embedded && (
|
||||
<PageHeader
|
||||
kicker={t('localAsr.kicker')}
|
||||
title={t('localAsr.title')}
|
||||
desc={t('localAsr.desc')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!embedded && (
|
||||
/* 性能/质量预期警告 —— embedded 模式下由 AdvancedSection 自己渲染,避免重复。 */
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 215, 130, 0.18)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
|
||||
⚠️ {t('localAsr.performanceWarning')}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{IS_WINDOWS && (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
@@ -748,6 +778,11 @@ export function LocalAsr() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Qwen3 模型管理区——只在 macOS 渲染(后端 #[cfg(target_os = "macos")] 独占)。
|
||||
Windows / Linux 看见镜像源 / 下载 / 模型列表都是 dead UI。Foundry 块自身已经
|
||||
被上方 IS_WINDOWS 守卫,错误 Card(共享 setError,被 Foundry handler 也写)
|
||||
保持无条件露出。 */}
|
||||
{IS_MAC && (<>
|
||||
{!engineAvailable && (
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 235, 200, 0.4)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)' }}>
|
||||
@@ -846,6 +881,7 @@ export function LocalAsr() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{error && (
|
||||
<Card style={{ marginBottom: 16, background: 'rgba(255, 220, 220, 0.5)' }}>
|
||||
@@ -853,6 +889,7 @@ export function LocalAsr() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{IS_MAC && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{models.map(model => (
|
||||
<ModelRow
|
||||
@@ -873,7 +910,8 @@ export function LocalAsr() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ interface OverviewProps {
|
||||
|
||||
const ASR_NAME_KEY_BY_ID: Record<string, string> = {
|
||||
volcengine: 'asrVolcengine',
|
||||
bailian: 'asrBailian',
|
||||
siliconflow: 'asrSiliconflow',
|
||||
zhipu: 'asrZhipu',
|
||||
groq: 'asrGroq',
|
||||
@@ -38,6 +39,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
|
||||
deepseek: 'deepseek',
|
||||
siliconflow: 'siliconflow',
|
||||
openai: 'openai',
|
||||
codex_oauth: 'codexOAuth',
|
||||
mimo: 'mimo',
|
||||
cometapi: 'cometapi',
|
||||
openrouterFree: 'openrouterFree',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Icon } from '../components/Icon';
|
||||
import { ShortcutRecorder } from '../components/ShortcutRecorder';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { LocalAsr } from './LocalAsr';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration';
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ import type {
|
||||
HotkeyStatus,
|
||||
HotkeyTrigger,
|
||||
MicrophoneDevice,
|
||||
PasteShortcut,
|
||||
PermissionStatus,
|
||||
WindowsImeStatus,
|
||||
} from '../lib/types';
|
||||
@@ -61,7 +63,7 @@ import i18n, {
|
||||
setLocalePreference,
|
||||
type SupportedLocale,
|
||||
} from '../i18n';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms';
|
||||
import {
|
||||
deleteLocalAsrModel,
|
||||
getLocalAsrSettings,
|
||||
@@ -79,9 +81,12 @@ interface SettingsProps {
|
||||
initialSection?: SettingsSectionId;
|
||||
}
|
||||
// "关于" tab 已移除(内容并入外层 SettingsModal 的 About 页,避免设置内外重复入口)。
|
||||
export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language';
|
||||
export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'advanced';
|
||||
|
||||
const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language'];
|
||||
// 「高级」放最末——本地推理 / 实验性开关都集中到这一栏,避免新手用户在主流程
|
||||
// 里误开 CPU 推理(之前提案:把 local-qwen3 / foundry-local-whisper 从主 ASR
|
||||
// 下拉藏进高级)。位置末尾也是「实验性」语义在 macOS 系统偏好里的惯用位置。
|
||||
const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'advanced'];
|
||||
|
||||
async function autostartIsEnabled(): Promise<boolean> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
@@ -186,7 +191,10 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
...(embedded ? { minHeight: 0, overflow: 'auto', paddingRight: 4 } : {}),
|
||||
// paddingBottom: 滚到底时让最后一张 Card / Collapsible 的 border + box-shadow
|
||||
// 不被滚动容器底边吃掉。16 跟 var(--ol-shadow-sm) 的扩散距离 + Card chrome 留白
|
||||
// 匹配,视觉上跟顶部 toolbar 的呼吸感对齐。
|
||||
...(embedded ? { minHeight: 0, overflow: 'auto', paddingRight: 4, paddingBottom: 16 } : {}),
|
||||
}}
|
||||
>
|
||||
{section === 'recording' && <RecordingSection />}
|
||||
@@ -194,6 +202,7 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
|
||||
{section === 'shortcuts' && <ShortcutsSection />}
|
||||
{section === 'permissions' && <PermissionsSection />}
|
||||
{section === 'language' && <LanguageSection />}
|
||||
{section === 'advanced' && <AdvancedSection />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -304,6 +313,8 @@ function RecordingSection() {
|
||||
savePrefs({ ...prefs, microphoneDeviceName });
|
||||
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
|
||||
savePrefs({ ...prefs, restoreClipboardAfterPaste });
|
||||
const onPasteShortcutChange = (pasteShortcut: PasteShortcut) =>
|
||||
savePrefs({ ...prefs, pasteShortcut });
|
||||
const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
|
||||
savePrefs({ ...prefs, allowNonTsfInsertionFallback });
|
||||
// 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。
|
||||
@@ -342,6 +353,7 @@ function RecordingSection() {
|
||||
: t('settings.recording.microphoneDefault');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.recording.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{t('settings.recording.desc')}</div>
|
||||
@@ -464,12 +476,32 @@ function RecordingSection() {
|
||||
>
|
||||
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
{/* ─── 插入与剪贴板(折叠) ──────────────────────────────────── */}
|
||||
<Collapsible title={t('settings.recording.insertGroupTitle')}>
|
||||
<SettingRow
|
||||
label={t('settings.recording.restoreClipboardLabel')}
|
||||
desc={t('settings.recording.restoreClipboardDesc')}
|
||||
>
|
||||
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
|
||||
</SettingRow>
|
||||
{capability.adapter !== 'macEventTap' && (
|
||||
<SettingRow
|
||||
label={t('settings.recording.pasteShortcutLabel')}
|
||||
desc={t('settings.recording.pasteShortcutDesc')}
|
||||
>
|
||||
<select
|
||||
value={prefs.pasteShortcut}
|
||||
onChange={e => onPasteShortcutChange(e.target.value as PasteShortcut)}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
>
|
||||
<option value="ctrlV">{t('settings.recording.pasteShortcutCtrlV')}</option>
|
||||
<option value="ctrlShiftV">{t('settings.recording.pasteShortcutCtrlShiftV')}</option>
|
||||
<option value="shiftInsert">{t('settings.recording.pasteShortcutShiftInsert')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
)}
|
||||
{capability.adapter === 'windowsLowLevel' && (
|
||||
<SettingRow
|
||||
label={t('settings.recording.allowNonTsfFallbackLabel')}
|
||||
@@ -481,6 +513,10 @@ function RecordingSection() {
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 历史与上下文(折叠) ────────────────────────────────── */}
|
||||
<Collapsible title={t('settings.recording.historyGroupTitle')}>
|
||||
<SettingRow
|
||||
label={t('settings.recording.historyRetentionLabel')}
|
||||
desc={t('settings.recording.historyRetentionDesc')}
|
||||
@@ -507,6 +543,10 @@ function RecordingSection() {
|
||||
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
|
||||
<Collapsible title={t('settings.recording.startupGroupTitle')}>
|
||||
<AutostartRow />
|
||||
<SettingRow
|
||||
label={t('settings.recording.startMinimizedLabel')}
|
||||
@@ -519,7 +559,8 @@ function RecordingSection() {
|
||||
{capability.statusHint}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1115,6 +1156,30 @@ export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolea
|
||||
);
|
||||
}
|
||||
|
||||
function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
title={t('settings.providers.thinkingModeHint')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingLeft: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
|
||||
{t('settings.providers.thinkingModeLabel')}
|
||||
</span>
|
||||
<Toggle on={enabled} onToggle={onToggle} />
|
||||
<span style={{ fontSize: 11.5, color: enabled ? 'var(--ol-blue)' : 'var(--ol-ink-4)' }}>
|
||||
{enabled ? t('settings.providers.thinkingModeOn') : t('settings.providers.thinkingModeOff')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LLM_PRESETS = [
|
||||
{
|
||||
id: 'ark',
|
||||
@@ -1140,6 +1205,24 @@ const LLM_PRESETS = [
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
modelPlaceholder: 'gpt-4o',
|
||||
},
|
||||
{
|
||||
// 谷歌官方 Gemini API(原生 generateContent,不走 OpenAI 兼容 shim)。
|
||||
// baseUrl 末尾 /v1beta 是当前 Generally Available 的 path(ai.google.dev/api)。
|
||||
// 后端 llm_gemini.rs 会拼成 `{baseUrl}/models/{model}:generateContent`,
|
||||
// 并按 Gemini 原生通道级 thinkingConfig 关闭或压低思考,不在前端维护模型适配表。
|
||||
// 模型列表用 ProviderTools「拉取模型」按钮取,
|
||||
// 由 commands.rs::fetch_provider_models 识别 generativelanguage 域名后按 Gemini shape 解析。
|
||||
id: 'gemini',
|
||||
nameKey: 'gemini',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
modelPlaceholder: 'gemini-2.5-flash',
|
||||
},
|
||||
{
|
||||
id: 'codex_oauth',
|
||||
nameKey: 'codexOAuth',
|
||||
baseUrl: '',
|
||||
modelPlaceholder: 'gpt-5.3-codex-spark',
|
||||
},
|
||||
{
|
||||
id: 'mimo',
|
||||
nameKey: 'mimo',
|
||||
@@ -1180,15 +1263,17 @@ const LLM_PRESETS = [
|
||||
|
||||
type LlmPresetId = typeof LLM_PRESETS[number]['id'];
|
||||
|
||||
const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
|
||||
const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration';
|
||||
|
||||
// `volcengine` 走自建流式客户端;其余走 OpenAI 兼容 `/audio/transcriptions`
|
||||
// (`coordinator.rs::is_whisper_compatible_provider`)。新增兼容厂商:
|
||||
// `volcengine` / `bailian` 走自建流式客户端;其余走 OpenAI 兼容
|
||||
// `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。
|
||||
// 新增兼容厂商:
|
||||
// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`;
|
||||
// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id;
|
||||
// 3. 在 i18n 的 `settings.providers.presets.<nameKey>` 加文案。
|
||||
const ASR_PRESETS = [
|
||||
{ id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' },
|
||||
{ id: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' },
|
||||
{ id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' },
|
||||
{ id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' },
|
||||
{ id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' },
|
||||
@@ -1219,8 +1304,10 @@ function ProvidersSection() {
|
||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
||||
const [asrModelRevision, setAsrModelRevision] = useState(0);
|
||||
const os = detectOS();
|
||||
// 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper)
|
||||
// 移到「高级」标签页,防止新手误开 CPU 推理。详见 AdvancedSection。
|
||||
const visibleAsrPresets = ASR_PRESETS.filter(
|
||||
p => p.id !== 'foundry-local-whisper' || os === 'win',
|
||||
p => p.id !== 'foundry-local-whisper' && p.id !== 'local-qwen3',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1229,7 +1316,10 @@ function ProvidersSection() {
|
||||
const llmId = knownLlm ? knownLlm.id : 'custom';
|
||||
setLlmProvider(llmId);
|
||||
setCommittedLlmProvider(llmId);
|
||||
const knownAsr = visibleAsrPresets.find(x => x.id === prefs.activeAsrProvider);
|
||||
// ASR 在 ALL ASR_PRESETS 里查(不是 visibleAsrPresets)——本地选项虽然
|
||||
// 从下拉里藏起来了,但若用户曾在「高级」里启用过 local-qwen3,主 Card
|
||||
// 仍要识别出 active 是本地,并切到「正在使用本地 ASR」的 notice 渲染。
|
||||
const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider);
|
||||
const asrId = knownAsr ? knownAsr.id : 'volcengine';
|
||||
setAsrProvider(asrId);
|
||||
setCommittedAsrProvider(asrId);
|
||||
@@ -1244,70 +1334,101 @@ function ProvidersSection() {
|
||||
const onLlmProviderChange = async (id: LlmPresetId) => {
|
||||
setLlmProvider(id);
|
||||
const seq = ++llmSwitchSeqRef.current;
|
||||
await setActiveLlmProvider(id);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeLlmProvider: id };
|
||||
await updatePrefs(next);
|
||||
emitSaved('saving', t('common.saving'));
|
||||
try {
|
||||
await setActiveLlmProvider(id);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
const preset = LLM_PRESETS.find(p => p.id === id);
|
||||
if (preset?.baseUrl) {
|
||||
const existing = await readCredential('ark.endpoint');
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('ark.endpoint', preset.baseUrl);
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeLlmProvider: id };
|
||||
await updatePrefs(next);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
if (preset?.modelPlaceholder) {
|
||||
const existing = await readCredential('ark.model_id');
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('ark.model_id', preset.modelPlaceholder);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
const preset = LLM_PRESETS.find(p => p.id === id);
|
||||
// 修 bug:所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽
|
||||
// (persistence.rs 没做 per-provider 隔离)。旧逻辑只在槽空时填默认值,
|
||||
// 老用户切换 preset 时槽里早有旧值——dropdown 看着切了,polish 实际还是
|
||||
// 打老 endpoint。改成:切到任何非 custom 预设都强制覆盖 endpoint 与 model
|
||||
// 到该预设的默认值,让"切换"真切到位。custom 预设没有默认值,跳过。
|
||||
if (preset && preset.id !== 'custom') {
|
||||
if (preset.baseUrl) {
|
||||
await setCredential('ark.endpoint', preset.baseUrl);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
if (preset.modelPlaceholder) {
|
||||
await setCredential('ark.model_id', preset.modelPlaceholder);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
setCommittedLlmProvider(id);
|
||||
emitSaved('saved', t('common.saved'));
|
||||
} catch (err) {
|
||||
// seq 守卫:只有当前 call 还是最新时才把 saving 翻成 failed;
|
||||
// 旧 call 早被 newer call 的 emitSaved('saving') 覆盖,再叠 failed 会
|
||||
// 把 newer 正在跑的 saving 假伪成失败。
|
||||
if (seq === llmSwitchSeqRef.current) {
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
setCommittedLlmProvider(id);
|
||||
};
|
||||
|
||||
const onLlmThinkingToggle = (enabled: boolean) => {
|
||||
if (!prefs) return;
|
||||
void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => {
|
||||
console.error('[settings] failed to update LLM thinking mode', error);
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
});
|
||||
};
|
||||
|
||||
const onAsrProviderChange = async (id: AsrPresetId) => {
|
||||
setAsrProvider(id);
|
||||
const seq = ++asrSwitchSeqRef.current;
|
||||
await setActiveAsrProvider(id);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeAsrProvider: id };
|
||||
await updatePrefs(next);
|
||||
emitSaved('saving', t('common.saving'));
|
||||
try {
|
||||
await setActiveAsrProvider(id);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
// OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩
|
||||
// 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。
|
||||
// volcengine 走另一套凭据,跳过。
|
||||
const preset = ASR_PRESETS.find(p => p.id === id);
|
||||
if (preset && preset.baseUrl) {
|
||||
const existing = await readCredential('asr.endpoint');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.endpoint', preset.baseUrl);
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeAsrProvider: id };
|
||||
await updatePrefs(next);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
if (preset && preset.model) {
|
||||
const existing = await readCredential('asr.model');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.model', preset.model);
|
||||
// OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩
|
||||
// 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。
|
||||
// volcengine 走另一套凭据,跳过。
|
||||
const preset = ASR_PRESETS.find(p => p.id === id);
|
||||
if (preset && preset.baseUrl) {
|
||||
const existing = await readCredential('asr.endpoint');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.endpoint', preset.baseUrl);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
if (preset && preset.model) {
|
||||
const existing = await readCredential('asr.model');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.model', preset.model);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
setCommittedAsrProvider(id);
|
||||
emitSaved('saved', t('common.saved'));
|
||||
} catch (err) {
|
||||
// seq 守卫同上 onLlmProviderChange:旧 call 不要把 newer call 的 saving
|
||||
// 伪造成 failed。
|
||||
if (seq === asrSwitchSeqRef.current) {
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
setCommittedAsrProvider(id);
|
||||
};
|
||||
|
||||
// preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走,
|
||||
// 否则受控 <select> 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder
|
||||
// 会先于实际数据切换、视觉上对不上。
|
||||
const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
|
||||
const codexOAuthSelected = committedLlmProvider === 'codex_oauth';
|
||||
const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider);
|
||||
|
||||
return (
|
||||
@@ -1322,7 +1443,9 @@ function ProvidersSection() {
|
||||
{t('settings.providers.llmDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow label={t('settings.providers.providerLabel')} desc={t('settings.providers.llmProviderDesc')}>
|
||||
{/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行,
|
||||
视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */}
|
||||
<SettingRow label={t('settings.providers.providerLabel')}>
|
||||
<select
|
||||
value={llmProvider}
|
||||
onChange={e => onLlmProviderChange(e.target.value as LlmPresetId)}
|
||||
@@ -1333,11 +1456,26 @@ function ProvidersSection() {
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
|
||||
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
|
||||
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
|
||||
{codexOAuthSelected ? (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
|
||||
{t('settings.providers.codexOAuthNotice')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
|
||||
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
|
||||
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
|
||||
</>
|
||||
)}
|
||||
<CredentialField key={`${committedLlmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
|
||||
placeholder={preset.modelPlaceholder || 'model-name'} mono />
|
||||
placeholder={preset.modelPlaceholder || 'model-name'} mono
|
||||
trailing={(
|
||||
<LlmThinkingToggle
|
||||
enabled={prefs?.llmThinkingEnabled ?? false}
|
||||
onToggle={onLlmThinkingToggle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
|
||||
</Card>
|
||||
|
||||
@@ -1346,16 +1484,71 @@ function ProvidersSection() {
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.asrTitle')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>{t('settings.providers.asrDesc')}</div>
|
||||
</div>
|
||||
<SettingRow label={t('settings.providers.providerLabel')} desc={t('settings.providers.asrProviderDesc')}>
|
||||
<select
|
||||
value={asrProvider}
|
||||
onChange={e => onAsrProviderChange(e.target.value as AsrPresetId)}
|
||||
style={{ ...inputStyle, maxWidth: 200 }}
|
||||
>
|
||||
{visibleAsrPresets.map(p => (
|
||||
<option key={p.id} value={p.id}>{t(`settings.providers.presets.${p.nameKey}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* desc 同 LLM 行去掉,避免 180px label 列里换行。 */}
|
||||
<SettingRow label={t('settings.providers.providerLabel')}>
|
||||
{(() => {
|
||||
// 平台本机引擎:macOS=Qwen3,Windows=Foundry。本机项始终在下拉里露出,
|
||||
// 当用户未启用本地推理时呈 disabled——用户能看到"它存在 + 提示",
|
||||
// 但只能在 Advanced 启用;启用后整下拉锁住,**实际激活的** local 项被选中
|
||||
// (pr_agent #403 fix:跨机器同步导致 macOS profile = foundry / Windows
|
||||
// profile = qwen3 时,UI 不能假装是平台本机引擎而要忠实显示)。
|
||||
const platformLocalAsr: AsrPresetId | null =
|
||||
os === 'mac' ? 'local-qwen3' : os === 'win' ? 'foundry-local-whisper' : null;
|
||||
const platformLocalNameKey = platformLocalAsr === 'local-qwen3'
|
||||
? 'asrLocalQwen3'
|
||||
: platformLocalAsr === 'foundry-local-whisper'
|
||||
? 'asrFoundryLocalWhisper'
|
||||
: null;
|
||||
const isLocked = committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper';
|
||||
// active 是本地时,selectedValue = 实际 committed 的 provider(不强制
|
||||
// 覆盖成 platformLocalAsr);非 active 时按 asrProvider(云端选项)显示。
|
||||
const selectedValue: AsrPresetId = isLocked ? committedAsrProvider : asrProvider;
|
||||
// 异常 local:当前激活的 local 不是本平台本机引擎(跨机器配置同步)。
|
||||
// 必须把它也作为 disabled <option> 加进列表,否则受控 <select> 会回退
|
||||
// 到第一项造成视觉假象。
|
||||
const anomalousLocal: AsrPresetId | null =
|
||||
isLocked && committedAsrProvider !== platformLocalAsr ? committedAsrProvider : null;
|
||||
const anomalousNameKey = anomalousLocal === 'local-qwen3'
|
||||
? 'asrLocalQwen3'
|
||||
: anomalousLocal === 'foundry-local-whisper'
|
||||
? 'asrFoundryLocalWhisper'
|
||||
: null;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', minWidth: 0 }}>
|
||||
<select
|
||||
value={selectedValue}
|
||||
disabled={isLocked}
|
||||
onChange={e => onAsrProviderChange(e.target.value as AsrPresetId)}
|
||||
style={{
|
||||
...inputStyle,
|
||||
maxWidth: 200,
|
||||
...(isLocked ? { opacity: 0.65, cursor: 'not-allowed' } : {}),
|
||||
}}
|
||||
>
|
||||
{visibleAsrPresets.map(p => (
|
||||
<option key={p.id} value={p.id}>{t(`settings.providers.presets.${p.nameKey}`)}</option>
|
||||
))}
|
||||
{platformLocalAsr && platformLocalNameKey && (
|
||||
<option key={platformLocalAsr} value={platformLocalAsr} disabled>
|
||||
{t(`settings.providers.presets.${platformLocalNameKey}`)}
|
||||
</option>
|
||||
)}
|
||||
{anomalousLocal && anomalousNameKey && (
|
||||
<option key={anomalousLocal} value={anomalousLocal} disabled>
|
||||
{t(`settings.providers.presets.${anomalousNameKey}`)}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
{platformLocalAsr && platformLocalNameKey && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5, maxWidth: 320 }}>
|
||||
{t('settings.providers.localAsrTakeoverHint', {
|
||||
name: t(`settings.providers.presets.${platformLocalNameKey}`),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</SettingRow>
|
||||
{committedAsrProvider === 'volcengine' ? (
|
||||
<>
|
||||
@@ -1384,7 +1577,10 @@ function ProvidersSection() {
|
||||
</div>
|
||||
</>
|
||||
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? (
|
||||
<LocalAsrProviderHint provider={committedAsrProvider} selectedProvider={asrProvider} />
|
||||
// 用户已经在用本地 ASR——dropdown 行的 localAsrActiveNotice 已经把
|
||||
// "在高级中切换或禁用"讲清楚了,body 不再重复 LocalAsrProviderHint。
|
||||
// 模型管理 UI 唯一入口在 AdvancedSection 里的 <LocalAsr embedded />。
|
||||
null
|
||||
) : (
|
||||
<>
|
||||
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
|
||||
@@ -1393,6 +1589,20 @@ function ProvidersSection() {
|
||||
defaultValue={asrPreset?.baseUrl || undefined} />
|
||||
<CredentialField key={`${committedAsrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
|
||||
placeholder={asrPreset?.model || 'whisper-1'} />
|
||||
{committedAsrProvider === 'bailian' && (
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${committedAsrProvider}:vocabulary_id`}
|
||||
label={t('settings.providers.bailianVocabularyIdLabel')}
|
||||
account="asr.vocabulary_id"
|
||||
mono
|
||||
placeholder="vocab-..."
|
||||
/>
|
||||
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
|
||||
{t('settings.providers.bailianVocabularyIdNote')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
|
||||
</>
|
||||
)}
|
||||
@@ -1401,6 +1611,260 @@ function ProvidersSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 「高级」标签页——本地推理 / 实验性开关都集中在这里。
|
||||
///
|
||||
/// 设计:
|
||||
/// - 主 Providers 下拉只列云端选项;本地推理 (local-qwen3 / foundry-local-whisper)
|
||||
/// 完全收纳到此栏,新手不会在主流程里误开 CPU 推理。
|
||||
/// - 启用本地推理时弹出**屏幕中央**的 modal(背景模糊)——一个框、一段短话、确认/取消。
|
||||
/// - 旧的「模型设置」独立页 (LocalAsr.tsx 作为 nav tab) 已下线,模型管理 UI
|
||||
/// 通过 <LocalAsr embedded /> 内嵌在此处统一管理。
|
||||
///
|
||||
/// 平台对称(每端只露本机有后端的引擎,另一端的引擎以 disabled 行 + "本平台暂不支持"提示露出):
|
||||
/// - macOS: Qwen3-ASR 主行可启用;Foundry **不显示**(macOS 不展示 Windows 端模型内容)。
|
||||
/// - Windows: Foundry 主行可启用;Qwen3 行 disabled,desc 为 notSupportedHere。
|
||||
/// - Linux: 「该平台暂未支持本地 ASR 模型集成」整段兜底。
|
||||
function AdvancedSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const os = detectOS();
|
||||
const isMac = os === 'mac';
|
||||
const isWin = os === 'win';
|
||||
const isLinux = os === 'linux';
|
||||
const platformSupported = isMac || isWin;
|
||||
const switchSeqRef = useRef(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切;
|
||||
// 点取消 → 回到 null。一次只允许一个 modal。
|
||||
const [pendingTarget, setPendingTarget] = useState<AsrPresetId | null>(null);
|
||||
|
||||
const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId;
|
||||
const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3';
|
||||
const isOnFoundry = activeAsrProvider === 'foundry-local-whisper';
|
||||
const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry;
|
||||
|
||||
const requestEnable = (target: AsrPresetId) => {
|
||||
setPendingTarget(target);
|
||||
};
|
||||
|
||||
const performSwitch = async (target: AsrPresetId) => {
|
||||
setBusy(true);
|
||||
const seq = ++switchSeqRef.current;
|
||||
try {
|
||||
await setActiveAsrProvider(target);
|
||||
if (seq !== switchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
await updatePrefs({ ...prefs, activeAsrProvider: target });
|
||||
}
|
||||
} finally {
|
||||
if (seq === switchSeqRef.current) {
|
||||
setBusy(false);
|
||||
setPendingTarget(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pendingNameKey =
|
||||
pendingTarget === 'local-qwen3' ? 'asrLocalQwen3'
|
||||
: pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── 屏幕中央确认 modal(背景模糊) ─────────────────────────────
|
||||
点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */}
|
||||
{pendingTarget && pendingNameKey && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.32)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) setPendingTarget(null);
|
||||
}}>
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(255, 188, 60, 0.12)',
|
||||
border: '1px solid rgba(220, 110, 0, 0.55)',
|
||||
maxWidth: 360,
|
||||
width: '100%',
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#A04500', marginBottom: 6 }}>
|
||||
⚠️ {t('settings.advanced.confirmEnableLocalTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.6, marginBottom: 10 }}>
|
||||
{t('settings.advanced.confirmEnableLocalBody', {
|
||||
target: t(`settings.providers.presets.${pendingNameKey}`),
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Btn variant="ghost" size="sm" disabled={busy} onClick={() => setPendingTarget(null)}>
|
||||
{t('common.cancel')}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void performSwitch(pendingTarget)}>
|
||||
{t('settings.advanced.confirm')}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 流式输入(全平台 opt-in) ───────────────────────────────────
|
||||
润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理
|
||||
时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径):
|
||||
- macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底)
|
||||
- Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法
|
||||
- Linux(实验):enigo XTest;Wayland compositor 拒绝 libei 时失败回落
|
||||
- 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级
|
||||
- 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落
|
||||
每个平台用各自的 hint key,互相不显示对方平台的细节。 */}
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
{t(isLinux
|
||||
? 'settings.advanced.streamingInsertTitleLinux'
|
||||
: 'settings.advanced.streamingInsertTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 10 }}>
|
||||
{t('settings.advanced.streamingInsertDesc')}
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.advanced.streamingInsertLabel')}
|
||||
desc={t(
|
||||
isMac
|
||||
? 'settings.advanced.streamingInsertHintMac'
|
||||
: isWin
|
||||
? 'settings.advanced.streamingInsertHintWindows'
|
||||
: 'settings.advanced.streamingInsertHintLinux'
|
||||
)}>
|
||||
<Toggle
|
||||
on={!!prefs?.streamingInsert}
|
||||
onToggle={(next) => {
|
||||
if (prefs) void updatePrefs({ ...prefs, streamingInsert: next });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.advanced.streamingInsertSaveClipboardLabel')}
|
||||
desc={t('settings.advanced.streamingInsertSaveClipboardHint')}>
|
||||
<Toggle
|
||||
on={!!prefs?.streamingInsertSaveClipboard}
|
||||
onToggle={(next) => {
|
||||
if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.advanced.localAsrTitle')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>
|
||||
{t('settings.advanced.localAsrDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: '#A04500',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'right',
|
||||
flexShrink: 0,
|
||||
maxWidth: '52%',
|
||||
paddingTop: 2,
|
||||
}}>
|
||||
⚠️ {t('settings.advanced.localAsrWarningShort')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!platformSupported ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
|
||||
{t('settings.advanced.platformNotSupported')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off
|
||||
+ 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台
|
||||
异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立
|
||||
"禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感
|
||||
(pr_agent #403 'Stale Windows state' 修法)。 */}
|
||||
<SettingRow
|
||||
label={t('settings.providers.presets.asrLocalQwen3')}
|
||||
desc={isMac ? t('settings.advanced.qwen3Desc') : t('settings.advanced.notSupportedHere')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Toggle
|
||||
on={isMac && isOnLocalQwen3}
|
||||
onToggle={isMac && !busy && pendingTarget === null ? (next) => {
|
||||
if (next) requestEnable('local-qwen3');
|
||||
else void performSwitch('volcengine');
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */}
|
||||
{isWin && (
|
||||
<SettingRow
|
||||
label={t('settings.providers.presets.asrFoundryLocalWhisper')}
|
||||
desc={t('settings.advanced.foundryDesc')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Toggle
|
||||
on={isOnFoundry}
|
||||
onToggle={!busy && pendingTarget === null ? (next) => {
|
||||
if (next) requestEnable('foundry-local-whisper');
|
||||
else void performSwitch('volcengine');
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出:
|
||||
- Linux / 不支持平台:根本没有任何引擎行
|
||||
- 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3):
|
||||
本机引擎 Toggle 是 off,关不动异常 active 的对方引擎
|
||||
否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */}
|
||||
{isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && (
|
||||
<SettingRow
|
||||
label={t('settings.advanced.disableLocalLabel')}
|
||||
desc={t('settings.advanced.disableLocalDesc')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Btn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={busy || pendingTarget !== null}
|
||||
onClick={() => void performSwitch('volcengine')}>
|
||||
{t('settings.advanced.disable')}
|
||||
</Btn>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local)
|
||||
inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */}
|
||||
{platformSupported && <LocalAsr embedded />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
|
||||
|
||||
function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) {
|
||||
@@ -1522,9 +1986,10 @@ interface CredentialFieldProps {
|
||||
mono?: boolean;
|
||||
mask?: boolean;
|
||||
defaultValue?: string;
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
function CredentialField({ label, account, placeholder, mono, mask, defaultValue }: CredentialFieldProps) {
|
||||
function CredentialField({ label, account, placeholder, mono, mask, defaultValue, trailing }: CredentialFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState('');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
@@ -1661,6 +2126,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
|
||||
<Icon name="check" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{trailing}
|
||||
{mask && (
|
||||
<button
|
||||
onClick={() => setRevealed(r => !r)}
|
||||
@@ -1980,7 +2446,7 @@ function PermissionsSection() {
|
||||
{desc}
|
||||
</div>
|
||||
<SettingRow label={t('settings.permissions.micLabel')} desc={t('settings.permissions.micDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<PermissionPill status={microphone} />
|
||||
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && (
|
||||
<Btn variant="ghost" size="sm" onClick={reRequestMicrophone}>
|
||||
@@ -2005,13 +2471,13 @@ function PermissionsSection() {
|
||||
label={t('settings.permissions.hotkeyLabel')}
|
||||
desc={capability ? t('settings.permissions.hotkeyDescWithAdapter', { adapter: adapterDisplayName(capability.adapter) }) : t('settings.permissions.hotkeyDescPlain')}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0 }}>
|
||||
<HotkeyStatusPill status={hotkey} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%' }}>
|
||||
{hotkey?.message && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hotkey.message}
|
||||
</span>
|
||||
)}
|
||||
<HotkeyStatusPill status={hotkey} />
|
||||
</div>
|
||||
</SettingRow>
|
||||
{windowsIme?.state !== 'notWindows' && (
|
||||
@@ -2019,18 +2485,20 @@ function PermissionsSection() {
|
||||
label={t('settings.permissions.windowsImeLabel')}
|
||||
desc={t('settings.permissions.windowsImeDesc')}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0 }}>
|
||||
<WindowsImeStatusPill status={windowsIme} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%' }}>
|
||||
{windowsIme && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t(`settings.permissions.windowsIme.${windowsIme.state}`)}
|
||||
</span>
|
||||
)}
|
||||
<WindowsImeStatusPill status={windowsIme} />
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
|
||||
<Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,14 @@ import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from
|
||||
import type { PolishMode, UserPreferences } from '../lib/types';
|
||||
import {
|
||||
applyStylePreferencesNotification,
|
||||
isStyleMasterEnabled,
|
||||
persistStylePreferenceChange,
|
||||
rollbackDefaultAndEnabledChange,
|
||||
rollbackDefaultModeChange,
|
||||
rollbackStyleEnabledChange,
|
||||
rollbackWholeStylePreferences,
|
||||
styleDefaultModePreferences,
|
||||
styleMasterOffPreferences,
|
||||
} from '../lib/stylePrefs';
|
||||
import { PageHeader, Pill } from './_atoms';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
@@ -74,13 +78,16 @@ export function Style() {
|
||||
|
||||
const onPickDefault = async (mode: PolishMode) => {
|
||||
if (!prefs) return;
|
||||
const next = { ...prefs, defaultMode: mode };
|
||||
const masterWasEnabled = isStyleMasterEnabled(prefs);
|
||||
const next = styleDefaultModePreferences(prefs, mode);
|
||||
const saved = await persistStylePreferenceChange(
|
||||
next,
|
||||
() => setDefaultPolishMode(mode),
|
||||
() => (masterWasEnabled ? setDefaultPolishMode(mode) : setSettings(next)),
|
||||
setPrefs,
|
||||
error => showSaveError(mode, error),
|
||||
rollbackDefaultModeChange(prefs, next),
|
||||
masterWasEnabled
|
||||
? rollbackDefaultModeChange(prefs, next)
|
||||
: rollbackDefaultAndEnabledChange(prefs, next),
|
||||
);
|
||||
if (saved) setSaveError(null);
|
||||
};
|
||||
@@ -112,13 +119,13 @@ export function Style() {
|
||||
);
|
||||
}
|
||||
|
||||
const masterEnabled = prefs.enabledModes.length > 0;
|
||||
const masterEnabled = isStyleMasterEnabled(prefs);
|
||||
|
||||
const onMasterToggle = async () => {
|
||||
if (!prefs) return;
|
||||
if (masterEnabled) {
|
||||
// 全部关闭 → 留 raw 和当前 default 兜底
|
||||
const next = { ...prefs, enabledModes: [] as PolishMode[] };
|
||||
// 全部关闭 → 留 raw 和当前 default 兜底,避免持久化空集合。
|
||||
const next = styleMasterOffPreferences(prefs);
|
||||
const saved = await persistStylePreferenceChange(
|
||||
next,
|
||||
() => setSettings(next),
|
||||
|
||||
@@ -3,12 +3,35 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
|
||||
import type { DictionaryEntry, VocabPreset } from '../lib/types';
|
||||
import {
|
||||
addCorrectionRule,
|
||||
addVocab,
|
||||
isTauri,
|
||||
listCorrectionRules,
|
||||
listVocab,
|
||||
removeCorrectionRule,
|
||||
removeVocab,
|
||||
setCorrectionRuleEnabled,
|
||||
setVocabEnabled,
|
||||
} from '../lib/ipc';
|
||||
import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types';
|
||||
import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets';
|
||||
import { Btn, Card, PageHeader } from './_atoms';
|
||||
import { Btn, Card, Collapsible, PageHeader } from './_atoms';
|
||||
|
||||
const NEW_PRESET_DRAFT_ID = '__new__';
|
||||
const NUM_TOKEN = '{num}';
|
||||
|
||||
function isSupportedCorrectionRule(pattern: string, replacement: string) {
|
||||
const tokenCount = pattern.split(NUM_TOKEN).length - 1;
|
||||
if (!pattern) return false;
|
||||
if (tokenCount > 1) return false;
|
||||
if (replacement.includes(NUM_TOKEN) && tokenCount === 0) return false;
|
||||
if (tokenCount === 1) {
|
||||
const [prefix, suffix] = pattern.split(NUM_TOKEN);
|
||||
return Boolean(prefix || suffix);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function Vocab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,6 +45,9 @@ export function Vocab() {
|
||||
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
|
||||
const [presetNameDraft, setPresetNameDraft] = useState('');
|
||||
const [presetPhrasesDraft, setPresetPhrasesDraft] = useState('');
|
||||
const [correctionRules, setCorrectionRules] = useState<CorrectionRule[]>([]);
|
||||
const [rulePatternDraft, setRulePatternDraft] = useState('');
|
||||
const [ruleReplacementDraft, setRuleReplacementDraft] = useState('');
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
@@ -36,8 +62,22 @@ export function Vocab() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCorrectionRules = async () => {
|
||||
try {
|
||||
const data = await listCorrectionRules();
|
||||
setCorrectionRules(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = () => {
|
||||
void refresh();
|
||||
void refreshCorrectionRules();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
refreshAll();
|
||||
void loadVocabPresets()
|
||||
.then(setPresets)
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)));
|
||||
@@ -75,6 +115,44 @@ export function Vocab() {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddCorrectionRule = async () => {
|
||||
const pattern = rulePatternDraft.trim();
|
||||
if (!pattern) return;
|
||||
const replacement = ruleReplacementDraft.trim();
|
||||
if (!isSupportedCorrectionRule(pattern, replacement)) {
|
||||
setError(t('vocab.corrections.invalid'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rule = await addCorrectionRule(pattern, replacement);
|
||||
setCorrectionRules(prev => [rule, ...prev]);
|
||||
setRulePatternDraft('');
|
||||
setRuleReplacementDraft('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveCorrectionRule = async (id: string) => {
|
||||
try {
|
||||
await removeCorrectionRule(id);
|
||||
setCorrectionRules(prev => prev.filter(r => r.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleCorrectionRule = async (rule: CorrectionRule) => {
|
||||
const next = !rule.enabled;
|
||||
setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: next } : r)));
|
||||
try {
|
||||
await setCorrectionRuleEnabled(rule.id, next);
|
||||
} catch (err) {
|
||||
setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: rule.enabled } : r)));
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (id: string) => {
|
||||
await removeVocab(id);
|
||||
setEntries(prev => prev.filter(e => e.id !== id));
|
||||
@@ -184,14 +262,17 @@ export function Vocab() {
|
||||
desc={t('vocab.desc')}
|
||||
right={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Btn icon="refresh" variant="ghost" size="sm" onClick={refresh}>{t('common.refresh')}</Btn>
|
||||
<Btn icon="refresh" variant="ghost" size="sm" onClick={refreshAll}>{t('common.refresh')}</Btn>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Card padding={0}>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
<Collapsible
|
||||
embedded
|
||||
title={t('vocab.presets.title')}
|
||||
desc={t('vocab.presets.tip')}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 12 }}>{t('vocab.presets.title')}</strong>
|
||||
{presets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -210,7 +291,6 @@ export function Vocab() {
|
||||
<Btn size="sm" variant="ghost" onClick={createPreset}>{t('vocab.presets.create')}</Btn>
|
||||
<Btn size="sm" variant="primary" onClick={applySelectedPresets}>{t('vocab.presets.apply')}</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 10 }}>{t('vocab.presets.tip')}</div>
|
||||
{editingPresetId && (
|
||||
<div style={{ marginTop: 10, display: 'grid', gap: 8 }}>
|
||||
<input value={presetNameDraft} onChange={e => setPresetNameDraft(e.target.value)} placeholder={t('vocab.presets.namePlaceholder')} />
|
||||
@@ -222,7 +302,7 @@ export function Vocab() {
|
||||
</div>
|
||||
)}
|
||||
{!editingPresetId && presets.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{presets.map(p => (
|
||||
<Btn key={`${p.id}-edit`} size="sm" variant="ghost" onClick={() => startEditPreset(p)}>
|
||||
{t('vocab.presets.edit', { name: p.name })}
|
||||
@@ -230,8 +310,50 @@ export function Vocab() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
embedded
|
||||
title={t('vocab.corrections.title')}
|
||||
desc={t('vocab.corrections.tip')}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto minmax(0, 1fr) auto', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
value={rulePatternDraft}
|
||||
onChange={e => setRulePatternDraft(e.target.value)}
|
||||
placeholder={t('vocab.corrections.patternPlaceholder')}
|
||||
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--ol-ink-4)', fontSize: 12 }}>→</span>
|
||||
<input
|
||||
value={ruleReplacementDraft}
|
||||
onChange={e => setRuleReplacementDraft(e.target.value)}
|
||||
placeholder={t('vocab.corrections.replacementPlaceholder')}
|
||||
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
|
||||
/>
|
||||
<Btn size="sm" variant="primary" onClick={() => void onAddCorrectionRule()}>{t('common.add')}</Btn>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: correctionRules.length ? undefined : 20 }}>
|
||||
{correctionRules.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('vocab.corrections.empty')}</span>
|
||||
)}
|
||||
{correctionRules.map(rule => (
|
||||
<CorrectionRuleChip
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onToggle={() => void onToggleCorrectionRule(rule)}
|
||||
onRemove={() => void onRemoveCorrectionRule(rule.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
embedded
|
||||
defaultOpen
|
||||
title={t('vocab.sectionTitle')}
|
||||
desc={t('vocab.tip')}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -248,26 +370,23 @@ export function Vocab() {
|
||||
/>
|
||||
<Btn variant="primary" icon="plus" onClick={onAdd}>{t('common.add')}</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 10 }}>
|
||||
{t('vocab.tip')}
|
||||
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: 80 }}>
|
||||
{loading && <div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
|
||||
{!loading && error && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)', lineHeight: 1.6 }}>
|
||||
{t('vocab.loadFailed', { err: error })}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
{t('vocab.empty')}
|
||||
</div>
|
||||
)}
|
||||
{!error && entries.map(e => (
|
||||
<VocabChip key={e.id} entry={e} onRemove={() => onRemove(e.id)} onToggle={() => onToggle(e)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 18, display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: 80 }}>
|
||||
{loading && <div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
|
||||
{!loading && error && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)', lineHeight: 1.6 }}>
|
||||
{t('vocab.loadFailed', { err: error })}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
{t('vocab.empty')}
|
||||
</div>
|
||||
)}
|
||||
{!error && entries.map(e => (
|
||||
<VocabChip key={e.id} entry={e} onRemove={() => onRemove(e.id)} onToggle={() => onToggle(e)} />
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
<style>{`
|
||||
@keyframes ol-chip-in {
|
||||
@@ -279,6 +398,46 @@ export function Vocab() {
|
||||
);
|
||||
}
|
||||
|
||||
interface CorrectionRuleChipProps {
|
||||
rule: CorrectionRule;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProps) {
|
||||
const { t } = useTranslation();
|
||||
const enabled = rule.enabled;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 8px 5px 10px',
|
||||
borderRadius: 999,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: enabled ? 'var(--ol-surface)' : 'var(--ol-surface-2)',
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={enabled ? t('vocab.corrections.tipDisabled') : t('vocab.corrections.tipEnabled')}
|
||||
style={{ background: 'transparent', border: 0, padding: 0, color: 'inherit', fontFamily: 'inherit', cursor: 'default' }}
|
||||
>
|
||||
{rule.pattern} → {rule.replacement}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={t('vocab.corrections.removeAria')}
|
||||
style={{ width: 18, height: 18, borderRadius: 999, border: 0, background: 'rgba(0,0,0,0.06)', color: 'var(--ol-ink-4)', cursor: 'default' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface VocabChipProps {
|
||||
entry: DictionaryEntry;
|
||||
onRemove: () => void;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Ported verbatim from design_handoff_openless/pages.jsx (PageHeader, Card,
|
||||
// Pill, Btn). Inline styles preserved 1:1.
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Icon } from '../components/Icon';
|
||||
|
||||
interface PageHeaderProps {
|
||||
@@ -143,3 +143,107 @@ export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onC
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollapsibleProps {
|
||||
/// 标题行内容(短文案,居左)。
|
||||
title: ReactNode;
|
||||
/// 可选的副标题 / 描述(小字,居标题下方)。
|
||||
desc?: ReactNode;
|
||||
/// 默认是否展开。默认 false(折叠,符合"默认只显示标题 + 右箭头"语义)。
|
||||
defaultOpen?: boolean;
|
||||
/// 嵌在 Card padding=0 容器里时设为 true:移除上下 margin,仅靠 Card 的 borderBottom 分割。
|
||||
embedded?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/// 折叠栏:默认收起,标题行右侧显示一个 `›` 箭头,点击切换展开/收起。展开时箭头
|
||||
/// 旋转 90°。内容区域用 `grid-template-rows: 0fr ↔ 1fr` 过渡——浏览器把 `1fr`
|
||||
/// 解析为内容实际高度,过渡到真实高度,避免 max-height 固定大值时短内容也走完
|
||||
/// 整段动画 / 短内容关闭时延迟生效的"卡卡"感。要求 Chromium 117+(Tauri 自带),
|
||||
/// 现代版本完全支持。
|
||||
///
|
||||
/// `embedded=true`:嵌在 `<Card padding={0}>` 里、与其他 Collapsible 共享一张 Card 时使用,
|
||||
/// 底部加一道 0.5px 分隔。
|
||||
/// `embedded=false`:独立 block,自带 Card 同款外观(border / radius / shadow)。
|
||||
export function Collapsible({ title, desc, defaultOpen = false, embedded = false, children }: CollapsibleProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderBottom: embedded ? '0.5px solid var(--ol-line)' : undefined,
|
||||
border: embedded ? undefined : '0.5px solid var(--ol-line)',
|
||||
borderRadius: embedded ? 0 : 'var(--ol-r-lg)',
|
||||
background: embedded ? 'transparent' : 'var(--ol-surface)',
|
||||
boxShadow: embedded ? 'none' : 'var(--ol-shadow-sm)',
|
||||
overflow: 'hidden',
|
||||
// 父级 flex column 带 minHeight:0 + overflow:auto 时,所有 flex 子项默认
|
||||
// shrink:1,会把 header 按钮也压成一条线。锁住不压缩,溢出走父容器滚动。
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
// 让屏幕阅读器朗读折叠状态;键盘 focus 保留浏览器默认 outline 而不是
|
||||
// outline: 'none',避免 Tab 切换时丢失视觉焦点指示(pr-agent #407 反馈)。
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 18px',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
textAlign: 'left',
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
|
||||
{desc && (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 3, lineHeight: 1.5 }}>{desc}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 18,
|
||||
height: 18,
|
||||
color: 'var(--ol-ink-4)',
|
||||
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.18s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<Icon name="chevRight" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
// grid-template-rows: 0fr → 1fr 让浏览器把 1fr 解析为内容实际高度。
|
||||
gridTemplateRows: open ? '1fr' : '0fr',
|
||||
transition: 'grid-template-rows 0.22s var(--ol-motion-soft)',
|
||||
}}
|
||||
// inert 把内部交互元素从 tab 顺序 + a11y 树移除,避免折叠后键盘用户
|
||||
// 仍能 tab 到不可见的输入框 / 按钮 / Toggle(pr-agent #407 反馈)。
|
||||
// 受支持范围:Chromium 102+(Tauri WebView 远高于此)/ Safari 15.4+。
|
||||
// React 18 类型没收 `inert`,用 spread 传 string-boolean 绕过编译器。
|
||||
{...(!open ? { inert: '' } : {})}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* minHeight: 0 必填:默认 grid item 不允许收缩到小于内容固有高度,
|
||||
没这条 trick 不生效,行高动画也跟着失败。 */}
|
||||
<div style={{ overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ padding: '0 18px 18px' }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user