diff --git a/README.md b/README.md index be2cc935..040edb3e 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,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, StepFun, and OpenCode Zen — 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, StepFun, and OpenCode Zen — 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 052e317a..b33bb033 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,9 +203,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、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、腾讯云 TokenHub、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/crates/openless-core/src/asr/mod.rs b/openless-all/app/crates/openless-core/src/asr/mod.rs index b016e1b1..a8177bfe 100644 --- a/openless-all/app/crates/openless-core/src/asr/mod.rs +++ b/openless-all/app/crates/openless-core/src/asr/mod.rs @@ -8,6 +8,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; @@ -20,6 +21,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/crates/openless-core/src/asr/tencent_cloud.rs b/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs new file mode 100644 index 00000000..6acef85a --- /dev/null +++ b/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs @@ -0,0 +1,715 @@ +//! 腾讯云实时语音识别 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::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 crate::config::{TaskSpawner, TokioTaskSpawner}; +use crate::ports::{TextStreamChunk, TextStreamSink}; + +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, + final_tx: Option>>, + final_segments: BTreeMap, + partial_segments: BTreeMap, + last_result_text: String, +} + +pub struct TencentCloudStreamingASR { + credentials: TencentCloudCredentials, + task_spawner: Arc, + state: ParkingMutex, + writer: SharedWriter, + final_rx: ParkingMutex>>>, + handshake_tx: ParkingMutex>>>, + audio_tx: ParkingMutex>>>, + pending_sends: Arc, + send_done: Arc, + partial_sink: ParkingMutex>>, +} + +impl TencentCloudStreamingASR { + pub fn new(credentials: TencentCloudCredentials) -> Self { + Self::with_task_spawner(credentials, Arc::new(TokioTaskSpawner)) + } + + pub fn with_task_spawner( + credentials: TencentCloudCredentials, + task_spawner: Arc, + ) -> Self { + Self { + credentials, + task_spawner, + 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()), + partial_sink: ParkingMutex::new(None), + } + } + + pub fn set_partial_sink(&self, sink: Arc) { + *self.partial_sink.lock() = Some(sink); + } + + 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.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); + let task_spawner = Arc::clone(&self.task_spawner); + task_spawner.spawn(Box::pin(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); + let task_spawner = Arc::clone(&self.task_spawner); + task_spawner.spawn(Box::pin(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) { + self.state.lock().pending_audio.clear(); + *self.handshake_tx.lock() = None; + *self.audio_tx.lock() = None; + let writer = Arc::clone(&self.writer); + self.task_spawner.spawn(Box::pin(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; + } + 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(); + let mut delta = None; + if slice_type == 2 { + state.final_segments.insert(index, text.to_string()); + state.partial_segments.remove(&index); + } else { + let previous = state + .partial_segments + .get(&index) + .map(String::as_str) + .unwrap_or(""); + delta = text + .strip_prefix(previous) + .filter(|suffix| !suffix.is_empty()) + .map(str::to_string); + state.partial_segments.insert(index, text.to_string()); + } + drop(state); + if let Some(delta) = delta { + if let Some(sink) = self.partial_sink.lock().clone() { + let _ = sink.publish(TextStreamChunk { + text: delta, + offset: 0, + }); + } + } + } + + 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); + self.task_spawner.spawn(Box::pin(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(_) + )); + } + + #[test] + fn auth_ok_requires_all_three_keys() { + assert!(credentials().auth_ok()); + assert!(!TencentCloudCredentials { + app_id: "app".into(), + secret_id: "id".into(), + secret_key: String::new(), + model: DEFAULT_MODEL.into(), + } + .auth_ok()); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index 54113e64..ab059626 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -15,8 +15,9 @@ use parking_lot::{Mutex, RwLock}; use crate::asr::{ BailianCredentials, BailianRealtimeASR, DashScopeMultimodalASR, DictionaryHotword, ElevenLabsBatchASR, MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials, - StepfunRealtimeASR, StepfunRealtimeCredentials, VolcengineCredentials, VolcengineStreamingASR, - WhisperBatchASR, XfyunCredentials, XfyunStreamingASR, + StepfunRealtimeASR, StepfunRealtimeCredentials, TencentCloudCredentials, + TencentCloudStreamingASR, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, + XfyunCredentials, XfyunStreamingASR, }; use crate::config::{TaskSpawner, TokioTaskSpawner}; use crate::credentials::{ @@ -24,7 +25,8 @@ use crate::credentials::{ ASR_API_KEY_ACCOUNT, ASR_ENDPOINT_ACCOUNT, ASR_MODEL_ACCOUNT, ASR_VOCABULARY_ID_ACCOUNT, LLM_API_KEY_ACCOUNT, LLM_ENDPOINT_ACCOUNT, LLM_EXTRA_HEADERS_ACCOUNT, LLM_TEMPERATURE_ACCOUNT, OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT, OMNI_EXTRA_HEADERS_ACCOUNT, OMNI_MODEL_ACCOUNT, - OMNI_TEMPERATURE_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, + OMNI_TEMPERATURE_ACCOUNT, TENCENT_CLOUD_APP_ID_ACCOUNT, TENCENT_CLOUD_SECRET_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, VOLCENGINE_APP_KEY_ACCOUNT, VOLCENGINE_AUTH_MODE_ACCOUNT, VOLCENGINE_RESOURCE_ID_ACCOUNT, XFYUN_API_KEY_ACCOUNT, XFYUN_APP_ID_ACCOUNT, }; @@ -59,6 +61,7 @@ pub const SHARED_CLOUD_ASR_PROVIDER_TYPES: &[&str] = &[ "openai-compatible", "xiaomi-mimo-asr", "iflytek", + "tencent-cloud", ]; pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ @@ -77,6 +80,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "minimax", "stepfun", "opencode", + "tencentTokenHub", "custom", "custom_responses", "custom_messages", @@ -117,6 +121,7 @@ enum CloudTranscriptionSessionKind { QwenRealtime(Arc), StepfunRealtime(Arc), Xfyun(Arc), + TencentCloud(Arc), } struct CloudTranscriptionSession { @@ -167,6 +172,9 @@ impl AudioConsumer for CloudTranscriptionSession { provider.consume_pcm_chunk(pcm) } CloudTranscriptionSessionKind::Xfyun(provider) => provider.consume_pcm_chunk(pcm), + CloudTranscriptionSessionKind::TencentCloud(provider) => { + provider.consume_pcm_chunk(pcm) + } } } } @@ -233,6 +241,11 @@ impl TranscriptionSession for CloudTranscriptionSession { timeout_transcription(Duration::from_secs(120), provider.await_final_result()) .await? } + CloudTranscriptionSessionKind::TencentCloud(provider) => { + let _ = provider.send_last_frame().await; + timeout_transcription(Duration::from_secs(120), provider.await_final_result()) + .await? + } }; Ok(TranscriptOutput { text: transcript.text, @@ -252,6 +265,7 @@ impl TranscriptionSession for CloudTranscriptionSession { CloudTranscriptionSessionKind::QwenRealtime(provider) => provider.cancel(), CloudTranscriptionSessionKind::StepfunRealtime(provider) => provider.cancel(), CloudTranscriptionSessionKind::Xfyun(provider) => provider.cancel(), + CloudTranscriptionSessionKind::TencentCloud(provider) => provider.cancel(), } Box::pin(async { Ok(()) }) } @@ -628,6 +642,59 @@ async fn build_cloud_transcription_session( provider.open_session().await.map_err(map_asr_error)?; (CloudTranscriptionSessionKind::Xfyun(provider), None) } + ActiveAsrProviderKind::TencentCloud => { + let app_id = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + TENCENT_CLOUD_APP_ID_ACCOUNT, + ) + .await? + .unwrap_or_default(); + let secret_id = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + ) + .await? + .unwrap_or_default(); + let secret_key = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ) + .await? + .unwrap_or_default(); + require_configured(&app_id, "Tencent Cloud AppID")?; + require_configured(&secret_id, "Tencent Cloud SecretID")?; + require_configured(&secret_key, "Tencent Cloud SecretKey")?; + let model = read_channel_credential( + credentials, + CredentialNamespace::Asr, + channel_id, + ASR_MODEL_ACCOUNT, + ) + .await? + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| crate::asr::tencent_cloud::DEFAULT_MODEL.to_string()); + let provider = Arc::new(TencentCloudStreamingASR::with_task_spawner( + TencentCloudCredentials { + app_id, + secret_id, + secret_key, + model: model.clone(), + }, + Arc::clone(&task_spawner), + )); + provider.set_partial_sink(Arc::clone(&partials)); + provider.open_session().await.map_err(map_asr_error)?; + ( + CloudTranscriptionSessionKind::TencentCloud(provider), + Some(model), + ) + } }; Ok((kind, crate::AsrCallLabel::new(effective, label_model))) } diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index db651678..dc627063 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -68,6 +68,9 @@ pub const VOLCENGINE_AUTH_MODE_ACCOUNT: &str = "volcengine.auth_mode"; pub const VOLCENGINE_API_KEY_ACCOUNT: &str = "volcengine.api_key"; pub const XFYUN_APP_ID_ACCOUNT: &str = "xfyun.app_id"; pub const XFYUN_API_KEY_ACCOUNT: &str = "xfyun.api_key"; +pub const TENCENT_CLOUD_APP_ID_ACCOUNT: &str = "tencent_cloud.app_id"; +pub const TENCENT_CLOUD_SECRET_ID_ACCOUNT: &str = "tencent_cloud.secret_id"; +pub const TENCENT_CLOUD_SECRET_KEY_ACCOUNT: &str = "tencent_cloud.secret_key"; pub const LLM_API_KEY_ACCOUNT: &str = "ark.api_key"; pub const LLM_MODEL_ACCOUNT: &str = "ark.model_id"; pub const LLM_ENDPOINT_ACCOUNT: &str = "ark.endpoint"; diff --git a/openless-all/app/crates/openless-core/src/credentials_legacy.rs b/openless-all/app/crates/openless-core/src/credentials_legacy.rs index 6b5bc878..74b3f190 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -99,6 +99,9 @@ struct LegacyEntry { advanced_config: Option, xfyun_app_id: Option, xfyun_api_key: Option, + tencent_cloud_app_id: Option, + tencent_cloud_secret_id: Option, + tencent_cloud_secret_key: Option, temperature: Option, extra_headers: Option>, request_format: Option, @@ -127,6 +130,9 @@ impl Default for LegacyEntry { advanced_config: None, xfyun_app_id: None, xfyun_api_key: None, + tencent_cloud_app_id: None, + tencent_cloud_secret_id: None, + tencent_cloud_secret_key: None, temperature: None, extra_headers: None, request_format: None, @@ -153,6 +159,9 @@ impl LegacyEntry { &self.advanced_config, &self.xfyun_app_id, &self.xfyun_api_key, + &self.tencent_cloud_app_id, + &self.tencent_cloud_secret_id, + &self.tencent_cloud_secret_key, &self.request_format, &self.messages_thinking, &self.max_tokens, @@ -379,6 +388,15 @@ fn decode_entry( (ASR_ADVANCED_CONFIG_ACCOUNT, entry.advanced_config), (XFYUN_APP_ID_ACCOUNT, entry.xfyun_app_id), (XFYUN_API_KEY_ACCOUNT, entry.xfyun_api_key), + (TENCENT_CLOUD_APP_ID_ACCOUNT, entry.tencent_cloud_app_id), + ( + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + entry.tencent_cloud_secret_id, + ), + ( + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + entry.tencent_cloud_secret_key, + ), ]); } if let Some(account) = extra_headers { @@ -432,6 +450,9 @@ pub fn read_legacy_accounts( ASR_ADVANCED_CONFIG_ACCOUNT, XFYUN_APP_ID_ACCOUNT, XFYUN_API_KEY_ACCOUNT, + TENCENT_CLOUD_APP_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, ][..], ), ( diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 23857658..e1f9aa87 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -188,6 +188,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "minimax" | "stepfun" | "opencode" + | "tencentTokenHub" ) } @@ -1652,10 +1653,12 @@ pub(crate) fn apply_openai_compatible_thinking_control( // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 // Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。 let is_opencode = provider_id.trim() == "opencode" - || (matches!(provider_id.trim(), "custom" | "custom_responses" | "custom_messages") - && url::Url::parse(base_url.trim()) - .ok() - .is_some_and(|url| url.host_str() == Some("opencode.ai"))); + || (matches!( + provider_id.trim(), + "custom" | "custom_responses" | "custom_messages" + ) && url::Url::parse(base_url.trim()) + .ok() + .is_some_and(|url| url.host_str() == Some("opencode.ai"))); let control = if is_opencode { model .trim() @@ -1704,6 +1707,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 => {} } } @@ -1715,6 +1726,7 @@ pub(crate) enum ThinkingControl { OpenRouterReasoning, DeepSeekThinking, MiniMaxThinking, + TokenHubThinking, } pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option { @@ -1727,6 +1739,7 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option Some(ThinkingControl::ReasoningEffort), + "tencentTokenHub" => Some(ThinkingControl::TokenHubThinking), // custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义 // endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。 _ => None, @@ -1767,6 +1780,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 } @@ -2140,8 +2156,12 @@ mod tests { #[tokio::test] async fn all_text_entrypoints_use_the_selected_protocol_over_http() { for (format, (preset, prefix)) in LlmRequestFormat::ALL.into_iter().flat_map(|format| { - [("custom", "/gateway/v1"), ("opencode", "/zen/v1"), ("opencode", "/zen/go/v1")] - .map(|entry| (format, entry)) + [ + ("custom", "/gateway/v1"), + ("opencode", "/zen/v1"), + ("opencode", "/zen/go/v1"), + ] + .map(|entry| (format, entry)) }) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); @@ -2308,7 +2328,11 @@ mod tests { ("custom", "https://opencode.ai/zen/v1", true), ("custom_responses", "https://opencode.ai/zen/v1", true), ("custom_messages", "https://opencode.ai/zen/v1", true), - ("custom", "https://OPENCODE.AI:443/zen/go/v1/chat/completions", true), + ( + "custom", + "https://OPENCODE.AI:443/zen/go/v1/chat/completions", + true, + ), ("custom", "https://opencode.ai.example/zen/v1", false), ("custom", "https://fakeopencode.ai/zen/v1", false), ("custom", "https://opencode.ai@example.com/zen/v1", false), @@ -2320,22 +2344,37 @@ mod tests { let provider = OpenAICompatibleLLMProvider::new( OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model) .with_thinking_enabled(enabled) - .with_protocol(LlmProtocolConfig { format, ..Default::default() }), + .with_protocol(LlmProtocolConfig { + format, + ..Default::default() + }), ); - let body = provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]); + let body = + provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]); match format { - LlmRequestFormat::ChatCompletions if zen && model.starts_with("deepseek-") => { - assert_eq!(body["thinking"]["type"], if enabled { "enabled" } else { "disabled" }); + LlmRequestFormat::ChatCompletions + if zen && model.starts_with("deepseek-") => + { + assert_eq!( + body["thinking"]["type"], + if enabled { "enabled" } else { "disabled" } + ); } LlmRequestFormat::Messages if enabled => { assert_eq!(body["thinking"]["type"], "adaptive"); } - _ => assert!(body.get("thinking").is_none(), "{preset} {endpoint} {model} {format:?}"), + _ => assert!( + body.get("thinking").is_none(), + "{preset} {endpoint} {model} {format:?}" + ), } assert!(body.get("reasoning_effort").is_none()); assert!(body.get("enable_thinking").is_none()); if format == LlmRequestFormat::Responses { - assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); + assert_eq!( + body["reasoning"]["effort"], + if enabled { "medium" } else { "low" } + ); assert!(body.get("messages").is_none()); } else { assert!(body.get("reasoning").is_none()); @@ -3201,6 +3240,47 @@ mod tests { assert_eq!(body["thinking"]["type"], "adaptive"); } + #[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_falls_back_to_base_url_for_custom_minimax_endpoint() { // 用 "custom" preset + 自定义 MiniMax base_url 接入时,base_url 兜底 diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index e79f9e20..9ac9cceb 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -21,6 +21,7 @@ const MIMO_PROVIDER_ID: &str = "xiaomi-mimo-asr"; const DASHSCOPE_MULTIMODAL_PROVIDER_ID: &str = "bailian-fun-asr-flash"; const ELEVENLABS_PROVIDER_ID: &str = "elevenlabs"; const XFYUN_PROVIDER_ID: &str = "iflytek"; +const TENCENT_CLOUD_PROVIDER_ID: &str = "tencent-cloud"; const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ ("volcengine", "asrVolcengine"), @@ -38,6 +39,7 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ (OPENAI_COMPATIBLE_ASR_PROVIDER_ID, "asrOpenAiCompatible"), ("xiaomi-mimo-asr", "asrXiaomiMimo"), (XFYUN_PROVIDER_ID, "asrIflytek"), + (TENCENT_CLOUD_PROVIDER_ID, "asrTencentCloud"), ("foundry-local-whisper", "asrFoundryLocalWhisper"), ("local-whisper", "asrLocalWhisper"), ("sherpa-onnx-local", "asrSherpaOnnxLocal"), @@ -63,6 +65,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("minimax", "minimax"), ("stepfun", "stepfun"), ("opencode", "opencode"), + ("tencentTokenHub", "tencentTokenHub"), ("custom", "customChatCompletions"), ("custom_responses", "customResponses"), ("custom_messages", "customMessages"), @@ -110,6 +113,7 @@ pub enum AuthRequirement { ApiKeyUnlessCustomEndpoint, Volcengine, Xfyun, + TencentCloud, OAuth, } @@ -187,11 +191,14 @@ fn provider_descriptor_with_label( match id.as_str() { "volcengine" => AuthRequirement::Volcengine, XFYUN_PROVIDER_ID => AuthRequirement::Xfyun, + TENCENT_CLOUD_PROVIDER_ID => AuthRequirement::TencentCloud, OPENAI_COMPATIBLE_ASR_PROVIDER_ID => AuthRequirement::EndpointModelOptionalApiKey, _ => AuthRequirement::ApiKey, }, match id.as_str() { - "volcengine" | XFYUN_PROVIDER_ID => ValidationProbe::AsrSilenceAllowsNoFinal, + "volcengine" | XFYUN_PROVIDER_ID | TENCENT_CLOUD_PROVIDER_ID => { + ValidationProbe::AsrSilenceAllowsNoFinal + } "stepfun" => ValidationProbe::StepfunNoSpeech, DASHSCOPE_MULTIMODAL_PROVIDER_ID => ValidationProbe::AsrNonSilent, _ => ValidationProbe::AsrSilence, @@ -303,6 +310,7 @@ pub enum ActiveAsrProviderKind { WhisperCompatible, Volcengine, Xfyun, + TencentCloud, } /// Non-secret facts read by a platform credential adapter. Core evaluates @@ -319,6 +327,9 @@ pub struct CredentialConfiguration { pub volcengine_resource_id: bool, pub xfyun_app_id: bool, pub xfyun_api_key: bool, + pub tencent_cloud_app_id: bool, + pub tencent_cloud_secret_id: bool, + pub tencent_cloud_secret_key: bool, pub llm_api_key: bool, pub llm_endpoint: bool, pub llm_endpoint_matches_default: bool, @@ -401,6 +412,11 @@ pub fn auth_requirement_satisfied( } AuthRequirement::Volcengine => volcengine_configured(configuration), AuthRequirement::Xfyun => configuration.xfyun_app_id && configuration.xfyun_api_key, + AuthRequirement::TencentCloud => { + configuration.tencent_cloud_app_id + && configuration.tencent_cloud_secret_id + && configuration.tencent_cloud_secret_key + } AuthRequirement::OAuth => configuration.codex_oauth && model, } } @@ -416,7 +432,8 @@ pub fn api_key_required( match descriptor.auth_requirement { AuthRequirement::None | AuthRequirement::EndpointModelOptionalApiKey - | AuthRequirement::OAuth => false, + | AuthRequirement::OAuth + | AuthRequirement::TencentCloud => false, AuthRequirement::ApiKeyUnlessCustomEndpoint => { let Some(endpoint) = configured_endpoint.filter(|value| !value.trim().is_empty()) else { @@ -476,6 +493,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { "openrouter" => Some("openai/whisper-large-v3-turbo"), "zenmux" => Some(crate::asr::whisper::ZENMUX_DEFAULT_MODEL), "xiaomi-mimo-asr" => Some(crate::asr::mimo::DEFAULT_MODEL), + TENCENT_CLOUD_PROVIDER_ID => Some(crate::asr::tencent_cloud::DEFAULT_MODEL), _ => None, } } @@ -496,6 +514,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "minimax" => Some("https://api.minimaxi.com/v1"), "stepfun" => Some("https://api.stepfun.com/v1"), + "tencentTokenHub" => Some("https://tokenhub.tencentmaas.com/v1"), _ => None, } } @@ -515,6 +534,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { "codingPlanX" => Some("gpt-5-mini"), "minimax" => Some("MiniMax-M3"), "stepfun" => Some("step-1o-turbo-vision"), + "tencentTokenHub" => Some("hy3"), _ => None, } } @@ -570,6 +590,7 @@ pub fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { DASHSCOPE_MULTIMODAL_PROVIDER_ID => ActiveAsrProviderKind::DashScopeMultimodal, ELEVENLABS_PROVIDER_ID => ActiveAsrProviderKind::ElevenLabs, XFYUN_PROVIDER_ID => ActiveAsrProviderKind::Xfyun, + TENCENT_CLOUD_PROVIDER_ID => ActiveAsrProviderKind::TencentCloud, value if is_whisper_compatible_provider(value) => ActiveAsrProviderKind::WhisperCompatible, _ => ActiveAsrProviderKind::Volcengine, } @@ -603,6 +624,10 @@ pub fn is_xfyun_provider(id: &str) -> bool { id == XFYUN_PROVIDER_ID } +pub fn is_tencent_cloud_provider(id: &str) -> bool { + id == TENCENT_CLOUD_PROVIDER_ID +} + pub fn is_whisper_compatible_provider(id: &str) -> bool { matches!( id, @@ -997,12 +1022,25 @@ mod tests { assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"opencode")); let descriptor = provider_descriptor(ProviderKind::Llm, "opencode").unwrap(); assert_eq!(descriptor.label_key, "opencode"); - assert_eq!(descriptor.default_endpoint.as_deref(), Some("https://opencode.ai/zen/v1")); - assert_eq!(descriptor.default_model.as_deref(), Some("deepseek-v4-flash")); - assert_eq!(descriptor.default_request_format, Some(LlmRequestFormat::ChatCompletions)); + assert_eq!( + descriptor.default_endpoint.as_deref(), + Some("https://opencode.ai/zen/v1") + ); + assert_eq!( + descriptor.default_model.as_deref(), + Some("deepseek-v4-flash") + ); + assert_eq!( + descriptor.default_request_format, + Some(LlmRequestFormat::ChatCompletions) + ); assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL); assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText); - assert!(api_key_required(ProviderKind::Llm, "opencode", Some("https://opencode.ai/zen/v1/chat/completions/"))); + assert!(api_key_required( + ProviderKind::Llm, + "opencode", + Some("https://opencode.ai/zen/v1/chat/completions/") + )); let mut configuration = CredentialConfiguration { llm_endpoint: true, llm_endpoint_matches_default: true, @@ -1014,6 +1052,47 @@ mod tests { assert!(llm_configured("opencode", &configuration)); } + #[test] + fn tencent_cloud_asr_and_tokenhub_supply_defaults_and_credentials() { + assert!(crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&"tencent-cloud")); + assert!( + crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"tencentTokenHub") + ); + let asr = provider_descriptor(ProviderKind::Asr, "tencent-cloud").unwrap(); + assert_eq!(asr.label_key, "asrTencentCloud"); + assert_eq!( + asr.default_model.as_deref(), + Some(crate::asr::tencent_cloud::DEFAULT_MODEL) + ); + assert_eq!(asr.auth_requirement, AuthRequirement::TencentCloud); + assert_eq!( + asr.validation_probe, + ValidationProbe::AsrSilenceAllowsNoFinal + ); + let mut configuration = CredentialConfiguration::default(); + assert!(!auth_requirement_satisfied(&asr, &configuration)); + configuration.tencent_cloud_app_id = true; + configuration.tencent_cloud_secret_id = true; + configuration.tencent_cloud_secret_key = true; + assert!(auth_requirement_satisfied(&asr, &configuration)); + assert_eq!( + active_asr_provider_kind("tencent-cloud"), + ActiveAsrProviderKind::TencentCloud + ); + + let llm = provider_descriptor(ProviderKind::Llm, "tencentTokenHub").unwrap(); + assert_eq!(llm.label_key, "tencentTokenHub"); + assert_eq!( + llm.default_endpoint.as_deref(), + Some("https://tokenhub.tencentmaas.com/v1") + ); + assert_eq!(llm.default_model.as_deref(), Some("hy3")); + assert_eq!( + llm.auth_requirement, + AuthRequirement::ApiKeyUnlessCustomEndpoint + ); + } + #[test] fn descriptors_are_the_single_source_for_defaults_auth_and_probes() { let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap(); diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index a4d1c2d4..3f396787 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -418,7 +418,7 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro ) && api_key.trim().is_empty() && !matches!( descriptor.auth_requirement, - AuthRequirement::Volcengine | AuthRequirement::Xfyun + AuthRequirement::Volcengine | AuthRequirement::Xfyun | AuthRequirement::TencentCloud ) { let label = match resolved.kind { @@ -436,7 +436,10 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro if model.is_none() && !matches!( descriptor.auth_requirement, - AuthRequirement::None | AuthRequirement::Volcengine | AuthRequirement::Xfyun + AuthRequirement::None + | AuthRequirement::Volcengine + | AuthRequirement::Xfyun + | AuthRequirement::TencentCloud ) { return Err(invalid_request("provider model is not configured")); @@ -450,7 +453,10 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro if endpoint.is_none() && !matches!( descriptor.auth_requirement, - AuthRequirement::None | AuthRequirement::Volcengine | AuthRequirement::Xfyun + AuthRequirement::None + | AuthRequirement::Volcengine + | AuthRequirement::Xfyun + | AuthRequirement::TencentCloud ) { return Err(provider_error("provider endpoint is not configured")); diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 123e285b..0f506728 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -5,7 +5,8 @@ use futures_util::future::BoxFuture; use openless_core::credentials::{ ASR_API_KEY_ACCOUNT, ASR_ENDPOINT_ACCOUNT, ASR_MODEL_ACCOUNT, LLM_API_KEY_ACCOUNT, LLM_ENDPOINT_ACCOUNT, LLM_MODEL_ACCOUNT, OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT, - OMNI_MODEL_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, + OMNI_MODEL_ACCOUNT, TENCENT_CLOUD_APP_ID_ACCOUNT, TENCENT_CLOUD_SECRET_ID_ACCOUNT, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT, VOLCENGINE_APP_KEY_ACCOUNT, VOLCENGINE_AUTH_MODE_ACCOUNT, VOLCENGINE_RESOURCE_ID_ACCOUNT, XFYUN_API_KEY_ACCOUNT, XFYUN_APP_ID_ACCOUNT, }; @@ -395,6 +396,21 @@ impl CredentialStore for LinuxCredentialStore { &active_asr_provider, XFYUN_API_KEY_ACCOUNT, ), + tencent_cloud_app_id: has( + CredentialNamespace::Asr, + &active_asr_provider, + TENCENT_CLOUD_APP_ID_ACCOUNT, + ), + tencent_cloud_secret_id: has( + CredentialNamespace::Asr, + &active_asr_provider, + TENCENT_CLOUD_SECRET_ID_ACCOUNT, + ), + tencent_cloud_secret_key: has( + CredentialNamespace::Asr, + &active_asr_provider, + TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ), llm_api_key: has( CredentialNamespace::Llm, &active_llm_provider, diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index bb2e9717..19942ce7 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -74,6 +74,7 @@ mod linux_app { model: String, auth_mode: String, resource_id: String, + app_id: String, // Secret inputs are intentionally write-only. Loading an editor never // exposes an existing key into egui state, logs or screenshots. primary_secret: String, @@ -1632,6 +1633,7 @@ mod linux_app { } openless_core::AuthRequirement::Volcengine => "火山引擎凭据", openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key", + openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey", openless_core::AuthRequirement::OAuth => "OAuth", } } @@ -1711,6 +1713,19 @@ mod linux_app { } else { (String::new(), String::new()) }; + let app_id = if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud + { + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + ) + .await? + .unwrap_or_default() + } else { + String::new() + }; Ok(ProviderEditor { kind, name: channel.name.clone(), @@ -1720,6 +1735,7 @@ mod linux_app { model, auth_mode, resource_id, + app_id, primary_secret: String::new(), secondary_secret: String::new(), }) @@ -1777,6 +1793,18 @@ mod linux_app { secret_edit(ui, "AppID", &mut editor.primary_secret); secret_edit(ui, "API Key", &mut editor.secondary_secret); } + openless_core::AuthRequirement::TencentCloud => { + ui.horizontal(|ui| { + ui.label("腾讯云 AppID"); + ui.text_edit_singleline(&mut editor.app_id); + }); + secret_edit(ui, "SecretID", &mut editor.primary_secret); + secret_edit(ui, "SecretKey", &mut editor.secondary_secret); + ui.horizontal(|ui| { + ui.label("Model"); + ui.text_edit_singleline(&mut editor.model); + }); + } _ => { secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret); ui.horizontal(|ui| { @@ -1913,6 +1941,40 @@ mod linux_app { ) .await?; } + openless_core::AuthRequirement::TencentCloud => { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + &editor.app_id, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, + &editor.primary_secret, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + &editor.secondary_secret, + ) + .await?; + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + model_account(editor.kind), + &editor.model, + ) + .await?; + } _ => { write_or_remove_provider_value( &backend, @@ -1958,6 +2020,11 @@ mod linux_app { openless_core::credentials::XFYUN_APP_ID_ACCOUNT, openless_core::credentials::XFYUN_API_KEY_ACCOUNT, ], + openless_core::AuthRequirement::TencentCloud => &[ + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, + openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ], _ => &[api_key_account(editor.kind)], }; for account in accounts { diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index f9fac199..b75d68fb 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -535,6 +535,9 @@ fn credential_configuration( volcengine_resource_id: configured(&snap.volcengine_resource_id), xfyun_app_id: configured(&snap.xfyun_app_id), xfyun_api_key: configured(&snap.xfyun_api_key), + tencent_cloud_app_id: configured(&snap.tencent_cloud_app_id), + tencent_cloud_secret_id: configured(&snap.tencent_cloud_secret_id), + tencent_cloud_secret_key: configured(&snap.tencent_cloud_secret_key), llm_api_key: configured(&snap.ark_api_key), llm_endpoint: llm_endpoint.is_some(), llm_endpoint_matches_default: llm_endpoint.is_some_and(|endpoint| { @@ -799,7 +802,10 @@ fn account_provider_kind(account: CredentialAccount) -> CredentialProviderKind { | CredentialAccount::AsrVocabularyId | CredentialAccount::AsrAdvancedConfig | CredentialAccount::XfyunAppId - | CredentialAccount::XfyunApiKey => CredentialProviderKind::Asr, + | CredentialAccount::XfyunApiKey + | CredentialAccount::TencentCloudAppId + | CredentialAccount::TencentCloudSecretId + | CredentialAccount::TencentCloudSecretKey => CredentialProviderKind::Asr, CredentialAccount::OmniApiKey | CredentialAccount::OmniEndpoint | CredentialAccount::OmniModel => CredentialProviderKind::Omni, @@ -831,6 +837,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/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index eaa643a4..03d25386 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -349,6 +349,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 { @@ -378,6 +387,17 @@ 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() } } @@ -1611,6 +1631,11 @@ 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)), @@ -1720,6 +1745,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; @@ -1760,6 +1797,12 @@ pub enum CredentialAccount { XfyunAppId, /// 讯飞实时语音转写 APIKey。 XfyunApiKey, + /// 腾讯云账号 AppID。 + TencentCloudAppId, + /// 腾讯云 API 密钥 SecretID。 + TencentCloudSecretId, + /// 腾讯云 API 密钥 SecretKey。 + TencentCloudSecretKey, /// 多模态(Omni)模型的 API Key。仅多模态管线读取。 OmniApiKey, /// 多模态(Omni)模型的 Base URL。 @@ -1789,6 +1832,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", @@ -1812,6 +1858,9 @@ impl CredentialAccount { CredentialAccount::AsrAdvancedConfig, CredentialAccount::XfyunAppId, CredentialAccount::XfyunApiKey, + CredentialAccount::TencentCloudAppId, + CredentialAccount::TencentCloudSecretId, + CredentialAccount::TencentCloudSecretKey, CredentialAccount::OmniApiKey, CredentialAccount::OmniEndpoint, CredentialAccount::OmniModel, @@ -1832,6 +1881,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, @@ -1853,6 +1905,9 @@ fn credentials_snapshot(root: &CredsRoot, include_omni: bool) -> CredentialsSnap 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/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 700a94ad..d9499ec9 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1011,6 +1011,7 @@ export const en: typeof zhCN = { minimax: 'MiniMax (M3)', stepfun: 'StepFun', opencode: 'OpenCode Zen', + tencentTokenHub: 'Tencent Cloud TokenHub', customChatCompletions: "Custom · Chat Completions", customResponses: "Custom · Responses", customMessages: "Custom · Messages", @@ -1029,6 +1030,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)', @@ -1062,6 +1064,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 606c5b40..59ad19c8 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1013,6 +1013,7 @@ export const ja: typeof zhCN = { minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', opencode: 'OpenCode Zen', + tencentTokenHub: 'Tencent Cloud TokenHub', customChatCompletions: "カスタム · Chat Completions", customResponses: "カスタム · Responses", customMessages: "カスタム · Messages", @@ -1031,6 +1032,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)', @@ -1064,6 +1066,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 0ffc6a58..893ca052 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1013,6 +1013,7 @@ export const ko: typeof zhCN = { minimax: 'MiniMax (M3)', stepfun: 'StepFun', opencode: 'OpenCode Zen', + tencentTokenHub: 'Tencent Cloud TokenHub', customChatCompletions: "사용자 지정 · Chat Completions", customResponses: "사용자 지정 · Responses", customMessages: "사용자 지정 · Messages", @@ -1031,6 +1032,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)', @@ -1064,6 +1066,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 b6d39c9c..87a5fa8a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1009,6 +1009,7 @@ export const zhCN = { minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', opencode: 'OpenCode Zen', + tencentTokenHub: '腾讯云 TokenHub', customChatCompletions: "自定义 · Chat Completions", customResponses: "自定义 · Responses", customMessages: "自定义 · Messages", @@ -1027,6 +1028,7 @@ export const zhCN = { asrOpenAiCompatible: '自定义 OpenAI 兼容', asrXiaomiMimo: '小米 MiMo ASR', asrIflytek: '讯飞实时语音转写', + asrTencentCloud: '腾讯云混元实时 ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', @@ -1060,6 +1062,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 1c7d5c84..0cd2270d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1011,6 +1011,7 @@ export const zhTW: typeof zhCN = { minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', opencode: 'OpenCode Zen', + tencentTokenHub: '騰訊雲 TokenHub', customChatCompletions: "自訂 · Chat Completions", customResponses: "自訂 · Responses", customMessages: "自訂 · Messages", @@ -1029,6 +1030,7 @@ export const zhTW: typeof zhCN = { asrOpenAiCompatible: '自訂 OpenAI 相容', asrXiaomiMimo: '小米 MiMo ASR', asrIflytek: '訊飛即時語音轉寫', + asrTencentCloud: '騰訊雲混元即時 ASR', asrElevenLabs: 'ElevenLabs Scribe', asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)', asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)', @@ -1062,6 +1064,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/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 3db537eb..030d7d7b 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -10,6 +10,7 @@ export type AuthRequirement = | 'api_key_unless_custom_endpoint' | 'volcengine' | 'xfyun' + | 'tencent_cloud' | 'o_auth' export interface ProviderDescriptor { @@ -29,13 +30,16 @@ export interface ProviderDescriptor { export function listProviderDescriptors(kind: ProviderKind): Promise { return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ ['opencode', 'opencode', 'chat_completions'], + ['tencentTokenHub', 'tencentTokenHub', 'chat_completions'], ['custom', 'customChatCompletions', 'chat_completions'], ['custom_responses', 'customResponses', 'responses'], ['custom_messages', 'customMessages', 'messages'], ].map(([providerType, labelKey, format]) => ({ kind, providerType, labelKey, - defaultEndpoint: providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null, - defaultModel: providerType === 'opencode' ? 'deepseek-v4-flash' : null, + defaultEndpoint: providerType === 'opencode' ? 'https://opencode.ai/zen/v1' + : providerType === 'tencentTokenHub' ? 'https://tokenhub.tencentmaas.com/v1' : null, + defaultModel: providerType === 'opencode' ? 'deepseek-v4-flash' + : providerType === 'tencentTokenHub' ? 'hy3' : null, authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [], defaultRequestFormat: format as LlmRequestFormat, supportedRequestFormats: ['chat_completions', 'responses', 'messages'], diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6faee63a..242b20b6 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -53,6 +53,7 @@ const LLM_NAME_KEY_BY_ID: Record = { alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', opencode: 'opencode', + 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 7e027d7d..ec83ce3e 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -6,6 +6,12 @@ const atlascloudPreset = LLM_LABELS.find(p => p.id === 'atlascloud'); if (LLM_LABELS.find(p => p.id === 'opencode')?.nameKey !== 'opencode') { throw new Error('OpenCode LLM label is missing'); } +if (LLM_LABELS.find(p => p.id === 'tencentTokenHub')?.nameKey !== 'tencentTokenHub') { + throw new Error('Tencent Cloud TokenHub LLM label is missing'); +} +if (ASR_LABELS.find(p => p.id === 'tencent-cloud')?.nameKey !== 'asrTencentCloud') { + throw new Error('Tencent Cloud ASR label is missing'); +} if (!atlascloudPreset) { throw new Error('Atlas Cloud LLM preset is missing'); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index f50e2e73..ca9aa133 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -91,7 +91,7 @@ export const LLM_LABELS = [ ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], - ['opencode', 'opencode'], + ['opencode', 'opencode'], ['tencentTokenHub', 'tencentTokenHub'], ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], ].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[]; @@ -329,6 +329,29 @@ export function ChannelCredentialFields({ ); } + if (descriptor?.authRequirement === 'tencent_cloud') { + return ( + <> + + + + +
+ {t('settings.providers.tencentCloudNote')} +
+ setAsrModelRevision(v => v + 1)} onTested={onTested} + onUserMutation={onUserMutation} /> + + ); + } + // 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与 // 切换仍由「高级 → 本地模型」里的 负责,这里只说明一句。 if (descriptor?.authRequirement === 'none') { diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 747accfd..e3d4814f 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -227,6 +227,7 @@ export const ASR_LABELS = [ { id: 'openai-compatible', nameKey: 'asrOpenAiCompatible' }, { id: 'xiaomi-mimo-asr', nameKey: 'asrXiaomiMimo' }, { id: 'iflytek', nameKey: 'asrIflytek' }, + { id: 'tencent-cloud', nameKey: 'asrTencentCloud' }, { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper' }, { id: 'local-whisper', nameKey: 'asrLocalWhisper' }, { id: 'sherpa-onnx-local', nameKey: 'asrSherpaOnnxLocal' },