diff --git a/README.md b/README.md index 4298c5f94..be2cc9358 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Every item below is one more layer sedimented into a default — a capability yo - **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). - **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, 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 14e93de12..052e317a1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **切换式与按住说话(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**:通过 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、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/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a0baeb3da..953d191a0 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3255,8 +3255,10 @@ impl OpenLessBackend { }; let preferences = self.get_preferences(); let mode = preferences.hotkey.mode; - let modifier_only = - crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey).is_some(); + let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required( + crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey), + mode, + ); let (intent, reservation) = { let mut hotkey = self .hotkey @@ -3436,7 +3438,40 @@ impl OpenLessBackend { key: CredentialKey, value: SecretValue, ) -> Result { + if key.namespace == crate::CredentialNamespace::Llm + && crate::llm_protocol::CONFIG_ACCOUNTS.contains(&key.account.as_str()) + { + crate::llm_protocol::LlmProtocolConfig::default() + .apply(&key.account, value.expose_secret())?; + } + let invalidate = if key.namespace == crate::CredentialNamespace::Llm { + let id = match &key.provider_id { + Some(id) => id.clone(), + None => { + self.deps + .credential_store + .active_provider(crate::ProviderSlot::Llm) + .await? + } + }; + self.list_channels(ChannelKind::Llm) + .await? + .into_iter() + .find(|channel| channel.id == id) + .map(|_| id) + } else { + None + }; self.deps.credential_store.write(key, value).await?; + if let Some(id) = invalidate { + self.deps + .credential_store + .mutate_channel(ChannelMutation::InvalidateTest { + kind: ChannelKind::Llm, + id, + }) + .await?; + } self.refresh_and_publish_credentials().await } @@ -3483,13 +3518,51 @@ impl OpenLessBackend { id: String, provider_type: String, ) -> Result<(), BackendError> { - self.apply_channel_mutation(ChannelMutation::SetProviderType { - kind, - id, - provider_type, - }) - .await - .map(|_| ()) + let provider_type = provider_type.trim().to_string(); + if provider_type.trim().is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "provider type must not be blank", + )); + } + let previous = self + .list_channels(kind) + .await? + .into_iter() + .find(|channel| channel.id == id) + .ok_or_else(|| { + BackendError::new(BackendErrorCode::InvalidArgument, "unknown channel") + })?; + let key = CredentialKey::new( + crate::CredentialNamespace::Llm, + Some(id.clone()), + crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, + )?; + let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type; + let old_format = if reset { + let value = self.deps.credential_store.read(key.clone()).await?; + self.deps.credential_store.remove(key.clone()).await?; + value + } else { + None + }; + let result = self + .deps + .credential_store + .mutate_channel(ChannelMutation::SetProviderType { + kind, + id, + provider_type, + }) + .await + .map(|_| ()); + if result.is_err() { + if let Some(value) = old_format { + self.deps.credential_store.write(key, value).await?; + } + } + result?; + self.refresh_and_publish_credentials().await.map(|_| ()) } pub async fn delete_channel_if_blank( @@ -3571,6 +3644,12 @@ impl OpenLessBackend { .map(|_| ()) } + pub async fn invalidate_channel_tests(&self, kind: ChannelKind) -> Result<(), BackendError> { + self.apply_channel_mutation(ChannelMutation::InvalidateTests { kind }) + .await + .map(|_| ()) + } + pub async fn active_provider(&self, slot: ProviderSlot) -> Result { self.deps.credential_store.active_provider(slot).await } @@ -8250,6 +8329,142 @@ mod tests { )); } + #[tokio::test] + async fn invalidating_llm_tests_preserves_asr_test_results() { + let (backend, _) = backend(); + for (kind, provider, name) in [ + (ChannelKind::Llm, "custom", "first"), + (ChannelKind::Llm, "custom_messages", "second"), + (ChannelKind::Asr, "openai-compatible", "asr"), + ] { + let id = backend + .create_channel(kind, provider.into(), name.into()) + .await + .unwrap(); + backend + .record_channel_test(kind, id, true, Some(1), None) + .await + .unwrap(); + } + + backend + .invalidate_channel_tests(ChannelKind::Llm) + .await + .unwrap(); + + assert!(backend + .list_channels(ChannelKind::Llm) + .await + .unwrap() + .iter() + .all(|channel| channel.last_test.is_none())); + assert!(backend.list_channels(ChannelKind::Asr).await.unwrap()[0] + .last_test + .is_some()); + } + + #[tokio::test] + async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() { + use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue}; + use crate::llm_protocol::*; + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: std::env::temp_dir() + .join(format!("openless-protocol-{}", uuid::Uuid::new_v4())), + ..BackendConfig::default() + }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(FakeEngine), + task_spawner: Arc::new(TokioTaskSpawner), + credential_store: Arc::new(InMemoryCredentialStore::default()), + services: crate::domains::BackendServices::unsupported(), + local_asr_runtime: None, + marketplace_config: None, + selection_runtime: None, + selection_polisher: None, + qa_runtime: None, + }, + ) + .unwrap(); + let id = backend + .create_channel(ChannelKind::Llm, "custom".into(), "test".into()) + .await + .unwrap(); + let key = |account: &str| { + CredentialKey::new(CredentialNamespace::Llm, Some(id.clone()), account).unwrap() + }; + backend + .set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages")) + .await + .unwrap(); + backend + .set_credential( + key(crate::credentials::LLM_API_KEY_ACCOUNT), + SecretValue::new("fixture-key"), + ) + .await + .unwrap(); + backend + .record_channel_test(ChannelKind::Llm, id.clone(), true, Some(1), None) + .await + .unwrap(); + assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0] + .last_test + .is_some()); + backend + .set_credential( + key(crate::credentials::LLM_MODEL_ACCOUNT), + SecretValue::new("new-model"), + ) + .await + .unwrap(); + assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0] + .last_test + .is_none()); + assert_eq!( + backend + .read_credential(key(REQUEST_FORMAT_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "messages" + ); + assert!(backend + .set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("invalid")) + .await + .is_err()); + backend + .set_channel_provider_type(ChannelKind::Llm, id.clone(), "custom_responses".into()) + .await + .unwrap(); + assert!(backend + .read_credential(key(REQUEST_FORMAT_ACCOUNT)) + .await + .unwrap() + .is_none()); + assert_eq!( + backend + .read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "fixture-key" + ); + assert_eq!( + backend + .read_credential(key(crate::credentials::LLM_MODEL_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "new-model" + ); + } + #[tokio::test] async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() { let (backend, _) = backend(); diff --git a/openless-all/app/crates/openless-core/src/asr/bailian.rs b/openless-all/app/crates/openless-core/src/asr/bailian.rs index d7f399bc2..9e2850e32 100644 --- a/openless-all/app/crates/openless-core/src/asr/bailian.rs +++ b/openless-all/app/crates/openless-core/src/asr/bailian.rs @@ -12,12 +12,14 @@ use std::time::{Duration, Instant}; use futures_util::{SinkExt, StreamExt}; use parking_lot::Mutex as ParkingMutex; use serde_json::{json, Value}; -use tokio::net::TcpStream; +use tokio::net::{lookup_host, TcpStream}; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify}; +use tokio_tungstenite::client_async_tls; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::header::HeaderValue; +use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use uuid::Uuid; use crate::config::{TaskSpawner, TokioTaskSpawner}; @@ -39,6 +41,92 @@ const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12); /// 热键彻底失灵(开不了也停不了,只能退出重开)。详见 stepfun_realtime.rs 同名常量。 const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// 单个候选地址的 TCP 上限。IPv6 黑洞时不能把整段 5s 耗在第一个 AAAA 上。 +const PER_ADDR_TCP_TIMEOUT: Duration = Duration::from_millis(1500); + +fn default_port_for_request( + request: &tokio_tungstenite::tungstenite::handshake::client::Request, +) -> Result { + let default_port = match request.uri().scheme_str() { + Some("ws") => 80, + Some("wss") => 443, + _ => { + return Err(WsError::Url( + tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme, + )) + } + }; + Ok(request.uri().port_u16().unwrap_or(default_port)) +} + +fn order_connect_addrs(mut addrs: Vec) -> Vec { + addrs.sort_by_key(|addr| u8::from(!addr.ip().is_ipv4())); + addrs +} + +async fn connect_ws_to_addrs( + request: tokio_tungstenite::tungstenite::handshake::client::Request, + addrs: Vec, +) -> Result< + ( + WsStream, + tokio_tungstenite::tungstenite::handshake::client::Response, + ), + WsError, +> { + if addrs.is_empty() { + return Err(WsError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no addresses for websocket endpoint", + ))); + } + + let mut last_err = None; + for addr in addrs { + match tokio::time::timeout(PER_ADDR_TCP_TIMEOUT, TcpStream::connect(addr)).await { + Ok(Ok(stream)) => match client_async_tls(request.clone(), stream).await { + Ok(connection) => return Ok(connection), + Err(error) => last_err = Some(error), + }, + Ok(Err(error)) => last_err = Some(WsError::Io(error)), + Err(_) => { + last_err = Some(WsError::Io(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "tcp connect timeout ({})", + if addr.ip().is_ipv4() { "v4" } else { "v6" } + ), + ))) + } + } + } + + Err(last_err.unwrap_or_else(|| { + WsError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "no tcp candidate", + )) + })) +} + +async fn connect_ws_prefer_ipv4( + request: tokio_tungstenite::tungstenite::handshake::client::Request, +) -> Result< + ( + WsStream, + tokio_tungstenite::tungstenite::handshake::client::Response, + ), + WsError, +> { + let port = default_port_for_request(&request)?; + let host = request.uri().host().unwrap_or("").to_string(); + let addrs = lookup_host((host.as_str(), port)) + .await + .map_err(WsError::Io)? + .collect::>(); + connect_ws_to_addrs(request, order_connect_addrs(addrs)).await +} + type WsStream = WebSocketStream>; type WsSink = futures_util::stream::SplitSink; type SharedWriter = Arc>>; @@ -171,7 +259,7 @@ impl BailianRealtimeASR { .map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?, ); - let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) + let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_ws_prefer_ipv4(request)) .await .map_err(|_| { BailianASRError::ConnectionFailed(format!( @@ -765,6 +853,100 @@ mod tests { }) } + #[test] + fn websocket_default_ports_match_uri_scheme() { + let ws = "ws://localhost/path".into_client_request().unwrap(); + let wss = "wss://localhost/path".into_client_request().unwrap(); + let explicit = "ws://localhost:9000/path".into_client_request().unwrap(); + + assert_eq!(default_port_for_request(&ws).unwrap(), 80); + assert_eq!(default_port_for_request(&wss).unwrap(), 443); + assert_eq!(default_port_for_request(&explicit).unwrap(), 9000); + } + + #[test] + fn websocket_default_port_rejects_non_websocket_scheme() { + let request = "https://localhost/path".into_client_request().unwrap(); + let explicit_port = "https://localhost:443/path".into_client_request().unwrap(); + assert!(matches!( + default_port_for_request(&request), + Err(WsError::Url( + tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme + )) + )); + assert!(matches!( + default_port_for_request(&explicit_port), + Err(WsError::Url( + tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme + )) + )); + } + + #[test] + fn order_connect_addrs_puts_ipv4_first() { + let v6: std::net::SocketAddr = "[2408:400a::1]:443".parse().unwrap(); + let v4: std::net::SocketAddr = "8.152.159.24:443".parse().unwrap(); + let ordered = order_connect_addrs(vec![v6, v4]); + assert!(ordered[0].ip().is_ipv4()); + assert!(ordered[1].ip().is_ipv6()); + } + + #[tokio::test] + async fn websocket_handshake_failure_falls_back_to_next_address() { + let rejected_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rejected_addr = rejected_listener.local_addr().unwrap(); + let rejected_task = tokio::spawn(async move { + let (stream, _) = rejected_listener.accept().await.unwrap(); + drop(stream); + }); + + let accepted_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let accepted_addr = accepted_listener.local_addr().unwrap(); + let accepted_task = tokio::spawn(async move { + let (stream, _) = accepted_listener.accept().await.unwrap(); + tokio_tungstenite::accept_async(stream).await.unwrap(); + }); + + let request = format!("ws://localhost:{}/path", accepted_addr.port()) + .into_client_request() + .unwrap(); + let (stream, _) = connect_ws_to_addrs(request, vec![rejected_addr, accepted_addr]) + .await + .unwrap(); + drop(stream); + + rejected_task.await.unwrap(); + accepted_task.await.unwrap(); + } + + #[tokio::test] + async fn websocket_connects_to_ipv6_candidate_when_available() { + let Ok(listener) = tokio::net::TcpListener::bind("[::1]:0").await else { + return; + }; + let addr = listener.local_addr().unwrap(); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + tokio_tungstenite::accept_async(stream).await.unwrap(); + }); + + let request = format!("ws://[::1]:{}/path", addr.port()) + .into_client_request() + .unwrap(); + let (stream, _) = connect_ws_to_addrs(request, vec![addr]).await.unwrap(); + drop(stream); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn websocket_connect_with_no_candidates_fails_immediately() { + let request = "ws://localhost/path".into_client_request().unwrap(); + let result = connect_ws_to_addrs(request, Vec::new()).await; + assert!( + matches!(result, Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound) + ); + } + // ---- merge_segments ---- #[test] 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 67b4640b1..54113e645 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -76,7 +76,10 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "codingPlanX", "minimax", "stepfun", + "opencode", "custom", + "custom_responses", + "custom_messages", ]; pub const SHARED_OMNI_PROVIDER_TYPES: &[&str] = &["openai", "gemini", "dashscope-omni", "custom"]; @@ -1037,12 +1040,9 @@ async fn build_cloud_polisher_provider( return Ok(CloudPolisherProvider::Gemini(provider)); } - let base_url = endpoint - .trim() - .trim_end_matches('/') - .trim_end_matches("/chat/completions") - .trim_end_matches('/') - .to_string(); + let protocol = + crate::llm_protocol::LlmProtocolConfig::load(credentials, channel_id, provider_type) + .await?; let temperature = read_channel_credential( credentials, CredentialNamespace::Llm, @@ -1068,10 +1068,11 @@ async fn build_cloud_polisher_provider( let config = crate::polish::OpenAICompatibleConfig::new( provider_type, "OpenLess LLM", - base_url, + endpoint, api_key, model, ) + .with_protocol(protocol) .with_thinking_enabled(context.polish.llm_thinking_enabled) .with_temperature(crate::polish::openai_compatible_temperature_for_provider( provider_type, diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index 8b0319602..db6516786 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -129,6 +129,13 @@ pub struct ChannelTestSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChannelMutation { + InvalidateTest { + kind: ChannelKind, + id: String, + }, + InvalidateTests { + kind: ChannelKind, + }, /// Commit a prepared local runtime and its channel in one metadata revision. ActivateLocalAsr { id: Option, @@ -519,6 +526,8 @@ impl CredentialMetadata { ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr, ChannelMutation::Create { kind, .. } | ChannelMutation::SetProviderType { kind, .. } + | ChannelMutation::InvalidateTest { kind, .. } + | ChannelMutation::InvalidateTests { kind } | ChannelMutation::DeleteIfBlank { kind, .. } | ChannelMutation::Rename { kind, .. } | ChannelMutation::Delete { kind, .. } @@ -630,6 +639,16 @@ impl CredentialMetadata { channel.last_test = None; (kind, ChannelMutationResult::Applied) } + ChannelMutation::InvalidateTest { kind, id } => { + find_channel_mut(&mut self.channels, kind, &id)?.last_test = None; + (kind, ChannelMutationResult::Applied) + } + ChannelMutation::InvalidateTests { kind } => { + for channel in self.channels.entry(kind).or_default() { + channel.last_test = None; + } + (kind, ChannelMutationResult::Applied) + } ChannelMutation::DeleteIfBlank { kind, id } => { let channels = self.channels.entry(kind).or_default(); let before = channels.len(); 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 3f1cfedd3..6b5bc8788 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -101,6 +101,10 @@ struct LegacyEntry { xfyun_api_key: Option, temperature: Option, extra_headers: Option>, + request_format: Option, + messages_thinking: Option, + max_tokens: Option, + thinking_budget: Option, } impl Default for LegacyEntry { @@ -125,6 +129,10 @@ impl Default for LegacyEntry { xfyun_api_key: None, temperature: None, extra_headers: None, + request_format: None, + messages_thinking: None, + max_tokens: None, + thinking_budget: None, } } } @@ -145,6 +153,10 @@ impl LegacyEntry { &self.advanced_config, &self.xfyun_app_id, &self.xfyun_api_key, + &self.request_format, + &self.messages_thinking, + &self.max_tokens, + &self.thinking_budget, ] .into_iter() .any(|value| value.as_deref().is_some_and(|value| !value.is_empty())) @@ -347,6 +359,15 @@ fn decode_entry( (endpoint, entry.base_url), (model, entry.model), ]; + if namespace == CredentialNamespace::Llm { + use crate::llm_protocol::*; + fields.extend([ + (REQUEST_FORMAT_ACCOUNT, entry.request_format), + (MESSAGES_THINKING_ACCOUNT, entry.messages_thinking), + (MAX_TOKENS_ACCOUNT, entry.max_tokens), + (THINKING_BUDGET_ACCOUNT, entry.thinking_budget), + ]); + } if namespace == CredentialNamespace::Asr { fields.extend([ (VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key), diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 8c57fa779..1aefdd1e8 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -44,6 +44,8 @@ pub enum ProviderKind { #[serde(rename_all = "camelCase")] pub struct ProviderRequest { pub kind: ProviderKind, + #[serde(default)] + pub thinking_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub channel_id: Option, } diff --git a/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs b/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs index 54f2be3f3..f54450004 100644 --- a/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs +++ b/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs @@ -1,7 +1,7 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; -use crate::shared_types::HotkeyMode; +use crate::shared_types::{HotkeyMode, HotkeyTrigger}; use crate::types::DictationPhase; /// Auto mode treats a shorter press as a latched Toggle press and a longer @@ -21,6 +21,17 @@ const MODIFIER_ARBITRATION_GRACE: Duration = Duration::from_millis(150); /// awaits microphone/ASR setup. Keep a bounded set of those early Combined ids. const MAX_PENDING_COMBINED: usize = 64; +/// macOS emits Fn Auto/Toggle edges only after native release arbitration. +pub(crate) fn modifier_arbitration_required( + trigger: Option, + mode: HotkeyMode, +) -> bool { + trigger.is_some() + && !(cfg!(target_os = "macos") + && trigger == Some(HotkeyTrigger::Fn) + && matches!(mode, HotkeyMode::Auto | HotkeyMode::Toggle)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum HotkeyIntent { Noop, @@ -266,6 +277,27 @@ impl HotkeyInterpreter { mod tests { use super::*; + #[test] + fn mac_fn_tap_arrives_pre_arbitrated_but_hold_and_other_modifiers_do_not() { + let fn_tap_needs_grace = !cfg!(target_os = "macos"); + assert_eq!( + modifier_arbitration_required(Some(HotkeyTrigger::Fn), HotkeyMode::Auto), + fn_tap_needs_grace + ); + assert_eq!( + modifier_arbitration_required(Some(HotkeyTrigger::Fn), HotkeyMode::Toggle), + fn_tap_needs_grace + ); + assert!(modifier_arbitration_required( + Some(HotkeyTrigger::Fn), + HotkeyMode::Hold + )); + assert!(modifier_arbitration_required( + Some(HotkeyTrigger::LeftOption), + HotkeyMode::Auto + )); + } + #[test] fn combined_before_pressed_cancels_only_that_generation() { let start = Instant::now(); diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 61e317df0..334294671 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod host_document; mod hotkey_interpreter; mod less_computer; pub mod llm_gemini; +pub mod llm_protocol; mod marketplace; pub mod model_store; pub mod net; diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs new file mode 100644 index 000000000..ee0e01e7a --- /dev/null +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -0,0 +1,772 @@ +//! 渠道级文本协议:请求格式、鉴权和正文事件由 Core 统一解释。 + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore}; +use crate::polish::{LLMError, OpenAICompatibleConfig}; +use crate::{BackendError, BackendErrorCode}; + +pub const REQUEST_FORMAT_ACCOUNT: &str = "ark.request_format"; +pub const MESSAGES_THINKING_ACCOUNT: &str = "ark.messages_thinking"; +pub const MAX_TOKENS_ACCOUNT: &str = "ark.max_tokens"; +pub const THINKING_BUDGET_ACCOUNT: &str = "ark.thinking_budget"; +pub const CONFIG_ACCOUNTS: [&str; 4] = [ + REQUEST_FORMAT_ACCOUNT, + MESSAGES_THINKING_ACCOUNT, + MAX_TOKENS_ACCOUNT, + THINKING_BUDGET_ACCOUNT, +]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmRequestFormat { + #[default] + ChatCompletions, + Responses, + Messages, +} + +impl LlmRequestFormat { + pub const ALL: [Self; 3] = [Self::ChatCompletions, Self::Responses, Self::Messages]; + + pub fn default_for(provider: &str) -> Self { + match provider { + "custom_responses" => Self::Responses, + "custom_messages" => Self::Messages, + _ => Self::ChatCompletions, + } + } + + pub fn selectable(provider: &str) -> bool { + !matches!(provider, "gemini" | "codex_oauth") + } + + pub fn parse(value: &str) -> Result { + match value.trim() { + "chat_completions" => Ok(Self::ChatCompletions), + "responses" => Ok(Self::Responses), + "messages" => Ok(Self::Messages), + _ => Err(config_error("llmRequestFormatInvalid")), + } + } + + pub fn url(self, endpoint: &str) -> Result { + endpoint_url( + endpoint, + match self { + Self::ChatCompletions => "/chat/completions", + Self::Responses => "/responses", + Self::Messages => "/messages", + }, + ) + } + + pub fn headers(self, api_key: &str) -> Vec<(String, String)> { + let mut headers = Vec::new(); + if self == Self::Messages { + headers.push(("anthropic-version".into(), "2023-06-01".into())); + } + if !api_key.trim().is_empty() { + headers.push(if self == Self::Messages { + ("x-api-key".into(), api_key.to_string()) + } else { + ("Authorization".into(), format!("Bearer {api_key}")) + }); + } + headers + } +} + +/// 更换格式只替换已知的末端路径,不破坏网关前缀及查询参数。 +pub fn endpoint_url(endpoint: &str, suffix: &str) -> Result { + let mut url = url::Url::parse(endpoint.trim()) + .map_err(|_| LLMError::ParseError("invalid LLM endpoint".into()))?; + let path = url.path().trim_end_matches('/'); + let prefix = ["/chat/completions", "/responses", "/messages", "/models"] + .iter() + .find_map(|suffix| path.strip_suffix(suffix)) + .unwrap_or(path); + url.set_path(&format!("{prefix}{suffix}")); + Ok(url.to_string()) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessagesThinking { + #[default] + Adaptive, + Budget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LlmProtocolConfig { + pub format: LlmRequestFormat, + pub messages_thinking: MessagesThinking, + pub max_tokens: u32, + pub thinking_budget: u32, +} + +impl Default for LlmProtocolConfig { + fn default() -> Self { + Self { + format: LlmRequestFormat::ChatCompletions, + messages_thinking: MessagesThinking::Adaptive, + max_tokens: 8192, + thinking_budget: 1024, + } + } +} + +impl LlmProtocolConfig { + pub fn validate_headers( + &self, + headers: &std::collections::HashMap, + ) -> Result<(), BackendError> { + if self.format == LlmRequestFormat::Messages + && headers.keys().any(|name| { + name.eq_ignore_ascii_case("x-api-key") + || name.eq_ignore_ascii_case("anthropic-version") + }) + { + return Err(config_error("llmProtocolHeaderConflict")); + } + Ok(()) + } + + pub async fn load( + store: &dyn CredentialStore, + channel: &str, + provider: &str, + ) -> Result { + let mut config = Self { + format: LlmRequestFormat::default_for(provider), + ..Self::default() + }; + if !LlmRequestFormat::selectable(provider) { + return Ok(config); + } + for account in CONFIG_ACCOUNTS { + let key = + CredentialKey::new(CredentialNamespace::Llm, Some(channel.to_string()), account)?; + if let Some(value) = store.read(key).await? { + config.apply(account, value.expose_secret())?; + } + } + config.validate()?; + Ok(config) + } + + pub fn apply(&mut self, account: &str, value: &str) -> Result<(), BackendError> { + let value = value.trim(); + if value.is_empty() { + return Ok(()); + } + match account { + REQUEST_FORMAT_ACCOUNT => self.format = LlmRequestFormat::parse(value)?, + MESSAGES_THINKING_ACCOUNT => { + self.messages_thinking = match value { + "adaptive" => MessagesThinking::Adaptive, + "budget" => MessagesThinking::Budget, + _ => return Err(config_error("llmThinkingModeInvalid")), + } + } + MAX_TOKENS_ACCOUNT => self.max_tokens = positive_tokens(value)?, + THINKING_BUDGET_ACCOUNT => { + self.thinking_budget = positive_tokens(value)?; + if self.thinking_budget < 1024 { + return Err(config_error("llmThinkingBudgetInvalid")); + } + } + _ => return Err(config_error("llmRequestFormatInvalid")), + } + Ok(()) + } + + pub fn validate(&self) -> Result<(), BackendError> { + if self.format == LlmRequestFormat::Messages + && (self.max_tokens == 0 + || self.messages_thinking == MessagesThinking::Budget + && (self.thinking_budget < 1024 || self.thinking_budget >= self.max_tokens)) + { + return Err(config_error("llmThinkingBudgetInvalid")); + } + Ok(()) + } +} + +fn positive_tokens(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| config_error("llmTokenLimitInvalid")) +} + +fn config_error(message: &str) -> BackendError { + BackendError::new(BackendErrorCode::InvalidArgument, message) +} + +pub(crate) fn request_body( + config: &OpenAICompatibleConfig, + stream: bool, + messages: Vec, +) -> Value { + let mut body = match config.protocol.format { + LlmRequestFormat::ChatCompletions => { + unreachable!("Chat Completions retains its provider rules") + } + LlmRequestFormat::Responses => { + let mut body = + json!({"model": config.model, "stream": stream, "store": false, "input": messages}); + let model = config + .model + .trim() + .strip_prefix("openai/") + .unwrap_or(config.model.trim()) + .to_ascii_lowercase(); + // 已知普通模型不接受 reasoning;未知网关模型按所选兼容协议声明参数。 + if !(model.starts_with("gpt-4") + || model.starts_with("gpt-3.5") + || model.starts_with("chatgpt-4")) + { + let effort = if model.starts_with("gpt-5-pro") + || model.contains("-pro") && model.starts_with("gpt-5.") + { + "high" + } else if config.thinking_enabled { + "medium" + } else { + "low" + }; + body["reasoning"] = json!({"effort": effort}); + } + body + } + LlmRequestFormat::Messages => { + let mut system = Vec::new(); + let mut turns = Vec::new(); + for message in messages { + if matches!(message["role"].as_str(), Some("system" | "developer")) { + if let Some(text) = message["content"].as_str() { + system.push(text.to_string()); + } + } else { + turns.push(message); + } + } + let mut body = json!({"model": config.model, "stream": stream, "messages": turns, "max_tokens": config.protocol.max_tokens}); + if !system.is_empty() { + body["system"] = json!(system.join("\n\n")); + } + if config.thinking_enabled { + body["thinking"] = + if config.protocol.messages_thinking == MessagesThinking::Adaptive { + json!({"type": "adaptive"}) + } else { + json!({"type": "enabled", "budget_tokens": config.protocol.thinking_budget}) + }; + } + body + } + }; + if body.get("reasoning").is_none() + && !(config.protocol.format == LlmRequestFormat::Messages && config.thinking_enabled) + { + if let Some(temperature) = config.temperature { + body["temperature"] = json!(temperature); + } + } + body +} + +fn response_error(message: &str) -> LLMError { + LLMError::ParseError(message.to_string()) +} + +fn check_stop_reason(value: &Value) -> Result { + if let Some(reason) = value.as_str() { + if !matches!(reason, "end_turn" | "stop_sequence") { + return Err(response_error("llmResponseIncomplete")); + } + return Ok(true); + } + Ok(false) +} + +pub(crate) fn extract_text(format: LlmRequestFormat, text: &str) -> Result { + if format == LlmRequestFormat::ChatCompletions { + return crate::polish::extract_assistant_content(text); + } + let value: Value = + serde_json::from_str(text).map_err(|_| response_error("invalid LLM JSON"))?; + if !value["error"].is_null() { + return Err(response_error("llmStreamError")); + } + let mut output = String::new(); + match format { + LlmRequestFormat::Responses => { + if value["status"] != "completed" { + return Err(response_error("llmResponseIncomplete")); + } + if let Some(items) = value["output"].as_array() { + for item in items { + if item["type"] == "message" && item["role"] == "assistant" { + append_blocks(&mut output, &item["content"], "output_text"); + } + } + } + } + LlmRequestFormat::Messages => { + if !check_stop_reason(&value["stop_reason"])? { + return Err(response_error("llmResponseIncomplete")); + } + append_blocks(&mut output, &value["content"], "text"); + } + LlmRequestFormat::ChatCompletions => unreachable!(), + } + if output.is_empty() { + return Err(response_error("empty LLM response")); + } + Ok(crate::polish::clean_polish_output(&output)) +} + +fn append_blocks(output: &mut String, blocks: &Value, kind: &str) { + if let Some(blocks) = blocks.as_array() { + for block in blocks { + if block["type"] == kind { + if let Some(text) = block["text"].as_str() { + output.push_str(text); + } + } + } + } +} + +pub(crate) enum StreamEvent { + Text(String), + Done, + Ignore, +} + +/// 共用 SSE 分帧;保留未完整的 UTF-8 字节,不能逐个 HTTP chunk 有损解码。 +pub(crate) struct TextEventStream { + format: LlmRequestFormat, + buffer: String, + pending: Vec, + messages_complete: bool, + pub done: bool, +} + +impl TextEventStream { + pub fn new(format: LlmRequestFormat) -> Self { + Self { + format, + buffer: String::new(), + pending: Vec::new(), + messages_complete: false, + done: false, + } + } + + pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> { + crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk)?; + // 在完整字符串上替换,兼容 CR 与 LF 分属不同网络块。 + if self.buffer.contains("\r\n") { + self.buffer = self.buffer.replace("\r\n", "\n"); + } + Ok(()) + } + + pub fn next(&mut self) -> Result, LLMError> { + if self.done { + return Ok(None); + } + let Some(end) = self.buffer.find("\n\n") else { + return Ok(None); + }; + let event = self.buffer[..end].to_string(); + self.buffer.drain(..end + 2); + let data = event + .lines() + .filter_map(|line| line.strip_prefix("data:").map(str::trim_start)) + .collect::>() + .join("\n"); + if data.is_empty() { + return Ok(Some(StreamEvent::Ignore)); + } + if self.format == LlmRequestFormat::ChatCompletions && data.trim() == "[DONE]" { + self.done = true; + return Ok(Some(StreamEvent::Done)); + } + let value: Value = match serde_json::from_str(&data) { + Ok(value) => value, + Err(_) if self.format == LlmRequestFormat::ChatCompletions => { + return Ok(Some(StreamEvent::Ignore)) + } + Err(_) => return Err(response_error("invalid LLM SSE JSON")), + }; + let kind = value["type"] + .as_str() + .or_else(|| { + event + .lines() + .find_map(|line| line.strip_prefix("event:").map(str::trim)) + }) + .unwrap_or(""); + if kind == "error" || !value["error"].is_null() { + return Err(response_error("llmStreamError")); + } + let text = match self.format { + LlmRequestFormat::ChatCompletions => value["choices"][0]["delta"]["content"].as_str(), + LlmRequestFormat::Responses => match kind { + "response.output_text.delta" => value["delta"].as_str(), + "response.completed" => { + if value["response"]["status"] + .as_str() + .is_some_and(|s| s != "completed") + { + return Err(response_error("llmResponseIncomplete")); + } + self.done = true; + None + } + "response.failed" | "response.incomplete" => { + return Err(response_error("llmResponseIncomplete")) + } + _ => None, + }, + LlmRequestFormat::Messages => match kind { + "content_block_start" if value["content_block"]["type"] == "text" => { + value["content_block"]["text"].as_str() + } + "content_block_delta" if value["delta"]["type"] == "text_delta" => { + value["delta"]["text"].as_str() + } + "message_delta" => { + if check_stop_reason(&value["delta"]["stop_reason"])? { + self.messages_complete = true; + } + None + } + "message_stop" => { + if !self.messages_complete { + return Err(response_error("llmResponseIncomplete")); + } + self.done = true; + None + } + _ => None, + }, + }; + Ok(Some( + if let Some(text) = text.filter(|text| !text.is_empty()) { + StreamEvent::Text(text.to_string()) + } else if self.done { + StreamEvent::Done + } else { + StreamEvent::Ignore + }, + )) + } + + pub fn finish(&mut self) -> Result<(), LLMError> { + crate::polish::finish_utf8_sse_chunks(&mut self.buffer, &mut self.pending)?; + if self.format != LlmRequestFormat::ChatCompletions && !self.done { + return Err(response_error("llmResponseIncomplete")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::{InMemoryCredentialStore, SecretValue}; + + #[tokio::test] + async fn protocol_defaults_overrides_and_channels_are_independent() { + let store = InMemoryCredentialStore::default(); + for (provider, expected) in [ + ("custom", LlmRequestFormat::ChatCompletions), + ("custom_responses", LlmRequestFormat::Responses), + ("custom_messages", LlmRequestFormat::Messages), + ("deepseek", LlmRequestFormat::ChatCompletions), + ] { + assert_eq!( + LlmProtocolConfig::load(&store, "a", provider) + .await + .unwrap() + .format, + expected + ); + } + let key = CredentialKey::new( + CredentialNamespace::Llm, + Some("a".into()), + REQUEST_FORMAT_ACCOUNT, + ) + .unwrap(); + store + .write(key.clone(), SecretValue::new("messages")) + .await + .unwrap(); + assert_eq!( + LlmProtocolConfig::load(&store, "a", "openai") + .await + .unwrap() + .format, + LlmRequestFormat::Messages + ); + assert_eq!( + LlmProtocolConfig::load(&store, "b", "openai") + .await + .unwrap() + .format, + LlmRequestFormat::ChatCompletions + ); + store.write(key, SecretValue::new("invalid")).await.unwrap(); + assert!(LlmProtocolConfig::load(&store, "a", "openai") + .await + .is_err()); + } + + #[test] + fn urls_and_auth_follow_format_without_losing_gateway_paths() { + for descriptor in crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) { + if LlmRequestFormat::selectable(descriptor.provider_type.as_str()) { + assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL); + assert_eq!( + descriptor.default_request_format, + Some(LlmRequestFormat::default_for( + descriptor.provider_type.as_str() + )) + ); + } else { + assert!(descriptor.supported_request_formats.is_empty()); + assert!(descriptor.default_request_format.is_none()); + } + } + for format in LlmRequestFormat::ALL { + let suffix = match format { + LlmRequestFormat::ChatCompletions => "chat/completions", + LlmRequestFormat::Responses => "responses", + LlmRequestFormat::Messages => "messages", + }; + for old in [ + "", + "/chat/completions/", + "/responses", + "/messages/", + "/models", + ] { + let base = format!("https://example.com/gateway/v1{old}?tenant=1#local"); + assert_eq!( + format.url(&base).unwrap(), + format!("https://example.com/gateway/v1/{suffix}?tenant=1#local") + ); + assert_eq!( + endpoint_url(&base, "/models").unwrap(), + "https://example.com/gateway/v1/models?tenant=1#local" + ); + } + let headers = format.headers("test-key"); + if format == LlmRequestFormat::Messages { + assert!(headers.contains(&("x-api-key".into(), "test-key".into()))); + assert!(!headers.iter().any(|(name, _)| name == "Authorization")); + } else { + assert_eq!( + headers, + vec![("Authorization".into(), "Bearer test-key".into())] + ); + } + } + let headers = + crate::provider_rules::parse_extra_headers(r#"{"X-API-Key":"override"}"#).unwrap(); + assert!(LlmProtocolConfig::default() + .validate_headers(&headers) + .is_ok()); + assert!(LlmProtocolConfig { + format: LlmRequestFormat::Messages, + ..Default::default() + } + .validate_headers(&headers) + .is_err()); + } + + #[test] + fn request_shapes_and_thinking_do_not_leak_between_protocols() { + let messages = vec![ + json!({"role":"system","content":"rules"}), + json!({"role":"user","content":"old"}), + json!({"role":"assistant","content":"answer"}), + json!({"role":"user","content":"new"}), + ]; + let mut config = OpenAICompatibleConfig::new( + "deepseek", + "test", + "https://example.com/v1", + "key", + "gateway-model", + ) + .with_temperature(Some(0.5)); + config.protocol.format = LlmRequestFormat::Responses; + for enabled in [false, true] { + config.thinking_enabled = enabled; + let body = request_body(&config, true, messages.clone()); + assert_eq!(body["input"], json!(messages)); + assert_eq!(body["store"], false); + assert_eq!( + body["reasoning"]["effort"], + if enabled { "medium" } else { "low" } + ); + for absent in [ + "messages", + "thinking", + "enable_thinking", + "reasoning_effort", + "temperature", + ] { + assert!(body.get(absent).is_none()); + } + } + config.model = "gpt-4o".into(); + let body = request_body(&config, false, messages.clone()); + assert!(body.get("reasoning").is_none()); + assert_eq!(body["temperature"], 0.5); + config.model = "gpt-5-pro".into(); + assert_eq!( + request_body(&config, false, messages.clone())["reasoning"]["effort"], + "high" + ); + config.protocol.format = LlmRequestFormat::Messages; + for enabled in [false, true] { + config.thinking_enabled = enabled; + for mode in [MessagesThinking::Adaptive, MessagesThinking::Budget] { + config.protocol.messages_thinking = mode; + let body = request_body(&config, false, messages.clone()); + assert_eq!(body["system"], "rules"); + assert_eq!(body["messages"], json!(&messages[1..])); + assert_eq!(body["max_tokens"], 8192); + if enabled { + assert_eq!( + body["thinking"]["type"], + if mode == MessagesThinking::Adaptive { + "adaptive" + } else { + "enabled" + } + ); + } else { + assert!(body.get("thinking").is_none()); + } + assert_eq!(body.get("temperature").is_none(), enabled); + if enabled && mode == MessagesThinking::Budget { + assert_eq!(body["thinking"]["budget_tokens"], 1024); + } + for absent in ["input", "reasoning", "reasoning_effort", "enable_thinking"] { + assert!(body.get(absent).is_none()); + } + } + } + } + + #[test] + fn budgets_are_validated_and_non_streaming_extracts_only_complete_text() { + let mut config = LlmProtocolConfig { + format: LlmRequestFormat::Messages, + messages_thinking: MessagesThinking::Budget, + ..Default::default() + }; + for value in ["0", "-1", "1.5", "4294967296"] { + assert!(config.apply(MAX_TOKENS_ACCOUNT, value).is_err()); + } + assert!(config.apply(THINKING_BUDGET_ACCOUNT, "1023").is_err()); + config.apply(THINKING_BUDGET_ACCOUNT, "8192").unwrap(); + assert!(config.validate().is_err()); + config.apply(MAX_TOKENS_ACCOUNT, "10000").unwrap(); + config.validate().unwrap(); + for (format, body) in [ + ( + LlmRequestFormat::Responses, + json!({"status":"completed", "output":[{"type":"reasoning","summary":"secret"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]}]}), + ), + ( + LlmRequestFormat::Messages, + json!({"stop_reason":"end_turn", "content":[{"type":"thinking","thinking":"secret"},{"type":"text","text":"你"},{"type":"text","text":"好"}]}), + ), + ] { + assert_eq!(extract_text(format, &body.to_string()).unwrap(), "你好"); + } + assert!(extract_text( + LlmRequestFormat::Responses, + r#"{"status":"incomplete","output":[]}"# + ) + .is_err()); + assert!(extract_text( + LlmRequestFormat::Messages, + r#"{"stop_reason":"max_tokens","content":[{"type":"text","text":"partial"}]}"# + ) + .is_err()); + } + + #[test] + fn sse_handles_every_byte_boundary_and_requires_successful_termination() { + for (format, fixture) in [ + (LlmRequestFormat::Responses, "event: response.output_text.delta\r\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"你好🙂\"}\r\n\r\ndata: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"secret\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"), + (LlmRequestFormat::Messages, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好🙂\"}}\r\n\r\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"secret\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n"), + ] { + for size in 1..=fixture.len() { + let mut stream = TextEventStream::new(format); + let mut text = String::new(); + for chunk in fixture.as_bytes().chunks(size) { + stream.push(chunk).unwrap(); + while let Some(event) = stream.next().unwrap() { + if let StreamEvent::Text(delta) = event { text.push_str(&delta); } + } + } + stream.finish().unwrap(); + assert_eq!(text, "你好🙂"); + } + assert!(TextEventStream::new(format).finish().is_err()); + } + for (format, event) in [ + (LlmRequestFormat::Responses, r#"{"type":"response.failed"}"#), + ( + LlmRequestFormat::Responses, + r#"{"type":"response.incomplete"}"#, + ), + ( + LlmRequestFormat::Messages, + r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#, + ), + ( + LlmRequestFormat::Messages, + r#"{"type":"error","error":{"message":"secret"}}"#, + ), + ] { + let mut stream = TextEventStream::new(format); + stream + .push(format!("data: {event}\n\n").as_bytes()) + .unwrap(); + let error = stream + .next() + .err() + .expect("must reject unsuccessful streams"); + assert!(!error.to_string().contains("secret")); + } + + let mut stream = TextEventStream::new(LlmRequestFormat::Messages); + stream + .push( + b"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", + ) + .unwrap(); + assert!(matches!(stream.next().unwrap(), Some(StreamEvent::Text(_)))); + let error = match stream.next() { + Err(error) => error, + Ok(_) => panic!("message_stop without stop_reason must fail"), + }; + assert!(error.to_string().contains("llmResponseIncomplete")); + } +} diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 0a0a84764..23857658e 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -1,6 +1,6 @@ #![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] #![allow(clippy::too_many_arguments)] -//! OpenAI-compatible chat completions client + polish prompts. +//! 渠道级文本协议客户端与润色提示词。 //! //! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例` //! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。 @@ -13,6 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; use thiserror::Error; +use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat, StreamEvent, TextEventStream}; use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage}; use crate::types::PolishMode; @@ -94,6 +95,7 @@ pub(crate) fn polish_total_timeout_secs(input_chars: usize) -> Duration { #[derive(Clone, Debug)] pub struct OpenAICompatibleConfig { + pub protocol: LlmProtocolConfig, pub provider_id: String, pub display_name: String, pub base_url: String, @@ -120,6 +122,10 @@ impl OpenAICompatibleConfig { let temperature = openai_compatible_temperature_for_provider(&provider_id, None); Self { + protocol: LlmProtocolConfig { + format: LlmRequestFormat::default_for(&provider_id), + ..Default::default() + }, provider_id, display_name: display_name.into(), base_url: base_url.into(), @@ -137,6 +143,11 @@ impl OpenAICompatibleConfig { self } + pub fn with_protocol(mut self, protocol: LlmProtocolConfig) -> Self { + self.protocol = protocol; + self + } + pub fn with_extra_headers(mut self, extra_headers: HashMap) -> Self { self.extra_headers = extra_headers; self @@ -176,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "opencode" ) } @@ -234,8 +246,7 @@ impl ActiveLLMProvider { } } - /// 流式润色仅支持 OpenAI-compatible;Codex 使用 Responses API,保留 1.x 的 - /// 非流式润色能力。调用方须先检查此能力,不能把不同的 SSE 协议混用。 + /// 普通渠道的三种文本协议均支持流式润色;Codex OAuth 保留专用调用路径。 /// Gemini 由共享 cloud_providers 单独分流,不进入 ActiveLLMProvider 枚举。 pub fn supports_streaming_polish(&self) -> bool { matches!(self, Self::OpenAI(_)) @@ -418,7 +429,6 @@ impl ActiveLLMProvider { pub struct OpenAICompatibleLLMProvider { config: OpenAICompatibleConfig, - client: reqwest::Client, /// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的 /// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。 /// @@ -434,15 +444,8 @@ impl OpenAICompatibleLLMProvider { // pool survives across utterances instead of paying a fresh TLS handshake // every polish. Falls back to a default client if the builder somehow fails // so we still surface a useful error at request time. - let timeout = config.request_timeout_secs; let no_proxy = crate::net::should_bypass_proxy(&config.base_url, crate::net::use_system_proxy()); - let base_url = config.base_url.clone(); - let client = crate::net::cached_client((timeout, no_proxy), || { - http_client_builder(&base_url, timeout) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - }); let polish_base_url = config.base_url.clone(); let polish_client = crate::net::cached_client((POLISH_CLIENT_HARD_CAP_SECS, no_proxy), || { @@ -452,7 +455,6 @@ impl OpenAICompatibleLLMProvider { }); Self { config, - client, polish_client, } } @@ -632,7 +634,7 @@ impl OpenAICompatibleLLMProvider { user_prompt: &str, budget: Duration, ) -> Result { - let url = chat_completions_url(&self.config.base_url); + let url = self.config.protocol.format.url(&self.config.base_url)?; let messages = build_polish_history_messages(system_prompt, prior_turns, user_prompt); let body = self.chat_body(false, messages); @@ -654,7 +656,7 @@ impl OpenAICompatibleLLMProvider { user_prompt: &str, budget: Duration, ) -> Result { - let url = chat_completions_url(&self.config.base_url); + let url = self.config.protocol.format.url(&self.config.base_url)?; let body = self.chat_body( false, vec![ @@ -674,6 +676,9 @@ impl OpenAICompatibleLLMProvider { } fn chat_body(&self, stream: bool, messages: Vec) -> Value { + if self.config.protocol.format != LlmRequestFormat::ChatCompletions { + return crate::llm_protocol::request_body(&self.config, stream, messages); + } let mut body = json!({ "model": self.config.model, "stream": stream, @@ -724,17 +729,19 @@ impl OpenAICompatibleLLMProvider { url: &str, body: &serde_json::Value, ) -> Result { - let mut request = self - .polish_client - .post(url) - .header("Content-Type", "application/json"); - if !self.config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); - } - for (k, v) in &self.config.extra_headers { - request = request.header(k.as_str(), v.as_str()); - } - let request = request.json(body); + self.config + .protocol + .validate() + .and_then(|_| { + self.config + .protocol + .validate_headers(&self.config.extra_headers) + }) + .map_err(|error| LLMError::ParseError(error.message))?; + let request = self + .authorize(self.polish_client.post(url)) + .header("Content-Type", "application/json") + .json(body); let response = send_with_transient_retry(request).await?; @@ -752,12 +759,10 @@ impl OpenAICompatibleLLMProvider { }); } - extract_assistant_content(&body_text) + crate::llm_protocol::extract_text(self.config.protocol.format, &body_text) } - /// 与 `chat_completion` 同条 HTTP 通路,但开 `stream: true` 并把 SSE chunk 一边 - /// 解析、一边通过 `on_delta` 推给调用方(用于实时把答案塞进浮窗气泡)。 - /// 最终返回拼好的完整字符串供调用方写入对话历史。 + /// 问答与润色共用协议解码,但问答保留配置中的整请求预算。 async fn chat_completion_history_streaming( &self, system_prompt: &str, @@ -769,125 +774,27 @@ impl OpenAICompatibleLLMProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let mut msgs: Vec = Vec::with_capacity(history.len() + 1); - msgs.push(json!({ "role": "system", "content": system_prompt })); - for m in history { - msgs.push(json!({ "role": m.role, "content": m.content })); - } - - let url = chat_completions_url(&self.config.base_url); - let body = self.chat_body(true, msgs); - - log::info!( - "[llm] POST {} provider={} model={} chat_turns={} stream=true", - crate::net::sanitized_url_for_logs(&url), - self.config.provider_id, - self.config.model, - history.len() - ); - - let mut request = self - .client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - if !self.config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); - } - for (k, v) in &self.config.extra_headers { - request = request.header(k.as_str(), v.as_str()); - } - let request = request.json(&body); - - let response = send_with_transient_retry(request).await?; - - let status = response.status(); - if !status.is_success() { - // 失败时仍把 body 读一遍方便诊断 - let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); - let preview = safe_str_slice(&body_text, preview_end); - log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); - return Err(LLMError::InvalidResponse { - status: status.as_u16(), - body: preview.to_string(), - }); - } - - // SSE 流:一帧 = 若干行,以 `\n\n` 分隔。每行如 `data: {...}` 或 `data: [DONE]`。 - // 一个 chunk() 可能包含半帧或多帧;用 buffer 累积后再按 `\n\n` 切。 - let mut response = response; - let mut buffer = String::new(); - let mut utf8_pending: Vec = Vec::new(); - let mut full_text = String::new(); - let mut cancelled = false; - loop { - // 取消旗标:用户取消 / 关浮窗时立即 break,不再 drain HTTP body。 - // 否则 reqwest 会读完整个流(包括 LLM 后续 token)烧 quota。详见 issue #161。 - if should_cancel() { - log::info!("[llm] stream cancelled by caller; breaking SSE loop"); - cancelled = true; - break; - } - let chunk_opt = response.chunk().await.map_err(llm_error_from_reqwest)?; - let Some(chunk) = chunk_opt else { break }; - append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; - - while let Some(idx) = buffer.find("\n\n") { - let event = buffer[..idx].to_string(); - buffer.drain(..idx + 2); - for line in event.lines() { - let Some(payload) = line - .strip_prefix("data: ") - .or_else(|| line.strip_prefix("data:")) - else { - continue; - }; - let payload = payload.trim(); - if payload.is_empty() || payload == "[DONE]" { - continue; - } - let v: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(e) => { - log::warn!( - "[llm] SSE parse skip: {e}; payload preview: {}", - safe_str_slice(payload, 80) - ); - continue; - } - }; - if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() { - if !delta.is_empty() { - full_text.push_str(delta); - on_delta(delta); - } - } - } - } - } - if !cancelled { - finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; - } - - log::info!( - "[llm] HTTP 200 stream done; total chars={}", - full_text.chars().count() - ); - - if full_text.is_empty() { - return Err(LLMError::InvalidResponse { - status: 200, - body: "empty stream".to_string(), - }); - } - Ok(full_text) + let mut messages = vec![json!({ "role": "system", "content": system_prompt })]; + for message in history { + messages.push(json!({ "role": message.role, "content": message.content })); + } + let budget = Duration::from_secs(self.config.request_timeout_secs); + tokio::time::timeout( + budget, + self.chat_completion_messages_streaming( + messages, + StreamingTimeouts { + first_token: budget, + idle: budget, + }, + on_delta, + should_cancel, + ), + ) + .await + .map_err(|_| LLMError::Timeout)? } - /// 把已经构造好的 `messages` 列表(包含 system + 历史 + 当前 user)作为 - /// `stream: true` 的 body 发出去,SSE 一帧一帧解析。供 `polish_streaming` 复用, - /// 跟 `chat_completion_history_streaming` 的 SSE 解析逻辑同款 —— 后者多了一步从 - /// `QaChatMessage[]` 装配 messages 的工作。 async fn chat_completion_messages_streaming( &self, messages: Vec, @@ -899,151 +806,132 @@ impl OpenAICompatibleLLMProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let url = chat_completions_url(&self.config.base_url); + if should_cancel() { + return Err(LLMError::Network("cancelled".into())); + } + self.config + .protocol + .validate() + .and_then(|_| { + self.config + .protocol + .validate_headers(&self.config.extra_headers) + }) + .map_err(|error| LLMError::ParseError(error.message))?; + let url = self.config.protocol.format.url(&self.config.base_url)?; let body = self.chat_body(true, messages); - - let mut request = self - .polish_client - .post(&url) + log::info!( + "[llm] POST {} provider={} model={} format={:?} stream=true", + crate::net::sanitized_url_for_logs(&url), + self.config.provider_id, + self.config.model, + self.config.protocol.format + ); + let request = self + .authorize(self.polish_client.post(&url)) .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - if !self.config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); - } - for (k, v) in &self.config.extra_headers { - request = request.header(k.as_str(), v.as_str()); - } - let request = request.json(&body); - - let response = send_with_transient_retry(request).await?; - + .header("Accept", "text/event-stream") + .json(&body); + let started = std::time::Instant::now(); + // 取消要能唤醒正在等待网络数据的请求,不能只在 chunk 之间检查。 + let cancellation = async { + while !should_cancel() { + tokio::time::sleep(Duration::from_millis(25)).await; + } + }; + tokio::pin!(cancellation); + let mut response = tokio::select! { + _ = &mut cancellation => return Err(LLMError::Network("cancelled".into())), + result = tokio::time::timeout(timeouts.first_token, send_with_transient_retry(request)) => { + result.map_err(|_| LLMError::Timeout)?? + } + }; let status = response.status(); if !status.is_success() { - let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); - let preview = safe_str_slice(&body_text, preview_end); - log::error!("[llm] streaming HTTP {} body={}", status.as_u16(), preview); + let body_text = tokio::select! { + _ = &mut cancellation => return Err(LLMError::Network("cancelled".into())), + result = tokio::time::timeout(timeouts.first_token.saturating_sub(started.elapsed()), response.text()) => { + result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)? + } + }; return Err(LLMError::InvalidResponse { status: status.as_u16(), - body: preview.to_string(), + body: safe_str_slice(&body_text, BODY_PREVIEW_LIMIT.min(body_text.len())) + .to_string(), }); } - - let mut response = response; - let mut buffer = String::new(); - let mut utf8_pending: Vec = Vec::new(); + let mut events = TextEventStream::new(self.config.protocol.format); let mut full_text = String::new(); - let mut delta_count: u64 = 0; let mut cancelled = false; - let stream_started = std::time::Instant::now(); - let mut first_content_at: Option = None; - loop { + while !events.done { if should_cancel() { - log::info!( - "[llm] polish stream cancelled by caller after {} deltas ({} chars); breaking SSE loop", - delta_count, - full_text.chars().count() - ); cancelled = true; break; } - // 首字之前用「还剩多少首字预算」,首字之后用「两个 chunk 之间能空多久」。 - // 注意首字预算是从请求发出起算的**总量**,不随 chunk 到达而重置——推理模型 - // 思考期的 reasoning_content 是一串正常 chunk,若让它续命,用户干等就没有上限。 - let budget = match first_content_at { - None => timeouts - .first_token - .saturating_sub(stream_started.elapsed()), - Some(_) => timeouts.idle, + let budget = if full_text.is_empty() { + timeouts.first_token.saturating_sub(started.elapsed()) + } else { + timeouts.idle }; - let chunk_opt = match tokio::time::timeout(budget, response.chunk()).await { - Ok(result) => result.map_err(llm_error_from_reqwest)?, - Err(_) => { - // 已经交给 on_delta 的字此刻就在用户屏幕上;上层 dictation 的 Failed - // 分支拿 typed_text 当 final_text,屏幕 / history / 剪贴板保持一致。 - match first_content_at { - None => log::error!( - "[llm] polish stream timed out waiting for first content delta (budget {:?}); \ - 模型可能仍在思考——加长首字预算或换非推理模型", - timeouts.first_token - ), - Some(first) => log::error!( - "[llm] polish stream stalled {:?} after {} chars (first delta at {:?}); \ - 已落屏的字保留", - timeouts.idle, - full_text.chars().count(), - first - ), - } - return Err(LLMError::Timeout); + let chunk = tokio::select! { + _ = &mut cancellation => { cancelled = true; break; } + result = tokio::time::timeout(budget, response.chunk()) => { + result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)? } }; - let Some(chunk) = chunk_opt else { break }; - append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; - - while let Some(idx) = buffer.find("\n\n") { - let event = buffer[..idx].to_string(); - buffer.drain(..idx + 2); - for line in event.lines() { - let Some(payload) = line - .strip_prefix("data: ") - .or_else(|| line.strip_prefix("data:")) - else { - continue; - }; - let payload = payload.trim(); - if payload.is_empty() || payload == "[DONE]" { - continue; - } - let v: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(e) => { - log::warn!( - "[llm] polish SSE parse skip: {e}; payload preview: {}", - safe_str_slice(payload, 80) - ); - continue; - } - }; - if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() { - if !delta.is_empty() { - if first_content_at.is_none() { - let elapsed = stream_started.elapsed(); - first_content_at = Some(elapsed); - // 首字延迟是判断「模型思考太久」还是「网络卡住」的关键读数。 - // 之前日志里没有它,7 分钟录音那次只能靠外部实测才量出 43s。 - log::info!( - "[llm] polish stream first content delta after {:.2}s (budget {:?})", - elapsed.as_secs_f64(), - timeouts.first_token - ); - } - full_text.push_str(delta); - delta_count += 1; - on_delta(delta); - } + let Some(chunk) = chunk else { + break; + }; + events.push(&chunk)?; + loop { + if should_cancel() { + cancelled = true; + break; + } + let Some(event) = events.next()? else { + break; + }; + if let StreamEvent::Text(delta) = event { + if full_text.is_empty() { + log::info!( + "[llm] first content delta after {:.2}s", + started.elapsed().as_secs_f64() + ); } + full_text.push_str(&delta); + on_delta(&delta); } } + if cancelled { + break; + } } if !cancelled { - finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; + events.finish()?; } - log::info!( - "[llm] polish stream done; total deltas={} chars={}", - delta_count, + "[llm] stream done; cancelled={} chars={}", + cancelled, full_text.chars().count() ); - if full_text.is_empty() { return Err(LLMError::InvalidResponse { status: 200, - body: "empty polish stream".to_string(), + body: "empty polish stream".into(), }); } Ok(full_text) } + + fn authorize(&self, mut request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + for (name, value) in self.config.protocol.format.headers(&self.config.api_key) { + request = request.header(name, value); + } + for (name, value) in &self.config.extra_headers { + request = request.header(name, value); + } + request + } } #[derive(Clone, Debug)] @@ -1762,8 +1650,21 @@ pub(crate) fn apply_openai_compatible_thinking_control( ) { // 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底, // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 - let control = openai_compatible_thinking_control(provider_id) - .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)); + // 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"))); + let control = if is_opencode { + model + .trim() + .starts_with("deepseek-") + .then_some(ThinkingControl::DeepSeekThinking) + } else { + openai_compatible_thinking_control(provider_id) + .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)) + }; match control { Some(ThinkingControl::ReasoningEffort) => { // OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort; @@ -1941,7 +1842,7 @@ mod tests { "https://user:pass@example.com/v1/chat/completions?token=query-secret#client-fragment" ); } - use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex as StdMutex; use std::thread; @@ -2236,6 +2137,215 @@ mod tests { request } + #[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)) + }) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for index in 0..5 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let split = request.windows(4).position(|w| w == b"\r\n\r\n").unwrap(); + let headers = String::from_utf8_lossy(&request[..split]).to_ascii_lowercase(); + let body: Value = serde_json::from_slice(&request[split + 4..]).unwrap(); + let path = match format { + LlmRequestFormat::ChatCompletions => "chat/completions", + LlmRequestFormat::Responses => "responses", + LlmRequestFormat::Messages => "messages", + }; + assert!(headers.starts_with(&format!("post {prefix}/{path}?tenant=1 "))); + if format == LlmRequestFormat::Messages { + assert!(headers.contains("x-api-key: fixture-key")); + assert!(headers.contains("anthropic-version: 2023-06-01")); + assert!(!headers.contains("authorization:")); + assert!(body["system"].as_str().is_some_and(|text| !text.is_empty())); + } else { + assert!(headers.contains("authorization: bearer fixture-key")); + } + assert!(!headers.contains("chatgpt-account-id")); + let messages = if format == LlmRequestFormat::Responses { + &body["input"] + } else { + &body["messages"] + }; + assert!(messages + .as_array() + .is_some_and(|messages| !messages.is_empty())); + if index == 1 { + assert!(messages + .as_array() + .unwrap() + .iter() + .any(|m| m["role"] == "assistant" && m["content"] == "prior answer")); + } + if index < 3 { + assert_eq!(body["stream"], false); + let response = match format { + LlmRequestFormat::ChatCompletions => json!({"choices":[{"message":{"content":"你好"}}]}), + LlmRequestFormat::Responses => json!({"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你好"}]}]}), + LlmRequestFormat::Messages => json!({"stop_reason":"end_turn","content":[{"type":"text","text":"你好"}]}), + }.to_string(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); + } else { + assert_eq!(body["stream"], true); + let response = match format { + LlmRequestFormat::ChatCompletions => "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + LlmRequestFormat::Responses => "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好\"}\r\n\r\ndata: {\"type\":\"response.completed\"}\r\n\r\n", + LlmRequestFormat::Messages => "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\r\n\r\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\r\n\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n", + }; + let split = response.find('好').unwrap() + 1; + write_chunked_sse_response( + &mut stream, + &[&response.as_bytes()[..split], &response.as_bytes()[split..]], + ); + } + } + }); + let config = OpenAICompatibleConfig::new( + preset, + "test", + format!("http://{address}{prefix}/chat/completions?tenant=1"), + "fixture-key", + "test", + ) + .with_protocol(LlmProtocolConfig { + format, + ..Default::default() + }); + let provider = OpenAICompatibleLLMProvider::new(config); + for history in [vec![], vec![("prior input".into(), "prior answer".into())]] { + assert_eq!( + provider + .polish( + "input", + PolishMode::Light, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &history + ) + .await + .unwrap(), + "你好" + ); + } + assert_eq!( + provider + .translate_to( + "hello", + "Chinese", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None + ) + .await + .unwrap(), + "你好" + ); + let output = std::sync::Mutex::new(String::new()); + let delta = |text: &str| output.lock().unwrap().push_str(text); + let history = vec![QaChatMessage { + role: "user".into(), + content: "hello".into(), + selection_text: None, + }]; + assert_eq!( + provider + .answer_chat_streaming( + &history, + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + delta, + || false + ) + .await + .unwrap(), + "你好" + ); + assert_eq!(*output.lock().unwrap(), "你好"); + output.lock().unwrap().clear(); + assert_eq!( + provider + .polish_streaming( + "input", + PolishMode::Light, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &[], + delta, + || false + ) + .await + .unwrap(), + "你好" + ); + assert_eq!(*output.lock().unwrap(), "你好"); + server.join().unwrap(); + } + } + + #[test] + fn opencode_thinking_is_scoped_to_model_host_and_protocol() { + for (preset, endpoint, zen) in [ + ("opencode", "https://opencode.ai/zen/v1", true), + ("opencode", "https://gateway.example/v1", true), + ("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.example/zen/v1", false), + ("custom", "https://fakeopencode.ai/zen/v1", false), + ("custom", "https://opencode.ai@example.com/zen/v1", false), + ("custom", "https://example.com/opencode.ai", false), + ] { + for model in ["deepseek-v4-flash", "minimax-m3", "gateway-model"] { + for enabled in [false, true] { + for format in LlmRequestFormat::ALL { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model) + .with_thinking_enabled(enabled) + .with_protocol(LlmProtocolConfig { format, ..Default::default() }), + ); + 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::Messages if enabled => { + assert_eq!(body["thinking"]["type"], "adaptive"); + } + _ => 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!(body.get("messages").is_none()); + } else { + assert!(body.get("reasoning").is_none()); + } + } + } + } + } + } + fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) { stream .write_all( @@ -2250,6 +2360,86 @@ mod tests { stream.write_all(b"0\r\n\r\n").unwrap(); } + #[tokio::test] + async fn protocol_stream_errors_and_cancellation_keep_already_emitted_text() { + let cancelled_provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "test", + "invalid endpoint", + "", + "test", + )); + let error = cancelled_provider + .chat_completion_messages_streaming( + Vec::new(), + StreamingTimeouts::for_input(0), + |_| panic!("cancelled request emitted text"), + || true, + ) + .await + .unwrap_err(); + assert!(matches!(error, LLMError::Network(ref message) if message == "cancelled")); + for (format, delta, terminal_error) in [ + ( + LlmRequestFormat::Responses, + r#"{"type":"response.output_text.delta","delta":"partial"}"#, + r#"{"type":"response.failed"}"#, + ), + ( + LlmRequestFormat::Messages, + r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}"#, + r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#, + ), + ] { + for cancel in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + read_http_request(&mut stream); + let fixture = format!("data: {delta}\n\ndata: {terminal_error}\n\n"); + write_chunked_sse_response(&mut stream, &[fixture.as_bytes()]); + }); + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "test", + format!("http://{address}"), + "", + "test", + ) + .with_protocol(LlmProtocolConfig { + format, + ..Default::default() + }), + ); + let cancelled = AtomicBool::new(false); + let output = std::sync::Mutex::new(String::new()); + let result = provider + .chat_completion_messages_streaming( + vec![json!({"role":"user","content":"hi"})], + StreamingTimeouts::for_input(2), + |text| { + output.lock().unwrap().push_str(text); + cancelled.store(cancel, Ordering::SeqCst); + }, + || cancelled.load(Ordering::SeqCst), + ) + .await; + assert_eq!(*output.lock().unwrap(), "partial"); + if cancel { + assert_eq!(result.unwrap(), "partial"); + } else { + assert!(result + .unwrap_err() + .to_string() + .contains("llmResponseIncomplete")); + } + server.join().unwrap(); + } + } + } + /// 带间隔的 SSE 发送:每个 chunk 前先睡一段,用来模拟「思考很久才出字」和 /// 「出字中途卡死」两种真实流。 fn write_chunked_sse_response_with_delays( 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 e182797a1..e79f9e20b 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -62,7 +62,10 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), ("stepfun", "stepfun"), - ("custom", "custom"), + ("opencode", "opencode"), + ("custom", "customChatCompletions"), + ("custom_responses", "customResponses"), + ("custom_messages", "customMessages"), ]; const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[ @@ -133,6 +136,8 @@ pub struct ProviderDescriptor { pub auth_requirement: AuthRequirement, pub validation_probe: ValidationProbe, pub static_models: Vec, + pub default_request_format: Option, + pub supported_request_formats: Vec, } pub fn provider_descriptors(kind: ProviderKind) -> Vec { @@ -210,6 +215,16 @@ fn provider_descriptor_with_label( ), }; Some(ProviderDescriptor { + default_request_format: (kind == ProviderKind::Llm + && crate::llm_protocol::LlmRequestFormat::selectable(&id)) + .then(|| crate::llm_protocol::LlmRequestFormat::default_for(&id)), + supported_request_formats: if kind == ProviderKind::Llm + && crate::llm_protocol::LlmRequestFormat::selectable(&id) + { + crate::llm_protocol::LlmRequestFormat::ALL.to_vec() + } else { + Vec::new() + }, kind, provider_type, label_key: label_key.to_string(), @@ -422,6 +437,8 @@ pub fn equivalent_endpoint(left: &str, right: &str) -> bool { .trim() .trim_end_matches('/') .trim_end_matches("/chat/completions") + .trim_end_matches("/responses") + .trim_end_matches("/messages") .trim_end_matches('/') } normalize(left).eq_ignore_ascii_case(normalize(right)) @@ -465,6 +482,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { match provider_type { + "opencode" => Some("https://opencode.ai/zen/v1"), "ark" => Some("https://ark.cn-beijing.volces.com/api/v3"), "deepseek" => Some("https://api.deepseek.com/v1"), "siliconflow" => Some("https://api.siliconflow.cn/v1"), @@ -485,7 +503,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { match provider_type { "ark" => Some("deepseek-v3-2"), - "deepseek" => Some("deepseek-v4-flash"), + "deepseek" | "opencode" => Some("deepseek-v4-flash"), "siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"), "atlascloud" => Some("qwen/qwen3.5-flash"), "openai" | "cometapi" => Some("gpt-4o"), @@ -973,6 +991,29 @@ mod tests { assert!(parse_extra_headers(r#"{"authorization":"secret"}"#).is_err()); } + #[test] + fn opencode_descriptor_supplies_defaults_formats_and_credentials() { + use crate::llm_protocol::LlmRequestFormat; + 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.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/"))); + let mut configuration = CredentialConfiguration { + llm_endpoint: true, + llm_endpoint_matches_default: true, + llm_model: true, + ..CredentialConfiguration::default() + }; + assert!(!llm_configured("opencode", &configuration)); + configuration.llm_api_key = true; + assert!(llm_configured("opencode", &configuration)); + } + #[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 ece54a2f7..a4d1c2d4e 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -23,6 +23,7 @@ use crate::domains::{ ProviderApi, ProviderCheckResult, ProviderKind, ProviderModelsResult, ProviderRequest, }; use crate::errors::{BackendError, BackendErrorCode}; +use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat}; use crate::ports::{TextPolisher, TextStreamChunk, TextStreamSink, TranscriptionEngine}; use crate::provider_rules::{ api_key_required, default_asr_endpoint, default_asr_model, default_llm_endpoint, @@ -158,6 +159,13 @@ impl ProviderService { }; Ok(ResolvedProvider { + thinking_enabled: request.thinking_enabled, + protocol: if request.kind == ProviderKind::Llm { + LlmProtocolConfig::load(self.credentials.as_ref(), &provider_id, &provider_type) + .await? + } else { + LlmProtocolConfig::default() + }, kind: request.kind, provider_id, provider_type, @@ -350,6 +358,8 @@ impl ProviderApi for ProviderService { #[derive(Debug, Clone)] struct ResolvedProvider { + thinking_enabled: bool, + protocol: LlmProtocolConfig, kind: ProviderKind, provider_id: String, provider_type: String, @@ -362,6 +372,7 @@ struct ResolvedProvider { impl ResolvedProvider { fn context(&self) -> DictationContext { let mut context = DictationContext::default(); + context.polish.llm_thinking_enabled = self.thinking_enabled; let invocation = ProviderInvocation { provider_id: self.provider_id.clone(), provider_type: self.provider_type.clone(), @@ -448,7 +459,9 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro validate_provider_endpoint(endpoint, resolved.kind == ProviderKind::Asr)?; } if let Some(headers) = resolved.extra_headers.as_deref() { - parse_extra_headers(headers)?; + resolved + .protocol + .validate_headers(&parse_extra_headers(headers)?)?; } } Ok(()) @@ -585,6 +598,19 @@ fn sanitize_validation_error(error: BackendError) -> BackendError { return error; } let message = error.message.as_str(); + for code in [ + "llmResponseIncomplete", + "llmStreamError", + "llmRequestFormatInvalid", + "llmThinkingModeInvalid", + "llmTokenLimitInvalid", + "llmThinkingBudgetInvalid", + "llmProtocolHeaderConflict", + ] { + if message == code || message == format!("parse error: {code}") { + return provider_error(code); + } + } if message.ends_with("is not configured") { return error; } @@ -620,8 +646,7 @@ async fn fetch_models( .or_else(|| default_omni_endpoint(&resolved.provider_type)) .ok_or_else(|| provider_error("provider endpoint is not configured"))?; let url = models_url(endpoint)?; - let is_gemini = - crate::net::sanitized_url_for_logs(&url).contains("generativelanguage.googleapis.com"); + let is_gemini = resolved.provider_type == "gemini"; let mut request_headers = Vec::new(); if let Some(api_key) = resolved .api_key @@ -631,9 +656,16 @@ async fn fetch_models( if is_gemini { request_headers.push(("x-goog-api-key".to_string(), api_key.to_string())); } else { - request_headers.push(("Authorization".to_string(), format!("Bearer {api_key}"))); + request_headers.extend(resolved.protocol.format.headers(api_key)); } } + if resolved.protocol.format == LlmRequestFormat::Messages + && !request_headers + .iter() + .any(|(name, _)| name == "anthropic-version") + { + request_headers.extend(resolved.protocol.format.headers("")); + } if let Some(extra_headers) = resolved.extra_headers.as_deref() { for (name, value) in parse_extra_headers(extra_headers)? { request_headers.push((name, value)); @@ -710,18 +742,8 @@ fn parse_model_list(body: &[u8], is_gemini: bool) -> Result, Backend } fn models_url(endpoint: &str) -> Result { - let mut url = url::Url::parse(endpoint.trim()) - .map_err(|_| invalid_request("provider endpoint is invalid"))?; - let path = url.path().trim_end_matches('/'); - let next_path = if path.ends_with("/models") { - path.to_string() - } else if let Some(prefix) = path.strip_suffix("/chat/completions") { - format!("{prefix}/models") - } else { - format!("{path}/models") - }; - url.set_path(&next_path); - Ok(url.to_string()) + crate::llm_protocol::endpoint_url(endpoint, "/models") + .map_err(|_| invalid_request("provider endpoint is invalid")) } fn map_transport_error(error: ProviderTransportError) -> BackendError { @@ -866,6 +888,48 @@ mod tests { id } + #[tokio::test] + async fn validation_and_model_lists_use_channel_protocol_and_thinking() { + use crate::llm_protocol::*; + for (format, preset, sse, path) in [ + ("chat_completions", "opencode", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "/v1/chat/completions"), + ("responses", "opencode", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"), + ("messages", "opencode", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"), + ("responses", "custom_responses", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"), + ("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"), + ] { + for enabled in [false, true] { + let (endpoint, request) = spawn_http_response("200 OK", "text/event-stream", sse); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[ + (LLM_ENDPOINT_ACCOUNT, &endpoint), (LLM_MODEL_ACCOUNT, "test"), (LLM_API_KEY_ACCOUNT, "fixture-key"), + (REQUEST_FORMAT_ACCOUNT, format), + ]).await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + service.validate(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: enabled }).await.unwrap(); + let request = request.recv_timeout(Duration::from_secs(2)).unwrap(); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with(&format!("POST {path} "))); + let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + if format == "responses" { assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); } + else if format == "messages" && enabled { assert_eq!(body["thinking"]["type"], "adaptive"); } + else { assert!(body.get("thinking").is_none()); } + } + let (endpoint, request) = spawn_http_response("200 OK", "application/json", r#"{"data":[{"id":"model"}]}"#); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[ + (LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{}", if format == "chat_completions" { "chat/completions" } else { format })), (LLM_MODEL_ACCOUNT, "test"), + (LLM_API_KEY_ACCOUNT, "fixture-key"), (REQUEST_FORMAT_ACCOUNT, format), + ]).await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + assert_eq!(service.list_models(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: false }).await.unwrap().models, vec!["model"]); + let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /v1/models ")); + if format == "messages" { assert!(request.contains("x-api-key: fixture-key")); } + else { assert!(request.contains("authorization: bearer fixture-key")); } + } + } + #[tokio::test] async fn openai_compatible_asr_without_key_reaches_the_configured_endpoint() { let (endpoint, request) = @@ -885,6 +949,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -918,6 +983,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -951,6 +1017,7 @@ mod tests { let error = service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -981,6 +1048,7 @@ mod tests { let result = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -1024,6 +1092,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -1092,6 +1161,7 @@ mod tests { let (service, credentials) = service_with_channel().await; let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some("missing".to_string()), }) @@ -1108,6 +1178,7 @@ mod tests { let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); let error = service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Omni, channel_id: Some("channel".to_string()), }) @@ -1166,6 +1237,7 @@ mod tests { let first_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(first_id.clone()), }) @@ -1173,6 +1245,7 @@ mod tests { .unwrap(); let second_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(second_id.clone()), }) @@ -1180,6 +1253,7 @@ mod tests { .unwrap(); let active_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: None, }) @@ -1286,6 +1360,7 @@ mod tests { let result = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -1327,6 +1402,7 @@ mod tests { transport.push_response(status, br#"{"data":[]}"#); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1340,6 +1416,7 @@ mod tests { transport.push_response(200, br#"not-json secret-body"#); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1351,6 +1428,7 @@ mod tests { transport.push_response(200, vec![b'x'; MODEL_LIST_MAX_BYTES + 1]); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1389,6 +1467,7 @@ mod tests { transport.push_error(transport_error); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1409,6 +1488,7 @@ mod tests { let error = service .list_models_with_cancellation( ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }, @@ -1441,6 +1521,7 @@ mod tests { let error = service .list_models_with_cancellation( ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }, @@ -1490,6 +1571,8 @@ mod tests { ]; for (provider_type, expected_models) in expected { let resolved = ResolvedProvider { + thinking_enabled: false, + protocol: LlmProtocolConfig::default(), kind: ProviderKind::Asr, provider_id: provider_type.to_string(), provider_type: provider_type.to_string(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 6266a809b..eb0ab1b2e 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -566,6 +566,10 @@ pub struct UserPreferences { /// 手动检查按钮显式指定 channel,与此 pref 解耦。 #[serde(default)] pub update_channel: UpdateChannel, + /// 是否由用户明确选择过更新渠道。旧版默认会把 Stable 写入配置,单看 + /// `update_channel` 无法区分默认值与主动切换;历史 Beta 则必然来自用户 opt-in。 + #[serde(default)] + pub update_channel_explicit: bool, /// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。 /// 写入新条目时执行清理,避免后台轮询。 #[serde(default = "default_history_retention_days")] @@ -887,6 +891,8 @@ struct UserPreferencesWire { sherpa_onnx_keep_loaded_secs: u32, #[serde(default)] update_channel: UpdateChannel, + #[serde(default)] + update_channel_explicit: Option, #[serde(default = "default_history_retention_days")] history_retention_days: u32, #[serde(default = "default_polish_context_window_minutes")] @@ -1054,6 +1060,8 @@ impl Default for UserPreferencesWire { sherpa_onnx_language_hint: prefs.sherpa_onnx_language_hint, sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs, update_channel: prefs.update_channel, + // None 保留旧配置缺少标记的信息;反序列化时只有历史 Beta 视为显式选择。 + update_channel_explicit: None, history_retention_days: prefs.history_retention_days, polish_context_window_minutes: prefs.polish_context_window_minutes, start_minimized: prefs.start_minimized, @@ -1121,6 +1129,9 @@ impl<'de> Deserialize<'de> for UserPreferences { }; let (local_asr_active_model, local_whisper_active_model) = migrate_local_asr_models(wire.local_asr_active_model, wire.local_whisper_active_model); + let update_channel_explicit = wire + .update_channel_explicit + .unwrap_or(matches!(wire.update_channel, UpdateChannel::Beta)); Ok(Self { hotkey: wire.hotkey, @@ -1216,6 +1227,7 @@ impl<'de> Deserialize<'de> for UserPreferences { sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint, sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs, update_channel: wire.update_channel, + update_channel_explicit, history_retention_days: wire.history_retention_days, polish_context_window_minutes: wire.polish_context_window_minutes, start_minimized: wire.start_minimized, @@ -1556,6 +1568,7 @@ impl Default for UserPreferences { sherpa_onnx_language_hint: String::new(), sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(), update_channel: UpdateChannel::default(), + update_channel_explicit: false, history_retention_days: default_history_retention_days(), polish_context_window_minutes: default_polish_context_window_minutes(), start_minimized: false, @@ -3118,6 +3131,32 @@ mod tests { assert!(!prefs.streaming_insert_save_clipboard); } + #[test] + fn update_channel_migration_preserves_only_explicit_legacy_beta_opt_in() { + let legacy_stable: UserPreferences = + serde_json::from_str(r#"{ "updateChannel": "stable" }"#).unwrap(); + assert_eq!(legacy_stable.update_channel, UpdateChannel::Stable); + assert!(!legacy_stable.update_channel_explicit); + + let legacy_beta: UserPreferences = + serde_json::from_str(r#"{ "updateChannel": "beta" }"#).unwrap(); + assert_eq!(legacy_beta.update_channel, UpdateChannel::Beta); + assert!(legacy_beta.update_channel_explicit); + + let explicit_stable: UserPreferences = serde_json::from_str( + r#"{ + "updateChannel": "stable", + "updateChannelExplicit": true + }"#, + ) + .unwrap(); + assert!(explicit_stable.update_channel_explicit); + + let round_trip: UserPreferences = + serde_json::from_str(&serde_json::to_string(&explicit_stable).unwrap()).unwrap(); + assert!(round_trip.update_channel_explicit); + } + #[test] fn paste_shortcut_round_trips_explicit_values() { for (raw, expected) in [ diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index 9065cd0c7..8775b8965 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -1108,6 +1108,7 @@ mod tests { .services() .provider .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: None, }) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ce7002bde..bb2e97170 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -349,6 +349,7 @@ mod linux_app { .services() .provider .list_models(openless_core::ProviderRequest { + thinking_enabled: backend.get_preferences().llm_thinking_enabled, kind: provider_kind(kind), channel_id: Some(channel_id.clone()), }) @@ -1981,6 +1982,7 @@ mod linux_app { .services() .provider .validate(openless_core::ProviderRequest { + thinking_enabled: backend.get_preferences().llm_thinking_enabled, kind: provider_kind(kind), channel_id: Some(channel_id.clone()), }) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 93621dfb6..46e9fe791 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4241,6 +4241,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "rustls", + "semver", "serde", "serde_json", "sha1", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 347d85a22..813854258 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ tauri-plugin-shell = "2.3.5" tauri-plugin-dialog = "2.7.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +semver = "1" # OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。 base64 = "0.22" sha2 = "0.10" diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 2fe833286..072d7b675 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -22,25 +22,13 @@ pub fn map_abi_to_arch(abi: &str) -> &'static str { } pub fn version_is_newer(remote: &str, current: &str) -> bool { - fn parts(v: &str) -> Vec { - v.split(|c| c == '.' || c == '-') - .filter_map(|p| p.parse().ok()) - .collect() - } - let remote_parts = parts(remote); - let current_parts = parts(current); - let max = remote_parts.len().max(current_parts.len()); - for i in 0..max { - let r = remote_parts.get(i).copied().unwrap_or(0); - let c = current_parts.get(i).copied().unwrap_or(0); - if r > c { - return true; - } - if r < c { - return false; - } - } - false + let (Ok(remote), Ok(current)) = ( + semver::Version::parse(remote), + semver::Version::parse(current), + ) else { + return false; + }; + remote > current } pub fn stable_manifest_urls(arch: &str) -> Vec { @@ -78,9 +66,12 @@ mod tests { } #[test] - fn version_is_newer_handles_beta_suffix() { - assert!(version_is_newer("1.3.8-1", "1.3.8")); - assert!(!version_is_newer("1.3.8", "1.3.8-1")); + fn version_is_newer_uses_semver_prerelease_ordering() { + assert!(version_is_newer("1.3.18", "1.3.18-Beta.7")); + assert!(version_is_newer("1.3.18-Beta.8", "1.3.18-Beta.7")); + assert!(!version_is_newer("1.3.18-Beta.7", "1.3.18-Beta.7")); + assert!(!version_is_newer("1.3.17", "1.3.18-Beta.7")); + assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7")); } #[test] diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 9d0ce9f5c..f9fac199a 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -243,6 +243,11 @@ fn read_vault_credential( key: &openless_core::CredentialKey, ) -> Result, openless_core::BackendError> { let result = match (key.namespace, key.account.as_str()) { + (openless_core::CredentialNamespace::Llm, account) + if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => + { + CredentialsVault::get_llm_protocol_option(key.provider_id.as_deref(), account) + } (openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => { match key.provider_id.as_deref() { Some(provider) => serde_json::to_string( @@ -309,6 +314,11 @@ fn write_vault_credential( value: &str, ) -> Result<(), openless_core::BackendError> { let result = match (key.namespace, key.account.as_str()) { + (openless_core::CredentialNamespace::Llm, account) + if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => + { + CredentialsVault::set_llm_protocol_option(key.provider_id.as_deref(), account, value) + } (openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => { match key.provider_id.as_deref() { Some(provider) => { @@ -738,6 +748,9 @@ fn credential_key( provider: Option, ) -> Result { let namespace = match account { + account if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => { + openless_core::CredentialNamespace::Llm + } LLM_EXTRA_HEADERS_ACCOUNT | LLM_TEMPERATURE_ACCOUNT => { openless_core::CredentialNamespace::Llm } @@ -853,6 +866,11 @@ mod tests { #[test] fn core_llm_accounts_are_supported_by_the_tauri_vault_adapter() { + for account in openless_core::llm_protocol::CONFIG_ACCOUNTS { + let key = credential_key(account, Some("channel-b".into())).unwrap(); + assert_eq!(key.namespace, openless_core::CredentialNamespace::Llm); + assert_eq!(key.provider_id.as_deref(), Some("channel-b")); + } for account in [ openless_core::credentials::LLM_API_KEY_ACCOUNT, openless_core::credentials::LLM_MODEL_ACCOUNT, diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 8f8ccb2d7..9929fa954 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -29,7 +29,11 @@ pub async fn validate_provider_credentials( let kind = parse_provider_kind(&kind)?; core.services() .provider - .validate(openless_core::ProviderRequest { kind, channel_id }) + .validate(openless_core::ProviderRequest { + kind, + channel_id, + thinking_enabled: core.get_preferences().llm_thinking_enabled, + }) .await .map_err(|error| error.message) } @@ -43,7 +47,11 @@ pub async fn list_provider_models( let kind = parse_provider_kind(&kind)?; core.services() .provider - .list_models(openless_core::ProviderRequest { kind, channel_id }) + .list_models(openless_core::ProviderRequest { + kind, + channel_id, + thinking_enabled: core.get_preferences().llm_thinking_enabled, + }) .await .map_err(|error| error.message) } diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2e16703ab..8a0c56043 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -2,7 +2,9 @@ use super::*; #[tauri::command] pub fn get_settings(core: CoreState<'_>) -> UserPreferences { - core.get_preferences() + let mut prefs = core.get_preferences(); + prefs.update_channel = effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION")); + prefs } #[tauri::command] @@ -143,8 +145,10 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> { } } -pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> Result<(), String> { - let _host_guard = coord.lock_settings_host(); +fn persist_settings_with_host_lock_held( + coord: &Coordinator, + prefs: UserPreferences, +) -> Result<(), String> { coord .backend() .update_settings( @@ -163,11 +167,22 @@ pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> R .map_err(|error| error.to_string()) } +fn persist_settings_preserving_update_channel( + coord: &Coordinator, + mut prefs: UserPreferences, +) -> Result<(), String> { + let _host_guard = coord.lock_settings_host(); + // 在同一把写锁内读取并回填,避免并发渠道切换被旧设置快照覆盖。 + preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences()); + persist_settings_with_host_lock_held(coord, prefs) +} + pub(crate) fn persist_strict_settings( coord: &Coordinator, - prefs: UserPreferences, + mut prefs: UserPreferences, ) -> Result<(), String> { let _host_guard = coord.lock_settings_host(); + preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences()); coord .backend() .update_settings( @@ -179,6 +194,21 @@ pub(crate) fn persist_strict_settings( .map_err(|error| error.to_string()) } +async fn invalidate_llm_tests_if_thinking_changed( + coord: &Coordinator, + previous: &UserPreferences, + next: &UserPreferences, +) -> Result<(), String> { + if previous.llm_thinking_enabled != next.llm_thinking_enabled { + coord + .backend() + .invalidate_channel_tests(openless_core::ChannelKind::Llm) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} + #[cfg(not(mobile))] #[tauri::command] pub async fn set_settings( @@ -195,10 +225,11 @@ pub async fn set_settings( .map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); + invalidate_llm_tests_if_thinking_changed(&coord, &remote_prev, &prefs).await?; // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 - persist_settings(&*coord, prefs)?; + persist_settings_preserving_update_channel(&*coord, prefs)?; let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule // 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。 @@ -249,7 +280,10 @@ pub async fn set_settings( #[cfg(mobile)] #[tauri::command] -pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) -> Result<(), String> { +pub async fn set_settings( + coord: CoordinatorState<'_>, + mut prefs: UserPreferences, +) -> Result<(), String> { let previous = coord.backend().get_preferences(); let packs = coord .backend() @@ -257,7 +291,8 @@ pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) -> .map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); - persist_settings(&*coord, prefs)?; + invalidate_llm_tests_if_thinking_changed(&coord, &previous, &prefs).await?; + persist_settings_preserving_update_channel(&*coord, prefs)?; let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。 coord.sync_capsule_style_from_preferences(); @@ -297,6 +332,63 @@ mod tests { ); assert_eq!(stale_settings_payload.default_mode, PolishMode::Light); } + + #[test] + fn update_channel_defaults_to_build_channel_until_user_selects_one() { + let mut prefs = UserPreferences::default(); + + assert_eq!( + effective_update_channel(None, &prefs, "2.0.0-Beta.1"), + UpdateChannel::Beta + ); + assert_eq!( + effective_update_channel(None, &prefs, "2.0.0"), + UpdateChannel::Stable + ); + let legacy_beta = UserPreferences { + update_channel: UpdateChannel::Beta, + update_channel_explicit: true, + ..UserPreferences::default() + }; + assert_eq!( + effective_update_channel(None, &legacy_beta, "2.0.0"), + UpdateChannel::Beta + ); + assert_eq!( + effective_update_channel(Some(UpdateChannel::Stable), &prefs, "2.0.0-Beta.1"), + UpdateChannel::Stable + ); + + assert!(select_update_channel(&mut prefs, UpdateChannel::Stable)); + assert!(prefs.update_channel_explicit); + assert_eq!( + effective_update_channel(None, &prefs, "2.0.0-Beta.1"), + UpdateChannel::Stable + ); + assert!(!select_update_channel(&mut prefs, UpdateChannel::Stable)); + assert!(select_update_channel(&mut prefs, UpdateChannel::Beta)); + assert_eq!(prefs.update_channel, UpdateChannel::Beta); + assert!(prefs.update_channel_explicit); + } + + #[test] + fn general_settings_save_preserves_dedicated_update_channel_fields() { + let current = UserPreferences { + update_channel: UpdateChannel::Stable, + update_channel_explicit: true, + ..UserPreferences::default() + }; + let mut stale_payload = UserPreferences { + update_channel: UpdateChannel::Beta, + update_channel_explicit: false, + ..UserPreferences::default() + }; + + preserve_update_channel_preferences(&mut stale_payload, ¤t); + + assert_eq!(stale_payload.update_channel, UpdateChannel::Stable); + assert!(stale_payload.update_channel_explicit); + } } // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── @@ -312,9 +404,38 @@ mod tests { // (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub // Release assets 里物理分离)。 +fn effective_update_channel( + requested: Option, + prefs: &UserPreferences, + app_version: &str, +) -> UpdateChannel { + requested.unwrap_or_else(|| { + if prefs.update_channel_explicit { + prefs.update_channel + } else if app_version.contains('-') { + UpdateChannel::Beta + } else { + UpdateChannel::Stable + } + }) +} + +fn select_update_channel(prefs: &mut UserPreferences, channel: UpdateChannel) -> bool { + let changed = prefs.update_channel != channel || !prefs.update_channel_explicit; + prefs.update_channel = channel; + prefs.update_channel_explicit = true; + changed +} + +fn preserve_update_channel_preferences(incoming: &mut UserPreferences, current: &UserPreferences) { + incoming.update_channel = current.update_channel; + incoming.update_channel_explicit = current.update_channel_explicit; +} + #[tauri::command] pub fn get_update_channel(core: CoreState<'_>) -> UpdateChannel { - core.get_preferences().update_channel + let prefs = core.get_preferences(); + effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION")) } #[tauri::command] @@ -322,12 +443,13 @@ pub fn set_update_channel( coord: CoordinatorState<'_>, channel: UpdateChannel, ) -> Result<(), String> { + // 渠道读取和持久化必须同属一个写临界区,避免反向覆盖并发常规设置。 + let _host_guard = coord.lock_settings_host(); let mut prefs = coord.backend().get_preferences(); - if prefs.update_channel == channel { + if !select_update_channel(&mut prefs, channel) { return Ok(()); } - prefs.update_channel = channel; - persist_settings(&*coord, prefs)?; + persist_settings_with_host_lock_held(&*coord, prefs)?; Ok(()) } @@ -465,7 +587,7 @@ pub struct AppUpdateMetadata { /// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。 /// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta); -/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。 +/// 不传则使用用户明确选择的渠道;尚未选择时跟随当前构建类型。 /// 返回 None = 当前是最新;Some(metadata) = 有新版可装。 #[tauri::command] #[cfg(not(mobile))] @@ -477,7 +599,8 @@ pub async fn app_check_update_with_channel( ) -> Result, String> { use tauri_plugin_updater::UpdaterExt; - let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel); + let prefs = coord.backend().get_preferences(); + let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION")); let mut builder = webview.updater_builder(); if let Some(ms) = timeout_ms { builder = builder.timeout(std::time::Duration::from_millis(ms)); @@ -541,7 +664,8 @@ pub async fn app_check_update_with_channel( ) -> Result, String> { #[cfg(target_os = "android")] { - let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel); + let prefs = coord.backend().get_preferences(); + let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION")); return crate::android::updater::check_update(channel).await; } #[cfg(not(target_os = "android"))] diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 5e67172d1..8b57097b1 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -437,7 +437,9 @@ mod platform { update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyCombinedEdge, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{ + HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyMode, HotkeyTrigger, + }; pub fn start_adapter( binding: HotkeyBinding, @@ -565,6 +567,9 @@ mod platform { const KEY_DOWN: CgEventType = 10; const KEY_UP: CgEventType = 11; const FLAGS_CHANGED: CgEventType = 12; + /// Brightness / volume / keyboard-backlight and similar macOS function-layer + /// actions are delivered as system-defined events instead of KEY_DOWN. + const SYSTEM_DEFINED: CgEventType = 14; const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE; const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF; @@ -577,6 +582,15 @@ mod platform { const FLAG_MASK_SECONDARY_FN: CgEventFlags = 0x0080_0000; const ESC_KEYCODE: i64 = 53; + // IOKit hidsystem/IOLLEvent.h + ev_keymap.h. systemDefined subtype 8 carries + // auxiliary control keys in data1's high 16 bits; 0..=23 are the scanned + // brightness / volume / media / illumination family. Globe/Menu is 25 and + // must stay excluded, otherwise a plain Fn tap could cancel itself. + const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8; + const NX_NUM_SCANNED_SPECIAL_KEYS: u16 = 24; + /// Auto / Toggle 下 Fn 是双用途键:短按用于听写,明显长按留给 macOS 功能层。 + /// 与 Auto 模式既有的 350ms 短按 / 长按分界保持一致。 + const FN_TAP_MAX_DURATION: std::time::Duration = std::time::Duration::from_millis(350); type CgEventTapCallBack = extern "C" fn( proxy: *mut c_void, @@ -621,6 +635,8 @@ mod platform { cancel_tx: Sender<()>, /// 组合键撤销专用通道,见模块注释——不与 tx 挤同一条串行 bridge。 combo_tx: Sender, + /// Auto / Toggle 下 Fn 延迟到松开才决定是否派发:短按派发,长按或功能层组合丢弃。 + fn_pressed_at: parking_lot::Mutex>, /// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on /// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。 handles: Arc, @@ -636,7 +652,10 @@ mod platform { combo_tx: Sender, status_tx: StartupTx>, ) { - let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN) | (1u64 << KEY_UP); + let mask: CgEventMask = (1u64 << FLAGS_CHANGED) + | (1u64 << KEY_DOWN) + | (1u64 << KEY_UP) + | (1u64 << SYSTEM_DEFINED); let handles = Arc::new(MacShutdownHandles { tap: std::sync::Mutex::new(None), runloop: std::sync::Mutex::new(None), @@ -646,6 +665,7 @@ mod platform { tx, cancel_tx, combo_tx, + fn_pressed_at: parking_lot::Mutex::new(None), handles: Arc::clone(&handles), })); @@ -719,6 +739,13 @@ mod platform { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false); } + // Fn+亮度 / 音量 / 键盘背光等不会产生 KEY_DOWN,而是 systemDefined。 + // 只要 Fn 触发键正按住,就把它视为功能层组合,不能同时唤起听写。 + SYSTEM_DEFINED => { + if let Some((subtype, data1)) = system_defined_event_payload(event) { + note_fn_function_layer_event(ctx, subtype, data1); + } + } _ => {} } event @@ -742,6 +769,9 @@ mod platform { ctx.shared.recording_fn_held.store(false, Ordering::SeqCst); log::info!("[hotkey] 录制态 Fn↑(松开)"); } + // 录制态只负责把 Fn 交给 ShortcutRecorder。不要同时更新听写触发 latch, + // 否则前端保存 Fn、退出录制态后,同一次物理松手会被误当成一次听写短按。 + return; } // Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。 @@ -788,17 +818,35 @@ mod platform { None, ); - let trigger = ctx.shared.binding.read().trigger; - if trigger == HotkeyTrigger::Custom { - return; - } - let expected_keycode = trigger_to_keycode(trigger); - if keycode != expected_keycode { + handle_dictation_trigger_flags_changed(ctx, keycode, flags, std::time::Instant::now()); + } + + fn fn_uses_tap_only_semantics(trigger: HotkeyTrigger, mode: HotkeyMode) -> bool { + trigger == HotkeyTrigger::Fn && matches!(mode, HotkeyMode::Auto | HotkeyMode::Toggle) + } + + /// 处理 modifier-only 听写触发键的 flagsChanged 边沿。 + /// + /// Fn 在 Auto / Toggle 下不能沿用其他修饰键的“按下即触发”:它同时是 macOS + /// 功能层修饰键。这里先记住按下,等松开后确认是短按且未叠加功能键,才补发一对 + /// Pressed / Released。这样长按、Fn+F1/音量/亮度等系统操作从未启动听写,不会闪胶囊。 + fn handle_dictation_trigger_flags_changed( + ctx: &CallbackContext, + keycode: i64, + flags: CgEventFlags, + now: std::time::Instant, + ) { + let (trigger, mode) = { + let binding = ctx.shared.binding.read(); + (binding.trigger, binding.mode) + }; + if trigger == HotkeyTrigger::Custom || keycode != trigger_to_keycode(trigger) { return; } - let mask = trigger_to_flag_mask(trigger); - let is_active = (flags & mask) != 0; + + let is_active = (flags & trigger_to_flag_mask(trigger)) != 0; let was_held = ctx.shared.trigger_held.load(Ordering::SeqCst); + let tap_only_fn = fn_uses_tap_only_semantics(trigger, mode); if is_active && !was_held { ctx.shared.trigger_held.store(true, Ordering::SeqCst); @@ -807,23 +855,46 @@ mod platform { .trigger_press_id .store(press_id, Ordering::SeqCst); ctx.shared.trigger_companion_seen.store(0, Ordering::SeqCst); - send_or_log( - &ctx.tx, - HotkeyEvent::Pressed { - at: std::time::Instant::now(), - press_id, - }, - ); - } else if !is_active && was_held { + if tap_only_fn { + *ctx.fn_pressed_at.lock() = Some(now); + log::debug!("[hotkey] Fn↓ 等待松开后判定短按 / 功能层操作"); + } else { + send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: now, press_id }); + } + return; + } + + if !is_active && was_held { ctx.shared.trigger_held.store(false, Ordering::SeqCst); let press_id = ctx.shared.trigger_press_id.swap(0, Ordering::SeqCst); - send_or_log( - &ctx.tx, - HotkeyEvent::Released { - at: std::time::Instant::now(), - press_id, - }, - ); + if !tap_only_fn { + send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id }); + return; + } + + let pressed_at = ctx.fn_pressed_at.lock().take(); + let companion_seen = press_id != 0 + && ctx.shared.trigger_companion_seen.load(Ordering::SeqCst) == press_id; + let held_for = pressed_at.map(|at| now.saturating_duration_since(at)); + if !companion_seen && held_for.is_some_and(|duration| duration < FN_TAP_MAX_DURATION) { + let pressed_at = pressed_at.expect("checked above"); + send_or_log( + &ctx.tx, + HotkeyEvent::Pressed { + at: pressed_at, + press_id, + }, + ); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id }); + } else { + log::info!( + "[hotkey] Fn 操作未触发听写(held_ms={}, companion_seen={companion_seen})", + held_for.map(|duration| duration.as_millis()).unwrap_or(0) + ); + } + } else if !is_active && trigger == HotkeyTrigger::Fn { + // 录制态 / binding 更新会主动重置 held latch;物理松手仍需清掉旧时间戳。 + ctx.fn_pressed_at.lock().take(); } } @@ -868,6 +939,40 @@ mod platform { note_companion_key_down(ctx); } + fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + + if event.is_null() { + return None; + } + let cls = AnyClass::get("NSEvent")?; + let ns_event: *mut AnyObject = + unsafe { msg_send![cls, eventWithCGEvent: event.cast::()] }; + if ns_event.is_null() { + return None; + } + let subtype: i16 = unsafe { msg_send![ns_event, subtype] }; + let data1: isize = unsafe { msg_send![ns_event, data1] }; + Some((subtype, data1)) + } + + fn is_auxiliary_function_key_event(subtype: i16, data1: isize) -> bool { + if subtype != NX_SUBTYPE_AUX_CONTROL_BUTTONS { + return false; + } + let key_type = ((data1 as u64 >> 16) & 0xffff) as u16; + key_type < NX_NUM_SCANNED_SPECIAL_KEYS + } + + fn note_fn_function_layer_event(ctx: &CallbackContext, subtype: i16, data1: isize) { + if is_auxiliary_function_key_event(subtype, data1) + && ctx.shared.binding.read().trigger == HotkeyTrigger::Fn + { + note_companion_key_down(ctx); + } + } + /// 触发键按住期间按下任意普通键 = 用户在打组合键(Option+任意字母/数字键、Option+Tab…), /// 不是想说话 —— 往组合键撤销通道发一次让上层撤销这次按下。 /// @@ -891,6 +996,16 @@ mod platform { { return; } + let deferred_fn = { + let binding = ctx.shared.binding.read(); + fn_uses_tap_only_semantics(binding.trigger, binding.mode) + }; + if deferred_fn { + // Auto / Toggle 下 Fn 尚未向 coordinator 派发 Pressed;记住 companion 即可, + // 松手时会整次丢弃。此时发送 abort 只会留下一个永远等不到 Pressed 的 pending id。 + log::info!("[hotkey] Fn 功能层组合按下 —— 本次短按候选作废"); + return; + } log::info!("[hotkey] 触发键与其他键组合按下 —— 撤销本次触发"); send_combo_abort_or_log(&ctx.combo_tx, press_id); } @@ -970,6 +1085,7 @@ mod platform { tx, cancel_tx, combo_tx, + fn_pressed_at: parking_lot::Mutex::new(None), handles: Arc::new(MacShutdownHandles { tap: std::sync::Mutex::new(None), runloop: std::sync::Mutex::new(None), @@ -1083,6 +1199,141 @@ mod platform { // 撤销全程不碰 Pressed/Released 那条串行通道 —— 它此刻正卡在 begin_session 里。 assert!(drain(&rx).is_empty()); } + + #[test] + fn mac_fn_auto_short_tap_dispatches_only_after_release() { + let shared = shared(HotkeyTrigger::Fn); + shared.binding.write().mode = HotkeyMode::Auto; + let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared)); + let pressed_at = std::time::Instant::now(); + + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + FLAG_MASK_SECONDARY_FN, + pressed_at, + ); + assert!(drain(&rx).is_empty(), "Fn down must stay a tap candidate"); + + let released_at = pressed_at + std::time::Duration::from_millis(120); + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + 0, + released_at, + ); + + let events = drain(&rx); + assert!(matches!(events.as_slice(), ( + [HotkeyEvent::Pressed { press_id: left, .. }, HotkeyEvent::Released { press_id: right, .. }] + ) if *left != 0 && left == right)); + assert_eq!(drain_combo(&combo_rx), 0); + } + + #[test] + fn mac_fn_auto_long_hold_is_reserved_for_system_functions() { + let shared = shared(HotkeyTrigger::Fn); + shared.binding.write().mode = HotkeyMode::Auto; + let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared)); + let pressed_at = std::time::Instant::now(); + + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + FLAG_MASK_SECONDARY_FN, + pressed_at, + ); + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + 0, + pressed_at + FN_TAP_MAX_DURATION, + ); + + assert!(drain(&rx).is_empty()); + assert_eq!(drain_combo(&combo_rx), 0); + } + + #[test] + fn mac_fn_function_layer_event_suppresses_tap_without_pending_abort() { + let shared = shared(HotkeyTrigger::Fn); + shared.binding.write().mode = HotkeyMode::Toggle; + let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared)); + let pressed_at = std::time::Instant::now(); + + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + FLAG_MASK_SECONDARY_FN, + pressed_at, + ); + // brightness-up (NX_KEYTYPE_BRIGHTNESS_UP=2) 的 subtype/data1 编码。 + note_fn_function_layer_event( + &ctx, + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + (2_i64 << 16) as isize, + ); + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + 0, + pressed_at + std::time::Duration::from_millis(80), + ); + + assert!(drain(&rx).is_empty()); + assert_eq!(drain_combo(&combo_rx), 0); + } + + #[test] + fn mac_system_defined_event_does_not_abort_non_fn_dictation() { + let shared = shared(HotkeyTrigger::LeftOption); + let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared)); + shared.trigger_press_id.store(7, Ordering::SeqCst); + shared.trigger_held.store(true, Ordering::SeqCst); + + note_fn_function_layer_event( + &ctx, + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + (2_i64 << 16) as isize, + ); + + assert_eq!(shared.trigger_companion_seen.load(Ordering::SeqCst), 0); + assert!(drain(&rx).is_empty()); + assert_eq!(drain_combo(&combo_rx), 0); + } + + #[test] + fn mac_globe_system_event_is_not_mistaken_for_a_function_layer_key() { + assert!(!is_auxiliary_function_key_event( + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + (25_i64 << 16) as isize, + )); + assert!(!is_auxiliary_function_key_event(0, (2_i64 << 16) as isize)); + } + + #[test] + fn mac_fn_hold_mode_keeps_press_to_talk_semantics() { + let shared = shared(HotkeyTrigger::Fn); + shared.binding.write().mode = HotkeyMode::Hold; + let (ctx, rx) = callback_context(Arc::clone(&shared)); + let pressed_at = std::time::Instant::now(); + + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + FLAG_MASK_SECONDARY_FN, + pressed_at, + ); + assert_eq!(edge_names(drain(&rx)), vec!["pressed"]); + + handle_dictation_trigger_flags_changed( + &ctx, + trigger_to_keycode(HotkeyTrigger::Fn), + 0, + pressed_at + std::time::Duration::from_secs(1), + ); + assert_eq!(edge_names(drain(&rx)), vec!["released"]); + } } } diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 304881485..eaa643a4f 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -398,9 +398,28 @@ struct CredsLlmEntry { temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] extraHeaders: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + requestFormat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + messagesThinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + maxTokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thinkingBudget: Option, } impl CredsLlmEntry { + fn protocol_option(&mut self, account: &str) -> Result<&mut Option> { + use openless_core::llm_protocol::*; + match account { + REQUEST_FORMAT_ACCOUNT => Ok(&mut self.requestFormat), + MESSAGES_THINKING_ACCOUNT => Ok(&mut self.messagesThinking), + MAX_TOKENS_ACCOUNT => Ok(&mut self.maxTokens), + THINKING_BUDGET_ACCOUNT => Ok(&mut self.thinkingBudget), + _ => anyhow::bail!("unsupported LLM protocol option"), + } + } + fn is_empty(&self) -> bool { // 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。 if self.channel.providerType.is_some() { @@ -416,6 +435,10 @@ impl CredsLlmEntry { && self.baseURL.as_deref().unwrap_or("").is_empty() && self.model.as_deref().unwrap_or("").is_empty() && self.temperature.is_none() + && self.requestFormat.is_none() + && self.messagesThinking.is_none() + && self.maxTokens.is_none() + && self.thinkingBudget.is_none() && self .extraHeaders .as_ref() @@ -2360,6 +2383,29 @@ impl CredentialsVault { save_credentials(&root) } + pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let id = id.unwrap_or(&root.active.llm).to_string(); + match root.providers.llm.get_mut(&id) { + Some(entry) => Ok(entry.protocol_option(account)?.clone()), + None => Ok(None), + } + } + + pub fn set_llm_protocol_option(id: Option<&str>, account: &str, value: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut config = openless_core::llm_protocol::LlmProtocolConfig::default(); + config.apply(account, value)?; + let mut root = load_credentials_for_update()?; + let id = id.unwrap_or(&root.active.llm).to_string(); + let entry = root.providers.llm.entry(id).or_default(); + *entry.protocol_option(account)? = + (!value.trim().is_empty()).then(|| value.trim().to_string()); + entry.channel.lastTest = None; + save_credentials(&root) + } + /// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。 pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> { let _guard = credentials_lock().lock(); @@ -2442,6 +2488,48 @@ mod tests { .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); } + #[test] + fn llm_protocol_options_survive_vault_reload_and_core_projection() { + use openless_core::llm_protocol::*; + let mut root = CredsRoot::default(); + let entry = root.providers.llm.entry("channel-b".into()).or_default(); + let values = ["messages", "budget", "8192", "2048"]; + for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { + *entry.protocol_option(account).unwrap() = Some(value.into()); + } + assert!(!entry.has_no_content()); + let serialized = serde_json::to_string(&root).unwrap(); + let mut restored: CredsRoot = serde_json::from_str(&serialized).unwrap(); + for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { + assert_eq!( + restored + .providers + .llm + .get_mut("channel-b") + .unwrap() + .protocol_option(account) + .unwrap() + .as_deref(), + Some(value) + ); + } + let decoded = + openless_core::credentials_legacy::decode_legacy_credentials(&serialized).unwrap(); + for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { + assert!(decoded + .secrets + .iter() + .any( + |(key, secret)| key.provider_id.as_deref() == Some("channel-b") + && key.account == account + && secret.expose_secret() == value + )); + } + let old: CredsLlmEntry = serde_json::from_str(r#"{"apiKey":"old-key"}"#).unwrap(); + assert!(old.requestFormat.is_none()); + assert!(!restored.providers.llm.contains_key("channel-a")); + } + #[test] fn omni_accounts_route_to_omni_namespace_only() { // 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902): diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 11faeead8..ee06ee61b 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -19,9 +19,11 @@ import { logClientError, openExternal, restartApp, + setUpdateChannel, type AppUpdateMetadata, type UpdateChannel, } from '../lib/ipc'; +import { isStableChannelSwitch } from '../lib/appVersion'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; @@ -50,6 +52,7 @@ export type CheckUpdateOptions = { export interface UseAutoUpdate { status: UpdateStatus; + currentVersion: string; version: string; progress: number | null; downloaded: number; @@ -72,6 +75,7 @@ export function useAutoUpdate(): UseAutoUpdate { const updateRef = useRef(null); const androidUpdateRef = useRef(null); const [status, setStatus] = useState('idle'); + const [currentVersion, setCurrentVersion] = useState(''); const [version, setVersion] = useState(''); const [downloaded, setDownloaded] = useState(0); const [contentLength, setContentLength] = useState(null); @@ -140,6 +144,7 @@ export function useAutoUpdate(): UseAutoUpdate { const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => { setStatus('checking'); + setCurrentVersion(''); setVersion(''); setErrorMessage(null); resetProgress(); @@ -157,6 +162,7 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('none'); return; } + setCurrentVersion(metadata.currentVersion); if (isAndroid()) { storeAndroidMetadata(metadata); setVersion(metadata.version); @@ -203,12 +209,18 @@ export function useAutoUpdate(): UseAutoUpdate { }; const installUpdate = async () => { + const persistStableChannelSwitch = () => + isStableChannelSwitch(currentVersion, version) + ? setUpdateChannel('stable') + : Promise.resolve(); + if (isAndroid()) { const payload = androidUpdateRef.current; if (!payload) return; resetProgress(); setStatus('downloading'); try { + await persistStableChannelSwitch(); await appDownloadAndInstallAndroidUpdate(payload); androidUpdateRef.current = null; setStatus('downloaded'); @@ -227,6 +239,7 @@ export function useAutoUpdate(): UseAutoUpdate { resetProgress(); setStatus('downloading'); try { + await persistStableChannelSwitch(); await update.download((event: DownloadEvent) => { if (event.event === 'Started') { resetProgress(); @@ -255,12 +268,14 @@ export function useAutoUpdate(): UseAutoUpdate { if (busy) return; await closeUpdate(); setStatus('idle'); + setCurrentVersion(''); setVersion(''); resetProgress(); }; return { status, + currentVersion, version, progress, downloaded, @@ -280,6 +295,7 @@ export function isDialogStatus(status: UpdateStatus): status is 'available' | 'd export function UpdateDialog({ status, + currentVersion, version, progress, downloaded, @@ -289,6 +305,7 @@ export function UpdateDialog({ onClose, }: { status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError'; + currentVersion: string; version: string; progress: number | null; downloaded: number; @@ -302,6 +319,8 @@ export function UpdateDialog({ const installing = status === 'installing'; const installError = status === 'installError'; const androidInstalled = isAndroid() && status === 'downloaded'; + const switchingToStable = status === 'available' + && isStableChannelSwitch(currentVersion, version); // Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change, // 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗 // 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。 @@ -309,13 +328,17 @@ export function UpdateDialog({ return createPortal(
-
{t(`settings.about.updateDialog.${status}.title`)}
+
+ {t(`settings.about.updateDialog.${switchingToStable ? 'stableChannelSwitch' : status}.title`)} +
{androidInstalled ? t('settings.about.updateDialog.androidInstalled.desc', { version, defaultValue: '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。' }) : installError ? t('settings.about.updateDialog.installError.desc', { error: errorMessage || t('settings.about.updateError') }) - : t(`settings.about.updateDialog.${status}.desc`, { version })} + : switchingToStable + ? t('settings.about.updateDialog.stableChannelSwitch.desc', { currentVersion, version }) + : t(`settings.about.updateDialog.${status}.desc`, { version })}
{(downloading || installing || status === 'downloaded') && (
diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index e4051d9d2..b527318e4 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -53,6 +53,7 @@ export function AutoUpdateGate() { return ( + currentVersion.includes('-') && !targetVersion.includes('-'); diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index d5a6e9695..c039153a4 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,6 +1,7 @@ import type { CredentialsStatus } from "../types" import { invokeOrMock } from "./shared" -import { mockCredentialsStatus } from "./mock-data" +import { mockCredentialsStatus, mockCredentialValues } from "./mock-data" +import { invalidateMockChannelTest } from "./channels" export interface ProviderCheckResult { ok: boolean @@ -19,7 +20,10 @@ export function getCredentials(): Promise { } export function setCredential(account: string, value: string, provider?: string): Promise { - return invokeOrMock("set_credential", { account, value, provider }, () => undefined) + return invokeOrMock("set_credential", { account, value, provider }, () => { + mockCredentialValues.set(`${provider ?? ''}:${account}`, value) + if (provider && account.startsWith('ark.')) invalidateMockChannelTest(provider) + }) } export function setActiveAsrProvider(provider: string): Promise { @@ -50,7 +54,7 @@ export function readCredential(account: string, provider?: string): Promise( "read_credential", { account, provider }, - () => null, + () => mockCredentialValues.get(`${provider ?? ''}:${account}`) ?? null, ) } diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 14ca64841..349a7e49b 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -7,6 +7,7 @@ // 凭据不走这里:按渠道 id 调 readCredential/setCredential(account, value, id)。 import { invokeOrMock } from "./shared" +import { mockCredentialValues } from "./mock-data" export type ChannelKind = "llm" | "asr" @@ -82,6 +83,15 @@ export function listChannels(kind: ChannelKind): Promise { return invokeOrMock("list_channels", { kind }, () => mockChannels[kind]) } +export function invalidateMockChannelTest(id: string): void { + const channel = mockChannels.llm.find(channel => channel.id === id) + if (channel) channel.lastTest = null +} + +export function invalidateMockChannelTests(kind: ChannelKind): void { + for (const channel of mockChannels[kind]) channel.lastTest = null +} + /** 返回后端分配的渠道 id。 */ export function createChannel( kind: ChannelKind, @@ -91,7 +101,11 @@ export function createChannel( return invokeOrMock( "create_channel", { kind, providerType, name }, - () => providerType, + () => { + const id = `${providerType}-${Date.now()}-${mockChannels[kind].length}` + mockChannels[kind].push({ id, name, providerType, enabled: true, order: mockChannels[kind].length, lastTest: null }) + return id + }, ) } @@ -104,7 +118,14 @@ export function setChannelProviderType( return invokeOrMock( "set_channel_provider_type", { kind, id, providerType }, - () => undefined, + () => { + const channel = mockChannels[kind].find(channel => channel.id === id) + if (channel && channel.providerType !== providerType) { + channel.providerType = providerType + channel.lastTest = null + if (kind === 'llm') mockCredentialValues.delete(`${id}:ark.request_format`) + } + }, ) } @@ -113,7 +134,18 @@ export function deleteChannelIfBlank( kind: ChannelKind, id: string, ): Promise { - return invokeOrMock("delete_channel_if_blank", { kind, id }, () => true) + return invokeOrMock("delete_channel_if_blank", { kind, id }, () => { + const channel = mockChannels[kind].find(channel => channel.id === id) + const prefix = `${id}:` + const hasCredentials = [...mockCredentialValues] + .some(([key, value]) => key.startsWith(prefix) && value.length > 0) + if (!channel || channel.name.trim() || hasCredentials) return false + mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id) + for (const key of mockCredentialValues.keys()) { + if (key.startsWith(prefix)) mockCredentialValues.delete(key) + } + return true + }) } export function renameChannel( @@ -125,7 +157,10 @@ export function renameChannel( } export function deleteChannel(kind: ChannelKind, id: string): Promise { - return invokeOrMock("delete_channel", { kind, id }, () => undefined) + return invokeOrMock("delete_channel", { kind, id }, () => { + mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id) + for (const key of mockCredentialValues.keys()) if (key.startsWith(`${id}:`)) mockCredentialValues.delete(key) + }) } export function setChannelEnabled( @@ -171,6 +206,9 @@ export function recordChannelTest( return invokeOrMock( "record_channel_test", { kind, id, ok, latencyMs, error }, - () => undefined, + () => { + const channel = mockChannels[kind].find(channel => channel.id === id) + if (channel) channel.lastTest = { ok, latencyMs, error, at: Math.floor(Date.now() / 1000) } + }, ) } diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 28d1118cf..e37cbcddf 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -1,3 +1,4 @@ +export const mockCredentialValues = new Map() import type { ActivityDay, CorrectionRule, @@ -109,6 +110,7 @@ export let mockSettings: UserPreferences = { startMinimized: false, themeMode: "system", updateChannel: "stable", + updateChannelExplicit: false, streamingInsert: true, streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index eef2a54b7..3db537eb8 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -1,6 +1,7 @@ import { invokeOrMock } from './shared' export type ProviderKind = 'asr' | 'llm' | 'omni' +export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages' export type AuthRequirement = | 'none' @@ -20,9 +21,23 @@ export interface ProviderDescriptor { authRequirement: AuthRequirement validationProbe: string staticModels: string[] + defaultRequestFormat: LlmRequestFormat | null + supportedRequestFormats: LlmRequestFormat[] } /** Core owns protocol, defaults, and credential requirements. */ export function listProviderDescriptors(kind: ProviderKind): Promise { - return invokeOrMock('list_provider_descriptors', { kind }, () => []) + return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ + ['opencode', 'opencode', '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, + 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/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index fc9b6e7b4..ba90137ea 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -7,6 +7,7 @@ import { type StartupSnapshot, } from "./shared" import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from "./mock-data" +import { invalidateMockChannelTests } from "./channels" export { BACKEND_CONTRACT_VERSION } export type { StartupSnapshot } @@ -27,7 +28,9 @@ export function getDefaultStyleSystemPrompts(): Promise { export function setSettings(prefs: UserPreferences): Promise { return invokeOrMock("set_settings", { prefs }, () => { + const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled mockSetSettings(prefs) + if (thinkingChanged) invalidateMockChannelTests("llm") return undefined }) } diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 97f6c2997..91e964495 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -256,8 +256,7 @@ export interface WindowsImeStatus { dllPath: string | null; } -/** 后台自动更新渠道。stable = 查正式版 manifest(默认);beta = 查 - * latest-android-{arch}-beta.json。手动「检查正式版/Beta 更新」按钮不受此字段影响。 */ +/** 后台自动更新渠道。未明确选择时跟随构建类型;手动检查按钮不受此字段影响。 */ export type UpdateChannel = 'stable' | 'beta'; export type ThemeMode = 'system' | 'light' | 'dark'; @@ -482,9 +481,11 @@ export interface UserPreferences { startMinimized: boolean; /** UI theme preference: follow OS, light, or dark. */ themeMode: ThemeMode; - /** 后台自动更新渠道。stable(默认)= AutoUpdateGate 查正式版 manifest; - * beta = 查 Beta manifest。About / Advanced 的手动检查按钮各自固定 stable/beta。 */ + /** 后台自动更新渠道。用户未明确选择时跟随当前构建类型; + * About / Advanced 的手动检查按钮各自固定 stable/beta。 */ updateChannel: UpdateChannel; + /** 是否由用户明确选择过更新渠道;缺失时由当前构建类型决定默认渠道。 */ + updateChannelExplicit?: boolean; /** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到 * 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落 * 到原一次性插入。默认 true。 */ diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 95ba91688..6faee63aa 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', + opencode: 'opencode', custom: 'custom', }; diff --git a/openless-all/app/src/pages/settings/BetaChannelSection.tsx b/openless-all/app/src/pages/settings/BetaChannelSection.tsx index 02f341e88..ea3958ec0 100644 --- a/openless-all/app/src/pages/settings/BetaChannelSection.tsx +++ b/openless-all/app/src/pages/settings/BetaChannelSection.tsx @@ -13,6 +13,7 @@ import { CheckUpdateButton } from './CheckUpdateButton'; export function BetaChannelSection() { const { t } = useTranslation(); const [channel, setChannel] = useState('stable'); + const [autoCheckChannel, setAutoCheckChannel] = useState(null); const [platformCaps, setPlatformCaps] = useState(null); useEffect(() => { @@ -34,7 +35,9 @@ export function BetaChannelSection() { await setUpdateChannel(target); } catch { setChannel(target === 'beta' ? 'stable' : 'beta'); + return; } + setAutoCheckChannel(target); }; if (platformCaps?.supportsAutoUpdate !== true) return null; @@ -49,7 +52,7 @@ export function BetaChannelSection() {
- +
); diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index d832189ef..4e0a699ab 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -38,6 +38,8 @@ const descriptors: ProviderDescriptor[] = [ : 'api_key', validationProbe: 'unsupported', staticModels: [], + defaultRequestFormat: null, + supportedRequestFormats: [], })); const asrPresets = (os: OS, supportsQwen3Mlx = true, currentProviderId?: string) => diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index c4f8cda88..d97d5ef26 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -51,6 +51,8 @@ interface PresetOption { defaultModel?: string; authRequirement?: ProviderDescriptor['authRequirement']; staticModels?: string[]; + defaultRequestFormat?: ProviderDescriptor['defaultRequestFormat']; + supportedRequestFormats?: ProviderDescriptor['supportedRequestFormats']; } /** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的 @@ -69,6 +71,8 @@ export function presetsFor( defaultModel: descriptor.defaultModel ?? undefined, authRequirement: descriptor.authRequirement, staticModels: descriptor.staticModels, + defaultRequestFormat: descriptor.defaultRequestFormat, + supportedRequestFormats: descriptor.supportedRequestFormats, })); if (kind === 'llm') return descriptorPresets; const available = descriptorPresets; @@ -725,6 +729,7 @@ function ChannelModal({ const { t } = useTranslation(); const [name, setName] = useState(channel.name); const [providerType, setProviderType] = useState(channel.providerType); + const [changingProvider, setChangingProvider] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const saveName = async () => { @@ -761,16 +766,17 @@ function ChannelModal({ const changeProvider = async (next: string) => { const previous = providerType; onUserMutation(); - setProviderType(next); + setChangingProvider(true); try { await setChannelProviderType(kind, channel.id, next); await fillProviderDefaults(next); + setProviderType(next); await onChanged(); } catch (error) { console.error('[channels] change provider failed', error); setProviderType(previous); emitSaved('failed', t('common.operationFailed')); - } + } finally { setChangingProvider(false); } }; const remove = async () => { @@ -796,6 +802,7 @@ function ChannelModal({ void changeProvider(next)} options={presets.map(p => ({ value: p.id, @@ -818,7 +825,7 @@ function ChannelModal({ /> {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} - void onChanged()} onUserMutation={onUserMutation} - /> + />} {isLocalEngine && (
diff --git a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx index 4d5587791..a799a5dce 100644 --- a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx +++ b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx @@ -8,7 +8,15 @@ import { Icon } from '../../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate'; import type { UpdateChannel } from '../../lib/ipc'; -export function CheckUpdateButton({ channel, compact = false }: { channel: UpdateChannel; compact?: boolean }) { +export function CheckUpdateButton({ + channel, + compact = false, + autoCheckChannel, +}: { + channel: UpdateChannel; + compact?: boolean; + autoCheckChannel?: UpdateChannel | null; +}) { const { t } = useTranslation(); const updater = useAutoUpdate(); const { status, checking, busy } = updater; @@ -31,6 +39,12 @@ export function CheckUpdateButton({ channel, compact = false }: { channel: Updat : 'settings.about.checkStableUpdateBtn'; const label = checking ? t('settings.about.checkingUpdate') : t(labelKey); + useEffect(() => { + if (autoCheckChannel) void updater.checkForUpdates(autoCheckChannel); + // checkForUpdates changes with updater state; this effect is driven only by a channel switch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoCheckChannel]); + return ( <> } + ; +} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 07fa719ab..7e027d7d7 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -3,6 +3,9 @@ import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; 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 (!atlascloudPreset) { throw new Error('Atlas Cloud LLM preset is missing'); @@ -29,6 +32,8 @@ const coreAsr = presetsFor('asr', 'win', true, undefined, [{ authRequirement: 'endpoint_model_optional_api_key', validationProbe: 'asr_silence', staticModels: [], + defaultRequestFormat: null, + supportedRequestFormats: [], }]); if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optional_api_key') { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 4a032e5fc..f50e2e734 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -1,7 +1,7 @@ // 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。 // 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。 -import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../../components/Icon'; import { detectOS } from '../../components/WindowChrome'; @@ -15,6 +15,7 @@ import { validateProviderCredentials, type ProviderDescriptor, } from '../../lib/ipc'; +import { LlmProtocolFields } from './LlmProtocolFields'; import { emitSaved } from '../../lib/savedEvent'; import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -90,7 +91,8 @@ export const LLM_LABELS = [ ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], - ['custom', 'custom'], + ['opencode', 'opencode'], + ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], ].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[]; // 多模态(Omni)模型预设(issue #902):一个模型同时接收「提示词 + 音频」一步输出 @@ -122,7 +124,7 @@ export function ChannelCredentialFields({ kind: 'llm' | 'asr'; providerType: string; channelId: string; - descriptor?: Partial>; + descriptor?: Partial>; /** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */ onTested?: () => void; /** 新建草稿发生用户交互时同步通知外层,避免关闭流程误删。 */ @@ -134,6 +136,13 @@ export function ChannelCredentialFields({ const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; const [llmModelRevision, setLlmModelRevision] = useState(0); + const [configRevision, setConfigRevision] = useState(0); + const [blockedFields, setBlockedFields] = useState>({}); + const trackField = useCallback((account: string, blocked: boolean) => { + setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked }); + }, []); + const onLlmMutation = () => { onUserMutation?.(); setConfigRevision(value => value + 1); }; + const [asrModelRevision, setAsrModelRevision] = useState(0); const unifiedBailian = providerType === 'bailian'; const [bailianModel, setBailianModel] = useState(''); @@ -156,10 +165,15 @@ export function ChannelCredentialFields({ const onLlmThinkingToggle = (enabled: boolean) => { if (!prefs) return; - void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => { - console.error('[settings] failed to update LLM thinking mode', error); - emitSaved('failed', t('common.operationFailed')); - }); + onLlmMutation(); + trackField('thinking', true); + void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })) + .then(() => onTested?.()) + .catch(error => { + console.error('[settings] failed to update LLM thinking mode', error); + emitSaved('failed', t('common.operationFailed')); + }) + .finally(() => trackField('thinking', false)); }; // Provider policy 必须 fail-closed:Core descriptor 尚未返回或加载失败时, @@ -174,6 +188,11 @@ export function ChannelCredentialFields({ const codexOAuthSelected = descriptor?.authRequirement === 'o_auth'; return ( <> + {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && ( + + )} {codexOAuthSelected ? (
{t('settings.providers.codexOAuthNotice')} @@ -181,29 +200,29 @@ export function ChannelCredentialFields({ ) : ( <> + account="ark.api_key" provider={channelId} mono mask onUserMutation={onLlmMutation} onBlockedChange={trackField} /> - {providerType === 'custom' && ( + defaultValue={defaultEndpoint || undefined} onUserMutation={onLlmMutation} onBlockedChange={trackField} /> + {['custom', 'custom_responses', 'custom_messages'].includes(providerType) && ( <> )} @@ -213,7 +232,7 @@ export function ChannelCredentialFields({ account="ark.model_id" provider={channelId} placeholder={defaultModel || 'model-name'} mono defaultValue={defaultModel || undefined} - onUserMutation={onUserMutation} + onUserMutation={onLlmMutation} onBlockedChange={trackField} trailing={( )} /> - setLlmModelRevision(v => v + 1)} onTested={onTested} onUserMutation={onUserMutation} /> @@ -571,17 +590,20 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); const setResult = (next: ProviderToolStatus, nextMessage: string) => { + if (!mounted.current) return; setStatus(next); setMessage(nextMessage); }; @@ -590,7 +612,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested // 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。 const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => { // Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。 - if (!provider || kind === 'omni') return; + if (!mounted.current || !provider || kind === 'omni') return; try { await recordChannelTest(kind, provider, ok, latencyMs, message); onTested?.(); @@ -600,6 +622,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested }; const validate = async () => { + if (disabled) return; onUserMutation?.(); setModels([]); setSelectedModel(''); @@ -631,6 +654,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested }; const loadModels = async () => { + if (disabled) return; onUserMutation?.(); setResult('loading', t('settings.providers.loadingModels')); try { @@ -665,15 +689,15 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
- + {showFetchModels && ( - + )} {showFetchModels && models.length > 0 && ( ({ value: model, label: model }))} placeholder={t('settings.providers.selectModel')} ariaLabel={t('settings.providers.selectModel')} @@ -693,6 +717,9 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested function providerErrorMessage(error: unknown, t: ReturnType['t']): string { const message = error instanceof Error ? error.message : String(error); + for (const code of ['llmRequestFormatInvalid', 'llmThinkingModeInvalid', 'llmTokenLimitInvalid', 'llmThinkingBudgetInvalid', 'llmResponseIncomplete', 'llmStreamError', 'llmProtocolHeaderConflict']) { + if (message.includes(code)) return t(`settings.providers.${code}`); + } if (message.startsWith('providerHttpStatus:')) { return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' }); } @@ -723,6 +750,7 @@ function providerErrorMessage(error: unknown, t: ReturnType void; label: string; account: string; provider?: string; @@ -738,7 +766,7 @@ interface CredentialFieldProps { options?: SelectOption[]; } -function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) { +function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange }: CredentialFieldProps) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); @@ -750,9 +778,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const [status, setStatus] = useState('idle'); // 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。 const [customModelMode, setCustomModelMode] = useState(false); + useEffect(() => { + onBlockedChange?.(account, !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError'); + }, [account, loaded, dirty, status, onBlockedChange]); + const debounceRef = useRef(null); const statusRef = useRef(null); const mountedRef = useRef(true); + const editRevision = useRef(0); + const saveQueue = useRef>(Promise.resolve()); + const markMutation = () => { editRevision.current += 1; onUserMutation?.(); }; useEffect(() => { let cancelled = false; @@ -816,22 +851,26 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const save = async (v: string, force = false) => { if (!loaded || (!dirty && !force)) return; if (!mountedRef.current) return; + const revision = editRevision.current; setStatus('saving'); emitSaved('saving', t('common.saving')); try { - await setCredential(account, v, provider); - if (!mountedRef.current) return; + // 按编辑顺序写入,旧请求完成不能把新值标记为已保存。 + const write = saveQueue.current.catch(() => undefined).then(() => setCredential(account, v, provider)); + saveQueue.current = write; + await write; + if (!mountedRef.current || revision !== editRevision.current) return; setDirty(false); showTemporaryStatus('saved'); } catch (error) { - if (!mountedRef.current) return; + if (!mountedRef.current || revision !== editRevision.current) return; console.error('[settings] failed to save credential', account, error); showTemporaryStatus('saveError'); } }; const handleChange = (e: React.ChangeEvent) => { - onUserMutation?.(); + markMutation(); const v = e.target.value; setValue(v); onValueChange?.(v); @@ -852,7 +891,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const fillDefault = async () => { if (!loaded || !defaultValue) return; - onUserMutation?.(); + markMutation(); setValue(defaultValue); onValueChange?.(defaultValue); setDirty(true); @@ -891,7 +930,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de setCustomModelMode(true); return; } - onUserMutation?.(); + markMutation(); setValue(v); onValueChange?.(v); if (!loaded) return;