Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 80 additions & 16 deletions openless-all/app/src-tauri/src/asr/volcengine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
Expand Down Expand Up @@ -138,10 +170,12 @@ 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;
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);
}

Expand Down Expand Up @@ -256,20 +290,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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
18 changes: 15 additions & 3 deletions openless-all/app/src-tauri/src/commands/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -185,6 +195,7 @@ pub fn set_credential(
CredentialAccount::VolcengineAppKey
| CredentialAccount::VolcengineAccessKey
| CredentialAccount::VolcengineResourceId
| CredentialAccount::VolcengineAuthMode
| CredentialAccount::AsrApiKey
| CredentialAccount::AsrEndpoint
| CredentialAccount::AsrModel
Expand Down Expand Up @@ -307,6 +318,7 @@ 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),
"ark.api_key" => Ok(CredentialAccount::ArkApiKey),
"ark.model_id" => Ok(CredentialAccount::ArkModelId),
"ark.endpoint" => Ok(CredentialAccount::ArkEndpoint),
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
7 changes: 7 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
20 changes: 17 additions & 3 deletions openless-all/app/src-tauri/src/coordinator/asr_wiring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions openless-all/app/src-tauri/src/persistence/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ struct CredsAsrEntry {
#[serde(skip_serializing_if = "Option::is_none")]
resourceId: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
authMode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
vocabularyId: Option<String>,
}

Expand All @@ -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()
}
}
Expand Down Expand Up @@ -1109,6 +1112,7 @@ 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::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)),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1172,6 +1180,7 @@ pub enum CredentialAccount {
VolcengineAppKey,
VolcengineAccessKey,
VolcengineResourceId,
VolcengineAuthMode,
ArkApiKey,
ArkModelId,
ArkEndpoint,
Expand All @@ -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",
Expand All @@ -1209,6 +1219,7 @@ impl CredentialAccount {
CredentialAccount::VolcengineAppKey,
CredentialAccount::VolcengineAccessKey,
CredentialAccount::VolcengineResourceId,
CredentialAccount::VolcengineAuthMode,
CredentialAccount::ArkApiKey,
CredentialAccount::ArkModelId,
CredentialAccount::ArkEndpoint,
Expand All @@ -1226,6 +1237,7 @@ 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 asr_api_key: Option<String>,
pub asr_endpoint: Option<String>,
pub asr_model: Option<String>,
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 プロバイダーは引き継ぎ済み',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 프로바이더 인수 완료',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「高级 → 本地模型」里管理。',
Expand Down
Loading