feat(asr): 火山引擎 ASR 新增方舟 API Key 鉴权模式 (#867)

* feat(asr): add Ark API Key auth mode for Volcengine ASR

* refactor(asr): isolate Ark API Key slot and harden auth-mode handling

Review fixes for #867:
- P2-1: store Ark API Key in a dedicated volcengine.api_key slot so the
  two auth modes never pollute each other's credentials
- P2-2: unify per-mode credential validation in
  VolcengineAuthMode::auth_ok (single trim semantics across open_session /
  overview configured status / dictation preflight)
- P2-3: await auth-mode persistence on switch, roll back with a failure
  toast when the write fails
- P2-4: add unit tests for endpoint/header selection per mode and
  per-mode credential validation

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
LeiYangandChris233 authored and GitHub committed 2026-08-02 00:58:46 +08:00
1 parent 5fd353de93
commit 83c4f7f4e9
12 files changed
+370 -42

No files matched your search

+169 -16
View File
@@ -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<Self>) -> 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<tokio_tungstenite::tungstenite::handshake::client::Request, VolcengineASRError>
{
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(),
@@ -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<CredentialAccount, String> {
"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),
@@ -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()
+18 -3
View File
@@ -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,
}
}
@@ -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())
}
}
}
}
}
@@ -213,6 +213,12 @@ struct CredsAsrEntry {
#[serde(skip_serializing_if = "Option::is_none")]
resourceId: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
authMode: Option<String>,
/// 方舟(Ark)API Key —— 仅 `api_key` 鉴权模式使用,与旧版 Access Token 槽位
/// (`accessKey`) 隔离,避免两模式切换时残留凭据互相污染。
#[serde(skip_serializing_if = "Option::is_none")]
volcengineApiKey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
vocabularyId: Option<String>,
}
@@ -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<String
}
CredentialAccount::VolcengineAccessKey => 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<String>,
pub volcengine_access_key: Option<String>,
pub volcengine_resource_id: Option<String>,
pub volcengine_auth_mode: Option<String>,
pub volcengine_api_key: Option<String>,
pub asr_api_key: Option<String>,
pub asr_endpoint: Option<String>,
pub asr_model: Option<String>,
@@ -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),
+5
View File
@@ -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.',
+5
View File
@@ -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 プロバイダーは引き継ぎ済み',
+5
View File
@@ -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 프로바이더 인수 완료',
+5
View File
@@ -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: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「高级 → 本地模型」里管理。',
+5
View File
@@ -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 提供商已被接管',
@@ -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 = {}) {
</SettingRow>
{committedAsrProvider === 'volcengine' ? (
<>
<CredentialField
key={`${committedAsrProvider}:app_key`}
label={t('settings.providers.volcengineAppKeyLabel')}
account="volcengine.app_key"
provider={committedAsrProvider}
mono
mask
/>
<CredentialField
key={`${committedAsrProvider}:access_key`}
label={t('settings.providers.volcengineAccessKeyLabel')}
account="volcengine.access_key"
provider={committedAsrProvider}
mono
mask
/>
<SettingRow label={t('settings.providers.volcengineAuthModeLabel')}>
<SelectLite
value={volcengineAuthMode}
onChange={async (v) => {
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 }}
/>
</SettingRow>
{/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key)
与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */}
{volcengineAuthMode === 'app_id_token' ? (
<>
<CredentialField
key={`${committedAsrProvider}:app_key`}
label={t('settings.providers.volcengineAppKeyLabel')}
account="volcengine.app_key"
provider={committedAsrProvider}
mono
mask
/>
<CredentialField
key={`${committedAsrProvider}:access_key`}
label={t('settings.providers.volcengineAccessKeyLabel')}
account="volcengine.access_key"
provider={committedAsrProvider}
mono
mask
/>
</>
) : (
<CredentialField
key={`${committedAsrProvider}:api_key`}
label={t('settings.providers.volcengineApiKeyLabel')}
account="volcengine.api_key"
provider={committedAsrProvider}
mono
mask
/>
)}
<CredentialField
key={`${committedAsrProvider}:resource_id`}
label={t('settings.providers.volcengineResourceIdLabel')}
@@ -498,7 +550,9 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
mono
placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.volcengineMappingNote')}
{volcengineAuthMode === 'api_key'
? t('settings.providers.volcengineApiKeyNote')
: t('settings.providers.volcengineMappingNote')}
</div>
</>
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' || committedAsrProvider === 'apple-speech' ? (