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:
1 parent
5fd353de93
commit
83c4f7f4e9
12 files changed
+370
-42
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 プロバイダーは引き継ぎ済み',
|
||||
|
||||
@@ -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 프로바이더 인수 완료',
|
||||
|
||||
@@ -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: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「高级 → 本地模型」里管理。',
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
Reference in new issue
Block a user