diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 80938774..b9ecd355 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,40 @@ 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", + } + } +} + #[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, } @@ -138,10 +170,12 @@ 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; + let auth_ok = match creds.auth_mode { + VolcengineAuthMode::AppIdToken => !creds.app_id.is_empty() && !creds.access_token.is_empty(), + VolcengineAuthMode::ApiKey => !creds.access_token.is_empty(), + }; + if !auth_ok || creds.resource_id.is_empty() { return Err(VolcengineASRError::CredentialsMissing); } @@ -256,20 +290,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 +913,15 @@ 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"); + } + /// 构造一个握手阶段返回给定 HTTP 状态码的 tungstenite 错误,用于分类测试。 fn http_ws_error(status: u16) -> tokio_tungstenite::tungstenite::Error { use tokio_tungstenite::tungstenite::http::Response; @@ -924,6 +987,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..fba75445 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -22,9 +22,19 @@ 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); + let auth_ok = match mode { + VolcengineAuthMode::AppIdToken => { + configured(&snap.volcengine_app_key) && configured(&snap.volcengine_access_key) + } + VolcengineAuthMode::ApiKey => configured(&snap.volcengine_access_key), + }; + auth_ok && configured(&snap.volcengine_resource_id) } pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool { @@ -185,6 +195,7 @@ pub fn set_credential( CredentialAccount::VolcengineAppKey | CredentialAccount::VolcengineAccessKey | CredentialAccount::VolcengineResourceId + | CredentialAccount::VolcengineAuthMode | CredentialAccount::AsrApiKey | CredentialAccount::AsrEndpoint | CredentialAccount::AsrModel @@ -307,6 +318,7 @@ 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), "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..a20065a7 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -280,6 +280,7 @@ 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)); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 868f8c97..e1ec4bd8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2481,6 +2481,7 @@ fn read_stepfun_realtime_credentials( } fn read_volc_credentials() -> VolcengineCredentials { + use crate::asr::volcengine::VolcengineAuthMode; let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey) .ok() .flatten() @@ -2494,7 +2495,13 @@ fn read_volc_credentials() -> VolcengineCredentials { .flatten() .filter(|s| !s.is_empty()) .unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string()); + let auth_mode = CredentialsVault::get(CredentialAccount::VolcengineAuthMode) + .ok() + .flatten() + .map(|s| VolcengineAuthMode::from_str(&s)) + .unwrap_or(VolcengineAuthMode::AppIdToken); VolcengineCredentials { + auth_mode, app_id, access_token, 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..67288c19 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,25 @@ 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 { + let auth_ok = match creds.auth_mode { + VolcengineAuthMode::AppIdToken => { + !creds.app_id.trim().is_empty() && !creds.access_token.trim().is_empty() + } + VolcengineAuthMode::ApiKey => !creds.access_token.trim().is_empty(), + }; + if 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..4bc86f19 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -213,6 +213,8 @@ struct CredsAsrEntry { #[serde(skip_serializing_if = "Option::is_none")] resourceId: Option, #[serde(skip_serializing_if = "Option::is_none")] + authMode: Option, + #[serde(skip_serializing_if = "Option::is_none")] vocabularyId: Option, } @@ -224,6 +226,7 @@ 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.vocabularyId.as_deref().unwrap_or("").is_empty() } } @@ -1109,6 +1112,7 @@ 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::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 +1140,10 @@ 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::ArkApiKey => { let entry = root.providers.llm.entry(llm_id).or_default(); entry.apiKey = normalized; @@ -1172,6 +1180,7 @@ pub enum CredentialAccount { VolcengineAppKey, VolcengineAccessKey, VolcengineResourceId, + VolcengineAuthMode, ArkApiKey, ArkModelId, ArkEndpoint, @@ -1194,6 +1203,7 @@ impl CredentialAccount { CredentialAccount::VolcengineAppKey => "volcengine.app_key", CredentialAccount::VolcengineAccessKey => "volcengine.access_key", CredentialAccount::VolcengineResourceId => "volcengine.resource_id", + CredentialAccount::VolcengineAuthMode => "volcengine.auth_mode", CredentialAccount::ArkApiKey => "ark.api_key", CredentialAccount::ArkModelId => "ark.model_id", CredentialAccount::ArkEndpoint => "ark.endpoint", @@ -1209,6 +1219,7 @@ impl CredentialAccount { CredentialAccount::VolcengineAppKey, CredentialAccount::VolcengineAccessKey, CredentialAccount::VolcengineResourceId, + CredentialAccount::VolcengineAuthMode, CredentialAccount::ArkApiKey, CredentialAccount::ArkModelId, CredentialAccount::ArkEndpoint, @@ -1226,6 +1237,7 @@ pub struct CredentialsSnapshot { pub volcengine_app_key: Option, pub volcengine_access_key: Option, pub volcengine_resource_id: Option, + pub volcengine_auth_mode: Option, pub asr_api_key: Option, pub asr_endpoint: Option, pub asr_model: Option, @@ -1444,6 +1456,7 @@ 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), 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..96888954 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,17 +486,37 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { {committedAsrProvider === 'volcengine' ? ( <> + + { + const mode = v as 'app_id_token' | 'api_key'; + setVolcengineAuthMode(mode); + void setCredential('volcengine.auth_mode', mode, committedAsrProvider); + }} + 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 }} + /> + + {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' ? (