From 49fd3366ae1442749a46ceb02165453d39d662fc Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Tue, 11 Aug 2026 18:52:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(asr):=20=E7=81=AB=E5=B1=B1=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E3=80=8C=E9=AA=8C=E8=AF=81=E3=80=8D=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20WS=20=E8=BF=9E=E9=80=9A=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=BF=85=E6=8A=A5=E3=80=8CAPI=20Key?= =?UTF-8?q?=20=E4=B8=BA=E7=A9=BA=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_asr_provider 此前没有火山分支,火山渠道点「验证」会掉进 OpenAI 兼容 HTTP 兜底——那条路只认 asr.api_key(火山从不写入的槽位),导致无论 哪种鉴权模式、凭据填得多对,连接检查都报「API Key 为空」。 - 新增 validate_volcengine_asr_provider:按鉴权模式取各自槽位凭据 (旧版 volcengine.access_key / 新版控制台 volcengine.api_key), 真连官方 WS 端点 + 1s 静音 + 收尾,与讯飞验证器同构;纯静音会话 服务端不回 final(NoFinalResult)不算失败——握手成功已证明凭据有效 - 凭据缺失按模式返回哨兵串(volcengineAppIdMissing 等),前端映射为 多语言文案,直接指出该补哪个输入框(en/ja/ko/zh-CN/zh-TW 同步) - 握手被拒(401/403)/ 被限流(429)的报错原样透传给验证按钮, 不再折叠成笼统的「操作失败」 - 补 volcengine_missing_credential_error 单测锁定两种模式的判定 Co-Authored-By: Claude Fable 5 --- .../app/src-tauri/src/asr/volcengine.rs | 2 +- .../app/src-tauri/src/commands/providers.rs | 115 +++++++++++++++++- 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 + .../src/pages/settings/ProvidersSection.tsx | 5 + 8 files changed, 129 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 0f14e65d..61ee90e4 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -30,7 +30,7 @@ use super::{AudioConsumer, DictionaryHotword, RawTranscript}; const ENDPOINT_APP_ID_TOKEN: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"; const ENDPOINT_API_KEY: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"; /// 200 ms of 16 kHz / 16-bit / mono PCM. -const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400; +pub(crate) const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400; /// 16 kHz · 16-bit · mono = 32 000 bytes/sec → 32 bytes/ms. const BYTES_PER_MS: f64 = 32.0; const HOTWORD_CAP: usize = 80; diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 45ab43ae..6cacf04a 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -420,6 +420,12 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { if active_asr == crate::asr::xfyun::PROVIDER_ID { return validate_xfyun_asr_provider(scope).await; } + // 火山走专属 WS 协议与 volcengine.* 凭据槽位,不能落进下面的 OpenAI 兼容 + // HTTP 兜底(那条路只认 asr.api_key —— 火山从不写入的槽位,填对也必报 + // 「API Key 为空」)。 + if active_asr == "volcengine" { + return validate_volcengine_asr_provider(scope).await; + } // StepFun 一入口双协议:`*-stream` 模型走实时 WS 验证,其余走批式 // /audio/transcriptions(与 build 侧 resolve_effective_asr_provider 同判据)。 if active_asr == "stepfun" || active_asr == crate::asr::stepfun_realtime::PROVIDER_ID { @@ -480,6 +486,83 @@ async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String } } +/// 按鉴权模式检查火山凭据完整性,返回给前端映射多语言文案的哨兵串 +/// (providerErrorMessage 识别)。与 [`VolcengineAuthMode::auth_ok`] 同一 +/// trim 语义,但区分缺哪一项,让用户直接知道该补哪个输入框。 +/// +/// [`VolcengineAuthMode::auth_ok`]: crate::asr::volcengine::VolcengineAuthMode::auth_ok +fn volcengine_missing_credential_error( + auth_mode: &crate::asr::volcengine::VolcengineAuthMode, + app_id: &str, + secret: &str, +) -> Option<&'static str> { + use crate::asr::volcengine::VolcengineAuthMode; + match auth_mode { + VolcengineAuthMode::AppIdToken => { + if app_id.trim().is_empty() { + return Some("volcengineAppIdMissing"); + } + if secret.trim().is_empty() { + return Some("volcengineAccessTokenMissing"); + } + } + VolcengineAuthMode::ApiKey => { + if secret.trim().is_empty() { + return Some("volcengineApiKeyMissing"); + } + } + } + None +} + +/// 火山 bigmodel 验证:真连 + 1s 静音 + 收尾。密钥槽位随鉴权模式(与 +/// `read_volc_credentials` 同规则):旧版读 volcengine.access_key,新版控制台 +/// 读 volcengine.api_key,互不污染。鉴权错误(401/403 → AuthRejected)在 +/// WebSocket 握手阶段即返回;纯静音会话服务端可能不回 final(等价「没说话」), +/// 这类 `NoFinalResult` 不算验证失败 —— 握手成功已经证明凭据有效。 +async fn validate_volcengine_asr_provider(scope: &ProviderScope) -> Result<(), String> { + use crate::asr::volcengine::{VolcengineAuthMode, VolcengineCredentials}; + let auth_mode = scope + .get(CredentialAccount::VolcengineAuthMode)? + .map(|s| VolcengineAuthMode::from_str(&s)) + .unwrap_or(VolcengineAuthMode::AppIdToken); + let app_id = scope + .get(CredentialAccount::VolcengineAppKey)? + .unwrap_or_default(); + let secret = match auth_mode { + VolcengineAuthMode::AppIdToken => scope.get(CredentialAccount::VolcengineAccessKey)?, + VolcengineAuthMode::ApiKey => scope.get(CredentialAccount::VolcengineApiKey)?, + } + .unwrap_or_default(); + if let Some(message) = volcengine_missing_credential_error(&auth_mode, &app_id, &secret) { + return Err(message.to_string()); + } + let resource_id = scope + .get(CredentialAccount::VolcengineResourceId)? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string()); + let asr = std::sync::Arc::new(crate::asr::VolcengineStreamingASR::new( + VolcengineCredentials { + auth_mode, + app_id, + access_token: secret, + resource_id, + }, + Vec::new(), + )); + asr.open_session().await.map_err(|e| e.to_string())?; + crate::asr::AudioConsumer::consume_pcm_chunk( + &*asr, + &vec![0u8; crate::asr::volcengine::TARGET_AUDIO_CHUNK_BYTES * 5], + ); + asr.send_last_frame().await.map_err(|e| e.to_string())?; + match asr.await_final_result().await { + Ok(_) => Ok(()), + Err(crate::asr::volcengine::VolcengineASRError::NoFinalResult) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + /// StepFun 实时 WS 验证:真连 + session.update + 500ms 静音 + 收尾。 /// 协议无 finish 事件,收尾走静音帧 + 宽限期(纯静音会话以空文本成功返回, /// 见 stepfun_realtime 模块注释),全程 ~2s。 @@ -1181,8 +1264,8 @@ mod tests { use super::{ asr_error_is_no_speech_rejection, fetch_provider_models, models_url, provider_llm_error_message, provider_log_context, provider_request_error_message, - sanitized_provider_destination, send_dashscope_multimodal_validation, ProviderConfig, - ProviderScope, + sanitized_provider_destination, send_dashscope_multimodal_validation, + volcengine_missing_credential_error, ProviderConfig, ProviderScope, }; use crate::endpoint_security::validate_http_endpoint; @@ -1216,6 +1299,34 @@ mod tests { } } + #[test] + fn volcengine_missing_credential_error_follows_auth_mode() { + use crate::asr::volcengine::VolcengineAuthMode; + // 旧版:先查 APP ID 再查 Access Token;全空格视为未填(trim 语义, + // 与 VolcengineAuthMode::auth_ok 一致)。 + assert_eq!( + volcengine_missing_credential_error(&VolcengineAuthMode::AppIdToken, " ", "tok"), + Some("volcengineAppIdMissing") + ); + assert_eq!( + volcengine_missing_credential_error(&VolcengineAuthMode::AppIdToken, "app", " "), + Some("volcengineAccessTokenMissing") + ); + assert_eq!( + volcengine_missing_credential_error(&VolcengineAuthMode::AppIdToken, "app", "tok"), + None + ); + // 新版控制台:只查 API Key,不要求 APP ID。 + assert_eq!( + volcengine_missing_credential_error(&VolcengineAuthMode::ApiKey, "", " "), + Some("volcengineApiKeyMissing") + ); + assert_eq!( + volcengine_missing_credential_error(&VolcengineAuthMode::ApiKey, "", "key"), + None + ); + } + #[test] fn silence_probe_content_rejection_is_not_a_credential_error() { // StepFun 对静音探针的实测应答(2026-07):鉴权/模型都通过,只是探针 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 41a8df4b..dfc937ab 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1059,6 +1059,8 @@ export const en: typeof zhCN = { asrMissingTextField: 'ASR response is missing the text field.', apiKeyMissing: 'API Key is empty.', endpointMissing: 'Endpoint is empty.', + volcengineAppIdMissing: 'APP ID is empty.', + volcengineAccessTokenMissing: 'Access Token is empty.', requestTimeout: 'Request timed out. Try again later.', }, shortcuts: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 091a090f..9ad313fa 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1061,6 +1061,8 @@ export const ja: typeof zhCN = { asrMissingTextField: 'ASR の応答に text フィールドがありません。', apiKeyMissing: 'API Key が空です。', endpointMissing: 'Endpoint が空です。', + volcengineAppIdMissing: 'APP ID が空です。', + volcengineAccessTokenMissing: 'Access Token が空です。', requestTimeout: 'リクエストがタイムアウトしました。後で再試行してください。', }, shortcuts: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e59634a8..090dee04 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1061,6 +1061,8 @@ export const ko: typeof zhCN = { asrMissingTextField: 'ASR 응답에 text 필드가 없습니다.', apiKeyMissing: 'API Key 가 비어 있습니다.', endpointMissing: 'Endpoint 가 비어 있습니다.', + volcengineAppIdMissing: 'APP ID 가 비어 있습니다.', + volcengineAccessTokenMissing: 'Access Token 이 비어 있습니다.', requestTimeout: '요청 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', }, shortcuts: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ba74b40d..7c0b8f9b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1057,6 +1057,8 @@ export const zhCN = { asrMissingTextField: 'ASR 响应缺少 text 字段。', apiKeyMissing: 'API Key 为空。', endpointMissing: 'Endpoint 为空。', + volcengineAppIdMissing: 'APP ID 为空。', + volcengineAccessTokenMissing: 'Access Token 为空。', requestTimeout: '请求超时,请稍后重试。', }, shortcuts: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 18259572..5615bac2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1059,6 +1059,8 @@ export const zhTW: typeof zhCN = { asrMissingTextField: 'ASR 響應缺少 text 字段。', apiKeyMissing: 'API Key 爲空。', endpointMissing: 'Endpoint 爲空。', + volcengineAppIdMissing: 'APP ID 爲空。', + volcengineAccessTokenMissing: 'Access Token 爲空。', requestTimeout: '請求超時,請稍後重試。', }, shortcuts: { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 3c3b66e5..cae71cc5 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -865,6 +865,11 @@ function providerErrorMessage(error: unknown, t: ReturnType Date: Wed, 12 Aug 2026 00:39:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(asr):=20=E7=BB=9F=E4=B8=80=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=20Resource=20ID=20=E7=A9=BA=E7=99=BD=E5=9B=9E?= =?UTF-8?q?=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src-tauri/src/asr/volcengine.rs | 30 +++++++++++++++++++ .../app/src-tauri/src/commands/providers.rs | 7 ++--- openless-all/app/src-tauri/src/coordinator.rs | 10 +++---- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 61ee90e4..58a7958a 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -103,6 +103,13 @@ impl VolcengineCredentials { "volc.seedasr.sauc.duration" } + /// 未配置或仅含空白字符时使用默认 Resource ID;保留非空配置的原始值。 + pub(crate) fn resolve_resource_id(configured: Option) -> String { + configured + .filter(|resource_id| !resource_id.trim().is_empty()) + .unwrap_or_else(|| Self::default_resource_id().to_string()) + } + /// 凭据是否满足当前鉴权模式的要求(统一 trim 语义,见 [`VolcengineAuthMode::auth_ok`])。 pub fn auth_ok(&self) -> bool { self.auth_mode.auth_ok(&self.app_id, &self.access_token) @@ -945,6 +952,29 @@ mod tests { ); } + #[test] + fn resource_id_resolution_defaults_only_missing_or_blank_values() { + let default_resource_id = "volc.seedasr.sauc.duration"; + let cases = [ + (None, default_resource_id), + (Some(""), default_resource_id), + (Some(" "), default_resource_id), + (Some("\t\r\n"), default_resource_id), + ( + Some("volc.bigasr.sauc.duration"), + "volc.bigasr.sauc.duration", + ), + (Some(" custom.resource.id "), " custom.resource.id "), + ]; + + for (configured, expected) in cases { + assert_eq!( + VolcengineCredentials::resolve_resource_id(configured.map(str::to_string)), + expected + ); + } + } + #[test] fn auth_mode_from_str_roundtrips() { assert_eq!(VolcengineAuthMode::from_str("api_key"), VolcengineAuthMode::ApiKey); diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 6cacf04a..0f47234d 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -537,10 +537,9 @@ async fn validate_volcengine_asr_provider(scope: &ProviderScope) -> Result<(), S if let Some(message) = volcengine_missing_credential_error(&auth_mode, &app_id, &secret) { return Err(message.to_string()); } - let resource_id = scope - .get(CredentialAccount::VolcengineResourceId)? - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string()); + let resource_id = VolcengineCredentials::resolve_resource_id( + scope.get(CredentialAccount::VolcengineResourceId)?, + ); let asr = std::sync::Arc::new(crate::asr::VolcengineStreamingASR::new( VolcengineCredentials { auth_mode, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fea8c78f..55e8f512 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3103,11 +3103,11 @@ fn read_volc_credentials() -> VolcengineCredentials { .flatten() .unwrap_or_default(), }; - let resource_id = CredentialsVault::get(CredentialAccount::VolcengineResourceId) - .ok() - .flatten() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string()); + let resource_id = VolcengineCredentials::resolve_resource_id( + CredentialsVault::get(CredentialAccount::VolcengineResourceId) + .ok() + .flatten(), + ); VolcengineCredentials { auth_mode, app_id,