diff --git a/README.md b/README.md index 1976a473..e30d1f39 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,9 @@ Every item below is one more layer sedimented into a default — a capability yo - 🎨 **Style Pack Marketplace** — browse, install, and like community **style packs** from the in-app Marketplace, and publish your own (custom system prompt per pack, switchable by hotkey). Backed by a moderated marketplace backend; uploads are reviewed before they go public. - ⚡ **Streaming insertion** — polished text is written to the cursor character by character to reduce perceived latency, with an automatic one-shot-paste fallback. Toggle in Settings → Recording. - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. -- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). +- **Cloud ASR**: Volcengine streaming ASR (bigasr), Tencent Cloud Hunyuan realtime ASR (Hy-ASR), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, Tencent Cloud TokenHub, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index 49af001c..2d355715 100644 --- a/README.zh.md +++ b/README.zh.md @@ -202,9 +202,9 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - 🎨 **风格包市场**——在应用内的 Marketplace 浏览、安装、点赞社区**风格包**,并发布自己的(每个包一套自定义系统提示词,可用快捷键切换)。由经过审核的市场后端支撑;上传内容公开前会经过审核。 - ⚡ **流式插入**——润色后的文本逐字符写入光标以降低感知延迟,并带有自动的一次性粘贴回退。可在 设置 → 录音 中切换。 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 -- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 +- **云端 ASR**:Volcengine 流式 ASR(bigasr)、腾讯云混元实时 ASR(Hy-ASR)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、腾讯云 TokenHub、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/src-tauri/src/asr/mod.rs b/openless-all/app/src-tauri/src/asr/mod.rs index 7b562fe7..75d9860b 100644 --- a/openless-all/app/src-tauri/src/asr/mod.rs +++ b/openless-all/app/src-tauri/src/asr/mod.rs @@ -14,6 +14,7 @@ pub mod mimo; pub mod pcm; pub mod qwen_realtime; pub mod stepfun_realtime; +pub mod tencent_cloud; pub mod volcengine; pub mod wav; pub mod whisper; @@ -25,6 +26,7 @@ pub use elevenlabs::ElevenLabsBatchASR; pub use mimo::MimoBatchASR; pub use qwen_realtime::{Qwen3RealtimeASR, Qwen3RealtimeCredentials}; pub use stepfun_realtime::{StepfunRealtimeASR, StepfunRealtimeCredentials}; +pub use tencent_cloud::{TencentCloudCredentials, TencentCloudStreamingASR}; pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR}; pub use whisper::WhisperBatchASR; pub use xfyun::{XfyunCredentials, XfyunStreamingASR}; diff --git a/openless-all/app/src-tauri/src/asr/tencent_cloud.rs b/openless-all/app/src-tauri/src/asr/tencent_cloud.rs new file mode 100644 index 00000000..4148305b --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/tencent_cloud.rs @@ -0,0 +1,676 @@ +//! 腾讯云实时语音识别 WebSocket 客户端。 +//! +//! 官方文档:https://cloud.tencent.com/document/api/1093/48982 +//! +//! 协议要点: +//! - 端点:`wss://asr.cloud.tencent.com/asr/v2/`; +//! - 鉴权:查询参数按字典序拼接后,以 SecretKey 做 HMAC-SHA1,再 Base64; +//! - 音频:16 kHz / 16-bit / 单声道 PCM,每 200ms 发送 6400 bytes; +//! - 收尾:发送文本消息 `{"type":"end"}`,等待 `final=1`; +//! - 默认模型:`Hy-ASR-3.0-preview`(腾讯云当前最新混元 ASR Preview)。 + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine; +use futures_util::{SinkExt, StreamExt}; +use hmac::{Hmac, Mac}; +use parking_lot::Mutex as ParkingMutex; +use serde_json::Value; +use sha1::Sha1; +use tokio::net::TcpStream; +use tokio::runtime::Handle; +use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use uuid::Uuid; + +use super::{AudioConsumer, RawTranscript}; + +pub const PROVIDER_ID: &str = "tencent-cloud"; +pub const DEFAULT_ENDPOINT: &str = "wss://asr.cloud.tencent.com/asr/v2"; +pub const DEFAULT_MODEL: &str = "Hy-ASR-3.0-preview"; +pub const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400; + +const BYTES_PER_MS: u64 = 32; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); +const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(15); + +type WsStream = WebSocketStream>; +type WsSink = futures_util::stream::SplitSink; +type SharedWriter = Arc>>; + +#[derive(Clone, Debug)] +pub struct TencentCloudCredentials { + pub app_id: String, + pub secret_id: String, + pub secret_key: String, + pub model: String, +} + +impl TencentCloudCredentials { + pub fn auth_ok(&self) -> bool { + !self.app_id.trim().is_empty() + && !self.secret_id.trim().is_empty() + && !self.secret_key.trim().is_empty() + } + + fn resolved_model(&self) -> &str { + let model = self.model.trim(); + if model.is_empty() { + DEFAULT_MODEL + } else { + model + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum TencentCloudASRError { + #[error("credentials missing")] + CredentialsMissing, + #[error("连接失败: {0}")] + ConnectionFailed(String), + #[error("凭据被拒或服务未开通({0})")] + AuthRejected(String), + #[error("账户不可用({0})")] + AccountUnavailable(String), + #[error("并发受限({0})")] + RateLimited(String), + #[error("识别失败: {0}")] + TaskFailed(String), + #[error("no final result")] + NoFinalResult, + #[error("final result timed out")] + FinalResultTimeout, +} + +#[derive(Default)] +struct SyncState { + pending_audio: Vec, + bytes_sent: u64, + started: bool, + finished: bool, + runtime: Option, + final_tx: Option>>, + final_segments: BTreeMap, + partial_segments: BTreeMap, + last_result_text: String, +} + +pub struct TencentCloudStreamingASR { + credentials: TencentCloudCredentials, + state: ParkingMutex, + writer: SharedWriter, + final_rx: ParkingMutex>>>, + handshake_tx: ParkingMutex>>>, + audio_tx: ParkingMutex>>>, + pending_sends: Arc, + send_done: Arc, +} + +impl TencentCloudStreamingASR { + pub fn new(credentials: TencentCloudCredentials) -> Self { + Self { + credentials, + state: ParkingMutex::new(SyncState::default()), + writer: Arc::new(AsyncMutex::new(None)), + final_rx: ParkingMutex::new(None), + handshake_tx: ParkingMutex::new(None), + audio_tx: ParkingMutex::new(None), + pending_sends: Arc::new(AtomicUsize::new(0)), + send_done: Arc::new(Notify::new()), + } + } + + pub fn connect_url(&self) -> String { + connect_url_at( + &self.credentials, + chrono::Utc::now().timestamp(), + random_nonce(), + Uuid::new_v4().to_string(), + ) + } + + pub async fn open_session(self: &Arc) -> Result<(), TencentCloudASRError> { + if !self.credentials.auth_ok() { + return Err(TencentCloudASRError::CredentialsMissing); + } + + let request = self + .connect_url() + .into_client_request() + .map_err(|error| TencentCloudASRError::ConnectionFailed(error.to_string()))?; + let (ws, _) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) + .await + .map_err(|_| { + TencentCloudASRError::ConnectionFailed(format!( + "连接超时({} ms)", + CONNECT_TIMEOUT.as_millis() + )) + })? + .map_err(|error| TencentCloudASRError::ConnectionFailed(error.to_string()))?; + let (write, read) = ws.split(); + *self.writer.lock().await = Some(write); + + let (final_tx, final_rx) = oneshot::channel(); + let (audio_tx, mut audio_rx) = mpsc::unbounded_channel::>(); + let (handshake_tx, handshake_rx) = oneshot::channel(); + { + let mut state = self.state.lock(); + *state = SyncState::default(); + state.runtime = Some(Handle::current()); + state.final_tx = Some(final_tx); + } + *self.final_rx.lock() = Some(final_rx); + *self.audio_tx.lock() = Some(audio_tx); + *self.handshake_tx.lock() = Some(handshake_tx); + self.pending_sends.store(0, Ordering::SeqCst); + + let writer = Arc::clone(&self.writer); + let pending_sends = Arc::clone(&self.pending_sends); + let send_done = Arc::clone(&self.send_done); + tokio::spawn(async move { + while let Some(chunk) = audio_rx.recv().await { + if let Err(error) = send_binary(&writer, chunk).await { + log::error!("[tencent-cloud-asr] audio frame send failed: {error}"); + } + if pending_sends.fetch_sub(1, Ordering::SeqCst) == 1 { + send_done.notify_waiters(); + } + } + }); + + let weak_self = Arc::downgrade(self); + tokio::spawn(async move { + let mut read = read; + while let Some(message) = read.next().await { + let Some(this) = weak_self.upgrade() else { + break; + }; + match message { + Ok(Message::Text(text)) => { + if !this.handle_text_message(&text) { + break; + } + } + Ok(Message::Close(_)) => { + this.finish_on_close(); + break; + } + Ok(_) => {} + Err(error) => { + log::error!("[tencent-cloud-asr] receive loop error: {error}"); + this.finish_with_partial_or_error(TencentCloudASRError::ConnectionFailed( + error.to_string(), + )); + break; + } + } + } + }); + + match tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake_rx).await { + Ok(Ok(Ok(()))) => Ok(()), + Ok(Ok(Err(error))) => { + self.cancel(); + Err(error) + } + Ok(Err(_)) => { + self.cancel(); + Err(TencentCloudASRError::ConnectionFailed( + "握手通道提前关闭".to_string(), + )) + } + Err(_) => { + self.cancel(); + Err(TencentCloudASRError::ConnectionFailed(format!( + "握手超时({} ms)", + HANDSHAKE_TIMEOUT.as_millis() + ))) + } + } + } + + pub async fn send_last_frame(&self) -> Result<(), TencentCloudASRError> { + self.flush_pending_audio(); + self.wait_for_pending_sends().await; + send_text(&self.writer, r#"{"type":"end"}"#).await + } + + pub async fn await_final_result(&self) -> Result { + self.await_final_result_with_timeout(FINAL_RESULT_TIMEOUT) + .await + } + + pub async fn await_final_result_with_timeout( + &self, + timeout: Duration, + ) -> Result { + let Some(receiver) = self.final_rx.lock().take() else { + return Err(TencentCloudASRError::NoFinalResult); + }; + match tokio::time::timeout(timeout, receiver).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(TencentCloudASRError::NoFinalResult), + Err(_) => { + log::error!( + "[tencent-cloud-asr] final result timed out after {} ms", + timeout.as_millis() + ); + self.cancel(); + Err(TencentCloudASRError::FinalResultTimeout) + } + } + } + + pub fn cancel(&self) { + let runtime = { + let mut state = self.state.lock(); + state.pending_audio.clear(); + state.runtime.clone() + }; + *self.handshake_tx.lock() = None; + *self.audio_tx.lock() = None; + if let Some(runtime) = runtime { + let writer = Arc::clone(&self.writer); + runtime.spawn(async move { + let mut guard = writer.lock().await; + if let Some(mut ws) = guard.take() { + let _ = ws.close().await; + } + }); + } + self.signal_error(TencentCloudASRError::NoFinalResult); + } + + fn flush_pending_audio(&self) { + let leftover = { + let mut state = self.state.lock(); + if state.pending_audio.is_empty() { + return; + } + let leftover = std::mem::take(&mut state.pending_audio); + state.bytes_sent += leftover.len() as u64; + leftover + }; + self.enqueue_audio(leftover); + } + + async fn wait_for_pending_sends(&self) { + let deadline = Instant::now() + Duration::from_millis(1_500); + while self.pending_sends.load(Ordering::SeqCst) > 0 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + log::warn!( + "[tencent-cloud-asr] {} audio frames still pending at end", + self.pending_sends.load(Ordering::SeqCst) + ); + break; + } + let _ = tokio::time::timeout(remaining, self.send_done.notified()).await; + } + } + + fn enqueue_audio(&self, chunk: Vec) { + let Some(sender) = self.audio_tx.lock().as_ref().cloned() else { + return; + }; + self.pending_sends.fetch_add(1, Ordering::SeqCst); + if sender.send(chunk).is_err() { + if self.pending_sends.fetch_sub(1, Ordering::SeqCst) == 1 { + self.send_done.notify_waiters(); + } + } + } + + fn handle_text_message(&self, text: &str) -> bool { + let value: Value = match serde_json::from_str(text) { + Ok(value) => value, + Err(error) => { + log::warn!("[tencent-cloud-asr] invalid json event: {error}"); + return true; + } + }; + let code = value.get("code").and_then(Value::as_i64).unwrap_or(-1); + if code != 0 { + let message = value + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error"); + let error = classify_server_error(code, message); + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Err(error.clone())); + } + self.finish_error(error); + return false; + } + + self.mark_started(); + if let Some(result) = value.get("result") { + self.record_result(result); + } + if value.get("final").and_then(Value::as_i64) == Some(1) { + self.finish_success(); + return false; + } + true + } + + fn mark_started(&self) { + let mut state = self.state.lock(); + if !state.started { + state.started = true; + state.runtime = Some(Handle::current()); + } + drop(state); + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Ok(())); + } + } + + fn record_result(&self, result: &Value) { + let Some(text) = result.get("voice_text_str").and_then(Value::as_str) else { + return; + }; + let text = text.trim(); + if text.is_empty() { + return; + } + let index = result.get("index").and_then(Value::as_i64).unwrap_or(0); + let slice_type = result + .get("slice_type") + .and_then(Value::as_i64) + .unwrap_or(1); + let mut state = self.state.lock(); + state.last_result_text = text.to_string(); + if slice_type == 2 { + state.final_segments.insert(index, text.to_string()); + state.partial_segments.remove(&index); + } else { + state.partial_segments.insert(index, text.to_string()); + } + } + + fn finish_on_close(&self) { + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Err(TencentCloudASRError::ConnectionFailed( + "连接在握手完成前被关闭".to_string(), + ))); + return; + } + self.finish_with_partial_or_error(TencentCloudASRError::NoFinalResult); + } + + fn finish_with_partial_or_error(&self, error: TencentCloudASRError) { + let has_text = { + let state = self.state.lock(); + !state.last_result_text.trim().is_empty() + || !state.final_segments.is_empty() + || !state.partial_segments.is_empty() + }; + if has_text { + self.finish_success(); + } else { + self.finish_error(error); + } + } + + fn finish_success(&self) { + let (sender, text, duration_ms) = { + let mut state = self.state.lock(); + if state.finished { + return; + } + state.finished = true; + state.pending_audio.clear(); + let mut segments = state.final_segments.clone(); + for (index, text) in &state.partial_segments { + segments.entry(*index).or_insert_with(|| text.clone()); + } + let text = if segments.is_empty() { + state.last_result_text.clone() + } else { + super::mimo::join_transcript_chunks( + &segments.into_values().collect::>(), + ) + }; + (state.final_tx.take(), text, state.bytes_sent / BYTES_PER_MS) + }; + *self.audio_tx.lock() = None; + if let Some(sender) = sender { + let _ = sender.send(Ok(RawTranscript { text, duration_ms })); + } + self.close_writer(); + } + + fn signal_error(&self, error: TencentCloudASRError) { + let sender = { + let mut state = self.state.lock(); + if state.finished { + return; + } + state.finished = true; + state.final_tx.take() + }; + *self.audio_tx.lock() = None; + if let Some(sender) = sender { + let _ = sender.send(Err(error)); + } + } + + fn finish_error(&self, error: TencentCloudASRError) { + if let Some(sender) = self.handshake_tx.lock().take() { + let _ = sender.send(Err(error.clone())); + } + self.signal_error(error); + self.close_writer(); + } + + fn close_writer(&self) { + let writer = Arc::clone(&self.writer); + if let Some(runtime) = self.state.lock().runtime.clone() { + runtime.spawn(async move { + let mut guard = writer.lock().await; + if let Some(mut ws) = guard.take() { + let _ = ws.close().await; + } + }); + } + } +} + +impl AudioConsumer for TencentCloudStreamingASR { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + let chunks = { + let mut state = self.state.lock(); + if !state.started || state.finished { + return; + } + state.pending_audio.extend_from_slice(pcm); + let mut chunks = Vec::new(); + while state.pending_audio.len() >= TARGET_AUDIO_CHUNK_BYTES { + let chunk = state + .pending_audio + .drain(..TARGET_AUDIO_CHUNK_BYTES) + .collect::>(); + state.bytes_sent += chunk.len() as u64; + chunks.push(chunk); + } + chunks + }; + for chunk in chunks { + self.enqueue_audio(chunk); + } + } +} + +async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), TencentCloudASRError> { + let mut guard = writer.lock().await; + let Some(ws) = guard.as_mut() else { + return Err(TencentCloudASRError::ConnectionFailed( + "websocket not open".to_string(), + )); + }; + ws.send(Message::Binary(data)) + .await + .map_err(|error| TencentCloudASRError::ConnectionFailed(error.to_string())) +} + +async fn send_text(writer: &SharedWriter, data: &str) -> Result<(), TencentCloudASRError> { + let mut guard = writer.lock().await; + let Some(ws) = guard.as_mut() else { + return Err(TencentCloudASRError::ConnectionFailed( + "websocket not open".to_string(), + )); + }; + ws.send(Message::Text(data.to_string())) + .await + .map_err(|error| TencentCloudASRError::ConnectionFailed(error.to_string())) +} + +fn connect_url_at( + credentials: &TencentCloudCredentials, + timestamp: i64, + nonce: u32, + voice_id: String, +) -> String { + let endpoint = format!("{}/{}", DEFAULT_ENDPOINT, credentials.app_id.trim()); + let parsed = url::Url::parse(&endpoint).expect("static Tencent Cloud endpoint parses"); + let host_and_path = format!( + "{}{}", + parsed.host_str().expect("Tencent Cloud endpoint has host"), + parsed.path() + ); + let params = BTreeMap::from([ + ( + "engine_model_type".to_string(), + credentials.resolved_model().to_string(), + ), + ("expired".to_string(), (timestamp + 86_400).to_string()), + ("filter_dirty".to_string(), "0".to_string()), + ("filter_empty_result".to_string(), "1".to_string()), + ("filter_modal".to_string(), "1".to_string()), + ("filter_punc".to_string(), "0".to_string()), + ("needvad".to_string(), "1".to_string()), + ("nonce".to_string(), nonce.to_string()), + ( + "secretid".to_string(), + credentials.secret_id.trim().to_string(), + ), + ("timestamp".to_string(), timestamp.to_string()), + ("voice_format".to_string(), "1".to_string()), + ("voice_id".to_string(), voice_id), + ]); + let query = query_string(¶ms); + let signing_text = format!("{host_and_path}?{query}"); + let signature = compute_signature(&signing_text, &credentials.secret_key); + format!("{endpoint}?{query}&signature={}", url_encode(&signature)) +} + +fn query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") +} + +fn url_encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +pub fn compute_signature(signing_text: &str, secret_key: &str) -> String { + let mut mac = Hmac::::new_from_slice(secret_key.trim().as_bytes()) + .expect("HMAC accepts any key length"); + mac.update(signing_text.as_bytes()); + base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()) +} + +fn random_nonce() -> u32 { + let bytes = Uuid::new_v4().into_bytes(); + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 1_000_000_000 + 1 +} + +fn classify_server_error(code: i64, message: &str) -> TencentCloudASRError { + let detail = format!("{code} {message}"); + match code { + 4002 | 4003 => TencentCloudASRError::AuthRejected(detail), + 4004 | 4005 => TencentCloudASRError::AccountUnavailable(detail), + 4006 => TencentCloudASRError::RateLimited(detail), + _ => TencentCloudASRError::TaskFailed(detail), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credentials() -> TencentCloudCredentials { + TencentCloudCredentials { + app_id: "1259220000".into(), + secret_id: "test-secret-id".into(), + secret_key: "key".into(), + model: DEFAULT_MODEL.into(), + } + } + + #[test] + fn hmac_sha1_signature_matches_known_vector() { + assert_eq!( + compute_signature("The quick brown fox jumps over the lazy dog", "key"), + "3nybhbi3iqa8ino29wqQcBydtNk=" + ); + } + + #[test] + fn connect_url_uses_sorted_signed_query_and_encodes_signature() { + let url = connect_url_at(&credentials(), 1_700_000_000, 42, "voice-id".into()); + assert!(url.starts_with( + "wss://asr.cloud.tencent.com/asr/v2/1259220000?engine_model_type=Hy-ASR-3.0-preview&expired=1700086400" + )); + assert!(url.contains("&nonce=42&secretid=test-secret-id×tamp=1700000000")); + assert!(url.contains("&voice_format=1&voice_id=voice-id&signature=")); + assert!(!url.ends_with('=')); + } + + #[test] + fn stable_segments_replace_partials_and_keep_order() { + let asr = TencentCloudStreamingASR::new(credentials()); + asr.record_result(&serde_json::json!({ + "slice_type": 1, + "index": 1, + "voice_text_str": "世界" + })); + asr.record_result(&serde_json::json!({ + "slice_type": 2, + "index": 0, + "voice_text_str": "你好" + })); + let state = asr.state.lock(); + assert_eq!( + state.final_segments.get(&0).map(String::as_str), + Some("你好") + ); + assert_eq!( + state.partial_segments.get(&1).map(String::as_str), + Some("世界") + ); + } + + #[test] + fn server_errors_are_classified_for_actionable_messages() { + assert!(matches!( + classify_server_error(4002, "auth failed"), + TencentCloudASRError::AuthRejected(_) + )); + assert!(matches!( + classify_server_error(4006, "concurrency exceeded"), + TencentCloudASRError::RateLimited(_) + )); + } +} diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 3a535688..081dcd50 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -116,6 +116,11 @@ pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnap AsrConfiguredFields::XfyunAppKey => { configured(&snap.xfyun_app_id) && configured(&snap.xfyun_api_key) } + AsrConfiguredFields::TencentCloudKeys => { + configured(&snap.tencent_cloud_app_id) + && configured(&snap.tencent_cloud_secret_id) + && configured(&snap.tencent_cloud_secret_key) + } } } @@ -431,7 +436,10 @@ fn account_channel_kind(account: CredentialAccount) -> ChannelKind { | CredentialAccount::AsrVocabularyId | CredentialAccount::AsrAdvancedConfig | CredentialAccount::XfyunAppId - | CredentialAccount::XfyunApiKey => ChannelKind::Asr, + | CredentialAccount::XfyunApiKey + | CredentialAccount::TencentCloudAppId + | CredentialAccount::TencentCloudSecretId + | CredentialAccount::TencentCloudSecretKey => ChannelKind::Asr, // Omni 凭据走独立命名空间、从不按渠道 id 定位(前端写入不带 provider); // 映射到 Asr 只为穷尽 match,实际调用点不可达。 CredentialAccount::OmniApiKey @@ -465,6 +473,9 @@ fn parse_account(s: &str) -> Result { "asr.advanced_config" => Ok(CredentialAccount::AsrAdvancedConfig), "xfyun.app_id" => Ok(CredentialAccount::XfyunAppId), "xfyun.api_key" => Ok(CredentialAccount::XfyunApiKey), + "tencent_cloud.app_id" => Ok(CredentialAccount::TencentCloudAppId), + "tencent_cloud.secret_id" => Ok(CredentialAccount::TencentCloudSecretId), + "tencent_cloud.secret_key" => Ok(CredentialAccount::TencentCloudSecretKey), "omni.api_key" => Ok(CredentialAccount::OmniApiKey), "omni.endpoint" => Ok(CredentialAccount::OmniEndpoint), "omni.model" => Ok(CredentialAccount::OmniModel), diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index e654d8fa..365268d4 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -358,6 +358,25 @@ mod tests { &whisper_keyless_ready )); + let tencent_cloud = CredentialsSnapshot { + tencent_cloud_app_id: Some("app".into()), + tencent_cloud_secret_id: Some("secret-id".into()), + tencent_cloud_secret_key: Some("secret-key".into()), + ..snapshot() + }; + assert!(asr_configured_for_provider( + crate::asr::tencent_cloud::PROVIDER_ID, + &tencent_cloud + )); + assert!(!asr_configured_for_provider( + crate::asr::tencent_cloud::PROVIDER_ID, + &CredentialsSnapshot { + tencent_cloud_app_id: Some("app".into()), + tencent_cloud_secret_id: Some("secret-id".into()), + ..snapshot() + } + )); + #[cfg(any(target_os = "macos", target_os = "linux"))] { assert!(asr_configured_for_provider( diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 762771c2..353f3da6 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -457,6 +457,9 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { if active_asr == crate::asr::xfyun::PROVIDER_ID { return validate_xfyun_asr_provider(scope).await; } + if active_asr == crate::asr::tencent_cloud::PROVIDER_ID { + return validate_tencent_cloud_asr_provider(scope).await; + } // 火山走专属 WS 协议与 volcengine.* 凭据槽位,不能落进下面的 OpenAI 兼容 // HTTP 兜底(那条路只认 asr.api_key —— 火山从不写入的槽位,填对也必报 // 「API Key 为空」)。 @@ -523,6 +526,56 @@ async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String } } +/// 腾讯云实时 ASR 验证:真实签名建连,发送 1 秒静音后正常收尾。 +async fn validate_tencent_cloud_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let app_id = scope + .get(CredentialAccount::TencentCloudAppId) + .map_err(|error| error.to_string())? + .unwrap_or_default(); + let secret_id = scope + .get(CredentialAccount::TencentCloudSecretId) + .map_err(|error| error.to_string())? + .unwrap_or_default(); + let secret_key = scope + .get(CredentialAccount::TencentCloudSecretKey) + .map_err(|error| error.to_string())? + .unwrap_or_default(); + if app_id.trim().is_empty() { + return Err("腾讯云 AppID 为空".to_string()); + } + if secret_id.trim().is_empty() { + return Err("腾讯云 SecretID 为空".to_string()); + } + if secret_key.trim().is_empty() { + return Err("腾讯云 SecretKey 为空".to_string()); + } + let model = scope + .get(CredentialAccount::AsrModel) + .map_err(|error| error.to_string())? + .filter(|model| !model.trim().is_empty()) + .unwrap_or_else(|| crate::asr::tencent_cloud::DEFAULT_MODEL.to_string()); + let asr = std::sync::Arc::new(crate::asr::TencentCloudStreamingASR::new( + crate::asr::TencentCloudCredentials { + app_id, + secret_id, + secret_key, + model, + }, + )); + asr.open_session().await.map_err(|error| error.to_string())?; + crate::asr::AudioConsumer::consume_pcm_chunk( + &*asr, + &vec![0u8; crate::asr::tencent_cloud::TARGET_AUDIO_CHUNK_BYTES * 5], + ); + asr.send_last_frame() + .await + .map_err(|error| error.to_string())?; + match asr.await_final_result().await { + Ok(_) | Err(crate::asr::tencent_cloud::TencentCloudASRError::NoFinalResult) => Ok(()), + Err(error) => Err(error.to_string()), + } +} + /// 按鉴权模式检查火山凭据完整性,返回给前端映射多语言文案的哨兵串 /// (providerErrorMessage 识别)。与 [`VolcengineAuthMode::auth_ok`] 同一 /// trim 语义,但区分缺哪一项,让用户直接知道该补哪个输入框。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index dcb38db6..1c9ecc4a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -27,7 +27,8 @@ use crate::asr::local::{ use crate::asr::{ BailianCredentials, BailianRealtimeASR, DashScopeMultimodalASR, DictionaryHotword, ElevenLabsBatchASR, MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials, RawTranscript, - VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, + TencentCloudCredentials, TencentCloudStreamingASR, VolcengineCredentials, + VolcengineStreamingASR, WhisperBatchASR, }; use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; use crate::coordinator_state::{ @@ -642,6 +643,8 @@ enum ActiveAsr { StepfunRealtime(Arc), /// 讯飞开放平台实时语音转写(RTASR)流式。 Xfyun(Arc), + /// 腾讯云实时语音识别(混元 Hy-ASR)。 + TencentCloud(Arc), #[cfg(target_os = "windows")] FoundryLocalWhisper(Arc), /// Windows sherpa-onnx 本地 ASR(offline batch + 实验 online streaming)。 @@ -691,6 +694,7 @@ pub(crate) enum ActiveAsrProviderKind { WhisperCompatible, Volcengine, Xfyun, + TencentCloud, } /// 「能否开始一次会话」所需的凭据形态(对应 `ensure_asr_credentials` 预检门)。 @@ -702,6 +706,8 @@ pub(crate) enum AsrPreflightCredential { VolcAppKey, /// 需要讯飞 AppID + APIKey。 XfyunAppKey, + /// 需要腾讯云 AppID + SecretID + SecretKey。 + TencentCloudKeys, } /// 概览页「已配置 / 未配置」状态所需的字段(对应 `asr_configured_for_provider`)。 @@ -718,6 +724,8 @@ pub(crate) enum AsrConfiguredFields { VolcAppKey, /// 讯飞 AppID + APIKey。 XfyunAppKey, + /// 腾讯云 AppID + SecretID + SecretKey。 + TencentCloudKeys, } impl ActiveAsrProviderKind { @@ -732,6 +740,7 @@ impl ActiveAsrProviderKind { | ActiveAsrProviderKind::WhisperCompatible => AsrPreflightCredential::AsrApiKey, ActiveAsrProviderKind::Volcengine => AsrPreflightCredential::VolcAppKey, ActiveAsrProviderKind::Xfyun => AsrPreflightCredential::XfyunAppKey, + ActiveAsrProviderKind::TencentCloud => AsrPreflightCredential::TencentCloudKeys, } } @@ -751,6 +760,7 @@ impl ActiveAsrProviderKind { } ActiveAsrProviderKind::Volcengine => AsrConfiguredFields::VolcAppKey, ActiveAsrProviderKind::Xfyun => AsrConfiguredFields::XfyunAppKey, + ActiveAsrProviderKind::TencentCloud => AsrConfiguredFields::TencentCloudKeys, } } } @@ -772,6 +782,8 @@ pub(crate) fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { ActiveAsrProviderKind::WhisperCompatible } else if is_xfyun_provider(id) { ActiveAsrProviderKind::Xfyun + } else if is_tencent_cloud_provider(id) { + ActiveAsrProviderKind::TencentCloud } else { ActiveAsrProviderKind::Volcengine } @@ -3127,6 +3139,13 @@ impl Coordinator { .map_err(|_| "重新转录超时".to_string())? .map_err(|e| e.to_string())? } + ActiveAsr::TencentCloud(asr) => { + asr.send_last_frame().await.map_err(|e| e.to_string())?; + tokio::time::timeout(timeout, asr.await_final_result()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } ActiveAsr::Whisper(w) => tokio::time::timeout(timeout, w.transcribe()) .await .map_err(|_| "重新转录超时".to_string())? @@ -3858,6 +3877,31 @@ fn read_xfyun_credentials() -> crate::asr::XfyunCredentials { crate::asr::XfyunCredentials { app_id, api_key } } +fn read_tencent_cloud_credentials() -> TencentCloudCredentials { + let app_id = CredentialsVault::get(CredentialAccount::TencentCloudAppId) + .ok() + .flatten() + .unwrap_or_default(); + let secret_id = CredentialsVault::get(CredentialAccount::TencentCloudSecretId) + .ok() + .flatten() + .unwrap_or_default(); + let secret_key = CredentialsVault::get(CredentialAccount::TencentCloudSecretKey) + .ok() + .flatten() + .unwrap_or_default(); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .unwrap_or_else(|| crate::asr::tencent_cloud::DEFAULT_MODEL.to_string()); + TencentCloudCredentials { + app_id, + secret_id, + secret_key, + model, + } +} + fn enabled_hotwords(inner: &Arc) -> Vec { inner .vocab @@ -4823,6 +4867,10 @@ mod tests { active_asr_provider_kind(crate::asr::elevenlabs::PROVIDER_ID), ActiveAsrProviderKind::ElevenLabs ); + assert_eq!( + active_asr_provider_kind(crate::asr::tencent_cloud::PROVIDER_ID), + ActiveAsrProviderKind::TencentCloud + ); assert_eq!( active_asr_provider_kind("volcengine"), ActiveAsrProviderKind::Volcengine @@ -4849,6 +4897,7 @@ mod tests { assert_eq!(WhisperCompatible.preflight_credential(), AsrApiKey); assert_eq!(Volcengine.preflight_credential(), VolcAppKey); assert_eq!(Xfyun.preflight_credential(), XfyunAppKey); + assert_eq!(TencentCloud.preflight_credential(), TencentCloudKeys); } #[test] @@ -4988,6 +5037,7 @@ mod tests { assert_eq!(WhisperCompatible.configured_fields(), EndpointModelOnly); assert_eq!(Volcengine.configured_fields(), VolcAppKey); assert_eq!(Xfyun.configured_fields(), XfyunAppKey); + assert_eq!(TencentCloud.configured_fields(), TencentCloudKeys); } #[cfg(target_os = "windows")] 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 a590e786..7a4d515f 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -183,6 +183,14 @@ pub(super) fn ensure_asr_credentials() -> Result<(), String> { Err("请先在设置中填写讯飞 AppID 和 API Key".to_string()) } } + AsrPreflightCredential::TencentCloudKeys => { + let creds = read_tencent_cloud_credentials(); + if creds.auth_ok() { + Ok(()) + } else { + Err("请先在设置中填写腾讯云 AppID、SecretID 和 SecretKey".to_string()) + } + } } } @@ -863,6 +871,10 @@ pub(super) fn is_xfyun_provider(id: &str) -> bool { id == crate::asr::xfyun::PROVIDER_ID } +pub(super) fn is_tencent_cloud_provider(id: &str) -> bool { + id == crate::asr::tencent_cloud::PROVIDER_ID +} + pub(super) fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String { if text.is_empty() { return String::new(); @@ -905,6 +917,10 @@ pub(super) enum QaAsrStart { asr: Arc, bridge: Arc, }, + TencentCloud { + asr: Arc, + bridge: Arc, + }, Ready { active: ActiveAsr, consumer: Arc, @@ -919,6 +935,9 @@ impl QaAsrStart { QaAsrStart::Qwen3Realtime { asr, .. } => ActiveAsr::Qwen3Realtime(Arc::clone(asr)), QaAsrStart::StepfunRealtime { asr, .. } => ActiveAsr::StepfunRealtime(Arc::clone(asr)), QaAsrStart::Xfyun { asr, .. } => ActiveAsr::Xfyun(Arc::clone(asr)), + QaAsrStart::TencentCloud { asr, .. } => { + ActiveAsr::TencentCloud(Arc::clone(asr)) + } QaAsrStart::Ready { active, .. } => active.clone(), } } @@ -930,6 +949,7 @@ impl QaAsrStart { QaAsrStart::Qwen3Realtime { bridge, .. } => Arc::clone(bridge) as _, QaAsrStart::StepfunRealtime { bridge, .. } => Arc::clone(bridge) as _, QaAsrStart::Xfyun { bridge, .. } => Arc::clone(bridge) as _, + QaAsrStart::TencentCloud { bridge, .. } => Arc::clone(bridge) as _, QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer), } } @@ -979,6 +999,15 @@ impl QaAsrStart { ); Ok(()) } + QaAsrStart::TencentCloud { asr, bridge } => { + asr.open_session().await.map_err(|e| e.to_string())?; + let target: Arc = Arc::clone(asr) as _; + let flushed = bridge.attach(target); + log::info!( + "[coord] QA Tencent Cloud ASR connected; flushed {flushed} deferred audio bytes" + ); + Ok(()) + } QaAsrStart::Ready { .. } => Ok(()), } } @@ -1197,6 +1226,17 @@ pub(super) async fn build_qa_asr_start( label, )) } + ActiveAsrProviderKind::TencentCloud => { + let creds = read_tencent_cloud_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(creds.model.clone())); + Ok(( + QaAsrStart::TencentCloud { + asr: Arc::new(TencentCloudStreamingASR::new(creds)), + bridge: Arc::new(DeferredAsrBridge::new()), + }, + label, + )) + } } } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index eda814b3..0ab0347b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2512,7 +2512,8 @@ pub(super) async fn begin_session_as( | ActiveAsrProviderKind::ElevenLabs | ActiveAsrProviderKind::WhisperCompatible | ActiveAsrProviderKind::Volcengine - | ActiveAsrProviderKind::Xfyun => {} + | ActiveAsrProviderKind::Xfyun + | ActiveAsrProviderKind::TencentCloud => {} } if is_bailian_provider(&effective_asr) { @@ -2906,6 +2907,76 @@ pub(super) async fn begin_session_as( let flushed_bytes = bridge.attach(target); log::info!("[coord] iFlytek ASR connected; flushed {flushed_bytes} deferred audio bytes"); finish_starting_session(inner, current_session_id).await; + } else if is_tencent_cloud_provider(&effective_asr) { + let creds = read_tencent_cloud_credentials(); + let asr_call_label = + AsrCallLabel::new(effective_asr.clone(), Some(creds.model.clone())); + let asr = Arc::new(TencentCloudStreamingASR::new(creds)); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::TencentCloud(Arc::clone(&asr)), + asr_call_label, + ); + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer, remote) + .await?; + + if let Err(error) = asr.open_session().await { + log::error!("[coord] open Tencent Cloud ASR session failed: {error}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::ActiveStarting => asr.cancel(), + } + discard_startup_resources_for_session(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("ASR 连接失败: {error}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(error.to_string()); + } + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } + let target: Arc = asr; + let flushed_bytes = bridge.attach(target); + log::info!( + "[coord] Tencent Cloud ASR connected; flushed {flushed_bytes} deferred audio bytes" + ); + finish_starting_session(inner, current_session_id).await; } else { let hotwords = enabled_hotwords(inner); let creds = read_volc_credentials(); @@ -4123,6 +4194,36 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } } } + ActiveAsr::TencentCloud(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] Tencent Cloud ASR send last frame failed: {error}"); + } + let timeout_duration = + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => { + log::error!("[coord] Tencent Cloud ASR await final failed: {error}"); + asr.cancel(); + Err(TranscribeFail::new( + format!("识别失败: {error}"), + error.to_string(), + )) + } + Err(_) => { + log::error!( + "[coord] Tencent Cloud ASR 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + Err(TranscribeFail::new( + "识别超时".to_string(), + "tencent cloud global timeout".to_string(), + )) + } + } + } #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => { debug_assert!(!uses_global_timeout); 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 85094202..a3dc8f92 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -569,6 +569,21 @@ pub(super) async fn transcribe_overlay_dictation_asr( } } } + ActiveAsr::TencentCloud(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] overlay QA: Tencent Cloud ASR send last frame failed: {error}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => { + asr.cancel(); + Err("tencent cloud global timeout".to_string()) + } + } + } ActiveAsr::Whisper(whisper) => { debug_assert!(uses_global_timeout); let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); @@ -1361,6 +1376,34 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { } } } + ActiveAsr::TencentCloud(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] QA: Tencent Cloud ASR send last frame failed: {error}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(raw)) => raw, + Ok(Err(error)) => { + log::error!("[coord] QA: Tencent Cloud ASR await final failed: {error}"); + finish_qa_with_error_if_current( + inner, + session_id, + format!("识别失败: {error}"), + ); + return Err(error.to_string()); + } + Err(_) => { + log::error!( + "[coord] QA: Tencent Cloud ASR 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + finish_qa_with_error_if_current(inner, session_id, "识别超时".to_string()); + return Err("tencent cloud global timeout".to_string()); + } + } + } ActiveAsr::Qwen3Realtime(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/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index 01148a9c..6acae622 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -231,6 +231,7 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) { ActiveAsr::Qwen3Realtime(q) => q.cancel(), ActiveAsr::StepfunRealtime(s) => s.cancel(), ActiveAsr::Xfyun(x) => x.cancel(), + ActiveAsr::TencentCloud(t) => t.cancel(), #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(local) => local.cancel(), #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 93cd05d3..4efe3287 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -341,6 +341,15 @@ struct CredsAsrEntry { /// 讯飞实时语音转写 APIKey(接口密钥)。 #[serde(skip_serializing_if = "Option::is_none")] xfyunApiKey: Option, + /// 腾讯云账号 AppID(实时语音识别 WebSocket 路径参数)。 + #[serde(skip_serializing_if = "Option::is_none")] + tencentCloudAppId: Option, + /// 腾讯云 API 密钥 SecretID。 + #[serde(skip_serializing_if = "Option::is_none")] + tencentCloudSecretId: Option, + /// 腾讯云 API 密钥 SecretKey。 + #[serde(skip_serializing_if = "Option::is_none")] + tencentCloudSecretKey: Option, } impl CredsAsrEntry { @@ -370,6 +379,9 @@ impl CredsAsrEntry { && self.advancedConfig.as_deref().unwrap_or("").is_empty() && self.xfyunAppId.as_deref().unwrap_or("").is_empty() && self.xfyunApiKey.as_deref().unwrap_or("").is_empty() + && self.tencentCloudAppId.as_deref().unwrap_or("").is_empty() + && self.tencentCloudSecretId.as_deref().unwrap_or("").is_empty() + && self.tencentCloudSecretKey.as_deref().unwrap_or("").is_empty() } } @@ -1609,6 +1621,15 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option asr.and_then(|e| pick(&e.advancedConfig)), CredentialAccount::XfyunAppId => asr.and_then(|e| pick(&e.xfyunAppId)), CredentialAccount::XfyunApiKey => asr.and_then(|e| pick(&e.xfyunApiKey)), + CredentialAccount::TencentCloudAppId => { + asr.and_then(|e| pick(&e.tencentCloudAppId)) + } + CredentialAccount::TencentCloudSecretId => { + asr.and_then(|e| pick(&e.tencentCloudSecretId)) + } + CredentialAccount::TencentCloudSecretKey => { + asr.and_then(|e| pick(&e.tencentCloudSecretKey)) + } CredentialAccount::OmniApiKey => omni.and_then(|e| pick(&e.apiKey)), CredentialAccount::OmniEndpoint => omni.and_then(|e| pick(&e.baseURL)), CredentialAccount::OmniModel => omni.and_then(|e| pick(&e.model)), @@ -1681,6 +1702,18 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option let entry = root.providers.asr.entry(asr_id).or_default(); entry.xfyunApiKey = normalized; } + CredentialAccount::TencentCloudAppId => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.tencentCloudAppId = normalized; + } + CredentialAccount::TencentCloudSecretId => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.tencentCloudSecretId = normalized; + } + CredentialAccount::TencentCloudSecretKey => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.tencentCloudSecretKey = normalized; + } CredentialAccount::OmniApiKey => { let entry = root.omni.providers.entry(omni_id).or_default(); entry.apiKey = normalized; @@ -1721,6 +1754,12 @@ pub enum CredentialAccount { XfyunAppId, /// 讯飞实时语音转写 APIKey。 XfyunApiKey, + /// 腾讯云账号 AppID。 + TencentCloudAppId, + /// 腾讯云 API 密钥 SecretID。 + TencentCloudSecretId, + /// 腾讯云 API 密钥 SecretKey。 + TencentCloudSecretKey, /// 多模态(Omni)模型的 API Key。仅多模态管线读取。 OmniApiKey, /// 多模态(Omni)模型的 Base URL。 @@ -1750,6 +1789,9 @@ impl CredentialAccount { CredentialAccount::AsrAdvancedConfig => "asr.advanced_config", CredentialAccount::XfyunAppId => "xfyun.app_id", CredentialAccount::XfyunApiKey => "xfyun.api_key", + CredentialAccount::TencentCloudAppId => "tencent_cloud.app_id", + CredentialAccount::TencentCloudSecretId => "tencent_cloud.secret_id", + CredentialAccount::TencentCloudSecretKey => "tencent_cloud.secret_key", CredentialAccount::OmniApiKey => "omni.api_key", CredentialAccount::OmniEndpoint => "omni.endpoint", CredentialAccount::OmniModel => "omni.model", @@ -1773,6 +1815,9 @@ impl CredentialAccount { CredentialAccount::AsrAdvancedConfig, CredentialAccount::XfyunAppId, CredentialAccount::XfyunApiKey, + CredentialAccount::TencentCloudAppId, + CredentialAccount::TencentCloudSecretId, + CredentialAccount::TencentCloudSecretKey, CredentialAccount::OmniApiKey, CredentialAccount::OmniEndpoint, CredentialAccount::OmniModel, @@ -1793,6 +1838,9 @@ pub struct CredentialsSnapshot { pub asr_model: Option, pub xfyun_app_id: Option, pub xfyun_api_key: Option, + pub tencent_cloud_app_id: Option, + pub tencent_cloud_secret_id: Option, + pub tencent_cloud_secret_key: Option, pub ark_api_key: Option, pub ark_model_id: Option, pub ark_endpoint: Option, @@ -2616,6 +2664,15 @@ impl CredentialsVault { asr_model: lookup_account(&root, CredentialAccount::AsrModel), xfyun_app_id: lookup_account(&root, CredentialAccount::XfyunAppId), xfyun_api_key: lookup_account(&root, CredentialAccount::XfyunApiKey), + tencent_cloud_app_id: lookup_account(&root, CredentialAccount::TencentCloudAppId), + tencent_cloud_secret_id: lookup_account( + &root, + CredentialAccount::TencentCloudSecretId, + ), + tencent_cloud_secret_key: lookup_account( + &root, + CredentialAccount::TencentCloudSecretKey, + ), ark_api_key: lookup_account(&root, CredentialAccount::ArkApiKey), ark_model_id: lookup_account(&root, CredentialAccount::ArkModelId), ark_endpoint: lookup_account(&root, CredentialAccount::ArkEndpoint), diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index d868641f..f438537e 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -176,6 +176,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "tencentTokenHub" ) } @@ -1818,7 +1819,7 @@ pub(crate) fn apply_openai_compatible_thinking_control( thinking_enabled: bool, ) { // 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底, - // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 + // 让用户用"自定义"preset 接入已知厂商时也能正确下发 thinking 控制参数。 let control = openai_compatible_thinking_control(provider_id) .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)); match control { @@ -1860,6 +1861,14 @@ pub(crate) fn apply_openai_compatible_thinking_control( "type": if thinking_enabled { "adaptive" } else { "disabled" }, }); } + Some(ThinkingControl::TokenHubThinking) => { + body["thinking"] = json!({ + "type": if thinking_enabled { "enabled" } else { "disabled" }, + }); + if thinking_enabled { + body["reasoning_effort"] = json!("medium"); + } + } None => {} } } @@ -1871,6 +1880,7 @@ pub(crate) enum ThinkingControl { OpenRouterReasoning, DeepSeekThinking, MiniMaxThinking, + TokenHubThinking, } pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option { @@ -1882,9 +1892,11 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option Some(ThinkingControl::EnableThinking), // StepFun step-3.x-flash 系列按官方文档接受 reasoning_effort(low/medium/high, // 无法完全关闭思考);非推理模型(如 step-1o-turbo-vision)会忽略该字段。 - "openai" | "codingPlanX" | "stepfun" => Some(ThinkingControl::ReasoningEffort), - // custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义 - // endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。 + "openai" | "codingPlanX" | "stepfun" => { + Some(ThinkingControl::ReasoningEffort) + } + "tencentTokenHub" => Some(ThinkingControl::TokenHubThinking), + // custom / 其他未声明 provider 走 base_url 兜底识别。 _ => None, } } @@ -1923,6 +1935,9 @@ pub(crate) fn openai_compatible_thinking_control_for_base_url( if host.contains("stepfun") { return Some(ThinkingControl::ReasoningEffort); } + if host.contains("tokenhub.tencentmaas.com") || host.contains("api.lkeap.cloud.tencent.com") { + return Some(ThinkingControl::TokenHubThinking); + } None } @@ -3743,6 +3758,48 @@ mod tests { assert_eq!(body["reasoning_effort"], "low"); } + #[test] + fn openai_chat_body_controls_tokenhub_thinking() { + for (thinking_enabled, expected) in [(true, "enabled"), (false, "disabled")] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "tencentTokenHub", + "Tencent TokenHub", + "https://tokenhub.tencentmaas.com/v1", + "k", + "hy3", + ) + .with_thinking_enabled(thinking_enabled), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], expected); + assert_eq!( + body.get("reasoning_effort").and_then(Value::as_str), + thinking_enabled.then_some("medium") + ); + } + } + + #[test] + fn openai_chat_body_detects_custom_tokenhub_endpoint() { + for base_url in [ + "https://tokenhub.tencentmaas.com/v1/", + "https://api.lkeap.cloud.tencent.com/plan/v3", + ] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new("custom", "Custom", base_url, "k", "hy3") + .with_thinking_enabled(false), + ); + + let body = + provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + } + #[test] fn openai_chat_body_omits_thinking_control_for_unknown_provider() { let provider = OpenAICompatibleLLMProvider::new( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 42192c2e..9c0cf5fb 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1010,6 +1010,7 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + tencentTokenHub: 'Tencent Cloud TokenHub', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian realtime ASR', @@ -1025,6 +1026,7 @@ export const en: typeof zhCN = { asrOpenAiCompatible: 'Custom OpenAI-compatible', asrXiaomiMimo: 'Xiaomi MiMo ASR', asrIflytek: 'iFlytek Realtime ASR', + asrTencentCloud: 'Tencent Cloud Hunyuan Realtime ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)', asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', @@ -1058,6 +1060,10 @@ export const en: typeof zhCN = { xfyunAppIdLabel: 'AppID', xfyunApiKeyLabel: 'API Key', xfyunNote: 'Get AppID and API Key from the iFlytek Open Platform "Realtime ASR" service page. Audio is 16 kHz / 16-bit / mono PCM; the standard API has no hotword parameter (configure personalized hotwords in the iFlytek console), and the language defaults to Mandarin Chinese.', + tencentCloudAppIdLabel: 'Tencent Cloud AppID', + tencentCloudSecretIdLabel: 'SecretID', + tencentCloudSecretKeyLabel: 'SecretKey', + tencentCloudNote: 'Uses Tencent Cloud Speech Recognition API credentials. The default Hy-ASR-3.0-preview supports Chinese, English, and 20 dialects; Preview accepts only mono 16 kHz PCM up to 60 seconds and does not yet support context or hotword boosting.', 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 Services → Local models.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2b5463ff..1653c295 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1012,6 +1012,7 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + tencentTokenHub: 'Tencent Cloud TokenHub', custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian リアルタイム ASR', @@ -1027,6 +1028,7 @@ export const ja: typeof zhCN = { asrOpenAiCompatible: 'カスタム OpenAI 互換', asrXiaomiMimo: 'Xiaomi MiMo ASR', asrIflytek: 'iFlytek リアルタイム音声認識', + asrTencentCloud: 'Tencent Cloud Hunyuan リアルタイム ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)', asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)', @@ -1060,6 +1062,10 @@ export const ja: typeof zhCN = { xfyunAppIdLabel: 'AppID', xfyunApiKeyLabel: 'API Key', xfyunNote: 'iFlytek オープンプラットフォームの「リアルタイム音声認識」サービスページで AppID と API Key を取得します。音声は 16kHz / 16bit / モノラル PCM。標準版 API にホットワード引数はありません(iFlytek コンソールで個別ホットワードを設定)。言語はデフォルトで中国語(普通話)です。', + tencentCloudAppIdLabel: 'Tencent Cloud AppID', + tencentCloudSecretIdLabel: 'SecretID', + tencentCloudSecretKeyLabel: 'SecretKey', + tencentCloudNote: 'Tencent Cloud 音声認識 API の認証情報を使用します。既定の Hy-ASR-3.0-preview は中国語・英語・20 方言に対応します。Preview は 60 秒以内の 16kHz モノラル PCM のみ対応し、コンテキストとホットワード強化は未対応です。', localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。', asrProviderTakenOver: 'ローカルエンジンを使用中です。上のドロップダウンで別のプロバイダーを選ぶと切り替えられます(ローカルエンジンは自動的に停止します)。ローカルモデルは「サービス → ローカルモデル」で管理します。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3ad19f3..fec0f9ee 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1012,6 +1012,7 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + tencentTokenHub: 'Tencent Cloud TokenHub', custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian 실시간 ASR', @@ -1027,6 +1028,7 @@ export const ko: typeof zhCN = { asrOpenAiCompatible: '커스텀 OpenAI 호환', asrXiaomiMimo: 'Xiaomi MiMo ASR', asrIflytek: 'iFlytek 실시간 음성 인식', + asrTencentCloud: 'Tencent Cloud Hunyuan 실시간 ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)', asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)', @@ -1060,6 +1062,10 @@ export const ko: typeof zhCN = { xfyunAppIdLabel: 'AppID', xfyunApiKeyLabel: 'API Key', xfyunNote: 'iFlytek 오픈 플랫폼 "실시간 음성 인식" 서비스 페이지에서 AppID와 API Key를 가져옵니다. 오디오는 16kHz/16bit/모노 PCM입니다. 표준 API에는 핫워드 매개변수가 없으며(iFlytek 콘솔에서 개별 핫워드 설정), 언어는 기본적으로 중국어(보통화)입니다.', + tencentCloudAppIdLabel: 'Tencent Cloud AppID', + tencentCloudSecretIdLabel: 'SecretID', + tencentCloudSecretKeyLabel: 'SecretKey', + tencentCloudNote: 'Tencent Cloud 음성 인식 API 자격 증명을 사용합니다. 기본 Hy-ASR-3.0-preview는 중국어, 영어 및 20개 방언을 지원합니다. Preview는 60초 이하의 16kHz 모노 PCM만 지원하며 컨텍스트 및 핫워드 강화는 아직 지원하지 않습니다.', localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', asrProviderTakenOver: '현재 로컬 엔진을 사용 중입니다. 위의 드롭다운에서 다른 공급자를 선택하면 전환됩니다(로컬 엔진은 자동으로 중지됨). 로컬 모델은 「서비스 → 로컬 모델」에서 관리합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 816224d3..c747381e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1008,6 +1008,7 @@ export const zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', + tencentTokenHub: '腾讯云 TokenHub', custom: '自定义', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里云百炼实时 ASR', @@ -1023,6 +1024,7 @@ export const zhCN = { asrOpenAiCompatible: '自定义 OpenAI 兼容', asrXiaomiMimo: '小米 MiMo ASR', asrIflytek: '讯飞实时语音转写', + asrTencentCloud: '腾讯云混元实时 ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', @@ -1056,6 +1058,10 @@ export const zhCN = { xfyunAppIdLabel: 'AppID', xfyunApiKeyLabel: 'API Key', xfyunNote: '在讯飞开放平台「实时语音转写」服务页获取 AppID 与 API Key。音频为 16kHz/16bit/单声道 PCM;标准版接口暂不支持热词参数(可在讯飞控制台配置个性化热词),语种默认中文普通话。', + tencentCloudAppIdLabel: '腾讯云 AppID', + tencentCloudSecretIdLabel: 'SecretID', + tencentCloudSecretKeyLabel: 'SecretKey', + tencentCloudNote: '使用腾讯云「语音识别」服务的 API 密钥。默认 Hy-ASR-3.0-preview 支持中英与 20 种方言;Preview 仅支持 60 秒以内的 16kHz 单声道 PCM,暂不支持上下文或热词增强。', localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。', asrProviderTakenOver: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「服务 → 本地模型」里管理。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 53442119..9cc9f3c8 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1010,6 +1010,7 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + tencentTokenHub: '騰訊雲 TokenHub', custom: '自定義', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里雲百煉即時 ASR', @@ -1025,6 +1026,7 @@ export const zhTW: typeof zhCN = { asrOpenAiCompatible: '自訂 OpenAI 相容', asrXiaomiMimo: '小米 MiMo ASR', asrIflytek: '訊飛即時語音轉寫', + asrTencentCloud: '騰訊雲混元即時 ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', @@ -1058,6 +1060,10 @@ export const zhTW: typeof zhCN = { xfyunAppIdLabel: 'AppID', xfyunApiKeyLabel: 'API Key', xfyunNote: '在訊飛開放平台「即時語音轉寫」服務頁取得 AppID 與 API Key。音訊為 16kHz/16bit/單聲道 PCM;標準版介面暫不支援熱詞參數(可在訊飛控制台設定個人化熱詞),語種預設中文普通話。', + tencentCloudAppIdLabel: '騰訊雲 AppID', + tencentCloudSecretIdLabel: 'SecretID', + tencentCloudSecretKeyLabel: 'SecretKey', + tencentCloudNote: '使用騰訊雲「語音辨識」服務的 API 金鑰。預設 Hy-ASR-3.0-preview 支援中英與 20 種方言;Preview 僅支援 60 秒內的 16kHz 單聲道 PCM,暫不支援上下文或熱詞增強。', localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。', asrProviderTakenOver: '目前使用的是本地引擎,在上方下拉直接選其他供應商即可切換(本地引擎會自動停用);本地模型在「服務 → 本地模型」裡管理。', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6eeddd61..28e65474 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -52,6 +52,7 @@ const LLM_NAME_KEY_BY_ID: Record = { openrouterFree: 'openrouterFree', alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', + tencentTokenHub: 'tencentTokenHub', custom: 'custom', }; diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 371d5e4f..1441de87 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -15,6 +15,20 @@ if (atlascloudPreset.modelPlaceholder !== 'qwen/qwen3.5-flash') { throw new Error(`unexpected Atlas Cloud default model: ${atlascloudPreset.modelPlaceholder}`); } +const tokenHubPreset = LLM_PRESETS.find(p => p.id === 'tencentTokenHub'); + +if (!tokenHubPreset) { + throw new Error('Tencent Cloud TokenHub LLM preset is missing'); +} + +if (tokenHubPreset.baseUrl !== 'https://tokenhub.tencentmaas.com/v1') { + throw new Error(`unexpected TokenHub base URL: ${tokenHubPreset.baseUrl}`); +} + +if (tokenHubPreset.modelPlaceholder !== 'hy3') { + throw new Error(`unexpected TokenHub default model: ${tokenHubPreset.modelPlaceholder}`); +} + const openAiCompatiblePreset = ASR_PRESETS.find(p => p.id === 'openai-compatible'); if (!openAiCompatiblePreset) { @@ -40,3 +54,15 @@ if (zenmuxPreset.baseUrl !== 'https://zenmux.ai/api/v1') { if (zenmuxPreset.model !== 'qwen/qwen3-asr-flash') { throw new Error(`unexpected ZenMux default model: ${zenmuxPreset.model}`); } + +const tencentCloudPreset = ASR_PRESETS.find(p => p.id === 'tencent-cloud'); + +if (!tencentCloudPreset) { + throw new Error('Tencent Cloud ASR preset is missing'); +} + +if (tencentCloudPreset.baseUrl !== '' || tencentCloudPreset.model !== 'Hy-ASR-3.0-preview') { + throw new Error( + `unexpected Tencent Cloud ASR defaults: baseUrl=${tencentCloudPreset.baseUrl}, model=${tencentCloudPreset.model}`, + ); +} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index a315b766..929fffb2 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -188,6 +188,14 @@ export const LLM_PRESETS = [ baseUrl: 'https://api.stepfun.com/v1', modelPlaceholder: 'step-1o-turbo-vision', }, + { + // 腾讯云 TokenHub 兼容 OpenAI Chat Completions;Hy3 正式版默认关闭思考, + // 延迟与输出可控,适合短文本润色。用户仍可在模型字段改用 TokenHub 其它模型。 + id: 'tencentTokenHub', + nameKey: 'tencentTokenHub', + baseUrl: 'https://tokenhub.tencentmaas.com/v1', + modelPlaceholder: 'hy3', + }, { id: 'custom', nameKey: 'custom', @@ -478,6 +486,29 @@ export function ChannelCredentialFields({ ); } + if (providerType === 'tencent-cloud') { + return ( + <> + + + + +
+ {t('settings.providers.tencentCloudNote')} +
+ setAsrModelRevision(v => v + 1)} onTested={onTested} + onUserMutation={onUserMutation} /> + + ); + } + // 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与 // 切换仍由「高级 → 本地模型」里的 负责,这里只说明一句。 if (LOCAL_ASR_PROVIDER_IDS.includes(providerType)) { diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index b08bd10e..90da6d92 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -259,6 +259,9 @@ export const ASR_PRESETS = [ // 讯飞实时语音转写(RTASR)流式:无 baseUrl/model 配置,凭据是 AppID + APIKey // 两字段(asr/xfyun.rs);音频 16k/16bit/mono,与 recorder 输出一致。 { id: 'iflytek', nameKey: 'asrIflytek', baseUrl: '', model: '' }, + // 腾讯云实时语音识别:AppID + SecretID + SecretKey 签名鉴权,默认使用当前最新 + // 混元 Hy-ASR Preview;专用 WebSocket client 见 asr/tencent_cloud.rs。 + { id: 'tencent-cloud', nameKey: 'asrTencentCloud', baseUrl: '', model: 'Hy-ASR-3.0-preview' }, { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, { id: 'local-whisper', nameKey: 'asrLocalWhisper', baseUrl: '', model: '' }, // 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置,