From 94d6a7199c2933ad5a59bcc8fe775ffee7196eb2 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 21:54:34 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(asr):=20=E6=8E=A5=E5=85=A5=20Qwen3-ASR?= =?UTF-8?q?=20/=20SiliconFlow=20/=20GLM-ASR=20/=20Groq=20=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=80=89=E6=9C=8D=E5=8A=A1=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用现有 OpenAI 兼容 `/audio/transcriptions` 通道(WhisperBatchASR),无需 新增 Rust 客户端。把 `coordinator.rs` 中的 whisper 分支条件改为 `is_whisper_compatible_provider(id)`,新增厂商时一处即可扩展。 设置页 ASR 下拉新增 4 项预设,切换时自动预填 baseUrl 与 model,避免用户 忘填模型 ID 必然踩坑。 Closes #212 --- openless-all/app/src-tauri/src/coordinator.rs | 13 ++++++-- openless-all/app/src/i18n/en.ts | 3 ++ openless-all/app/src/i18n/zh-CN.ts | 3 ++ openless-all/app/src/pages/Settings.tsx | 31 +++++++++++++++---- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2dab885a..9a3d144c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -905,7 +905,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); - if active_asr == "whisper" { + if is_whisper_compatible_provider(&active_asr) { let (api_key, base_url, model) = read_whisper_credentials(); let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model)); *inner.asr.lock() = Some(ActiveAsr::Whisper(Arc::clone(&whisper))); @@ -1540,13 +1540,13 @@ fn ensure_microphone_permission(_inner: &Arc) -> Result<(), String> { fn ensure_asr_credentials() -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); - if active_asr == "whisper" { + if is_whisper_compatible_provider(&active_asr) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() .unwrap_or_default(); if api_key.trim().is_empty() { - return Err("请先在设置中填写 Whisper ASR API Key".to_string()); + return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } return Ok(()); } @@ -1559,6 +1559,13 @@ fn ensure_asr_credentials() -> Result<(), String> { } } +/// `whisper` 是 OpenAI 原生;`qwen` / `siliconflow` / `zhipu` / `groq` 都暴露 +/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 +/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 +fn is_whisper_compatible_provider(id: &str) -> bool { + matches!(id, "whisper" | "qwen" | "siliconflow" | "zhipu" | "groq") +} + /// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 /// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 async fn polish_or_passthrough( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..56240039 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -282,7 +282,10 @@ export const en: typeof zhCN = { openai: 'OpenAI', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', + asrQwen: 'Qwen3-ASR (DashScope compatible mode)', asrSiliconflow: 'SiliconFlow SenseVoice', + asrZhipu: 'Zhipu GLM-ASR', + asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', }, volcengineAppKeyLabel: 'APP ID', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c98358bb..892b9bfd 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -280,7 +280,10 @@ export const zhCN = { openai: 'OpenAI', custom: '自定义', asrVolcengine: '火山引擎 bigasr', + asrQwen: '通义千问 Qwen3-ASR(DashScope 兼容)', asrSiliconflow: '硅基流动 SenseVoice', + asrZhipu: '智谱 GLM-ASR', + asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', }, volcengineAppKeyLabel: 'APP ID', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2e60c839..f5e46d4e 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -274,11 +274,18 @@ type LlmPresetId = typeof LLM_PRESETS[number]['id']; const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; -// SiliconFlow ASR 暂未在后端实现(coordinator.rs 只路由 whisper / volcengine)。 -// 在后端接入前不暴露给用户,避免选了之后必然失败。重新启用见 issue #58 的 follow-up。 +// `volcengine` 走自建流式客户端;其余走 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.` 加文案。 const ASR_PRESETS = [ - { id: 'volcengine', nameKey: 'asrVolcengine' }, - { id: 'whisper', nameKey: 'asrWhisper' }, + { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, + { id: 'qwen', nameKey: 'asrQwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3-asr-flash' }, + { 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' }, + { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, ] as const; type AsrPresetId = typeof ASR_PRESETS[number]['id']; @@ -319,9 +326,20 @@ function ProvidersSection() { const next = { ...prefs, activeAsrProvider: id }; await updatePrefs(next); } + // 切换到 OpenAI 兼容厂商时同步预填 endpoint + model:每家 base URL / 模型 ID + // 都是固定的,不预填用户必然踩坑。volcengine 走另一套凭据,跳过。 + const preset = ASR_PRESETS.find(p => p.id === id); + if (preset && preset.baseUrl) { + await setCredential('asr.endpoint', preset.baseUrl); + } + if (preset && preset.model) { + await setCredential('asr.model', preset.model); + setAsrModelRevision(v => v + 1); + } }; const preset = LLM_PRESETS.find(p => p.id === llmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; + const asrPreset = ASR_PRESETS.find(p => p.id === asrProvider); return ( <> @@ -397,9 +415,10 @@ function ProvidersSection() { <> + placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'} + defaultValue={asrPreset?.baseUrl || undefined} /> + placeholder={asrPreset?.model || 'whisper-1'} /> setAsrModelRevision(v => v + 1)} /> )} From 0a4d33c75547e9eaf54247736112849677687962 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 22:01:34 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(asr):=20QA=20=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=94=B9=E7=94=A8=E4=B8=93=E7=94=A8=20Volcen?= =?UTF-8?q?gine=20=E6=A3=80=E6=9F=A5=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB?= =?UTF-8?q?=20active=5Fasr=20=E8=AF=AF=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 (PR #213): 主听写路径分支化 `ensure_asr_credentials` 后,QA 路径 仍然复用同一函数,导致两类用户可见 bug: 1. 用户选了 qwen/siliconflow/zhipu/groq 但没填 `asr.api_key` → QA 报 "请先填写 ASR 服务商 API Key",可火山凭据明明是齐的。 2. 反之 `asr.api_key` 已填、火山凭据为空 → QA 通过 ensure,进 open_session 再以 Volcengine 凭据错误失败,更难诊断。 修法是给 QA 加专用 `ensure_qa_volcengine_credentials()`,只看 Volcengine 字段; dictation 路径继续走 `ensure_asr_credentials` 不变。 --- openless-all/app/src-tauri/src/coordinator.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9a3d144c..a2e6b4ea 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1566,6 +1566,18 @@ fn is_whisper_compatible_provider(id: &str) -> bool { matches!(id, "whisper" | "qwen" | "siliconflow" | "zhipu" | "groq") } +/// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以 +/// 凭据校验也只看 Volcengine 字段,不依赖 active_asr。dictation 路径请用 +/// `ensure_asr_credentials`。 +fn ensure_qa_volcengine_credentials() -> Result<(), String> { + let creds = read_volc_credentials(); + if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() { + Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string()) + } else { + Ok(()) + } +} + /// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 /// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 async fn polish_or_passthrough( @@ -1752,7 +1764,11 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { // 2. 凭据缺失走静默 fallback:与 dictation 一致的"用户的话不丢"约定。 // 缺火山凭据 → 后续 Recorder 仍会跑,只是 ASR 拿不到结果,end_qa_session // 会发 idle 事件关浮窗。 - if let Err(message) = ensure_asr_credentials() { + // 注意:QA 强制走 Volcengine 流式(见下方注释),所以这里必须直接校验 + // Volcengine 字段,不能复用 `ensure_asr_credentials`——后者会按用户在设置 + // 里选的 active_asr 走 OpenAI 兼容分支,让 QA 把 `asr.api_key` 当成必要项, + // 或在 Volcengine 凭据其实为空时误判通过。Codex P1,PR #213。 + if let Err(message) = ensure_qa_volcengine_credentials() { log::warn!("[coord] QA: ASR credentials missing: {message}"); finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}")); return Err(message); From 3416919da668ea87abe09b6ec16195bedca94e87 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 22:18:45 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(asr):=20OpenAI=20=E5=85=BC=E5=AE=B9=20p?= =?UTF-8?q?reset=20=E4=B9=9F=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=20endpoin?= =?UTF-8?q?t=20=E4=B8=8E=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent #213 reviewer guide: 切到新 preset 后用户若手动清空 asr.endpoint,read_whisper_credentials 会返回空字符串, WhisperBatchASR 拼出相对路径 `/audio/transcriptions`,要等用户录完 才以 reqwest 错误失败。model 同理(兜底成 "whisper-1" 对 SiliconFlow 等是无效模型名,runtime 才 400)。 在 ensure_asr_credentials 的兼容分支里加前置校验,把错误时机提前到 按下热键的瞬间,复用与 api_key 同款 hard-error 语义。 --- openless-all/app/src-tauri/src/coordinator.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2e6b4ea..7ac28249 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1548,6 +1548,24 @@ fn ensure_asr_credentials() -> Result<(), String> { if api_key.trim().is_empty() { return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } + // 切换 preset 时前端会自动预填 endpoint/model,但用户可能手动清空。 + // 提前在这里报错,比录完后让 reqwest 拼出 `/audio/transcriptions` 相对路径 + // 再失败友好得多。model 同理:read_whisper_credentials 兜底成 "whisper-1", + // 对 SiliconFlow / GLM 等是错的,提前提示用户去填本厂商模型 ID。 + let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .unwrap_or_default(); + if endpoint.trim().is_empty() { + return Err("请先在设置中填写 ASR 服务商接口地址".to_string()); + } + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .unwrap_or_default(); + if model.trim().is_empty() { + return Err("请先在设置中填写 ASR 模型名".to_string()); + } return Ok(()); } From 9809b19eab25a37d6d9daf6f4c528e5f5c5edb70 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 05:52:51 +0800 Subject: [PATCH 4/5] =?UTF-8?q?revert(asr):=20=E7=A7=BB=E9=99=A4=20endpoin?= =?UTF-8?q?t/model=20=E5=89=8D=E7=BD=AE=E7=A1=AC=E9=94=99=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent 在 commit 3416919 后改口反对前置校验("breaks silent-fallback contract, removes the previous backend defaulting behavior")。维护者 @H-Chris233 同样要求处理。 回滚到 0a4d33c 状态:兼容分支只校验 api_key(与 main 上既有 whisper 分支一致),endpoint/model 缺失走 read_whisper_credentials 的既有 fallback(base_url 空字符串、model 兜底 "whisper-1"),让 runtime 失败信号在 coordinator 既有错误胶囊路径里出。 api_key 校验保留,因为这段不是本 PR 引入——main 上既有,超出 V1 范围。 --- openless-all/app/src-tauri/src/coordinator.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 7ac28249..a2e6b4ea 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1548,24 +1548,6 @@ fn ensure_asr_credentials() -> Result<(), String> { if api_key.trim().is_empty() { return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); } - // 切换 preset 时前端会自动预填 endpoint/model,但用户可能手动清空。 - // 提前在这里报错,比录完后让 reqwest 拼出 `/audio/transcriptions` 相对路径 - // 再失败友好得多。model 同理:read_whisper_credentials 兜底成 "whisper-1", - // 对 SiliconFlow / GLM 等是错的,提前提示用户去填本厂商模型 ID。 - let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) - .ok() - .flatten() - .unwrap_or_default(); - if endpoint.trim().is_empty() { - return Err("请先在设置中填写 ASR 服务商接口地址".to_string()); - } - let model = CredentialsVault::get(CredentialAccount::AsrModel) - .ok() - .flatten() - .unwrap_or_default(); - if model.trim().is_empty() { - return Err("请先在设置中填写 ASR 模型名".to_string()); - } return Ok(()); } From d048107abb17ffec65fe1a84a743b3029175f97d Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 06:53:40 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(asr):=20=E6=8A=8A=20Qwen3-ASR-Flash=20?= =?UTF-8?q?=E4=BB=8E=20V1=20=E6=8B=BF=E6=8E=89=EF=BC=8C=E9=9C=80=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=95=99=E7=BB=99=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewer 指出 DashScope 的 Qwen3-ASR-Flash 不走 OpenAI Whisper 的 multipart `/audio/transcriptions`,而是 MultiModalConversation 协议 (messages=[{content:[{audio:...}]}])。核实两个独立公开实现 (xinnan-tech/xiaozhi-esp32-server、jianchang512/pyvideotrans)确认 属实——把 qwen 强行塞进 WhisperBatchASR 必然 transcription 失败。 按 PR 描述里"待复核风险 → 不通则降级到 V2"的预案: - 从 ASR_PRESETS 删除 qwen 项 - 从 is_whisper_compatible_provider 拿掉 "qwen" - i18n 删掉 asrQwen 文案 - 注释里点名 Qwen 留给 V2 的原因 剩下 SiliconFlow / GLM-ASR / Groq / OpenAI Whisper 四家都有公开 OpenAI 兼容 /audio/transcriptions 端点,继续走原通道。 --- openless-all/app/src-tauri/src/coordinator.rs | 8 ++++++-- openless-all/app/src/i18n/en.ts | 1 - openless-all/app/src/i18n/zh-CN.ts | 1 - openless-all/app/src/pages/Settings.tsx | 1 - 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2e6b4ea..396cccc4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1559,11 +1559,15 @@ fn ensure_asr_credentials() -> Result<(), String> { } } -/// `whisper` 是 OpenAI 原生;`qwen` / `siliconflow` / `zhipu` / `groq` 都暴露 +/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露 /// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 /// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 +/// +/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation +/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要 +/// 单独 ASR 客户端,留给 V2。 fn is_whisper_compatible_provider(id: &str) -> bool { - matches!(id, "whisper" | "qwen" | "siliconflow" | "zhipu" | "groq") + matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq") } /// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 56240039..7bed8b13 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -282,7 +282,6 @@ export const en: typeof zhCN = { openai: 'OpenAI', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', - asrQwen: 'Qwen3-ASR (DashScope compatible mode)', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 892b9bfd..f1eaeff4 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -280,7 +280,6 @@ export const zhCN = { openai: 'OpenAI', custom: '自定义', asrVolcengine: '火山引擎 bigasr', - asrQwen: '通义千问 Qwen3-ASR(DashScope 兼容)', asrSiliconflow: '硅基流动 SenseVoice', asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index f5e46d4e..f395071f 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -281,7 +281,6 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; // 3. 在 i18n 的 `settings.providers.presets.` 加文案。 const ASR_PRESETS = [ { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, - { id: 'qwen', nameKey: 'asrQwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3-asr-flash' }, { 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' },