diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2dab885a..396cccc4 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,29 @@ fn ensure_asr_credentials() -> Result<(), String> { } } +/// `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" | "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( @@ -1745,7 +1768,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); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..7bed8b13 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -283,6 +283,8 @@ export const en: typeof zhCN = { custom: 'Custom', asrVolcengine: 'Volcengine bigasr', 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..f1eaeff4 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -281,6 +281,8 @@ export const zhCN = { custom: '自定义', asrVolcengine: '火山引擎 bigasr', 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..f395071f 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -274,11 +274,17 @@ 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: '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 +325,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 +414,10 @@ function ProvidersSection() { <> + placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'} + defaultValue={asrPreset?.baseUrl || undefined} /> + placeholder={asrPreset?.model || 'whisper-1'} /> setAsrModelRevision(v => v + 1)} /> )}