diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 80938774..cff742c4 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -24,7 +24,8 @@ use uuid::Uuid; use super::frame::{self, Flags, MessageType, Serialization}; use super::{AudioConsumer, DictionaryHotword, RawTranscript}; -const ENDPOINT: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"; +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/plan/sauc/bigmodel_async"; /// 200 ms of 16 kHz / 16-bit / mono PCM. const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400; /// 16 kHz · 16-bit · mono = 32 000 bytes/sec → 32 bytes/ms. @@ -41,9 +42,55 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const CONNECT_MAX_ATTEMPTS: usize = 3; const CONNECT_RETRY_BACKOFF: Duration = Duration::from_millis(250); +/// Volcengine ASR 鉴权模式。 +/// +/// - `AppIdToken`:旧版语音控制台应用,使用 `X-Api-App-Key` + `X-Api-Access-Key` 双表头鉴权。 +/// - `ApiKey`:新版方舟(Ark)语音模型,使用单个 `X-Api-Key` 表头鉴权。 +/// +/// 两种模式共享完全相同的 WebSocket 端点与二进制帧协议,仅握手鉴权头不同。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VolcengineAuthMode { + AppIdToken, + ApiKey, +} + +impl VolcengineAuthMode { + pub fn from_str(s: &str) -> Self { + match s { + "api_key" => Self::ApiKey, + _ => Self::AppIdToken, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::AppIdToken => "app_id_token", + Self::ApiKey => "api_key", + } + } + + /// 当前模式下所需凭据是否齐备(统一 trim 语义)。 + /// + /// `secret` 的语义随模式:AppIdToken = Access Token(旧版语音控制台), + /// ApiKey = 方舟语音模型 API Key。`app_id` 仅在 AppIdToken 模式要求非空。 + /// + /// 所有按模式判定凭据完整性的入口(`open_session`、`volcengine_configured`、 + /// `ensure_asr_credentials`)都应复用此方法,避免三处规则漂移。 + pub fn auth_ok(&self, app_id: &str, secret: &str) -> bool { + let app_id_ok = match self { + Self::AppIdToken => !app_id.trim().is_empty(), + Self::ApiKey => true, + }; + app_id_ok && !secret.trim().is_empty() + } +} + #[derive(Clone, Debug)] pub struct VolcengineCredentials { + pub auth_mode: VolcengineAuthMode, + /// App ID(AppIdToken 模式使用;ApiKey 模式下为空)。 pub app_id: String, + /// Access Token(AppIdToken 模式下)或 API Key(ApiKey 模式下)。 pub access_token: String, pub resource_id: String, } @@ -52,6 +99,11 @@ impl VolcengineCredentials { pub fn default_resource_id() -> &'static str { "volc.seedasr.sauc.duration" } + + /// 凭据是否满足当前鉴权模式的要求(统一 trim 语义,见 [`VolcengineAuthMode::auth_ok`])。 + pub fn auth_ok(&self) -> bool { + self.auth_mode.auth_ok(&self.app_id, &self.access_token) + } } #[derive(Debug, thiserror::Error)] @@ -138,10 +190,10 @@ impl VolcengineStreamingASR { } pub async fn open_session(self: &Arc) -> Result<(), VolcengineASRError> { - if self.credentials.app_id.is_empty() - || self.credentials.access_token.is_empty() - || self.credentials.resource_id.is_empty() - { + let creds = &self.credentials; + // 统一走 VolcengineCredentials::auth_ok(trim 语义),与概览页凭据状态检测、 + // dictation 预检保持同一判定规则。 + if !creds.auth_ok() || creds.resource_id.trim().is_empty() { return Err(VolcengineASRError::CredentialsMissing); } @@ -256,20 +308,40 @@ impl VolcengineStreamingASR { connect_id: &str, ) -> Result { - let mut request = ENDPOINT + let endpoint = match &self.credentials.auth_mode { + VolcengineAuthMode::AppIdToken => ENDPOINT_APP_ID_TOKEN, + VolcengineAuthMode::ApiKey => ENDPOINT_API_KEY, + }; + let mut request = endpoint .into_client_request() .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?; let headers = request.headers_mut(); - headers.insert( - "X-Api-App-Key", - HeaderValue::from_str(&self.credentials.app_id) - .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, - ); - headers.insert( - "X-Api-Access-Key", - HeaderValue::from_str(&self.credentials.access_token) - .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, - ); + + // 根据鉴权模式选择表头: + // - AppIdToken:X-Api-App-Key + X-Api-Access-Key(旧版语音控制台) + // - ApiKey:X-Api-Key(新版方舟语音模型,单头即可) + match &self.credentials.auth_mode { + VolcengineAuthMode::AppIdToken => { + headers.insert( + "X-Api-App-Key", + HeaderValue::from_str(&self.credentials.app_id) + .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, + ); + headers.insert( + "X-Api-Access-Key", + HeaderValue::from_str(&self.credentials.access_token) + .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, + ); + } + VolcengineAuthMode::ApiKey => { + headers.insert( + "X-Api-Key", + HeaderValue::from_str(&self.credentials.access_token) + .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, + ); + } + } + headers.insert( "X-Api-Resource-Id", HeaderValue::from_str(&self.credentials.resource_id) @@ -859,6 +931,86 @@ mod tests { ); } + #[test] + fn auth_mode_from_str_roundtrips() { + assert_eq!(VolcengineAuthMode::from_str("api_key"), VolcengineAuthMode::ApiKey); + assert_eq!(VolcengineAuthMode::from_str("app_id_token"), VolcengineAuthMode::AppIdToken); + assert_eq!(VolcengineAuthMode::from_str(""), VolcengineAuthMode::AppIdToken); // 默认回退 + assert_eq!(VolcengineAuthMode::ApiKey.as_str(), "api_key"); + assert_eq!(VolcengineAuthMode::AppIdToken.as_str(), "app_id_token"); + } + + #[test] + fn auth_ok_matches_mode_requirements() { + let app_id_token = VolcengineAuthMode::AppIdToken; + let api_key = VolcengineAuthMode::ApiKey; + // AppIdToken:需要 app_id + secret 都非空。 + assert!(app_id_token.auth_ok("app", "token")); + assert!(!app_id_token.auth_ok("", "token")); + assert!(!app_id_token.auth_ok("app", "")); + // 全空格视为未配置(统一 trim 语义,与 volcengine_configured / 预检一致)。 + assert!(!app_id_token.auth_ok(" ", " ")); + // ApiKey:只需 API Key,app_id 可为空。 + assert!(api_key.auth_ok("", "key")); + assert!(api_key.auth_ok("app", "key")); + assert!(!api_key.auth_ok("app", "")); + assert!(!api_key.auth_ok("", " ")); + } + + #[test] + fn build_connect_request_selects_endpoint_and_headers_per_mode() { + let cases = [ + ( + VolcengineAuthMode::AppIdToken, + ENDPOINT_APP_ID_TOKEN, + true, // 双表头(X-Api-App-Key / X-Api-Access-Key) + false, // 不应带 X-Api-Key + ), + ( + VolcengineAuthMode::ApiKey, + ENDPOINT_API_KEY, + false, // 不应带双表头 + true, // 单表头 X-Api-Key + ), + ]; + for (mode, endpoint, expects_app_headers, expects_api_key) in cases { + let asr = VolcengineStreamingASR::new( + VolcengineCredentials { + auth_mode: mode.clone(), + app_id: "app".into(), + access_token: "secret".into(), + resource_id: VolcengineCredentials::default_resource_id().into(), + }, + vec![], + ); + let req = asr.build_connect_request("connect-id").unwrap(); + assert_eq!( + req.uri().to_string(), + endpoint, + "端点应随鉴权模式切换(mode={mode:?})" + ); + let headers = req.headers(); + assert_eq!( + headers.contains_key("X-Api-App-Key"), + expects_app_headers, + "mode={mode:?} X-Api-App-Key" + ); + assert_eq!( + headers.contains_key("X-Api-Access-Key"), + expects_app_headers, + "mode={mode:?} X-Api-Access-Key" + ); + assert_eq!( + headers.contains_key("X-Api-Key"), + expects_api_key, + "mode={mode:?} X-Api-Key" + ); + // 两种模式都必须携带资源与连接标识头。 + assert!(headers.contains_key("X-Api-Resource-Id")); + assert!(headers.contains_key("X-Api-Connect-Id")); + } + } + /// 构造一个握手阶段返回给定 HTTP 状态码的 tungstenite 错误,用于分类测试。 fn http_ws_error(status: u16) -> tokio_tungstenite::tungstenite::Error { use tokio_tungstenite::tungstenite::http::Response; @@ -924,6 +1076,7 @@ mod tests { async fn await_final_result_returns_error_when_final_frame_never_arrives() { let asr = VolcengineStreamingASR::new( VolcengineCredentials { + auth_mode: VolcengineAuthMode::AppIdToken, app_id: "app".into(), access_token: "token".into(), resource_id: VolcengineCredentials::default_resource_id().into(), diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 583de699..ff6430bc 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -22,9 +22,21 @@ pub fn get_credentials() -> CredentialsStatus { } fn volcengine_configured(snap: &CredentialsSnapshot) -> bool { - configured(&snap.volcengine_app_key) - && configured(&snap.volcengine_access_key) - && configured(&snap.volcengine_resource_id) + use crate::asr::volcengine::VolcengineAuthMode; + let mode = snap + .volcengine_auth_mode + .as_deref() + .map(VolcengineAuthMode::from_str) + .unwrap_or(VolcengineAuthMode::AppIdToken); + // 两种模式的密钥来源不同:AppIdToken 读 Access Token 槽,ApiKey 读独立的 API Key 槽。 + let (app_id, secret) = match mode { + VolcengineAuthMode::AppIdToken => ( + snap.volcengine_app_key.as_deref().unwrap_or(""), + snap.volcengine_access_key.as_deref().unwrap_or(""), + ), + VolcengineAuthMode::ApiKey => ("", snap.volcengine_api_key.as_deref().unwrap_or("")), + }; + mode.auth_ok(app_id, secret) && configured(&snap.volcengine_resource_id) } pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool { @@ -185,6 +197,8 @@ pub fn set_credential( CredentialAccount::VolcengineAppKey | CredentialAccount::VolcengineAccessKey | CredentialAccount::VolcengineResourceId + | CredentialAccount::VolcengineAuthMode + | CredentialAccount::VolcengineApiKey | CredentialAccount::AsrApiKey | CredentialAccount::AsrEndpoint | CredentialAccount::AsrModel @@ -307,6 +321,8 @@ fn parse_account(s: &str) -> Result { "volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey), "volcengine.access_key" => Ok(CredentialAccount::VolcengineAccessKey), "volcengine.resource_id" => Ok(CredentialAccount::VolcengineResourceId), + "volcengine.auth_mode" => Ok(CredentialAccount::VolcengineAuthMode), + "volcengine.api_key" => Ok(CredentialAccount::VolcengineApiKey), "ark.api_key" => Ok(CredentialAccount::ArkApiKey), "ark.model_id" => Ok(CredentialAccount::ArkModelId), "ark.endpoint" => Ok(CredentialAccount::ArkEndpoint), diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 576cb662..c893125c 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -280,10 +280,36 @@ mod tests { volcengine_app_key: Some("app".into()), volcengine_access_key: Some("access".into()), volcengine_resource_id: Some("resource".into()), + volcengine_auth_mode: None, // 默认 AppIdToken 模式 ..snapshot() }; assert!(asr_configured_for_provider("volcengine", &volcengine)); + // AppIdToken 模式缺 access_key → 未配置(即使 app_key / resource_id 已填)。 + let volcengine_no_access = CredentialsSnapshot { + volcengine_app_key: Some("app".into()), + volcengine_resource_id: Some("resource".into()), + ..snapshot() + }; + assert!(!asr_configured_for_provider("volcengine", &volcengine_no_access)); + + // ApiKey 模式:只需独立 api_key 槽 + resource_id,无需 app_key。 + let volcengine_api_key = CredentialsSnapshot { + volcengine_api_key: Some("key".into()), + volcengine_resource_id: Some("resource".into()), + volcengine_auth_mode: Some("api_key".into()), + ..snapshot() + }; + assert!(asr_configured_for_provider("volcengine", &volcengine_api_key)); + // ApiKey 模式缺 api_key(旧 access_key 槽有值也不满足)→ 未配置。 + let volcengine_api_key_missing = CredentialsSnapshot { + volcengine_access_key: Some("old-access-token".into()), + volcengine_resource_id: Some("resource".into()), + volcengine_auth_mode: Some("api_key".into()), + ..snapshot() + }; + assert!(!asr_configured_for_provider("volcengine", &volcengine_api_key_missing)); + let whisper_key_only = CredentialsSnapshot { asr_api_key: Some("key".into()), ..snapshot() diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 88e2c94a..ba4eae9f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2507,22 +2507,37 @@ fn read_stepfun_realtime_credentials( } fn read_volc_credentials() -> VolcengineCredentials { + use crate::asr::volcengine::VolcengineAuthMode; let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey) .ok() .flatten() .unwrap_or_default(); - let access_token = CredentialsVault::get(CredentialAccount::VolcengineAccessKey) + let auth_mode = CredentialsVault::get(CredentialAccount::VolcengineAuthMode) .ok() .flatten() - .unwrap_or_default(); + .map(|s| VolcengineAuthMode::from_str(&s)) + .unwrap_or(VolcengineAuthMode::AppIdToken); + // 密钥槽位随鉴权模式:AppIdToken 读旧版 Access Token,ApiKey 读独立的方舟 API Key, + // 两者互不污染,切换模式不会把旧模式的凭据带进新模式的握手。 + let secret = match auth_mode { + VolcengineAuthMode::AppIdToken => CredentialsVault::get(CredentialAccount::VolcengineAccessKey) + .ok() + .flatten() + .unwrap_or_default(), + VolcengineAuthMode::ApiKey => CredentialsVault::get(CredentialAccount::VolcengineApiKey) + .ok() + .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()); VolcengineCredentials { + auth_mode, app_id, - access_token, + access_token: secret, resource_id, } } diff --git a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs index a32396a0..d0b40587 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -119,11 +119,21 @@ pub(super) fn ensure_asr_credentials() -> Result<(), String> { Ok(()) } AsrPreflightCredential::VolcAppKey => { + use crate::asr::volcengine::VolcengineAuthMode; 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 { + // 统一走 VolcengineAuthMode::auth_ok:与 open_session / volcengine_configured + // 共用同一份按模式判定 + trim 语义,避免三处规则漂移。 + if creds.auth_ok() { Ok(()) + } else { + match creds.auth_mode { + VolcengineAuthMode::AppIdToken => { + Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string()) + } + VolcengineAuthMode::ApiKey => { + Err("请先在设置中填写火山方舟语音模型 API Key".to_string()) + } + } } } } diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 8fa1eaa7..32d4cc92 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -213,6 +213,12 @@ struct CredsAsrEntry { #[serde(skip_serializing_if = "Option::is_none")] resourceId: Option, #[serde(skip_serializing_if = "Option::is_none")] + authMode: Option, + /// 方舟(Ark)API Key —— 仅 `api_key` 鉴权模式使用,与旧版 Access Token 槽位 + /// (`accessKey`) 隔离,避免两模式切换时残留凭据互相污染。 + #[serde(skip_serializing_if = "Option::is_none")] + volcengineApiKey: Option, + #[serde(skip_serializing_if = "Option::is_none")] vocabularyId: Option, } @@ -224,6 +230,8 @@ impl CredsAsrEntry { && self.appKey.as_deref().unwrap_or("").is_empty() && self.accessKey.as_deref().unwrap_or("").is_empty() && self.resourceId.as_deref().unwrap_or("").is_empty() + && self.authMode.as_deref().unwrap_or("").is_empty() + && self.volcengineApiKey.as_deref().unwrap_or("").is_empty() && self.vocabularyId.as_deref().unwrap_or("").is_empty() } } @@ -1109,6 +1117,8 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option asr.and_then(|e| pick(&e.accessKey)), CredentialAccount::VolcengineResourceId => asr.and_then(|e| pick(&e.resourceId)), + CredentialAccount::VolcengineAuthMode => asr.and_then(|e| pick(&e.authMode)), + CredentialAccount::VolcengineApiKey => asr.and_then(|e| pick(&e.volcengineApiKey)), CredentialAccount::ArkApiKey => llm.and_then(|e| pick(&e.apiKey)), CredentialAccount::ArkModelId => llm.and_then(|e| pick(&e.model)), CredentialAccount::ArkEndpoint => llm.and_then(|e| pick(&e.baseURL)), @@ -1136,6 +1146,14 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option let entry = root.providers.asr.entry(asr_id).or_default(); entry.resourceId = normalized; } + CredentialAccount::VolcengineAuthMode => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.authMode = normalized; + } + CredentialAccount::VolcengineApiKey => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.volcengineApiKey = normalized; + } CredentialAccount::ArkApiKey => { let entry = root.providers.llm.entry(llm_id).or_default(); entry.apiKey = normalized; @@ -1172,6 +1190,9 @@ pub enum CredentialAccount { VolcengineAppKey, VolcengineAccessKey, VolcengineResourceId, + VolcengineAuthMode, + /// 方舟(Ark)语音模型 API Key(`api_key` 鉴权模式使用,独立于旧版 Access Token 槽位)。 + VolcengineApiKey, ArkApiKey, ArkModelId, ArkEndpoint, @@ -1194,6 +1215,8 @@ impl CredentialAccount { CredentialAccount::VolcengineAppKey => "volcengine.app_key", CredentialAccount::VolcengineAccessKey => "volcengine.access_key", CredentialAccount::VolcengineResourceId => "volcengine.resource_id", + CredentialAccount::VolcengineAuthMode => "volcengine.auth_mode", + CredentialAccount::VolcengineApiKey => "volcengine.api_key", CredentialAccount::ArkApiKey => "ark.api_key", CredentialAccount::ArkModelId => "ark.model_id", CredentialAccount::ArkEndpoint => "ark.endpoint", @@ -1209,6 +1232,8 @@ impl CredentialAccount { CredentialAccount::VolcengineAppKey, CredentialAccount::VolcengineAccessKey, CredentialAccount::VolcengineResourceId, + CredentialAccount::VolcengineAuthMode, + CredentialAccount::VolcengineApiKey, CredentialAccount::ArkApiKey, CredentialAccount::ArkModelId, CredentialAccount::ArkEndpoint, @@ -1226,6 +1251,8 @@ pub struct CredentialsSnapshot { pub volcengine_app_key: Option, pub volcengine_access_key: Option, pub volcengine_resource_id: Option, + pub volcengine_auth_mode: Option, + pub volcengine_api_key: Option, pub asr_api_key: Option, pub asr_endpoint: Option, pub asr_model: Option, @@ -1444,6 +1471,8 @@ impl CredentialsVault { volcengine_app_key: lookup_account(&root, CredentialAccount::VolcengineAppKey), volcengine_access_key: lookup_account(&root, CredentialAccount::VolcengineAccessKey), volcengine_resource_id: lookup_account(&root, CredentialAccount::VolcengineResourceId), + volcengine_auth_mode: lookup_account(&root, CredentialAccount::VolcengineAuthMode), + volcengine_api_key: lookup_account(&root, CredentialAccount::VolcengineApiKey), asr_api_key: lookup_account(&root, CredentialAccount::AsrApiKey), asr_endpoint: lookup_account(&root, CredentialAccount::AsrEndpoint), asr_model: lookup_account(&root, CredentialAccount::AsrModel), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f3a9caa6..cfb01bc8 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -807,8 +807,13 @@ export const en: typeof zhCN = { elevenLabsUploadNotice: 'ElevenLabs uploads recorded audio to the configured endpoint for batch transcription.', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', + volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineAuthModeLabel: 'Auth mode', + volcengineAuthModeAppIdToken: 'Legacy app (APP ID + Access Token)', + volcengineAuthModeApiKey: 'Ark API Key', volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.seedasr.sauc.duration.', + volcengineApiKeyNote: 'Authenticate with Volcengine Ark speech model API Key, no APP ID needed. Resource ID defaults to volc.seedasr.sauc.duration.', localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.', asrProviderTakenOver: 'A local engine is active. Pick another provider in the dropdown above to switch (the local engine stops automatically); manage local models under Advanced → Local models.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 17988bb5..29f305e6 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -809,8 +809,13 @@ export const ja: typeof zhCN = { elevenLabsUploadNotice: 'ElevenLabs は録音音声を設定済みのエンドポイントへアップロードしてバッチ文字起こしします。', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', + volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineAuthModeLabel: '認証モード', + volcengineAuthModeAppIdToken: 'レガシーアプリ(APP ID + Access Token)', + volcengineAuthModeApiKey: 'Ark API Key', volcengineMappingNote: 'Secret Key は現在不要。Resource ID のデフォルトは volc.seedasr.sauc.duration。', + volcengineApiKeyNote: 'Volcengine Ark 音声モデルの API Key で認証、APP ID は不要です。Resource ID のデフォルトは volc.seedasr.sauc.duration。', localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。', asrProviderTakenOver: 'ASR プロバイダーは引き継ぎ済み', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 81e86b39..93f7b516 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -809,8 +809,13 @@ export const ko: typeof zhCN = { elevenLabsUploadNotice: 'ElevenLabs는 녹음 오디오를 설정된 엔드포인트에 업로드해 일괄 전사합니다.', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', + volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineAuthModeLabel: '인증 모드', + volcengineAuthModeAppIdToken: '레거시 앱 (APP ID + Access Token)', + volcengineAuthModeApiKey: 'Ark API Key', volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.seedasr.sauc.duration.', + volcengineApiKeyNote: 'Volcengine Ark 음성 모델 API 키로 인증하며 APP ID는 필요하지 않습니다. Resource ID 기본값은 volc.seedasr.sauc.duration입니다.', localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', asrProviderTakenOver: 'ASR 프로바이더 인수 완료', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a55d89fd..9ce8fa0e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -805,8 +805,13 @@ export const zhCN = { elevenLabsUploadNotice: 'ElevenLabs 会将录音上传到所配置的端点进行批量转写。', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', + volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineAuthModeLabel: '鉴权模式', + volcengineAuthModeAppIdToken: '旧版应用(APP ID + Access Token)', + volcengineAuthModeApiKey: '方舟 API Key', volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。', + volcengineApiKeyNote: '使用火山方舟语音模型的 API Key 鉴权,无需 APP ID。Resource ID 默认使用 volc.seedasr.sauc.duration。', localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。', asrProviderTakenOver: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「高级 → 本地模型」里管理。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0a58f9e7..d396e914 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -807,8 +807,13 @@ export const zhTW: typeof zhCN = { elevenLabsUploadNotice: 'ElevenLabs 會將錄音上傳至已設定的端點進行批次轉寫。', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', + volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineAuthModeLabel: '鑑權模式', + volcengineAuthModeAppIdToken: '舊版應用(APP ID + Access Token)', + volcengineAuthModeApiKey: '方舟 API Key', volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.seedasr.sauc.duration。', + volcengineApiKeyNote: '使用火山方舟語音模型的 API Key 鑑權,無需 APP ID。Resource ID 預設使用 volc.seedasr.sauc.duration。', localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。', asrProviderTakenOver: 'ASR 提供商已被接管', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index f0e2520b..bf500e85 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -196,6 +196,18 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { const os = detectOS(); const unifiedBailian = committedAsrProvider === 'bailian' && os !== 'android'; const [bailianModel, setBailianModel] = useState(''); + const [volcengineAuthMode, setVolcengineAuthMode] = useState<'app_id_token' | 'api_key'>('app_id_token'); + + useEffect(() => { + if (committedAsrProvider === 'volcengine') { + readCredential('volcengine.auth_mode', 'volcengine') + .then(v => { + if (v === 'api_key') setVolcengineAuthMode('api_key'); + else setVolcengineAuthMode('app_id_token'); + }) + .catch(() => setVolcengineAuthMode('app_id_token')); + } + }, [committedAsrProvider]); useEffect(() => { if (committedAsrProvider !== 'bailian') setBailianModel(''); @@ -474,22 +486,62 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { {committedAsrProvider === 'volcengine' ? ( <> - - + + { + const mode = v as 'app_id_token' | 'api_key'; + const prev = volcengineAuthMode; + setVolcengineAuthMode(mode); + try { + await setCredential('volcengine.auth_mode', mode, committedAsrProvider); + } catch (error) { + // 写入失败必须回滚 UI 并提示:否则模式看着已切换、重启后却静默回退, + // 配合独立 API Key 槽会造成「Key 存在但模式不对」的混乱。 + console.error('[settings] failed to save volcengine auth mode', error); + setVolcengineAuthMode(prev); + emitSaved('failed', t('common.operationFailed')); + } + }} + options={[ + { value: 'app_id_token', label: t('settings.providers.volcengineAuthModeAppIdToken') }, + { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, + ]} + ariaLabel={t('settings.providers.volcengineAuthModeLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 260 }} + /> + + {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) + 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} + {volcengineAuthMode === 'app_id_token' ? ( + <> + + + + ) : ( + + )}
- {t('settings.providers.volcengineMappingNote')} + {volcengineAuthMode === 'api_key' + ? t('settings.providers.volcengineApiKeyNote') + : t('settings.providers.volcengineMappingNote')}
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' || committedAsrProvider === 'apple-speech' ? (