From ace7fe47abda5b47d04a76d1f40135436ce1f216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 13:20:32 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs(readme):=20=E9=A1=B6=E9=83=A8=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=20Discord=20=E7=A4=BE=E5=8C=BA=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=88badge=20+=20=E7=A4=BE=E5=8C=BA=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E4=B8=AD=E8=8B=B1=E5=8F=8C=E8=AF=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- README.zh.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b481d9df..abb056ed 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Release License Stars + Discord

@@ -37,7 +38,7 @@

- 💬  QQ Group  1078960553 + 💬  Join our Discord  ·  QQ Group  1078960553


diff --git a/README.zh.md b/README.zh.md index 80230a7b..79dec76e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -27,6 +27,7 @@ Release License Stars + Discord

@@ -37,7 +38,7 @@

- 💬  QQ 群  1078960553 + 💬  加入 Discord 社区  ·  QQ 群  1078960553


From f482ac21ee756c8e953bd5c661ae5830ec7242a9 Mon Sep 17 00:00:00 2001 From: Ian Gao Date: Thu, 4 Jun 2026 16:04:54 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(macos):=20Beta=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=89=8D=E9=87=8D=E7=BD=AE=20TCC=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/lib.rs | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 5679745e..076e6c8e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -47,6 +47,8 @@ use std::sync::Arc; use std::time::Duration; const LOG_ROTATE_LIMIT_BYTES: u64 = 10 * 1024 * 1024; +#[cfg(target_os = "macos")] +const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 @@ -820,9 +822,49 @@ fn restart_app(app: AppHandle) { log::info!("[updater] stripped xattr on {:?} before restart", bundle); } } + #[cfg(target_os = "macos")] + reset_tcc_for_beta_restart(); app.restart(); } +#[cfg(target_os = "macos")] +fn reset_tcc_for_beta_restart() { + if !is_beta_build() { + log::info!("[updater] skipping TCC reset before stable restart"); + return; + } + + // Beta builds are currently ad-hoc signed. Their code hash changes across builds, so + // old TCC rows can leave System Settings checked while AXIsProcessTrusted() is false. + reset_tcc_service_for_beta_restart("Accessibility"); + reset_tcc_service_for_beta_restart("Microphone"); +} + +#[cfg(target_os = "macos")] +fn is_beta_build() -> bool { + env!("CARGO_PKG_VERSION").contains('-') +} + +#[cfg(target_os = "macos")] +fn reset_tcc_service_for_beta_restart(service: &str) { + match std::process::Command::new("/usr/bin/tccutil") + .args(["reset", service, OPENLESS_BUNDLE_ID]) + .status() + { + Ok(status) if status.success() => { + log::info!("[updater] reset TCC {service} before beta restart"); + } + Ok(status) => { + log::warn!( + "[updater] reset TCC {service} before beta restart exited with {status}" + ); + } + Err(e) => { + log::warn!("[updater] reset TCC {service} before beta restart failed: {e}"); + } + } +} + /// 把日志同时写到 stderr + ~/Library/Logs/OpenLess/openless.log(match Swift `Log.swift`)。 fn init_file_logger() { use simplelog::{ From 5cf015de17165e80cc7aa92a13a85fdff0078ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 19:31:15 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs(readme):=20=E5=AE=9E=E9=99=85=E6=95=88?= =?UTF-8?q?=E6=9E=9C=E7=A4=BA=E4=BE=8B=E6=8D=A2=E6=88=90=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E5=9B=9E=E9=82=AE=E4=BB=B6=E7=94=A8=E4=BE=8B=EF=BC=88=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E5=8F=8C=E8=AF=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「一个实际例子 / A concrete example」从未充分测试的 SQL→ChatGPT 演示 换成经过测试的客户回邮件用例:口语输入 → 结构化清理稿,更贴合 OpenLess「只清理你的话、不替你作答」的定位,宣传与实际相符。 收束句由「回车发送」改为「粘贴给 ChatGPT/Claude 续写」,标语保持不变。 --- README.md | 15 ++++++++------- README.zh.md | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b481d9df..6037fa53 100644 --- a/README.md +++ b/README.md @@ -122,20 +122,21 @@ Two features define how OpenLess feels in daily use: Hold the hotkey and say: -> uh… so… I want ChatGPT to write me a SQL query, from the orders table get last month's orders, group by customer, sort by amount desc, top ten +> uh… so… I need to reply to the client about the proposal they sent last time, we talked it over internally and the overall direction is fine but a few details need changing, first the delivery timeline is too tight we'd like to push it back two weeks, second on budget their quote came in about twenty percent higher than we expected can we negotiate that, and overall we're positive about working together so reassure them, keep the tone polite not too blunt Release the hotkey. A moment later, your input box reads: ```text -Please write a SQL query that: +Reply to the client about the proposal they sent. After internal discussion, the overall direction is fine, but a few details need adjusting: -- Pulls orders from last month from the `orders` table. -- Groups by customer. -- Sorts by total amount, descending. -- Returns the top 10 rows only. +1. Delivery timeline + (a) The current timeline is too tight; we'd like to extend it by two weeks. +2. Budget + (a) Their quote is about 20% higher than we expected — can we renegotiate? +Keep the overall message positive about the partnership, and keep the tone polite rather than blunt. ``` -No edits are required — press Enter and send. That is the core idea: **compose prompts by speaking, faster and more cleanly than typing them.** +No edits are required — paste it into ChatGPT or Claude and let it draft the email. That is the core idea: **compose prompts by speaking, faster and more cleanly than typing them.** ## Why OpenLess is open source diff --git a/README.zh.md b/README.zh.md index 80230a7b..8bfa016f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -122,20 +122,21 @@ OpenLess 是一款跨平台(macOS 与 Windows)语音输入应用,是 [Typeless]( 按住快捷键,说: -> 嗯…那个…我想让 ChatGPT 帮我写个 SQL,从 orders 表里取上个月的订单,按客户分组,金额从大到小排,取前十条 +> 嗯…那个,我要给客户回个邮件,就是上次他们提的那个方案嘛,我们内部讨论了一下觉得大方向没问题,但是有几个细节要改,第一个就是交付时间太紧了我们希望延两周,第二个是预算那块他们报的价格比我们预期高了大概百分之二十能不能谈一下,然后整体合作意向是积极的让他们放心,语气客气一点别太生硬 松开快捷键。片刻之后,你的输入框里出现: ```text -请写一条 SQL 查询,要求: +给客户回邮件,关于上次他们提的方案,我们内部讨论后认为大方向没问题,但有几个细节需要调整: -- 从 `orders` 表中取出上个月的订单。 -- 按客户分组。 -- 按总金额降序排列。 -- 仅返回前 10 行。 +1. 交付时间 + (a) 目前交付时间太紧,希望延长两周。 +2. 预算 + (a) 他们的报价比我们预期高了大约 20%,能否再谈一下? +整体合作意向是积极的,语气上请客气一点,不要太生硬。 ``` -无需任何修改——按下回车发送即可。这就是核心理念:**用嘴写提示词,比打字更快、更干净。** +无需任何修改——直接粘贴给 ChatGPT 或 Claude,让它替你把邮件写完。这就是核心理念:**用嘴写提示词,比打字更快、更干净。** ## 为什么 OpenLess 选择开源 From 14c3874eb843a0563a7a7b9c78787cd5dfb1fc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 20:07:56 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(overview):=20port=20=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=90=8E=E7=8A=B6=E6=80=81=E4=B8=8D=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E4=BF=AE=E5=A4=8D=E5=88=B0=20beta=20(#573)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #532/#573:在 Settings 填写/更新凭据但不切换提供商时,Overview 页 原有的 useEffect 不会重跑,asrConfigured 仍停留在「未配置」。 该修复曾随 #538 合入 main,但被 beta→main 合并覆盖,beta(开发主分支)上缺失, 导致用户(如 Ubuntu 24.04)配好火山引擎 ASR 后界面仍显示「未配置」。 - commands.rs: set_credential 保存/删除成功后 emit "credentials:changed" - Overview.tsx: 监听该事件调用 refreshCredentials()(复用 credentialsRequestSeq 防竞态, 比 #538 原版裸 getCredentials() 更稳) --- openless-all/app/src-tauri/src/commands.rs | 9 +++++-- openless-all/app/src/pages/Overview.tsx | 28 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 10deede9..80d0fee1 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -783,10 +783,15 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result< ensure_main_window(&window)?; let acc = parse_account(&account)?; if value.is_empty() { - CredentialsVault::remove(acc).map_err(|e| e.to_string()) + CredentialsVault::remove(acc).map_err(|e| e.to_string())?; } else { - CredentialsVault::set(acc, &value).map_err(|e| e.to_string()) + CredentialsVault::set(acc, &value).map_err(|e| e.to_string())?; } + // 通知前端凭据已变更(如 Overview 页需要刷新 asrConfigured 状态)。 + // issue #532 / #573:在 Settings 填写凭据但不切换提供商时,Overview 不会重拉状态, + // 仍显示「未配置」。该修复曾随 #538 合入 main,但被 beta→main 合并覆盖,beta 上缺失。 + let _ = window.emit("credentials:changed", ()); + Ok(()) } #[tauri::command] diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 609fbe47..9c43e3f1 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -101,6 +101,34 @@ export function Overview({ onOpenHistory }: OverviewProps) { refreshCredentials(); }, [refreshCredentials, prefs?.activeAsrProvider, prefs?.activeLlmProvider]); + // 凭据被保存后重新拉取状态(issue #532 / #573:在 Settings 中填写/更新凭据 + // 但不切换提供商时,上面的 useEffect 不会重跑,导致概览页的状态仍停留在「未配置」)。 + // 复用 refreshCredentials() 以带上 credentialsRequestSeq 防竞态。 + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('credentials:changed', () => { + if (cancelled) return; + refreshCredentials(); + }); + if (cancelled) { + handle(); + } else { + unlisten = handle; + } + } catch { + // browser dev mock — 没有 Tauri event bridge + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [refreshCredentials]); + const metrics = useMemo(() => { const today = new Date(); today.setHours(0, 0, 0, 0); From 1d4a3adaad06920e35c8d63f07117a2904487e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 20:19:40 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(asr):=20=E6=94=AF=E6=8C=81=20OpenRoute?= =?UTF-8?q?r=20=E4=BD=9C=E4=B8=BA=20ASR=20=E6=8F=90=E4=BE=9B=E5=95=86=20(#?= =?UTF-8?q?582)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter 的 /audio/transcriptions 与 OpenAI 路径相同、也走 Bearer,但请求体是 application/json:{model, input_audio:{data:, format:"wav"}},而非 OpenAI 官方的 multipart/form-data。直接当 whisper 兼容 provider 会被 OpenRouter 拒绝。 - whisper.rs: 新增 AsrRequestFormat{Multipart,OpenRouterJson},transcribe_chunk 按格式 分发;OpenRouterJson 走 JSON+标准 base64(带 padding),不带 multipart 专属的 prompt/response_format 字段。用 with_request_format builder 避免改 new() 签名(不动 既有 4 处构造点)。 - coordinator.rs: is_whisper_compatible_provider 注册 openrouter;新增 whisper_request_format;verbose_json 对 openrouter 保持关闭;base64 膨胀 ~33%,长 录音按 30s 切分(同 zhipu)。 - 前端: ASR_PRESETS / AsrPresetId / Overview 名称映射 + 5 份 i18n 加 openrouter。 - 测试: OpenRouterJson 发 JSON body 单测 + 注册/格式断言;既有 transcribe 测试不变。 默认 model 用报告人已验证的 openai/whisper-large-v3-turbo。 新增直接依赖 base64=0.22(此前已是传递依赖,未引入新 crate)。 --- openless-all/app/src-tauri/Cargo.lock | 1 + openless-all/app/src-tauri/Cargo.toml | 2 + openless-all/app/src-tauri/src/asr/whisper.rs | 169 ++++++++++++++---- openless-all/app/src-tauri/src/coordinator.rs | 55 ++++-- .../src-tauri/src/coordinator/dictation.rs | 20 ++- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Overview.tsx | 1 + .../src/pages/settings/ProvidersSection.tsx | 3 + .../app/src/pages/settings/shared.tsx | 1 + 13 files changed, 206 insertions(+), 51 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index b1f8c9ce..b04c5855 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3680,6 +3680,7 @@ version = "1.3.6-2" dependencies = [ "anyhow", "arboard", + "base64 0.22.1", "block2 0.5.1", "bytes", "bzip2 0.4.4", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index b0a3e014..1779b069 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -26,6 +26,8 @@ tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。 +base64 = "0.22" sha2 = "0.10" bzip2 = "0.4" tar = "0.4" diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index eabeec10..cc3120d2 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -2,6 +2,7 @@ //! to any OpenAI-compatible `/audio/transcriptions` endpoint on session end. use anyhow::{Context, Result}; +use base64::Engine; use parking_lot::Mutex; use crate::asr::wav::encode_wav_16k_mono; @@ -21,6 +22,19 @@ pub const PROMPT_CHAR_BUDGET: usize = 240; /// 区切り文字(ASCII)。Whisper のトークナイザはどの言語でも安定して扱える。 const PROMPT_SEPARATOR: &str = ", "; +/// `/audio/transcriptions` 请求体编码方式。 +/// +/// OpenAI 官方及多数兼容厂商用 `multipart/form-data`(file + model)。 +/// OpenRouter 虽路径相同、也走 Bearer,但请求体是 `application/json`: +/// `{model, input_audio:{data:, format:"wav"}}`(issue #582)。 +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AsrRequestFormat { + /// `multipart/form-data`(既有行为,默认)。 + Multipart, + /// OpenRouter `application/json` + base64 音频。 + OpenRouterJson, +} + pub struct WhisperBatchASR { api_key: String, base_url: String, @@ -36,6 +50,8 @@ pub struct WhisperBatchASR { /// (SiliconFlow)は response_format 自体が無いので false にして従来の /// `json` のまま送る(壊さない)。 verbose_json: bool, + /// 请求体编码方式。默认 `Multipart`,OpenRouter 走 `OpenRouterJson`。 + request_format: AsrRequestFormat, buffer: Mutex>, } @@ -55,10 +71,18 @@ impl WhisperBatchASR { prompt, max_chunk_duration_ms, verbose_json, + request_format: AsrRequestFormat::Multipart, buffer: Mutex::new(Vec::new()), } } + /// 设置请求体编码方式(默认 `Multipart`)。OpenRouter 需 `OpenRouterJson`。 + /// 用 builder 而非给 `new()` 加参数,避免改动既有 4 处构造点的签名。 + pub fn with_request_format(mut self, request_format: AsrRequestFormat) -> Self { + self.request_format = request_format; + self + } + /// Stop collecting audio, encode the buffer as WAV, and POST to the /// Whisper transcriptions endpoint. /// @@ -110,40 +134,62 @@ impl WhisperBatchASR { .collect(); let wav = encode_wav_16k_mono(&samples); let url = transcription_url(&self.base_url)?; - - let wav_part = reqwest::multipart::Part::bytes(wav) - .file_name("audio.wav") - .mime_str("audio/wav") - .context("set MIME type")?; - let mut form = reqwest::multipart::Form::new() - .part("file", wav_part) - .text("model", self.model.clone()); - - // verbose_json 対応プロバイダ(OpenAI / Groq)のときだけ、セグメント - // メタデータ付きの応答を要求し、temperature も 0 に固定する。非対応 - // プロバイダ(SiliconFlow の SenseVoice / TeleSpeech 等)には送らず - // 従来どおりの応答にして、未知パラメータでの 4xx を避ける。 - if self.verbose_json { - form = form - .text("response_format", "verbose_json") - .text("temperature", "0"); - } - - // `prompt` は空文字を送らない:OpenAI 互換実装によっては空文字でエラーに - // なるリスクがある(Groq は許容するが防御的にスキップ)。`trim()` で - // 空白のみのケースも除外。 - if let Some(prompt) = self.prompt.as_ref() { - let trimmed = prompt.trim(); - if !trimmed.is_empty() { - form = form.text("prompt", trimmed.to_string()); - } - } - let client = reqwest::Client::new(); - let resp = client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .multipart(form) + + let request = match self.request_format { + AsrRequestFormat::Multipart => { + let wav_part = reqwest::multipart::Part::bytes(wav) + .file_name("audio.wav") + .mime_str("audio/wav") + .context("set MIME type")?; + let mut form = reqwest::multipart::Form::new() + .part("file", wav_part) + .text("model", self.model.clone()); + + // verbose_json 対応プロバイダ(OpenAI / Groq)のときだけ、セグメント + // メタデータ付きの応答を要求し、temperature も 0 に固定する。非対応 + // プロバイダ(SiliconFlow の SenseVoice / TeleSpeech 等)には送らず + // 従来どおりの応答にして、未知パラメータでの 4xx を避ける。 + if self.verbose_json { + form = form + .text("response_format", "verbose_json") + .text("temperature", "0"); + } + + // `prompt` は空文字を送らない:OpenAI 互換実装によっては空文字でエラーに + // なるリスクがある(Groq は許容するが防御的にスキップ)。`trim()` で + // 空白のみのケースも除外。 + if let Some(prompt) = self.prompt.as_ref() { + let trimmed = prompt.trim(); + if !trimmed.is_empty() { + form = form.text("prompt", trimmed.to_string()); + } + } + + client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .multipart(form) + } + AsrRequestFormat::OpenRouterJson => { + // OpenRouter /audio/transcriptions:application/json,音频走标准 + // base64(带 padding)。不带 multipart 专属的 prompt/response_format + // 字段,避免未知字段导致 4xx;verbose_json 对该协议保持关闭。 + let body = serde_json::json!({ + "model": self.model, + "input_audio": { + "data": base64::engine::general_purpose::STANDARD.encode(&wav), + "format": "wav", + }, + }); + client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + } + }; + + let resp = request .send() .await .context("Whisper HTTP request failed")?; @@ -725,6 +771,63 @@ mod tests { server.join().unwrap(); } + #[tokio::test] + async fn openrouter_format_posts_json_with_base64_audio() { + // issue #582:OpenRouterJson 走 application/json + input_audio.data(base64), + // 而非 multipart;响应仍按 {text} 解析。 + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "timed out waiting for ASR test request" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept ASR test request failed: {err}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let request = read_http_request(&mut stream); + let request_text = String::from_utf8_lossy(&request); + let lower = request_text.to_ascii_lowercase(); + assert!(request_text.starts_with("POST /audio/transcriptions HTTP/1.1")); + assert!(lower.contains("content-type: application/json")); + assert!(lower.contains("authorization: bearer key")); + // body 是 JSON:含 input_audio.data + format:"wav",且不是 multipart。 + assert!(request_text.contains("input_audio")); + assert!(request_text.contains(r#""format":"wav""#)); + assert!(!lower.contains("multipart/form-data")); + write_json_response(&mut stream, r#"{"text":"openrouter ok"}"#); + }); + let base_url = format!("http://{}", addr); + + let asr = WhisperBatchASR::new( + "key".to_string(), + base_url, + "openai/whisper-large-v3-turbo".to_string(), + None, + None, + false, + ) + .with_request_format(AsrRequestFormat::OpenRouterJson); + let pcm = vec![0u8; 32_000 * 2]; + asr.consume_pcm_chunk(&pcm); + + let transcript = asr.transcribe().await.unwrap(); + assert_eq!(transcript.text, "openrouter ok"); + server.join().unwrap(); + } + fn start_whisper_test_server(texts: Vec<&'static str>) -> (String, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 72ce6dce..4ab7e5d2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -184,7 +184,9 @@ fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option { match provider_id { - "zhipu" => Some(30_000), + // OpenRouter 把音频 base64 进 JSON body,体积比二进制大 ~33%,长录音易撞 + // body/时长上限,保守按 30s 切分(与 zhipu 同)。 + "zhipu" | "openrouter" => Some(30_000), _ => None, } } @@ -2489,7 +2491,16 @@ async fn build_local_qwen3( /// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要 /// 单独 ASR 客户端,留给 V2。 fn is_whisper_compatible_provider(id: &str) -> bool { - matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq") + matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter") +} + +/// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是 +/// `application/json` + base64 音频(issue #582),其余兼容厂商沿用 multipart。 +fn whisper_request_format(provider_id: &str) -> crate::asr::whisper::AsrRequestFormat { + match provider_id { + "openrouter" => crate::asr::whisper::AsrRequestFormat::OpenRouterJson, + _ => crate::asr::whisper::AsrRequestFormat::Multipart, + } } /// 该 provider 的 `/audio/transcriptions` 是否支持 `response_format=verbose_json` @@ -2665,14 +2676,17 @@ async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> Result = whisper; Ok(QaAsrStart::Ready { active, consumer }) @@ -4057,6 +4071,27 @@ mod tests { assert!(!whisper_supports_verbose_json("zhipu")); } + #[test] + fn openrouter_is_whisper_compatible_json_provider() { + use crate::asr::whisper::AsrRequestFormat; + // issue #582:OpenRouter 走 whisper 兼容路由,但请求体是 JSON+base64。 + assert!(is_whisper_compatible_provider("openrouter")); + assert_eq!( + whisper_request_format("openrouter"), + AsrRequestFormat::OpenRouterJson + ); + // 其余兼容厂商保持 multipart。 + assert_eq!( + whisper_request_format("whisper"), + AsrRequestFormat::Multipart + ); + assert_eq!(whisper_request_format("groq"), AsrRequestFormat::Multipart); + // OpenRouter 的 JSON 协议不吃 response_format,verbose_json 保持关闭。 + assert!(!whisper_supports_verbose_json("openrouter")); + // base64 膨胀,长录音保守按 30s 切分。 + assert_eq!(batch_asr_chunk_limit_ms("openrouter"), Some(30_000)); + } + #[test] fn qa_asr_provider_kind_tracks_active_provider() { assert_eq!( diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index e06d0172..dfbbe3a5 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -811,14 +811,17 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { // 互換プロバイダにも揃えるのが筋。 let whisper_prompt = crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); - let whisper = Arc::new(WhisperBatchASR::new( - api_key, - base_url, - model, - whisper_prompt, - batch_asr_chunk_limit_ms(&active_asr), - whisper_supports_verbose_json(&active_asr), - )); + let whisper = Arc::new( + WhisperBatchASR::new( + api_key, + base_url, + model, + whisper_prompt, + batch_asr_chunk_limit_ms(&active_asr), + whisper_supports_verbose_json(&active_asr), + ) + .with_request_format(whisper_request_format(&active_asr)), + ); store_asr_for_session( inner, current_session_id, @@ -2166,6 +2169,7 @@ mod tests { #[test] fn batch_asr_chunk_limit_applies_only_to_zhipu() { assert_eq!(batch_asr_chunk_limit_ms("zhipu"), Some(30_000)); + assert_eq!(batch_asr_chunk_limit_ms("openrouter"), Some(30_000)); assert_eq!(batch_asr_chunk_limit_ms("whisper"), None); assert_eq!(batch_asr_chunk_limit_ms("siliconflow"), None); assert_eq!(batch_asr_chunk_limit_ms("groq"), None); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 2957b4db..74c2890d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -618,6 +618,7 @@ export const en: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', + asrOpenrouter: 'OpenRouter Whisper', asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)', asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', asrLocalQwen3: 'Local Qwen3-ASR', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 4ca0a390..f43eae92 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -620,6 +620,7 @@ export const ja: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(互換)', + asrOpenrouter: 'OpenRouter Whisper', asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)', asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)', asrLocalQwen3: 'ローカル Qwen3-ASR', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0b849849..a0657a27 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -620,6 +620,7 @@ export const ko: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(호환)', + asrOpenrouter: 'OpenRouter Whisper', asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)', asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)', asrLocalQwen3: '로컬 Qwen3-ASR', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ca387876..6e20aa23 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -616,6 +616,7 @@ export const zhCN = { asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrOpenrouter: 'OpenRouter Whisper', asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 043e71b9..bbe51030 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -618,6 +618,7 @@ export const zhTW: typeof zhCN = { asrZhipu: '智譜 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrOpenrouter: 'OpenRouter Whisper', asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 609fbe47..acb830d7 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -30,6 +30,7 @@ const ASR_NAME_KEY_BY_ID: Record = { zhipu: 'asrZhipu', groq: 'asrGroq', whisper: 'asrWhisper', + openrouter: 'asrOpenrouter', 'foundry-local-whisper': 'asrFoundryLocalWhisper', 'sherpa-onnx-local': 'asrSherpaOnnxLocal', 'local-qwen3': 'asrLocalQwen3', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 8dd052ec..dae82737 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -142,6 +142,9 @@ const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: st { 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' }, { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, + // OpenRouter 的 /audio/transcriptions 走 application/json + base64(issue #582), + // 后端 coordinator.rs::whisper_request_format 对该 id 切换到 OpenRouterJson 编码。 + { id: 'openrouter', nameKey: 'asrOpenrouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/whisper-large-v3-turbo' }, { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, // 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置, // 模型在「高级 → 本地模型」里下载与切换。 diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 214f9284..18eb644d 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -179,6 +179,7 @@ export type AsrPresetId = | "zhipu" | "groq" | "whisper" + | "openrouter" | "foundry-local-whisper" | "sherpa-onnx-local" | "local-qwen3" From d8ec007fcdae0c1eb69d7a1db83d0cd64f2f8b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 20:38:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(hotkey):=20=E5=85=A8=E5=B1=80=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E5=8F=AF=E5=81=9C=E7=94=A8/=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=E5=BC=80=E5=85=B3=20(#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0602 版本默认注册的「切换风格 / 唤起 App」全局快捷键无法关闭,与其他软件冲突 且会拦截软件内快捷键。本 PR 复用 QA 快捷键已有的 `Option=None` 停用模式,给这两个 action 键加上「可清空停用」能力。 默认仍开启(Some 默认键),对老用户零行为变化,仅新增可关。 后端: - types.rs: 内层 AppPrefs switch_style_hotkey/open_app_hotkey 改 Option;default fn 返回 Some(默认键);Wire::default 改携带 Some(缺字段=启用);from-wire 去掉 unwrap_or_else 直传 Option(None=停用不再被塌缩成默认键——这正是「无法关闭」的根因)。 - coordinator.rs: action_hotkey_binding 返回 Option;supervisor / update 加 None 反注册 分支;reset-latch 对 Option 解包。 - commands.rs: set_switch_style_hotkey/set_open_app_hotkey 接受 Option(None=停用,跳过 validate/overlap);所有 reject_*_overlap 读取处对 Option 解包(停用项不参与冲突检测)。 前端: - types.ts/ipc.ts: switchStyleHotkey/openAppHotkey 与 setter 改 nullable。 - ShortcutsSection: 有值→录制器+「停用」按钮;停用→「启用」按钮恢复默认(仿 QA 行)。 - lib/hotkey.ts: defaultSwitchStyleShortcut/defaultOpenAppShortcut helper。 - 5 份 i18n 加 settings.shortcuts.enable/disable。 测试:默认启用、缺字段启用、None 往返为 null、显式绑定往返、overlap 拒绝(含停用项跳过)。 --- openless-all/app/src-tauri/src/commands.rs | 133 +++++++++++------- openless-all/app/src-tauri/src/coordinator.rs | 36 +++-- openless-all/app/src-tauri/src/types.rs | 80 +++++++++-- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/hotkey.ts | 10 ++ openless-all/app/src/lib/ipc.ts | 5 +- openless-all/app/src/lib/types.ts | 8 +- .../src/pages/settings/ShortcutsSection.tsx | 108 +++++++++++--- 12 files changed, 293 insertions(+), 97 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 10deede9..f3cd82d1 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -1879,8 +1879,12 @@ pub fn set_qa_hotkey( if let Some(binding) = binding.as_ref() { reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, binding)?; reject_qa_translation_hotkey_overlap(binding, &prefs.translation_hotkey)?; - reject_qa_switch_style_hotkey_overlap(binding, &prefs.switch_style_hotkey)?; - reject_qa_open_app_hotkey_overlap(binding, &prefs.open_app_hotkey)?; + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + reject_qa_switch_style_hotkey_overlap(binding, switch_style)?; + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + reject_qa_open_app_hotkey_overlap(binding, open_app)?; + } } prefs.qa_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -1920,8 +1924,12 @@ pub fn set_dictation_hotkey( reject_dictation_qa_hotkey_overlap(&binding, qa_hotkey)?; } reject_dictation_translation_hotkey_overlap(&binding, &prefs.translation_hotkey)?; - reject_dictation_switch_style_hotkey_overlap(&binding, &prefs.switch_style_hotkey)?; - reject_dictation_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?; + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + reject_dictation_switch_style_hotkey_overlap(&binding, switch_style)?; + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + reject_dictation_open_app_hotkey_overlap(&binding, open_app)?; + } prefs.dictation_hotkey = binding; sync_dictation_hotkey_legacy_fields(&mut prefs); coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -1941,8 +1949,12 @@ pub fn set_translation_hotkey( if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() { reject_qa_translation_hotkey_overlap(qa_hotkey, &binding)?; } - reject_translation_switch_style_hotkey_overlap(&binding, &previous.switch_style_hotkey)?; - reject_translation_open_app_hotkey_overlap(&binding, &previous.open_app_hotkey)?; + if let Some(switch_style) = previous.switch_style_hotkey.as_ref() { + reject_translation_switch_style_hotkey_overlap(&binding, switch_style)?; + } + if let Some(open_app) = previous.open_app_hotkey.as_ref() { + reject_translation_open_app_hotkey_overlap(&binding, open_app)?; + } let mut prefs = previous.clone(); prefs.translation_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -1956,40 +1968,55 @@ pub fn set_translation_hotkey( Ok(()) } +/// 设置「切换风格」全局快捷键。`binding == None`(前端传 null)= 停用:清空绑定并 +/// 反注册全局键。镜像 `set_qa_hotkey` 的 `Option=None` 停用模式(issue #576)。 #[tauri::command] pub fn set_switch_style_hotkey( coord: CoordinatorState<'_>, - binding: ShortcutBinding, + binding: Option, ) -> Result<(), String> { - crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - reject_modifier_only_action_shortcut(&binding)?; - let mut prefs = coord.prefs().get(); - reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, &binding)?; - reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, &binding)?; - if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { - reject_qa_switch_style_hotkey_overlap(qa_hotkey, &binding)?; + if let Some(binding) = binding.as_ref() { + crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + reject_modifier_only_action_shortcut(binding)?; + } + let mut prefs = coord.prefs().get(); + if let Some(binding) = binding.as_ref() { + reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, binding)?; + reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, binding)?; + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_qa_switch_style_hotkey_overlap(qa_hotkey, binding)?; + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + reject_switch_style_open_app_hotkey_overlap(binding, open_app)?; + } } - reject_switch_style_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?; prefs.switch_style_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; coord.update_switch_style_hotkey_binding(); Ok(()) } +/// 设置「唤起 App」全局快捷键。`binding == None`(前端传 null)= 停用(同上)。 #[tauri::command] pub fn set_open_app_hotkey( coord: CoordinatorState<'_>, - binding: ShortcutBinding, + binding: Option, ) -> Result<(), String> { - crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - reject_modifier_only_action_shortcut(&binding)?; - let mut prefs = coord.prefs().get(); - reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &binding)?; - reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &binding)?; - if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { - reject_qa_open_app_hotkey_overlap(qa_hotkey, &binding)?; + if let Some(binding) = binding.as_ref() { + crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + reject_modifier_only_action_shortcut(binding)?; + } + let mut prefs = coord.prefs().get(); + if let Some(binding) = binding.as_ref() { + reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, binding)?; + reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, binding)?; + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_qa_open_app_hotkey_overlap(qa_hotkey, binding)?; + } + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + reject_switch_style_open_app_hotkey_overlap(switch_style, binding)?; + } } - reject_switch_style_open_app_hotkey_overlap(&prefs.switch_style_hotkey, &binding)?; prefs.open_app_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; coord.update_open_app_hotkey_binding(); @@ -2030,8 +2057,12 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R reject_dictation_qa_hotkey_overlap(&shortcut, qa_hotkey)?; } reject_dictation_translation_hotkey_overlap(&shortcut, &prefs.translation_hotkey)?; - reject_dictation_switch_style_hotkey_overlap(&shortcut, &prefs.switch_style_hotkey)?; - reject_dictation_open_app_hotkey_overlap(&shortcut, &prefs.open_app_hotkey)?; + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + reject_dictation_switch_style_hotkey_overlap(&shortcut, switch_style)?; + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + reject_dictation_open_app_hotkey_overlap(&shortcut, open_app)?; + } prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; sync_dictation_hotkey_legacy_fields(&mut prefs); @@ -2088,30 +2119,34 @@ fn reject_hotkey_overlap( } fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> { + // 停用(None)的 action 快捷键不参与任何冲突检测。 + let switch_style = prefs.switch_style_hotkey.as_ref(); + let open_app = prefs.open_app_hotkey.as_ref(); if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, qa_hotkey)?; reject_qa_translation_hotkey_overlap(qa_hotkey, &prefs.translation_hotkey)?; - reject_qa_switch_style_hotkey_overlap(qa_hotkey, &prefs.switch_style_hotkey)?; - reject_qa_open_app_hotkey_overlap(qa_hotkey, &prefs.open_app_hotkey)?; + if let Some(switch_style) = switch_style { + reject_qa_switch_style_hotkey_overlap(qa_hotkey, switch_style)?; + } + if let Some(open_app) = open_app { + reject_qa_open_app_hotkey_overlap(qa_hotkey, open_app)?; + } } reject_dictation_translation_hotkey_overlap( &prefs.dictation_hotkey, &prefs.translation_hotkey, )?; - reject_dictation_switch_style_hotkey_overlap( - &prefs.dictation_hotkey, - &prefs.switch_style_hotkey, - )?; - reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &prefs.open_app_hotkey)?; - reject_translation_switch_style_hotkey_overlap( - &prefs.translation_hotkey, - &prefs.switch_style_hotkey, - )?; - reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &prefs.open_app_hotkey)?; - reject_switch_style_open_app_hotkey_overlap( - &prefs.switch_style_hotkey, - &prefs.open_app_hotkey, - )?; + if let Some(switch_style) = switch_style { + reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, switch_style)?; + reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, switch_style)?; + } + if let Some(open_app) = open_app { + reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, open_app)?; + reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, open_app)?; + } + if let (Some(switch_style), Some(open_app)) = (switch_style, open_app) { + reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?; + } Ok(()) } @@ -4064,14 +4099,14 @@ mod tests { primary: "T".to_string(), modifiers: vec!["ctrl".to_string(), "alt".to_string()], }, - switch_style_hotkey: ShortcutBinding { + switch_style_hotkey: Some(ShortcutBinding { primary: "S".to_string(), modifiers: vec!["ctrl".to_string(), "alt".to_string()], - }, - open_app_hotkey: ShortcutBinding { + }), + open_app_hotkey: Some(ShortcutBinding { primary: "O".to_string(), modifiers: vec!["ctrl".to_string(), "alt".to_string()], - }, + }), hotkey: HotkeyBinding { trigger: HotkeyTrigger::Custom, mode: HotkeyMode::Hold, @@ -4488,7 +4523,7 @@ mod tests { }; let prefs = UserPreferences { translation_hotkey: binding.clone(), - switch_style_hotkey: binding, + switch_style_hotkey: Some(binding), ..Default::default() }; @@ -4507,8 +4542,8 @@ mod tests { modifiers: vec!["cmd".into(), "shift".into()], }; let prefs = UserPreferences { - switch_style_hotkey: binding.clone(), - open_app_hotkey: binding, + switch_style_hotkey: Some(binding.clone()), + open_app_hotkey: Some(binding), ..Default::default() }; diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 72ce6dce..a7b12cef 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -728,7 +728,12 @@ impl Coordinator { } fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { - let binding = action_hotkey_binding(&self.inner, kind); + // None = 用户主动停用:反注册全局键,立即生效。 + let Some(binding) = action_hotkey_binding(&self.inner, kind) else { + take_action_hotkey_on_main_thread(&self.inner, kind); + log::info!("[coord] action hotkey {kind:?} 已停用(用户清空)"); + return; + }; if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&self.inner, kind); log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭"); @@ -1517,7 +1522,12 @@ fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let binding = action_hotkey_binding(&inner, kind); + // None = 用户主动停用:反注册并睡着等 prefs 改动(由 update 路径唤醒)。 + let Some(binding) = action_hotkey_binding(&inner, kind) else { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + }; if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&inner, kind); std::thread::sleep(std::time::Duration::from_secs(5)); @@ -1711,7 +1721,7 @@ fn action_hotkey_slot( fn action_hotkey_binding( inner: &Arc, kind: ActionHotkeyKind, -) -> crate::types::ShortcutBinding { +) -> Option { let prefs = inner.prefs.get(); match kind { ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, @@ -1868,17 +1878,21 @@ fn reset_shortcut_held_state(inner: &Arc) { } } } - if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) { - if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) { - log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + if !is_modifier_only_shortcut(switch_style) { + if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(switch_style.clone()) { + log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); + } } } } - if !is_modifier_only_shortcut(&prefs.open_app_hotkey) { - if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { - if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) { - log::warn!("[coord] reset open-app hotkey latch failed: {e}"); + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + if !is_modifier_only_shortcut(open_app) { + if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(open_app.clone()) { + log::warn!("[coord] reset open-app hotkey latch failed: {e}"); + } } } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index abf94245..5f8c16a3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -604,10 +604,13 @@ pub struct UserPreferences { pub custom_combo_hotkey: Option, #[serde(default = "default_translation_hotkey")] pub translation_hotkey: ShortcutBinding, + /// 「切换风格」全局快捷键。`None` = 停用(不注册全局键);`Some(...)` = 注册。 + /// 默认 `Some(默认键)`,对老用户零行为变化,仅新增可清空(issue #576)。 #[serde(default = "default_switch_style_hotkey")] - pub switch_style_hotkey: ShortcutBinding, + pub switch_style_hotkey: Option, + /// 「唤起 App」全局快捷键。`None` = 停用;`Some(...)` = 注册。默认 `Some(默认键)`。 #[serde(default = "default_open_app_hotkey")] - pub open_app_hotkey: ShortcutBinding, + pub open_app_hotkey: Option, /// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。 /// 仅在 active_asr_provider == "local-qwen3" 时有意义。 #[serde(default = "default_local_asr_model")] @@ -886,8 +889,9 @@ impl Default for UserPreferencesWire { qa_save_history: prefs.qa_save_history, custom_combo_hotkey: prefs.custom_combo_hotkey, translation_hotkey: None, - switch_style_hotkey: None, - open_app_hotkey: None, + // 默认携带默认键(Some),保证缺字段时仍是启用状态;None 专表「用户主动停用」。 + switch_style_hotkey: prefs.switch_style_hotkey, + open_app_hotkey: prefs.open_app_hotkey, local_asr_active_model: prefs.local_asr_active_model, local_asr_mirror: prefs.local_asr_mirror, local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs, @@ -968,10 +972,11 @@ impl<'de> Deserialize<'de> for UserPreferences { translation_hotkey: wire .translation_hotkey .unwrap_or_else(default_translation_hotkey), - switch_style_hotkey: wire - .switch_style_hotkey - .unwrap_or_else(default_switch_style_hotkey), - open_app_hotkey: wire.open_app_hotkey.unwrap_or_else(default_open_app_hotkey), + // 直传 Option:None = 用户主动停用,不再用 unwrap_or_else 塌缩成默认键 + // (那正是 #576「无法关闭」的根因)。缺字段时 wire 的 serde struct-default + // 会落到 Some(默认键),保证老用户/新用户仍是启用。 + switch_style_hotkey: wire.switch_style_hotkey, + open_app_hotkey: wire.open_app_hotkey, local_asr_active_model: wire.local_asr_active_model, local_asr_mirror: wire.local_asr_mirror, local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs, @@ -1014,18 +1019,18 @@ fn default_translation_hotkey() -> ShortcutBinding { } } -fn default_switch_style_hotkey() -> ShortcutBinding { - ShortcutBinding { +fn default_switch_style_hotkey() -> Option { + Some(ShortcutBinding { primary: "S".into(), modifiers: default_app_shortcut_modifiers(), - } + }) } -fn default_open_app_hotkey() -> ShortcutBinding { - ShortcutBinding { +fn default_open_app_hotkey() -> Option { + Some(ShortcutBinding { primary: "O".into(), modifiers: default_app_shortcut_modifiers(), - } + }) } fn default_app_shortcut_modifiers() -> Vec { @@ -2320,6 +2325,53 @@ mod tests { assert!(!restored.audio_cue_on_record); } + #[test] + fn action_hotkeys_default_to_enabled() { + // issue #576:默认仍开启(Some 默认键),对老用户零行为变化。 + let prefs = UserPreferences::default(); + assert!(prefs.switch_style_hotkey.is_some()); + assert!(prefs.open_app_hotkey.is_some()); + } + + #[test] + fn missing_action_hotkeys_default_to_enabled() { + // 老用户/缺字段:wire 的 struct-default 落到 Some(默认键),不应被当成停用。 + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(prefs.switch_style_hotkey.is_some()); + assert!(prefs.open_app_hotkey.is_some()); + } + + #[test] + fn disabled_action_hotkeys_round_trip_as_null() { + // issue #576:用户清空(None=停用)后存盘→读回必须仍是 None, + // 不能像旧逻辑那样被 unwrap_or_else 塌缩回默认键。 + let disabled = UserPreferences { + switch_style_hotkey: None, + open_app_hotkey: None, + ..Default::default() + }; + let json = serde_json::to_string(&disabled).unwrap(); + assert!( + json.contains("\"switchStyleHotkey\":null"), + "停用应序列化成 null,实际: {json}" + ); + let restored: UserPreferences = serde_json::from_str(&json).unwrap(); + assert!(restored.switch_style_hotkey.is_none()); + assert!(restored.open_app_hotkey.is_none()); + } + + #[test] + fn explicit_action_hotkey_binding_round_trips() { + // 旧 preferences.json 里带实际绑定 → 读回应保留为 Some(启用)。 + let prefs: UserPreferences = serde_json::from_str( + r#"{"switchStyleHotkey":{"primary":"S","modifiers":["cmd","shift"]}}"#, + ) + .unwrap(); + let binding = prefs.switch_style_hotkey.expect("应保留为 Some"); + assert_eq!(binding.primary, "S"); + assert_eq!(binding.modifiers, vec!["cmd".to_string(), "shift".to_string()]); + } + #[test] fn missing_custom_style_prompts_defaults_to_empty() { let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 2957b4db..20c8aa74 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -684,6 +684,8 @@ export const en: typeof zhCN = { confirm: 'Confirm capsule insertion', switchStyle: 'Switch to previous style', openApp: 'Open OpenLess', + enable: 'Enable', + disable: 'Disable', confirmHint: 'Click ✓ on the capsule', notSupported: 'Not yet supported', }, diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 4ca0a390..c6be55ab 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -686,6 +686,8 @@ export const ja: typeof zhCN = { confirm: 'カプセル入力を確定', switchStyle: '前のスタイルに切り替え', openApp: 'OpenLess を開く', + enable: '有効化', + disable: '無効化', confirmHint: '右側の ✓ をクリック', notSupported: '未対応', }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0b849849..53d41013 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -686,6 +686,8 @@ export const ko: typeof zhCN = { confirm: '캡슐 입력 확정', switchStyle: '이전 스타일로 전환', openApp: 'OpenLess 열기', + enable: '활성화', + disable: '비활성화', confirmHint: '오른쪽 ✓ 클릭', notSupported: '지원되지 않음', }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ca387876..6cae44dd 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -682,6 +682,8 @@ export const zhCN = { confirm: '胶囊确认插入', switchStyle: '切换上一次风格', openApp: '打开 OpenLess', + enable: '启用', + disable: '停用', confirmHint: '点击右侧 ✓', notSupported: '暂未支持', }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 043e71b9..d079e9d6 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -684,6 +684,8 @@ export const zhTW: typeof zhCN = { confirm: '膠囊確認插入', switchStyle: '切換上一次風格', openApp: '打開 OpenLess', + enable: '啟用', + disable: '停用', confirmHint: '點擊右側 ✓', notSupported: '暫未支持', }, diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 208c0fa9..5e976930 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -12,6 +12,16 @@ export function defaultAppShortcutModifiers(): string[] { return currentPlatform().isMac ? ['cmd', 'shift'] : ['ctrl', 'shift']; } +// 「停用」后重新「启用」时恢复的默认键,与后端 default_switch_style_hotkey / +// default_open_app_hotkey 保持一致(issue #576)。 +export function defaultSwitchStyleShortcut(): ShortcutBinding { + return { primary: 'S', modifiers: defaultAppShortcutModifiers() }; +} + +export function defaultOpenAppShortcut(): ShortcutBinding { + return { primary: 'O', modifiers: defaultAppShortcutModifiers() }; +} + export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { if (!trigger) return i18n.t('hotkey.fallback'); if (trigger === 'custom') return i18n.t('hotkey.triggers.custom'); diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 842f103f..b3a249c8 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -1100,11 +1100,12 @@ export function setTranslationHotkey(binding: ShortcutBinding): Promise { return invokeOrMock("set_translation_hotkey", { binding }, () => undefined) } -export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise { +// binding = null 表示停用(清空全局键),与 set_qa_hotkey 一致(issue #576)。 +export function setSwitchStyleHotkey(binding: ShortcutBinding | null): Promise { return invokeOrMock("set_switch_style_hotkey", { binding }, () => undefined) } -export function setOpenAppHotkey(binding: ShortcutBinding): Promise { +export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise { return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined) } diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 6b1bbd86..9e1e660c 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -255,10 +255,10 @@ export interface UserPreferences { customComboHotkey: ComboBinding | null; /** 录音中触发翻译的全局快捷键。默认 Shift。 */ translationHotkey: ShortcutBinding; - /** 切换到上一个润色风格的全局快捷键。 */ - switchStyleHotkey: ShortcutBinding; - /** 打开 OpenLess 主窗口的全局快捷键。 */ - openAppHotkey: ShortcutBinding; + /** 切换到上一个润色风格的全局快捷键。null = 用户已停用(issue #576)。 */ + switchStyleHotkey: ShortcutBinding | null; + /** 打开 OpenLess 主窗口的全局快捷键。null = 用户已停用(issue #576)。 */ + openAppHotkey: ShortcutBinding | null; /** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */ localAsrActiveModel: string; /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index e26b3f54..3f11fcfa 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -1,8 +1,9 @@ // 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。 +import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; -import { defaultQaShortcut } from '../../lib/hotkey'; +import { defaultOpenAppShortcut, defaultQaShortcut, defaultSwitchStyleShortcut } from '../../lib/hotkey'; import { setDictationHotkey, setOpenAppHotkey, @@ -15,6 +16,31 @@ import { Card } from '../_atoms'; import { SettingRow } from './shared'; import { detectOS } from '../../components/WindowChrome'; +const enableBtnStyle: CSSProperties = { + alignSelf: 'flex-start', + fontSize: 12, + padding: '5px 14px', + background: 'var(--ol-blue)', + color: '#fff', + border: 0, + borderRadius: 6, + fontFamily: 'inherit', + fontWeight: 500, + cursor: 'pointer', +}; + +const disableBtnStyle: CSSProperties = { + alignSelf: 'flex-start', + fontSize: 11, + padding: '3px 10px', + background: 'transparent', + color: 'var(--ol-ink-4)', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 6, + fontFamily: 'inherit', + cursor: 'pointer', +}; + export function ShortcutsSection() { const { t } = useTranslation(); const os = detectOS(); @@ -84,24 +110,72 @@ export function ShortcutsSection() { )} - { - await setSwitchStyleHotkey(binding); - await savePrefs({ ...prefs, switchStyleHotkey: binding }); - }} - /> + {prefs.switchStyleHotkey ? ( +
+ { + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + /> + +
+ ) : ( + + )}
- { - await setOpenAppHotkey(binding); - await savePrefs({ ...prefs, openAppHotkey: binding }); - }} - /> + {prefs.openAppHotkey ? ( +
+ { + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + /> + +
+ ) : ( + + )}
{readonlyRows.map(([k, v]) => ( From 83e4135175864193e4b24b06a77fa11a1bd69222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 20:45:47 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore(capsule):=20#470=20=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=20v2=20=E2=80=94=E2=80=94=20=E7=BB=99=E8=83=B6=E5=9B=8A?= =?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BA=E7=9A=84=E6=9A=97=E7=82=B9=E8=A1=A5?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E6=97=A5=E5=BF=97=EF=BC=88=E9=9B=B6?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #470「Windows 语音输入看不到录音胶囊」根因仍未定(PR #471 已明说只加诊断、需真机 日志;byte92 报 macOS 也复现,疑跨平台)。本 PR 不改任何 show/hide/position 行为, 仅在几个静默路径补日志,把根因收敛到 A/B/C 单一分支,帮助拿日志定位: - coordinator.rs show_capsule_window_no_activate(Windows):两处 window_handle() / RawWindowHandle::Win32 取不到时此前静默 return false,补 warn(Win32 show 失败最可能的暗点)。 - coordinator.rs emit_capsule:get_webview_window("capsule") 取不到时此前静默 return, 补一次性 warn(暗点 A0:窗口压根没创建/已销毁)。沿用 AtomicBool 一次性门,避免 ~30Hz 刷屏。 - lib.rs position_capsule_bottom_center(Windows):定位只夹了上边(.max(mon.top)), 多显示器/负坐标/异常 DPI 下可能算到屏幕外却无观测,补显示器几何 + 最终落点 debug log。 - Capsule.tsx:webview 收到第一个 capsule:state 事件时打一次 console.info,区分 「后端没 emit」与「emit 了但窗口没显示/没渲染」。 注:Windows-cfg 部分本机无法交叉编译验证(ring 的 C 代码需 MSVC),依赖 Windows CI。 --- openless-all/app/src-tauri/src/coordinator.rs | 14 ++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 10 +++++++++- openless-all/app/src/components/Capsule.tsx | 9 +++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 72ce6dce..3dad786a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -100,6 +100,8 @@ fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy { static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false); static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); +// #470 诊断 v2:capsule webview 句柄取不到时的一次性门,区分「窗口压根没创建」(A0)。 +static CAPSULE_WINDOW_MISSING_LOGGED: AtomicBool = AtomicBool::new(false); /// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str, /// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish @@ -4989,9 +4991,13 @@ fn show_capsule_window_no_activate( }; let Ok(handle) = window.window_handle() else { + // #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return, + // 无法观测「胶囊完全不显示」是否卡在这里。 + log::warn!("[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped"); return false; }; let RawWindowHandle::Win32(raw) = handle.as_raw() else { + log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped"); return false; }; let hwnd = HWND(raw.hwnd.get() as *mut _); @@ -5226,6 +5232,14 @@ fn emit_capsule( let app_for_main = app.clone(); let _ = app.run_on_main_thread(move || { let Some(window) = app_for_main.get_webview_window("capsule") else { + // #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到 + // (窗口未创建/已销毁)。此前静默 return,无法观测。一次性 warn。 + if !CAPSULE_WINDOW_MISSING_LOGGED.swap(true, Ordering::SeqCst) { + log::warn!( + "[capsule] capsule webview window not found — emit_capsule show path skipped (state={})", + capsule_state_log_name(state) + ); + } return; }; let show_capsule = inner_for_main.prefs.get().show_capsule; diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 076e6c8e..4b65b3d8 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1376,7 +1376,15 @@ pub(crate) fn position_capsule_bottom_center( let offset_from_bottom = (capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale; let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32; - window.set_position(PhysicalPosition::new(x, y.max(mon.top)))?; + let clamped_y = y.max(mon.top); + // #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、 + // 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与 + // 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。 + log::debug!( + "[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}", + mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y + ); + window.set_position(PhysicalPosition::new(x, clamped_y))?; return Ok(()); } // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。 diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index e7ddb5d3..48065a57 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -286,6 +286,9 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: // 动画结束就 unmount → 用户看到半截动画被截断。 // v1.3.1-6: 从 240ms 加到 360ms 让用户看清退出动画(240ms 太快感知不到)。 const EXIT_ANIM_MS = 360; +// #470 诊断 v2:模块级一次性门,只在 webview 收到第一个 capsule:state 事件时打 log。 +let capsuleStateFirstLogged = false; + // 初始可见 state:Tauri 内运行从 idle 开始(等后端 capsule:state 事件), // 浏览器 dev 模式从 recording 开始以便直接看到胶囊。 const INITIAL_VISIBLE_STATE: CapsuleState = isTauri ? 'idle' : 'recording'; @@ -321,6 +324,12 @@ export function Capsule() { const { listen } = await import('@tauri-apps/api/event'); const handle = await listen('capsule:state', event => { const p = event.payload; + if (!capsuleStateFirstLogged) { + capsuleStateFirstLogged = true; + // #470 诊断 v2:确认 capsule webview 确实收到了后端事件 —— 区分「后端没 + // emit」与「emit 了但窗口没显示/没渲染」。配合后端 [capsule] 日志定位根因。 + console.info('[capsule] first capsule:state received in webview, state=', p.state); + } setState(p.state); setLevel(p.level ?? 0); setMessage(p.message ?? undefined);