diff --git a/openless-all/app/src-tauri/src/asr/elevenlabs.rs b/openless-all/app/src-tauri/src/asr/elevenlabs.rs new file mode 100644 index 000000000..508142afe --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/elevenlabs.rs @@ -0,0 +1,550 @@ +//! ElevenLabs Scribe ASR client. +//! +//! ElevenLabs Speech-to-Text uses `POST /v1/speech-to-text` with +//! `multipart/form-data` (`model_id` + `file`) and the `xi-api-key` header — +//! *not* OpenAI's Bearer `/audio/transcriptions` protocol — so it needs its +//! own client rather than riding on `WhisperBatchASR`. +//! +//! ElevenLabs accepts files up to 5 GB per request, so unlike MiMo (10 MB +//! base64 limit) there is no need to split the buffer: a single dictation +//! session always fits in one request. + +use anyhow::{Context, Result}; +use futures_util::StreamExt; +use parking_lot::Mutex; +use serde_json::Value; +use std::time::Duration; + +use crate::asr::wav::encode_wav_16k_mono; +use crate::asr::RawTranscript; + +pub const PROVIDER_ID: &str = "elevenlabs"; +pub const DEFAULT_ENDPOINT: &str = "https://api.elevenlabs.io/v1"; +// `scribe_v2` is the current recommended Scribe model (higher accuracy, 99 +// languages, ~40% cheaper). `scribe_v1` is deprecated and is removed from the +// API on 2026-07-09, so it would be a poor default to ship. +pub const DEFAULT_MODEL: &str = "scribe_v2"; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + +pub struct ElevenLabsBatchASR { + api_key: String, + base_url: String, + model: String, + buffer: Mutex>, +} + +impl ElevenLabsBatchASR { + pub fn new(api_key: String, base_url: String, model: String) -> Self { + Self { + api_key, + base_url, + model, + buffer: Mutex::new(Vec::new()), + } + } + + pub async fn transcribe(&self) -> Result { + // clone rather than take: only on a successful response do we clear the + // buffer, so a credential / network failure keeps the user's audio for + // a retry (mirrors WhisperBatchASR / MimoBatchASR). + let pcm = self.buffer.lock().clone(); + if pcm.is_empty() { + return Ok(RawTranscript { + text: String::new(), + duration_ms: 0, + }); + } + + let result = self.transcribe_inner(&pcm).await; + if result.is_ok() { + self.buffer.lock().clear(); + } + result + } + + pub fn buffer_duration_ms(&self) -> u64 { + pcm_duration_ms(&self.buffer.lock()) + } + + async fn transcribe_inner(&self, pcm: &[u8]) -> Result { + if self.api_key.trim().is_empty() { + anyhow::bail!("ElevenLabs API key missing"); + } + + let duration_ms = pcm_duration_ms(pcm); + let samples: Vec = pcm + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + let wav = encode_wav_16k_mono(&samples); + let url = speech_to_text_url(&self.base_url)?; + let resolved = crate::endpoint_security::resolve_http_endpoint(&url) + .await + .context("resolve ElevenLabs endpoint")?; + + let wav_part = reqwest::multipart::Part::bytes(wav) + .file_name("audio.wav") + .mime_str("audio/wav") + .context("set MIME type")?; + // `tag_audio_events=false`: by default Scribe emits bracketed non-speech + // events like "(laughter)" / "(高音)" into `text`; for dictation those + // pollute the inserted text, so disable them. + let form = reqwest::multipart::Form::new() + .part("file", wav_part) + .text("model_id", self.model.clone()) + .text("tag_audio_events", "false") + .text("timestamps_granularity", "none"); + + // Never forward the custom credential header or audio to a redirect + // target. Unlike standard Authorization headers, `xi-api-key` is not + // guaranteed to be stripped by HTTP clients on cross-origin redirects. + let mut client_builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(transcribe_timeout(duration_ms as f64 / 1000.0)); + if let Some(resolved) = &resolved { + client_builder = client_builder.resolve_to_addrs(&resolved.host, &resolved.addrs); + } + let client = client_builder + .build() + .context("build ElevenLabs ASR HTTP client")?; + let resp = client + .post(&url) + .header("xi-api-key", self.api_key.trim()) + .multipart(form) + .send() + .await + .context("ElevenLabs ASR HTTP request failed")?; + + let status = resp.status(); + let body = read_response_limited(resp).await?; + if !status.is_success() { + let body = String::from_utf8_lossy(&body); + if let Some(code) = safe_error_code(&body) { + anyhow::bail!("ElevenLabs ASR API error {} (code: {})", status, code); + } + anyhow::bail!("ElevenLabs ASR API error {}", status); + } + + let json: Value = serde_json::from_slice(&body).context("parse ElevenLabs ASR response")?; + Ok(RawTranscript { + text: extract_text(&json)?.trim().to_string(), + duration_ms, + }) + } + + pub fn cancel(&self) { + self.buffer.lock().clear(); + } +} + +impl crate::recorder::AudioConsumer for ElevenLabsBatchASR { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.buffer.lock().extend_from_slice(pcm); + } +} + +/// Build the `/v1/speech-to-text` endpoint from a configured base URL. +/// +/// Accepts either the API root (`https://api.elevenlabs.io/v1`) or the full +/// endpoint already ending in `/speech-to-text`, so re-saving the resolved URL +/// is idempotent. +pub fn speech_to_text_url(base_url: &str) -> Result { + crate::endpoint_security::validate_http_endpoint(base_url) + .map_err(anyhow::Error::msg) + .context("validate ElevenLabs endpoint")?; + let parsed = reqwest::Url::parse(base_url.trim()).context("parse ElevenLabs base URL")?; + let loopback_http = parsed.scheme() == "http" + && parsed.host_str().is_some_and(|host| { + let host = host.trim_start_matches('[').trim_end_matches(']'); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if parsed.scheme() != "https" && !loopback_http { + anyhow::bail!("ElevenLabs endpoint must use HTTPS (HTTP is allowed only for loopback)"); + } + let mut url = parsed.clone(); + let path = parsed.path().trim_end_matches('/'); + let next_path = if path.ends_with("/speech-to-text") { + path.to_string() + } else { + format!("{path}/speech-to-text") + }; + url.set_path(&next_path); + Ok(url.to_string()) +} + +async fn read_response_limited(response: reqwest::Response) -> Result> { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("read ElevenLabs ASR response")?; + append_response_chunk(&mut body, &chunk)?; + } + Ok(body) +} + +fn append_response_chunk(body: &mut Vec, chunk: &[u8]) -> Result<()> { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + anyhow::bail!("ElevenLabs ASR response too large"); + } + body.extend_from_slice(chunk); + Ok(()) +} + +/// Batch transcription gets a fixed network allowance plus time proportional +/// to the recording, while retaining a practical minimum for short clips. +pub fn transcribe_timeout(audio_secs: f64) -> Duration { + Duration::from_secs(30.max((audio_secs * 0.5).ceil() as u64 + 20)) +} + +/// Pull the transcript out of the Scribe response. +/// +/// Single-channel responses carry a top-level `text`; multichannel responses +/// carry `transcripts: [{ text, ... }]` instead. Concatenate the latter so a +/// stray multichannel result still yields usable text. +pub fn extract_text(json: &Value) -> Result { + if let Some(text) = json.get("text") { + return text + .as_str() + .map(str::to_string) + .context("ElevenLabs ASR response `text` must be a string"); + } + if let Some(transcripts) = json.get("transcripts") { + let transcripts = transcripts + .as_array() + .context("ElevenLabs ASR response `transcripts` must be an array")?; + return transcripts + .iter() + .map(|item| { + item.get("text") + .and_then(|text| text.as_str()) + .context("ElevenLabs ASR transcript missing string `text`") + }) + .collect::>>() + .map(|texts| texts.join(" ")); + } + anyhow::bail!("ElevenLabs ASR response missing `text` or `transcripts`") +} + +fn pcm_duration_ms(pcm: &[u8]) -> u64 { + super::pcm::pcm_duration_ms(pcm) +} + +/// Return only locally-known, non-sensitive error codes. +/// +/// The response body is untrusted, especially for custom endpoints. Never +/// propagate arbitrary fields into errors because callers log and display the +/// resulting message. Unknown codes deliberately collapse to the HTTP status. +fn safe_error_code(body: &str) -> Option<&'static str> { + let json: Value = serde_json::from_str(body).ok()?; + match json.pointer("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/detail/code").and_then(Value::as_str) { + Some("audio_too_short") => Some("audio_too_short"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recorder::AudioConsumer; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn url_targets_speech_to_text() { + assert_eq!( + speech_to_text_url("https://api.elevenlabs.io/v1").unwrap(), + "https://api.elevenlabs.io/v1/speech-to-text" + ); + assert_eq!( + speech_to_text_url("https://api.elevenlabs.io/v1/").unwrap(), + "https://api.elevenlabs.io/v1/speech-to-text" + ); + assert_eq!( + speech_to_text_url("https://api.elevenlabs.io/v1/speech-to-text").unwrap(), + "https://api.elevenlabs.io/v1/speech-to-text" + ); + } + + #[test] + fn url_rejects_insecure_non_loopback_endpoint() { + let error = speech_to_text_url("http://api.example.com/v1").unwrap_err(); + assert!(format!("{error:#}").to_ascii_lowercase().contains("https")); + + assert!(speech_to_text_url("http://127.0.0.1:8080/v1").is_ok()); + assert!(speech_to_text_url("http://localhost:8080/v1").is_ok()); + assert!(speech_to_text_url("http://[::1]:8080/v1").is_ok()); + } + + #[test] + fn url_rejects_sensitive_network_targets() { + assert!(speech_to_text_url("https://169.254.169.254/v1").is_err()); + assert!(speech_to_text_url("https://100.64.0.1/v1").is_err()); + assert!(speech_to_text_url("https://metadata.google.internal/v1").is_err()); + } + + #[test] + fn timeout_scales_with_recording_duration() { + assert_eq!(transcribe_timeout(0.0), Duration::from_secs(30)); + assert_eq!(transcribe_timeout(20.0), Duration::from_secs(30)); + assert_eq!(transcribe_timeout(120.0), Duration::from_secs(80)); + } + + #[test] + fn extract_text_reads_single_channel() { + let json = serde_json::json!({ "language_code": "en", "text": "hello world" }); + assert_eq!(extract_text(&json).unwrap(), "hello world"); + } + + #[test] + fn extract_text_joins_multichannel_transcripts() { + let json = serde_json::json!({ + "transcripts": [ { "text": "left" }, { "text": "right" } ] + }); + assert_eq!(extract_text(&json).unwrap(), "left right"); + } + + #[test] + fn extract_text_rejects_missing_transcript_field() { + let json = serde_json::json!({ "language_code": "en" }); + assert!(extract_text(&json).is_err()); + } + + #[tokio::test] + async fn transcribe_posts_multipart_speech_to_text_request() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "timed out waiting for ElevenLabs ASR test request" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept ElevenLabs ASR test request failed: {err}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let request = read_http_request(&mut stream); + let request_text = String::from_utf8_lossy(&request); + let lower = request_text.to_ascii_lowercase(); + assert!(request_text.starts_with("POST /v1/speech-to-text HTTP/1.1")); + assert!(lower.contains("xi-api-key: key")); + assert!(lower.contains("content-type: multipart/form-data")); + assert!(request_text.contains("name=\"model_id\"")); + assert!(request_text.contains("scribe_v2")); + assert!(request_text.contains("name=\"file\"")); + assert!(request_text.contains("name=\"tag_audio_events\"\r\n\r\nfalse\r\n")); + assert!(request_text.contains("name=\"timestamps_granularity\"\r\n\r\nnone\r\n")); + write_json_response( + &mut stream, + r#"{"language_code":"en","text":"elevenlabs ok"}"#, + ); + }); + + let asr = ElevenLabsBatchASR::new( + "key".to_string(), + format!("http://{}/v1", addr), + DEFAULT_MODEL.to_string(), + ); + asr.consume_pcm_chunk(&vec![0u8; 32_000]); + let transcript = asr.transcribe().await.unwrap(); + + assert_eq!(transcript.text, "elevenlabs ok"); + assert_eq!(transcript.duration_ms, 1_000); + server.join().unwrap(); + } + + #[test] + fn response_limit_accepts_one_megabyte_and_rejects_the_next_byte() { + let mut body = Vec::new(); + append_response_chunk(&mut body, &vec![b'a'; MAX_RESPONSE_BYTES]).unwrap(); + assert_eq!(body.len(), MAX_RESPONSE_BYTES); + + let error = append_response_chunk(&mut body, b"a") + .unwrap_err() + .to_string(); + assert!(error.contains("response too large")); + } + + #[tokio::test] + async fn transcribe_redacts_untrusted_error_response_body() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "timed out waiting for ElevenLabs ASR test request" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept ElevenLabs ASR test request failed: {err}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _request = read_http_request(&mut stream); + let body = r#"{"detail":{"code":"audio_too_short","message":"SENSITIVE_API_KEY_VALUE","request_id":"SENSITIVE_REQUEST_ID","transcript":"SENSITIVE_TRANSCRIPT"}}"#; + let response = format!( + "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.flush().unwrap(); + }); + + let asr = ElevenLabsBatchASR::new( + "synthetic-test-key".to_string(), + format!("http://{}/v1", addr), + DEFAULT_MODEL.to_string(), + ); + asr.consume_pcm_chunk(&vec![0u8; 32_000]); + let error = asr.transcribe().await.unwrap_err().to_string(); + + assert!(error.contains("400 Bad Request")); + assert!(error.contains("audio_too_short")); + assert!(!error.contains("SENSITIVE_API_KEY_VALUE")); + assert!(!error.contains("SENSITIVE_REQUEST_ID")); + assert!(!error.contains("SENSITIVE_TRANSCRIPT")); + assert!(!error.contains("message")); + server.join().unwrap(); + } + + #[tokio::test] + async fn transcribe_does_not_follow_redirects_with_credentials() { + let redirect_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let redirect_addr = redirect_listener.local_addr().unwrap(); + let target_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + target_listener.set_nonblocking(true).unwrap(); + let target_addr = target_listener.local_addr().unwrap(); + let followed = Arc::new(AtomicBool::new(false)); + let target_followed = Arc::clone(&followed); + + let redirect_server = thread::spawn(move || { + let (mut stream, _) = redirect_listener.accept().unwrap(); + let _request = read_http_request(&mut stream); + let response = format!( + "HTTP/1.1 302 Found\r\nlocation: http://{}/v1/speech-to-text\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + target_addr + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.flush().unwrap(); + }); + let target_server = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match target_listener.accept() { + Ok((mut stream, _)) => { + target_followed.store(true, Ordering::SeqCst); + write_json_response( + &mut stream, + r#"{"language_code":"en","text":"redirect followed"}"#, + ); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept redirect target request failed: {err}"), + } + } + }); + + let asr = ElevenLabsBatchASR::new( + "synthetic-test-key".to_string(), + format!("http://{}/v1", redirect_addr), + DEFAULT_MODEL.to_string(), + ); + asr.consume_pcm_chunk(&vec![0u8; 32_000]); + let error = asr.transcribe().await.unwrap_err().to_string(); + + assert!(error.contains("302 Found")); + redirect_server.join().unwrap(); + target_server.join().unwrap(); + assert!(!followed.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn transcribe_empty_buffer_skips_request() { + let asr = ElevenLabsBatchASR::new( + "key".to_string(), + "http://127.0.0.1:1/v1".to_string(), + DEFAULT_MODEL.to_string(), + ); + let transcript = asr.transcribe().await.unwrap(); + assert_eq!(transcript.text, ""); + assert_eq!(transcript.duration_ms, 0); + } + + fn read_http_request(stream: &mut TcpStream) -> Vec { + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + let mut expected_len = None; + loop { + let read = stream.read(&mut buf).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + if expected_len.is_none() { + expected_len = parse_expected_request_len(&request); + } + if expected_len.is_some_and(|len| request.len() >= len) { + break; + } + } + request + } + + fn parse_expected_request_len(request: &[u8]) -> Option { + let header_end = request.windows(4).position(|w| w == b"\r\n\r\n")? + 4; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_len = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + })?; + Some(header_end + content_len) + } + + fn write_json_response(stream: &mut TcpStream, body: &str) { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.flush().unwrap(); + } +} diff --git a/openless-all/app/src-tauri/src/asr/mod.rs b/openless-all/app/src-tauri/src/asr/mod.rs index b43b4d496..ce1a5d481 100644 --- a/openless-all/app/src-tauri/src/asr/mod.rs +++ b/openless-all/app/src-tauri/src/asr/mod.rs @@ -7,6 +7,7 @@ pub mod bailian; pub mod dashscope_multimodal; +pub mod elevenlabs; mod frame; pub mod local; pub mod mimo; @@ -18,6 +19,7 @@ pub mod whisper; pub use bailian::{BailianCredentials, BailianRealtimeASR}; pub use dashscope_multimodal::DashScopeMultimodalASR; +pub use elevenlabs::ElevenLabsBatchASR; pub use mimo::MimoBatchASR; pub use qwen_realtime::{Qwen3RealtimeASR, Qwen3RealtimeCredentials}; pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR}; diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 84adce2ce..305b0c91e 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -158,7 +158,12 @@ pub(crate) async fn release_sherpa_runtime_if_inactive( } #[tauri::command] -pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { +pub fn set_credential( + window: Window, + account: String, + value: String, + provider: Option, +) -> Result<(), String> { ensure_main_window(&window)?; if account == LLM_EXTRA_HEADERS_ACCOUNT { CredentialsVault::set_active_llm_extra_headers_json(&value).map_err(|e| e.to_string())?; @@ -166,7 +171,22 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result< return Ok(()); } let acc = parse_account(&account)?; - if value.is_empty() { + if let Some(provider) = provider { + if !matches!( + acc, + CredentialAccount::VolcengineAppKey + | CredentialAccount::VolcengineAccessKey + | CredentialAccount::VolcengineResourceId + | CredentialAccount::AsrApiKey + | CredentialAccount::AsrEndpoint + | CredentialAccount::AsrModel + | CredentialAccount::AsrVocabularyId + ) { + return Err("provider-scoped credential must be an ASR account".to_string()); + } + CredentialsVault::set_for_asr_provider(&provider, acc, &value) + .map_err(|e| e.to_string())?; + } else if value.is_empty() { CredentialsVault::remove(acc).map_err(|e| e.to_string())?; } else { CredentialsVault::set(acc, &value).map_err(|e| e.to_string())?; @@ -246,13 +266,21 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> { /// 读出某个账号的实际值(用于设置页预填表单)。 /// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。 #[tauri::command] -pub fn read_credential(window: Window, account: String) -> Result, String> { +pub fn read_credential( + window: Window, + account: String, + provider: Option, +) -> Result, String> { ensure_main_window(&window)?; if account == LLM_EXTRA_HEADERS_ACCOUNT { return CredentialsVault::get_active_llm_extra_headers_json().map_err(|e| e.to_string()); } let acc = parse_account(&account)?; - CredentialsVault::get(acc).map_err(|e| e.to_string()) + if let Some(provider) = provider { + CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string()) + } else { + CredentialsVault::get(acc).map_err(|e| e.to_string()) + } } fn ensure_main_window(window: &Window) -> Result<(), String> { diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index ca128b9c8..f589cce24 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -84,6 +84,12 @@ pub async fn list_provider_models(kind: String) -> Result Result { // 校验,拒绝指向内网/回环/link-local/CGNAT/IPv6 ULA/元数据服务的地址;localhost/ // 127.0.0.1/::1 仍放行 http(本地 Whisper 服务)。覆盖 validate_provider_credentials // (asr/llm) 连通性测试与 list_provider_models 模型列表两条 HTTP 路径。 - crate::coordinator::validate_llm_endpoint(&base_url) + crate::endpoint_security::validate_http_endpoint(&base_url) .map_err(|_| "endpointInvalid".to_string())?; Ok(ProviderConfig { base_url, @@ -260,6 +266,9 @@ async fn validate_asr_provider() -> Result<(), String> { crate::coordinator::validate_dashscope_multimodal_model(&model)?; return validate_dashscope_multimodal_asr_provider().await; } + if active_asr == crate::asr::elevenlabs::PROVIDER_ID { + return validate_elevenlabs_asr_provider().await; + } let config = read_openai_provider_config("asr")?; let model = CredentialsVault::get(CredentialAccount::AsrModel) @@ -286,6 +295,39 @@ async fn validate_mimo_asr_provider() -> Result<(), String> { .map_err(|e| e.to_string()) } +async fn validate_elevenlabs_asr_provider() -> Result<(), String> { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .map_err(|e| e.to_string())? + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "API Key 为空".to_string())?; + let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .map_err(|e| e.to_string())? + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_ENDPOINT.to_string()); + crate::endpoint_security::validate_http_endpoint(&base_url) + .map_err(|_| "endpointInvalid".to_string())?; + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .map_err(|e| e.to_string())? + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_MODEL.to_string()); + let asr = crate::asr::ElevenLabsBatchASR::new(api_key, base_url, model); + crate::recorder::AudioConsumer::consume_pcm_chunk( + &asr, + &encode_wav_16k_mono_silence(250)[44..], + ); + asr.transcribe().await.map(|_| ()).map_err(|error| { + if error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(reqwest::Error::is_timeout) + }) { + "providerRequestTimeout".to_string() + } else { + error.to_string() + } + }) +} + /// fun-asr-flash 官方公开示例音频,用于连通性校验。该模型对纯静音会返回 /// 400("no speech" 类错误),无法像 Whisper/Mimo 那样发静音探活;改用这段 /// 阿里官方文档在案的示例 wav(由 DashScope 侧拉取),key/endpoint/model 有效 @@ -361,7 +403,7 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { return Err("API Key 为空".to_string()); } // 已知残留(issue #609 F-01 孪生 gap):Bailian endpoint 走 `wss://`,与 http/https-only 的 - // validate_llm_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。 + // validate_http_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。 let stored_endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) @@ -745,29 +787,29 @@ mod tests { // API Key 发请求,read_openai_provider_config(连通性测试 + 模型列表 chokepoint)现在复用 // LLM 路径的 SSRF 校验。read_openai_provider_config 依赖凭据库无法纯单测,这里直接对它调用 // 的校验器锁定 ASR 形态 endpoint 的拒绝/放行契约。 - use crate::coordinator::validate_llm_endpoint; + use crate::endpoint_security::validate_http_endpoint; #[test] fn asr_endpoint_rejects_metadata_cgnat_and_non_https_public() { // 元数据 / CGNAT / 非 https 外网:拒绝,避免带 API Key 的 ASR 请求被指向高价值目标 / 明文外泄。 - assert!(validate_llm_endpoint("http://169.254.169.254/v1/audio/transcriptions").is_err()); - assert!(validate_llm_endpoint("http://100.64.0.1/v1/audio/transcriptions").is_err()); - assert!(validate_llm_endpoint("http://api.example.com/v1/audio/transcriptions").is_err()); + assert!(validate_http_endpoint("http://169.254.169.254/v1/audio/transcriptions").is_err()); + assert!(validate_http_endpoint("http://100.64.0.1/v1/audio/transcriptions").is_err()); + assert!(validate_http_endpoint("http://api.example.com/v1/audio/transcriptions").is_err()); } #[test] fn asr_endpoint_accepts_public_https_localhost_and_lan() { // 公网 https(如自建 Whisper 网关)放行。 - validate_llm_endpoint("https://api.example.com/v1/audio/transcriptions") + validate_http_endpoint("https://api.example.com/v1/audio/transcriptions") .expect("公网 https ASR endpoint 必须通过"); // 本地 Whisper 服务:localhost / 127.0.0.1 http 放行。 - validate_llm_endpoint("http://localhost:9000/v1").expect("本地 Whisper http 必须通过"); - validate_llm_endpoint("http://127.0.0.1:9000/v1").expect("本地 Whisper http 必须通过"); + validate_http_endpoint("http://localhost:9000/v1").expect("本地 Whisper http 必须通过"); + validate_http_endpoint("http://127.0.0.1:9000/v1").expect("本地 Whisper http 必须通过"); // F-01 放宽:局域网(RFC1918)http ASR 网关放行(用户局域网自托管 Whisper)。 - validate_llm_endpoint("http://192.168.1.50:9000/v1/audio/transcriptions") + validate_http_endpoint("http://192.168.1.50:9000/v1/audio/transcriptions") .expect("局域网 http ASR endpoint 必须通过"); // Mimo 官方默认 endpoint(https)放行。 - validate_llm_endpoint(crate::asr::mimo::DEFAULT_ENDPOINT) + validate_http_endpoint(crate::asr::mimo::DEFAULT_ENDPOINT) .expect("Mimo 官方默认 endpoint 必须通过"); } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index ef1ddc7a8..3b314542c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -26,8 +26,8 @@ use crate::asr::local::{ }; use crate::asr::{ BailianCredentials, BailianRealtimeASR, DashScopeMultimodalASR, DictionaryHotword, - MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials, RawTranscript, VolcengineCredentials, - VolcengineStreamingASR, WhisperBatchASR, + ElevenLabsBatchASR, MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials, RawTranscript, + VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; use crate::coordinator_state::{ @@ -182,6 +182,7 @@ enum ActiveAsr { Mimo(Arc), /// 百炼 Fun-ASR-Flash 录音文件识别(DashScope multimodal-generation 批量 HTTP)。 DashScopeMultimodal(Arc), + ElevenLabs(Arc), Bailian(Arc), /// 百炼 Qwen3-ASR-Flash 实时(OpenAI Realtime 风格 WS 协议)。 Qwen3Realtime(Arc), @@ -224,6 +225,7 @@ pub(crate) enum ActiveAsrProviderKind { Qwen3Realtime, Mimo, DashScopeMultimodal, + ElevenLabs, WhisperCompatible, Volcengine, } @@ -258,6 +260,7 @@ impl ActiveAsrProviderKind { | ActiveAsrProviderKind::Qwen3Realtime | ActiveAsrProviderKind::Mimo | ActiveAsrProviderKind::DashScopeMultimodal + | ActiveAsrProviderKind::ElevenLabs | ActiveAsrProviderKind::WhisperCompatible => AsrPreflightCredential::AsrApiKey, ActiveAsrProviderKind::Volcengine => AsrPreflightCredential::VolcAppKey, } @@ -265,7 +268,9 @@ impl ActiveAsrProviderKind { pub(crate) fn configured_fields(self) -> AsrConfiguredFields { match self { - ActiveAsrProviderKind::Bailian | ActiveAsrProviderKind::Qwen3Realtime => { + ActiveAsrProviderKind::Bailian + | ActiveAsrProviderKind::Qwen3Realtime + | ActiveAsrProviderKind::ElevenLabs => { AsrConfiguredFields::ApiKeyOnly } ActiveAsrProviderKind::Mimo | ActiveAsrProviderKind::DashScopeMultimodal => { @@ -286,6 +291,8 @@ pub(crate) fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { ActiveAsrProviderKind::Mimo } else if is_dashscope_multimodal_provider(id) { ActiveAsrProviderKind::DashScopeMultimodal + } else if is_elevenlabs_provider(id) { + ActiveAsrProviderKind::ElevenLabs } else if is_whisper_compatible_provider(id) { ActiveAsrProviderKind::WhisperCompatible } else { @@ -1787,6 +1794,9 @@ impl Coordinator { let dashscope_timeout = whisper_transcribe_timeout( crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0, ); + let elevenlabs_timeout = crate::asr::elevenlabs::transcribe_timeout( + crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0, + ); let raw = match start.active_asr() { ActiveAsr::Volcengine(asr) => { asr.send_last_frame().await.map_err(|e| e.to_string())?; @@ -1823,6 +1833,12 @@ impl Coordinator { .map_err(|_| "重新转录超时".to_string())? .map_err(|e| e.to_string())? } + ActiveAsr::ElevenLabs(e) => { + tokio::time::timeout(elevenlabs_timeout, e.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; @@ -2220,6 +2236,24 @@ fn read_mimo_credentials() -> (String, String, String) { (api_key, base_url, model) } +fn read_elevenlabs_credentials() -> (String, String, String) { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .ok() + .flatten() + .unwrap_or_default(); + let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_ENDPOINT.to_string()); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_MODEL.to_string()); + (api_key, base_url, model) +} + fn read_dashscope_multimodal_credentials() -> (String, String, String) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() @@ -2686,6 +2720,10 @@ mod tests { active_asr_provider_kind(crate::asr::dashscope_multimodal::PROVIDER_ID), ActiveAsrProviderKind::DashScopeMultimodal ); + assert_eq!( + active_asr_provider_kind(crate::asr::elevenlabs::PROVIDER_ID), + ActiveAsrProviderKind::ElevenLabs + ); assert_eq!( active_asr_provider_kind("volcengine"), ActiveAsrProviderKind::Volcengine @@ -2708,6 +2746,7 @@ mod tests { assert_eq!(Qwen3Realtime.preflight_credential(), AsrApiKey); assert_eq!(Mimo.preflight_credential(), AsrApiKey); assert_eq!(DashScopeMultimodal.preflight_credential(), AsrApiKey); + assert_eq!(ElevenLabs.preflight_credential(), AsrApiKey); assert_eq!(WhisperCompatible.preflight_credential(), AsrApiKey); assert_eq!(Volcengine.preflight_credential(), VolcAppKey); } @@ -2801,6 +2840,7 @@ mod tests { assert_eq!(Qwen3Realtime.configured_fields(), ApiKeyOnly); assert_eq!(Mimo.configured_fields(), ApiKeyEndpointModel); assert_eq!(DashScopeMultimodal.configured_fields(), ApiKeyEndpointModel); + assert_eq!(ElevenLabs.configured_fields(), ApiKeyOnly); assert_eq!(WhisperCompatible.configured_fields(), EndpointModelOnly); assert_eq!(Volcengine.configured_fields(), VolcAppKey); } @@ -3598,87 +3638,6 @@ fn whisper_transcribe_timeout(audio_secs: f64) -> std::time::Duration { std::time::Duration::from_secs(secs) } -pub(crate) fn validate_llm_endpoint(raw: &str) -> anyhow::Result<()> { - use std::net::IpAddr; - - let url = - url::Url::parse(raw).map_err(|e| anyhow::anyhow!("LLM endpoint 不是合法 URL:{e}"))?; - let host = url - .host_str() - .ok_or_else(|| anyhow::anyhow!("LLM endpoint 缺少主机名"))? - .to_ascii_lowercase(); - - const METADATA_HOSTS: [&str; 2] = ["metadata.google.internal", "169.254.169.254"]; - if METADATA_HOSTS.iter().any(|m| host.contains(m)) { - anyhow::bail!("LLM endpoint 指向云元数据服务,已拒绝:{host}"); - } - - let scheme = url.scheme(); - let bare_host = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host.as_str()); - - let Ok(ip) = bare_host.parse::() else { - if bare_host == "localhost" { - return Ok(()); - } - if scheme != "https" { - anyhow::bail!("LLM endpoint 必须使用 https(仅 localhost / 局域网允许 http):{raw}"); - } - return Ok(()); - }; - - let canonical = match ip { - IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), - v4 => v4, - }; - - let is_lan = match canonical { - IpAddr::V4(v4) => ip_v4_is_lan(v4), - IpAddr::V6(v6) => ip_v6_is_lan(v6), - }; - if is_lan { - return Ok(()); - } - - let is_blocked = match canonical { - IpAddr::V4(v4) => ip_v4_is_blocked(v4), - IpAddr::V6(v6) => ip_v6_is_blocked(v6), - }; - if is_blocked { - anyhow::bail!("LLM endpoint 指向保留/危险地址,已拒绝(防 SSRF):{ip}"); - } - - if scheme != "https" { - anyhow::bail!("LLM endpoint 必须使用 https(仅 localhost / 局域网允许 http):{raw}"); - } - - Ok(()) -} - -fn ip_v4_is_lan(ip: std::net::Ipv4Addr) -> bool { - ip.is_loopback() || ip.is_private() -} - -fn ip_v4_is_blocked(ip: std::net::Ipv4Addr) -> bool { - let octets = ip.octets(); - let is_cgnat = octets[0] == 100 && (64..=127).contains(&octets[1]); - ip.is_link_local() || ip.is_unspecified() || ip.is_broadcast() || is_cgnat -} - -fn ip_v6_is_lan(ip: std::net::Ipv6Addr) -> bool { - let segs = ip.segments(); - let is_ula = (segs[0] & 0xfe00) == 0xfc00; - ip.is_loopback() || is_ula -} - -fn ip_v6_is_blocked(ip: std::net::Ipv6Addr) -> bool { - let segs = ip.segments(); - let is_link_local = (segs[0] & 0xffc0) == 0xfe80; - ip.is_unspecified() || is_link_local -} - /// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 /// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 /// 「准备做下一步副作用前」用。 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 0433fe4de..3da15bea8 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -366,6 +366,10 @@ pub(super) fn is_dashscope_multimodal_provider(id: &str) -> bool { id == crate::asr::dashscope_multimodal::PROVIDER_ID } +pub(super) fn is_elevenlabs_provider(id: &str) -> bool { + id == crate::asr::elevenlabs::PROVIDER_ID +} + pub(super) fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String { if text.is_empty() { return String::new(); @@ -566,6 +570,13 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> let consumer: Arc = asr; Ok(QaAsrStart::Ready { active, consumer }) } + ActiveAsrProviderKind::ElevenLabs => { + let (api_key, base_url, model) = read_elevenlabs_credentials(); + let asr = Arc::new(ElevenLabsBatchASR::new(api_key, base_url, model)); + let active = ActiveAsr::ElevenLabs(Arc::clone(&asr)); + let consumer: Arc = asr; + Ok(QaAsrStart::Ready { active, consumer }) + } ActiveAsrProviderKind::WhisperCompatible => { let (api_key, base_url, model) = read_whisper_credentials(); let whisper_prompt = diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 1a75dfe0e..e109a7041 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1631,6 +1631,7 @@ pub(super) async fn begin_session_as( | ActiveAsrProviderKind::Qwen3Realtime | ActiveAsrProviderKind::Mimo | ActiveAsrProviderKind::DashScopeMultimodal + | ActiveAsrProviderKind::ElevenLabs | ActiveAsrProviderKind::WhisperCompatible | ActiveAsrProviderKind::Volcengine => {} } @@ -1806,6 +1807,17 @@ pub(super) async fn begin_session_as( let consumer: Arc = asr; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) .await?; + } else if is_elevenlabs_provider(&effective_asr) { + let (api_key, base_url, model) = read_elevenlabs_credentials(); + let asr = Arc::new(ElevenLabsBatchASR::new(api_key, base_url, model)); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::ElevenLabs(Arc::clone(&asr)), + ); + let consumer: Arc = asr; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; } else if is_whisper_compatible_provider(&effective_asr) { let (api_key, base_url, model) = read_whisper_credentials(); // 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や @@ -2530,6 +2542,30 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } } } + ActiveAsr::ElevenLabs(e) => { + debug_assert!(uses_global_timeout); + let audio_secs = e.buffer_duration_ms() as f64 / 1000.0; + let timeout_duration = crate::asr::elevenlabs::transcribe_timeout(audio_secs); + log::info!( + "[coord] ElevenLabs dynamic timeout: {}s (audio {:.2}s)", + timeout_duration.as_secs(), + audio_secs + ); + match tokio::time::timeout(timeout_duration, e.transcribe()).await { + Ok(Ok(r)) => Ok(r), + Ok(Err(error)) => { + log::error!("[coord] ElevenLabs ASR transcribe failed: {error}"); + Err(TranscribeFail::new( + format!("识别失败: {error}"), + error.to_string(), + )) + } + Err(_) => Err(TranscribeFail::new( + "识别超时".to_string(), + "elevenlabs dynamic timeout".to_string(), + )), + } + } ActiveAsr::Bailian(asr) => { debug_assert!(uses_global_timeout); if let Err(e) = asr.send_last_frame().await { diff --git a/openless-all/app/src-tauri/src/coordinator/qa_session.rs b/openless-all/app/src-tauri/src/coordinator/qa_session.rs index 3cad37425..77eb20521 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -136,13 +136,25 @@ pub(super) async fn take_current_dictation_transcript_for_qa( }; let mut raw = match transcribe_overlay_dictation_asr(inner, current_session_id, asr).await { - Ok(raw) => raw, - Err(error) => { + OverlayDictationTranscribeOutcome::Done(Ok(raw)) => raw, + OverlayDictationTranscribeOutcome::Done(Err(error)) => { restore_prepared_windows_ime_session(inner, current_session_id); set_phase_idle_if_session_matches(inner, current_session_id); finish_qa_with_error(inner, format!("识别失败: {error}")); return Err(error); } + OverlayDictationTranscribeOutcome::Cancelled => { + restore_prepared_windows_ime_session(inner, current_session_id); + { + let mut state = inner.state.lock(); + if state.session_id == current_session_id { + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + } + finish_qa_idle_silently(inner); + return Ok(None); + } }; if inner.state.lock().cancelled { @@ -205,13 +217,18 @@ pub(super) async fn wait_for_dictation_listening(inner: &Arc) -> Result<( } } +pub(super) enum OverlayDictationTranscribeOutcome { + Done(Result), + Cancelled, +} + pub(super) async fn transcribe_overlay_dictation_asr( _inner: &Arc, _current_session_id: SessionId, asr: ActiveAsr, -) -> Result { +) -> OverlayDictationTranscribeOutcome { let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); - match asr { + let result = match asr { ActiveAsr::Volcengine(asr) => { debug_assert!(uses_global_timeout); if let Err(error) = asr.send_last_frame().await { @@ -285,6 +302,22 @@ pub(super) async fn transcribe_overlay_dictation_asr( Err(_) => Err("dashscope multimodal global timeout".to_string()), } } + ActiveAsr::ElevenLabs(asr) => { + debug_assert!(uses_global_timeout); + let audio_secs = asr.buffer_duration_ms() as f64 / 1000.0; + let timeout_duration = crate::asr::elevenlabs::transcribe_timeout(audio_secs); + tokio::select! { + result = tokio::time::timeout(timeout_duration, asr.transcribe()) => match result { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("elevenlabs dynamic timeout".to_string()), + }, + _ = wait_for_overlay_dictation_cancel(_inner, _current_session_id) => { + asr.cancel(); + return OverlayDictationTranscribeOutcome::Cancelled; + } + } + } #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { debug_assert!(!uses_global_timeout); @@ -357,7 +390,8 @@ pub(super) async fn transcribe_overlay_dictation_asr( Err(_) => Err("apple speech transcribe timeout".to_string()), } } - } + }; + OverlayDictationTranscribeOutcome::Done(result) } pub(super) async fn answer_qa_question_text( @@ -892,6 +926,30 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { } } } + ActiveAsr::ElevenLabs(e) => { + debug_assert!(uses_global_timeout); + let audio_secs = e.buffer_duration_ms() as f64 / 1000.0; + let timeout_duration = crate::asr::elevenlabs::transcribe_timeout(audio_secs); + tokio::select! { + result = tokio::time::timeout(timeout_duration, e.transcribe()) => match result { + Ok(Ok(raw)) => raw, + Ok(Err(error)) => { + log::error!("[coord] QA: ElevenLabs ASR transcribe failed: {error}"); + finish_qa_with_error(inner, format!("识别失败: {error}")); + return Err(error.to_string()); + } + Err(_) => { + finish_qa_with_error(inner, "识别超时".to_string()); + return Err("elevenlabs dynamic timeout".to_string()); + } + }, + _ = wait_for_qa_processing_cancel(inner) => { + e.cancel(); + finish_qa_idle_silently(inner); + return Ok(()); + } + } + } #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { debug_assert!(!uses_global_timeout); @@ -1238,6 +1296,27 @@ pub(super) fn finish_qa_idle_silently(inner: &Arc) { state.selection = None; } +async fn wait_for_qa_processing_cancel(inner: &Arc) { + loop { + if inner.qa_state.lock().cancelled { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +async fn wait_for_overlay_dictation_cancel(inner: &Arc, session_id: SessionId) { + loop { + { + let state = inner.state.lock(); + if state.cancelled || state.session_id != session_id { + return; + } + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + pub(super) fn cancel_qa_session(inner: &Arc) { let phase = inner.qa_state.lock().phase; if phase == QaPhase::Idle { @@ -1308,3 +1387,110 @@ where ) .await?) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::net::TcpListener; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread; + use std::time::Duration; + + #[tokio::test] + async fn overlay_elevenlabs_cancel_finishes_idle_without_error_capsule() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let request_started = Arc::new(AtomicBool::new(false)); + let release_server = Arc::new(AtomicBool::new(false)); + let server_started = Arc::clone(&request_started); + let server_release = Arc::clone(&release_server); + let server = thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if server_release.load(Ordering::SeqCst) { + return; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for ElevenLabs overlay request" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept ElevenLabs overlay request failed: {error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = [0u8; 4096]; + assert!(stream.read(&mut request).unwrap() > 0); + server_started.store(true, Ordering::SeqCst); + while !server_release.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(10)); + } + }); + + let coordinator = Coordinator::new(); + let session_id = new_session_id(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Listening; + state.cancelled = false; + state.session_id = session_id; + } + { + let mut state = coordinator.inner.qa_state.lock(); + state.phase = QaPhase::Processing; + state.cancelled = false; + } + + let asr = Arc::new(ElevenLabsBatchASR::new( + "synthetic-test-key".to_string(), + format!("http://{addr}/v1"), + crate::asr::elevenlabs::DEFAULT_MODEL.to_string(), + )); + crate::recorder::AudioConsumer::consume_pcm_chunk(asr.as_ref(), &vec![0u8; 32_000]); + super::super::resources::store_asr_for_session( + &coordinator.inner, + session_id, + ActiveAsr::ElevenLabs(asr), + ); + + let transcribe = tokio::spawn({ + let inner = Arc::clone(&coordinator.inner); + async move { take_current_dictation_transcript_for_qa(&inner).await } + }); + + let request_wait = tokio::time::timeout(Duration::from_secs(5), async { + while !request_started.load(Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + if request_wait.is_ok() { + cancel_session(&coordinator.inner); + } + + let transcribe_result = tokio::time::timeout(Duration::from_secs(2), transcribe).await; + release_server.store(true, Ordering::SeqCst); + server.join().unwrap(); + + request_wait.expect("ElevenLabs overlay request did not start"); + let result = transcribe_result + .expect("ElevenLabs overlay cancellation did not finish") + .expect("overlay transcription task panicked"); + assert!(matches!(result, Ok(None))); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + assert_eq!(coordinator.inner.qa_state.lock().phase, QaPhase::Idle); + assert!(!coordinator.inner.qa_state.lock().cancelled); + assert_eq!( + *coordinator.inner.last_capsule_state.lock(), + Some(CapsuleState::Idle) + ); + } +} diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index b38f6ead7..c766ffd95 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -69,6 +69,7 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) { ActiveAsr::Whisper(w) => w.cancel(), ActiveAsr::Mimo(m) => m.cancel(), ActiveAsr::DashScopeMultimodal(m) => m.cancel(), + ActiveAsr::ElevenLabs(e) => e.cancel(), ActiveAsr::Bailian(b) => b.cancel(), ActiveAsr::Qwen3Realtime(q) => q.cancel(), #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/endpoint_security.rs b/openless-all/app/src-tauri/src/endpoint_security.rs new file mode 100644 index 000000000..52a1ad6d0 --- /dev/null +++ b/openless-all/app/src-tauri/src/endpoint_security.rs @@ -0,0 +1,109 @@ +//! Shared validation for user-configurable HTTP endpoints. +//! +//! Provider validation and the real request path must call the same policy; +//! otherwise a saved endpoint can bypass the checks performed by the +//! "validate connection" button. + +use std::net::IpAddr; + +pub(crate) struct ResolvedEndpoint { + pub(crate) host: String, + pub(crate) addrs: Vec, +} + +pub(crate) fn validate_http_endpoint(raw: &str) -> anyhow::Result<()> { + let url = url::Url::parse(raw).map_err(|e| anyhow::anyhow!("endpoint 不是合法 URL:{e}"))?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("endpoint 缺少主机名"))? + .to_ascii_lowercase(); + + const METADATA_HOSTS: [&str; 2] = ["metadata.google.internal", "169.254.169.254"]; + if METADATA_HOSTS + .iter() + .any(|metadata| host.contains(metadata)) + { + anyhow::bail!("endpoint 指向云元数据服务,已拒绝:{host}"); + } + + let scheme = url.scheme(); + let bare_host = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host.as_str()); + + let Ok(ip) = bare_host.parse::() else { + if bare_host == "localhost" { + return Ok(()); + } + if scheme != "https" { + anyhow::bail!("endpoint 必须使用 https(仅 localhost / 局域网允许 http):{raw}"); + } + return Ok(()); + }; + + let canonical = match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + v4 => v4, + }; + + let is_lan = match canonical { + IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(), + IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00, + }; + if is_lan { + return Ok(()); + } + + let is_blocked = match canonical { + IpAddr::V4(v4) => { + let octets = v4.octets(); + let is_cgnat = octets[0] == 100 && (64..=127).contains(&octets[1]); + v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || is_cgnat + } + IpAddr::V6(v6) => { + let is_link_local = (v6.segments()[0] & 0xffc0) == 0xfe80; + v6.is_unspecified() || is_link_local + } + }; + if is_blocked { + anyhow::bail!("endpoint 指向保留/危险地址,已拒绝(防 SSRF):{ip}"); + } + + if scheme != "https" { + anyhow::bail!("endpoint 必须使用 https(仅 localhost / 局域网允许 http):{raw}"); + } + + Ok(()) +} + +/// Resolve a hostname once, validate every result, and return the addresses so +/// the HTTP client can pin this exact resolution and avoid DNS rebinding. +pub(crate) async fn resolve_http_endpoint(raw: &str) -> anyhow::Result> { + validate_http_endpoint(raw)?; + let url = url::Url::parse(raw)?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("endpoint 缺少主机名"))?; + if host.parse::().is_ok() { + return Ok(None); + } + let port = url + .port_or_known_default() + .ok_or_else(|| anyhow::anyhow!("endpoint 缺少端口"))?; + let addrs: Vec<_> = tokio::net::lookup_host((host, port)).await?.collect(); + if addrs.is_empty() { + anyhow::bail!("endpoint 主机名无法解析:{host}"); + } + for addr in &addrs { + let host = match addr.ip() { + IpAddr::V4(ip) => ip.to_string(), + IpAddr::V6(ip) => format!("[{ip}]"), + }; + validate_http_endpoint(&format!("{}://{host}", url.scheme()))?; + } + Ok(Some(ResolvedEndpoint { + host: host.to_string(), + addrs, + })) +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index e47f8ceac..884a4c2e5 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ mod correction; // Linux 退化为纯轮询兜底。仅桌面端。详见 issue #470。 #[cfg(not(mobile))] mod device_watch; +mod endpoint_security; mod external_url; #[cfg(not(mobile))] mod global_hotkey_runtime; diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 22dd7e27a..302a2b314 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -948,6 +948,24 @@ impl CredentialsVault { save_credentials(&root) } + pub fn get_for_asr_provider(id: &str, account: CredentialAccount) -> Result> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials(); + root.active.asr = id.to_string(); + Ok(lookup_account(&root, account)) + } + + pub fn set_for_asr_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let active = root.active.asr.clone(); + root.active.asr = id.to_string(); + let value = (!value.is_empty()).then(|| value.to_string()); + write_account(&mut root, account, value); + root.active.asr = active; + save_credentials(&root) + } + pub fn remove(account: CredentialAccount) -> Result<()> { let _guard = credentials_lock().lock(); let mut root = load_credentials_for_update()?; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index c990873ff..9ecb30bd5 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -762,7 +762,7 @@ export const en: typeof zhCN = { codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', - asrDesc: 'Used to turn speech into text in real time.', + asrDesc: 'Used to turn recorded speech into text.', presets: { ark: 'ARK (Volcengine Ark)', deepseek: 'DeepSeek', @@ -787,11 +787,13 @@ export const en: typeof zhCN = { asrWhisper: 'OpenAI Whisper (compatible)', asrOpenrouter: 'OpenRouter Whisper', asrXiaomiMimo: 'Xiaomi MiMo ASR', + asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)', asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', asrLocalQwen3: 'Local Qwen3-ASR', asrAppleSpeech: 'Apple Speech (macOS)', }, + elevenLabsUploadNotice: 'ElevenLabs uploads recorded audio to the configured endpoint for batch transcription.', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', @@ -1057,7 +1059,7 @@ export const en: typeof zhCN = { qqDesc: 'Search the group number in QQ to join, or scan the QR code.', copyQq: 'Copy group number', privacy: 'Privacy', - privacyDesc: 'All data stays on this device. Cloud APIs do not retain recordings.', + privacyDesc: 'Recordings may be sent to the cloud provider you configure for transcription.', localFirst: 'Local-first', linksTitle: 'Documentation', betaChannelLabel: 'Join Beta channel', @@ -1153,7 +1155,7 @@ export const en: typeof zhCN = { exportSuccess: 'Saved', exportFailed: 'Export failed', privacy: 'Privacy', - privacyDesc: 'All transcripts stay on this device. Cloud APIs are used only for real-time calls.', + privacyDesc: 'Transcripts stay on this device; configured cloud providers may receive recorded audio for transcription.', localFirst: 'Local-first', }, }, diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f3843fb13..ba15e5263 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -764,7 +764,7 @@ export const ja: typeof zhCN = { codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', - asrDesc: '口述をリアルタイムでテキストに転写。', + asrDesc: '録音した音声をテキストに文字起こしします。', presets: { ark: 'ARK(Volcengine Ark)', deepseek: 'DeepSeek', @@ -789,11 +789,13 @@ export const ja: typeof zhCN = { asrWhisper: 'OpenAI Whisper(互換)', asrOpenrouter: 'OpenRouter Whisper', asrXiaomiMimo: 'Xiaomi MiMo ASR', + asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)', asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)', asrLocalQwen3: 'ローカル Qwen3-ASR', asrAppleSpeech: 'Apple 音声認識 (macOS)', }, + elevenLabsUploadNotice: 'ElevenLabs は録音音声を設定済みのエンドポイントへアップロードしてバッチ文字起こしします。', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', @@ -1025,7 +1027,7 @@ export const ja: typeof zhCN = { qqDesc: 'QQ でグループ番号を検索して参加するか、QR コードをスキャンしてください。', copyQq: 'グループ番号をコピー', privacy: 'プライバシー', - privacyDesc: 'すべてのデータはローカルに保存。クラウド API は録音を保持しません。', + privacyDesc: '録音は、設定したクラウドプロバイダーへ文字起こしのため送信される場合があります。', localFirst: 'ローカル優先', linksTitle: 'ドキュメント', betaChannelLabel: 'Beta チャンネルに参加', @@ -1121,7 +1123,7 @@ export const ja: typeof zhCN = { exportSuccess: '保存しました', exportFailed: 'エクスポート失敗', privacy: 'プライバシー', - privacyDesc: 'すべての認識結果はローカルにのみ保存されます。クラウド API はリアルタイム呼び出しにのみ使用。', + privacyDesc: '認識結果はローカルに保存されます。設定したクラウドプロバイダーは文字起こしのため録音を受信する場合があります。', localFirst: 'ローカル優先', }, }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 14d7074e9..598564828 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -764,7 +764,7 @@ export const ko: typeof zhCN = { codexOAuthNotice: 'Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.', asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', - asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', + asrDesc: '녹음된 음성을 텍스트로 전사합니다.', presets: { ark: 'ARK (Volcengine Ark)', deepseek: 'DeepSeek', @@ -789,11 +789,13 @@ export const ko: typeof zhCN = { asrWhisper: 'OpenAI Whisper(호환)', asrOpenrouter: 'OpenRouter Whisper', asrXiaomiMimo: 'Xiaomi MiMo ASR', + asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)', asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)', asrLocalQwen3: '로컬 Qwen3-ASR', asrAppleSpeech: 'Apple 음성 (macOS)', }, + elevenLabsUploadNotice: 'ElevenLabs는 녹음 오디오를 설정된 엔드포인트에 업로드해 일괄 전사합니다.', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', @@ -1025,7 +1027,7 @@ export const ko: typeof zhCN = { qqDesc: 'QQ 에서 그룹 번호를 검색해 가입하거나 QR 코드로 입장하세요.', copyQq: '그룹 번호 복사', privacy: '프라이버시', - privacyDesc: '모든 데이터는 로컬에 저장됩니다. 클라우드 API 는 녹음을 보관하지 않습니다.', + privacyDesc: '녹음은 전사를 위해 설정한 클라우드 공급자에게 전송될 수 있습니다.', localFirst: '로컬 우선', linksTitle: '문서 링크', betaChannelLabel: 'Beta 채널 참여', @@ -1121,7 +1123,7 @@ export const ko: typeof zhCN = { exportSuccess: '저장됨', exportFailed: '내보내기 실패', privacy: '프라이버시', - privacyDesc: '모든 인식 결과는 로컬에만 저장됩니다. 클라우드 API 는 실시간 호출에만 사용됩니다.', + privacyDesc: '인식 결과는 로컬에 저장되며 설정한 클라우드 공급자가 전사를 위해 녹음을 수신할 수 있습니다.', localFirst: '로컬 우선', }, }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e7ee86f28..0bcba46b9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -760,7 +760,7 @@ export const zhCN = { codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', - asrDesc: '用于将口述实时转写为文本。', + asrDesc: '用于将录制的语音转写为文本。', presets: { ark: 'ARK(火山方舟)', deepseek: 'DeepSeek', @@ -785,11 +785,13 @@ export const zhCN = { asrWhisper: 'OpenAI Whisper(兼容)', asrOpenrouter: 'OpenRouter Whisper', asrXiaomiMimo: '小米 MiMo ASR', + asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', asrAppleSpeech: 'Apple 语音(macOS)', }, + elevenLabsUploadNotice: 'ElevenLabs 会将录音上传到所配置的端点进行批量转写。', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', @@ -1055,7 +1057,7 @@ export const zhCN = { qqDesc: '使用 QQ 搜索群号加入,或扫码进群。', copyQq: '复制群号', privacy: '隐私', - privacyDesc: '所有数据仅保存在本机,云端 API 不保留录音。', + privacyDesc: '录音可能会发送到你配置的云端服务商进行转写。', localFirst: '本地优先', linksTitle: '文档链接', betaChannelLabel: '加入 Beta 渠道', @@ -1151,7 +1153,7 @@ export const zhCN = { exportSuccess: '已保存', exportFailed: '导出失败', privacy: '隐私', - privacyDesc: '所有识别结果只保存在本机,云端 API 仅用于实时调用。', + privacyDesc: '识别结果保存在本机;所配置的云端服务商可能接收录音以完成转写。', localFirst: '本地优先', }, }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 47dbd434f..02527a291 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -762,7 +762,7 @@ export const zhTW: typeof zhCN = { codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', - asrDesc: '用於將口述實時轉寫爲文本。', + asrDesc: '用於將錄製的語音轉寫為文字。', presets: { ark: 'ARK(火山方舟)', deepseek: 'DeepSeek', @@ -787,11 +787,13 @@ export const zhTW: typeof zhCN = { asrWhisper: 'OpenAI Whisper(兼容)', asrOpenrouter: 'OpenRouter Whisper', asrXiaomiMimo: '小米 MiMo ASR', + asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', asrLocalQwen3: '本地 Qwen3-ASR', asrAppleSpeech: 'Apple 語音(macOS)', }, + elevenLabsUploadNotice: 'ElevenLabs 會將錄音上傳至已設定的端點進行批次轉寫。', volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', @@ -1023,7 +1025,7 @@ export const zhTW: typeof zhCN = { qqDesc: '使用 QQ 搜索羣號加入,或掃碼進羣。', copyQq: '複製羣號', privacy: '隱私', - privacyDesc: '所有資料僅保存在本機,雲端 API 不保留錄音。', + privacyDesc: '錄音可能會傳送至你設定的雲端服務商進行轉寫。', localFirst: '本地優先', linksTitle: '文件連結', betaChannelLabel: '加入 Beta 渠道', @@ -1119,7 +1121,7 @@ export const zhTW: typeof zhCN = { exportSuccess: '已儲存', exportFailed: '匯出失敗', privacy: '隱私', - privacyDesc: '所有識別結果只保存在本機,雲端 API 僅用於實時調用。', + privacyDesc: '識別結果保存在本機;已設定的雲端服務商可能接收錄音以完成轉寫。', localFirst: '本地優先', }, }, diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index 77d9edf0d..49ebe0ca9 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -18,8 +18,8 @@ export function getCredentials(): Promise { ) } -export function setCredential(account: string, value: string): Promise { - return invokeOrMock("set_credential", { account, value }, () => undefined) +export function setCredential(account: string, value: string, provider?: string): Promise { + return invokeOrMock("set_credential", { account, value, provider }, () => undefined) } export function setActiveAsrProvider(provider: string): Promise { @@ -38,10 +38,10 @@ export function setActiveLlmProvider(provider: string): Promise { ) } -export function readCredential(account: string): Promise { +export function readCredential(account: string, provider?: string): Promise { return invokeOrMock( "read_credential", - { account }, + { account, provider }, () => null, ) } diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index c0cd862ed..db2cc3038 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -288,18 +288,20 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { await updatePrefs(next); if (seq !== asrSwitchSeqRef.current) return; } - // asr.endpoint / asr.model 是所有 ASR 厂商共用的一对凭据槽(persistence.rs - // 未做 per-provider 隔离)。若只在槽空时填默认值,老用户从 A 厂商切到 B 厂商 - // 时槽里仍是 A 的 endpoint/model —— dropdown 切了、实际还打 A 的地址。改成切到 - // 有默认值的预设就强制覆盖,让切换真切到位。volcengine 走另一套凭据、本地引擎 - // 无 baseUrl,都被 if 守卫天然跳过。与 onLlmProviderChange 同款修法。 + // 凭据按 provider 隔离。切换回来时优先保留该 provider 已保存的自定义值, + // 仅在当前 entry 为空时写入 preset 默认值。 const preset = ASR_PRESETS.find(p => p.id === id); - if (preset && preset.baseUrl) { - await setCredential('asr.endpoint', preset.baseUrl); + const [storedEndpoint, storedModel] = await Promise.all([ + readCredential('asr.endpoint', id), + readCredential('asr.model', id), + ]); + if (seq !== asrSwitchSeqRef.current) return; + if (preset?.baseUrl && !storedEndpoint?.trim()) { + await setCredential('asr.endpoint', preset.baseUrl, id); if (seq !== asrSwitchSeqRef.current) return; } - if (preset && preset.model) { - await setCredential('asr.model', preset.model); + if (preset?.model && !storedModel?.trim()) { + await setCredential('asr.model', preset.model, id); if (seq !== asrSwitchSeqRef.current) return; } setCommittedAsrProvider(id); @@ -446,6 +448,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { key={`${committedAsrProvider}:app_key`} label={t('settings.providers.volcengineAppKeyLabel')} account="volcengine.app_key" + provider={committedAsrProvider} mono mask /> @@ -453,6 +456,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { key={`${committedAsrProvider}:access_key`} label={t('settings.providers.volcengineAccessKeyLabel')} account="volcengine.access_key" + provider={committedAsrProvider} mono mask /> @@ -460,6 +464,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { key={`${committedAsrProvider}:resource_id`} label={t('settings.providers.volcengineResourceIdLabel')} account="volcengine.resource_id" + provider={committedAsrProvider} mono placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
@@ -473,12 +478,14 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { null ) : ( <> - + {/* 统一百炼保留 endpoint 供用户选择区域或工作空间域名;后端按模型转换协议与路径。 */} {unifiedBailian && ( @@ -490,6 +497,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { key={`${committedAsrProvider}:vocabulary_id`} label={t('settings.providers.bailianVocabularyIdLabel')} account="asr.vocabulary_id" + provider={committedAsrProvider} mono placeholder="vocab-..." /> @@ -498,8 +506,13 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
)} + {committedAsrProvider === 'elevenlabs' && ( +
+ {t('settings.providers.elevenLabsUploadNotice')} +
+ )} {/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */} - setAsrModelRevision(v => v + 1)} /> + setAsrModelRevision(v => v + 1)} /> )} @@ -556,7 +569,7 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -function ProviderTools({ kind, modelAccount, onModelSelected, showFetchModels = true }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetchModels = true }: { kind: 'llm' | 'asr'; modelAccount: string; provider?: string; onModelSelected: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); const mobile = useMobileLayout(); const [models, setModels] = useState([]); @@ -613,7 +626,7 @@ function ProviderTools({ kind, modelAccount, onModelSelected, showFetchModels = const applyModel = async (model: string) => { setResult('loading', t('common.saving')); try { - await setCredential(modelAccount, model); + await setCredential(modelAccount, model, provider); setSelectedModel(model); onModelSelected(); setResult('success', t('settings.providers.modelSaved', { model })); @@ -678,6 +691,7 @@ type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveEr interface CredentialFieldProps { label: string; account: string; + provider?: string; placeholder?: string; mono?: boolean; mask?: boolean; @@ -686,7 +700,7 @@ interface CredentialFieldProps { onValueChange?: (value: string) => void; } -function CredentialField({ label, account, placeholder, mono, mask, defaultValue, trailing, onValueChange }: CredentialFieldProps) { +function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange }: CredentialFieldProps) { const { t } = useTranslation(); const mobile = useMobileLayout(); const [value, setValue] = useState(''); @@ -709,7 +723,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue clearTimeout(debounceRef.current); debounceRef.current = null; } - readCredential(account) + readCredential(account, provider) .then(v => { if (cancelled) return; setValue(v ?? ''); @@ -726,7 +740,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue return () => { cancelled = true; }; - }, [account, onValueChange]); + }, [account, provider, onValueChange]); useEffect(() => { mountedRef.current = true; @@ -763,7 +777,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue setStatus('saving'); emitSaved('saving', t('common.saving')); try { - await setCredential(account, v); + await setCredential(account, v, provider); if (!mountedRef.current) return; setDirty(false); showTemporaryStatus('saved'); diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index e185ac170..396e4f59d 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -212,6 +212,7 @@ export const inputStyle: CSSProperties = { // 3. i18n 的 `settings.providers.presets.` 补各语言文案。 export const ASR_PRESETS = [ { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, + { id: 'elevenlabs', nameKey: 'asrElevenLabs', baseUrl: 'https://api.elevenlabs.io/v1', model: 'scribe_v2' }, { id: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' }, // Qwen3-ASR-Flash 实时:OpenAI Realtime 风格 WS(/api-ws/v1/realtime), // 与上面经典 inference 协议不同,由 asr/qwen_realtime.rs 专用 client 处理。