From 4cfacf1c41b5a5b8c30ce2c5958f8e6493688d87 Mon Sep 17 00:00:00 2001 From: zhou-zhichao Date: Wed, 2 Sep 2026 03:10:49 +0200 Subject: [PATCH 1/6] feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash Dictation polish uses OpenAI-compatible chat/completions. For OpenCode Go, change the Base URL to /zen/go/v1. --- README.md | 2 +- README.zh.md | 2 +- .../app/src-tauri/src/commands/credentials.rs | 1 + openless-all/app/src-tauri/src/polish.rs | 38 ++++++++++++++++++- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Overview.tsx | 1 + .../pages/settings/ProvidersSection.test.ts | 20 ++++++++++ .../src/pages/settings/ProvidersSection.tsx | 10 +++++ 12 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1976a4737..7febed707 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,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 49af001c0..a510ab5c0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -204,7 +204,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/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 3a5356887..b4417ce48 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -151,6 +151,7 @@ fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> { "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "stepfun" => Some("https://api.stepfun.com/v1"), + "opencode" => Some("https://opencode.ai/zen/v1"), _ => None, } } diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index d868641fc..1facaa80a 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -176,6 +176,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "opencode" ) } @@ -1875,7 +1876,7 @@ pub(crate) enum ThinkingControl { pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option { match provider_id.trim() { - "deepseek" => Some(ThinkingControl::DeepSeekThinking), + "deepseek" | "opencode" => Some(ThinkingControl::DeepSeekThinking), // provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。 "minimax" => Some(ThinkingControl::MiniMaxThinking), "openrouterFree" => Some(ThinkingControl::OpenRouterReasoning), @@ -1911,7 +1912,7 @@ pub(crate) fn openai_compatible_thinking_control_for_base_url( if host.contains("minimax") { return Some(ThinkingControl::MiniMaxThinking); } - if host.contains("deepseek") { + if host.contains("deepseek") || host.contains("opencode.ai") { return Some(ThinkingControl::DeepSeekThinking); } if host.contains("openrouter") { @@ -3619,6 +3620,39 @@ mod tests { assert_eq!(body["thinking"]["type"], "disabled"); } + #[test] + fn openai_chat_body_adds_deepseek_thinking_toggle_for_opencode() { + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "opencode", + "OpenCode Zen", + "https://opencode.ai/zen/v1", + "k", + "deepseek-v4-flash", + )); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + + #[test] + fn openai_chat_body_falls_back_to_base_url_for_custom_opencode_endpoint() { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://opencode.ai/zen/go/v1", + "k", + "deepseek-v4-flash", + ) + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + #[test] fn openai_chat_body_disables_minimax_thinking_by_preset() { // provider_id 预设命中 "minimax" → 走 MiniMaxThinking 分支,关闭时下发 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 42192c2ed..51d9dd521 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1010,6 +1010,7 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian realtime ASR', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2b5463ff6..b55ea9cd7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1012,6 +1012,7 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian リアルタイム ASR', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3ad19f3b..710b6ba68 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1012,6 +1012,7 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian 실시간 ASR', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 816224d3e..a577ad67f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1008,6 +1008,7 @@ export const zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', + opencode: 'OpenCode Zen', custom: '自定义', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里云百炼实时 ASR', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 534421198..ae7503bb6 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1010,6 +1010,7 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', custom: '自定義', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里雲百煉即時 ASR', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6eeddd610..7f5ad7bf8 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/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 371d5e4f2..4e0cc1265 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -15,6 +15,26 @@ if (atlascloudPreset.modelPlaceholder !== 'qwen/qwen3.5-flash') { throw new Error(`unexpected Atlas Cloud default model: ${atlascloudPreset.modelPlaceholder}`); } +const opencodePreset = LLM_PRESETS.find(p => p.id === 'opencode'); + +if (!opencodePreset) { + throw new Error('OpenCode LLM preset is missing'); +} + +if (opencodePreset.baseUrl !== 'https://opencode.ai/zen/v1') { + throw new Error(`unexpected OpenCode base URL: ${opencodePreset.baseUrl}`); +} + +if (opencodePreset.modelPlaceholder !== 'deepseek-v4-flash') { + throw new Error(`unexpected OpenCode default model: ${opencodePreset.modelPlaceholder}`); +} + +const customPresetIndex = LLM_PRESETS.findIndex(p => p.id === 'custom'); +const opencodePresetIndex = LLM_PRESETS.findIndex(p => p.id === 'opencode'); +if (customPresetIndex < 0 || opencodePresetIndex < 0 || opencodePresetIndex >= customPresetIndex) { + throw new Error('OpenCode preset must sit before the custom fallback preset'); +} + const openAiCompatiblePreset = ASR_PRESETS.find(p => p.id === 'openai-compatible'); if (!openAiCompatiblePreset) { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index a315b7662..c37b3b658 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -188,6 +188,16 @@ export const LLM_PRESETS = [ baseUrl: 'https://api.stepfun.com/v1', modelPlaceholder: 'step-1o-turbo-vision', }, + { + // OpenCode Zen:OpenAI 兼容 /zen/v1/chat/completions。 + // 默认模型 deepseek-v4-flash(官方 model id,见 https://opencode.ai/docs/zen)。 + // OpenCode Go 订阅把 Base URL 改成 https://opencode.ai/zen/go/v1,Key 用 Go 控制台那把。 + // provider_id 在后端 polish.rs 命中 "opencode" → DeepSeekThinking(默认模型是 Flash)。 + id: 'opencode', + nameKey: 'opencode', + baseUrl: 'https://opencode.ai/zen/v1', + modelPlaceholder: 'deepseek-v4-flash', + }, { id: 'custom', nameKey: 'custom', From 6a7cd5f4cd1170025b73e210d3582a446d72780f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 8 Sep 2026 01:39:55 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(macos):=20=E9=81=BF=E5=85=8D=20Fn=20?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E4=B8=8E=E5=8A=9F=E8=83=BD=E9=94=AE=E8=AF=AF?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=90=AC=E5=86=99=20(#1008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto 和 Toggle 模式只在 Fn 短按松开且未叠加功能层操作时触发听写;长按及亮度、音量等 systemDefined 事件留给 macOS。 保留显式 Hold 模式的按住说话语义,并跳过 Fn 短按释放后的冗余仲裁延迟。 Co-authored-by: Chris233 --- .../app/crates/openless-core/src/api.rs | 6 +- .../openless-core/src/hotkey_interpreter.rs | 34 +- openless-all/app/src-tauri/src/hotkey.rs | 301 ++++++++++++++++-- 3 files changed, 313 insertions(+), 28 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a0baeb3da..75f5f7f61 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 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/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"]); + } } } From af9eaa3e16e664173186b2481eb824a20e402e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=A9=E5=BD=AC=28=5E-=5E=29?= <146098469+HKLHaoBin@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:47:09 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(asr):=20=E7=99=BE=E7=82=BC=20WebSocket?= =?UTF-8?q?=20=E4=BC=98=E5=85=88=E8=B5=B0=20IPv4=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=BC=80=E6=9C=AC=E6=9C=BA=20IPv6=20=E9=BB=91=E6=B4=9E=20(#103?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞 dashscope 的 AAAA 在部分 Windows 双栈网上 TCP 443 会挂死,connect_async 先试 IPv6 会卡满 5 秒超时,听写进不了 Listening。 Co-authored-by: Cursor * fix(asr): preserve websocket ports and retry handshake addresses --------- Co-authored-by: Cursor Co-authored-by: Chris233 --- .../crates/openless-core/src/asr/bailian.rs | 188 +++++++++++++++++- 1 file changed, 185 insertions(+), 3 deletions(-) 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] From a9ee22f3340c9a10880951675f9eaf788f3c552e Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 8 Sep 2026 15:41:36 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(updater):=20Beta=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=B7=9F=E9=9A=8F=20Beta=20=E6=B8=A0?= =?UTF-8?q?=E9=81=93=20(#1041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(updater): default beta builds to beta channel (#1034) * fix(updater): persist channel switches atomically --- .../crates/openless-core/src/shared_types.rs | 39 ++++++ openless-all/app/src-tauri/Cargo.lock | 1 + openless-all/app/src-tauri/Cargo.toml | 1 + .../src-tauri/src/android/updater_logic.rs | 35 ++--- .../app/src-tauri/src/commands/settings.rs | 130 ++++++++++++++++-- .../app/src/components/AutoUpdate.tsx | 27 +++- .../app/src/components/AutoUpdateGate.tsx | 1 + openless-all/app/src/i18n/en.ts | 4 + openless-all/app/src/i18n/ja.ts | 4 + openless-all/app/src/i18n/ko.ts | 4 + openless-all/app/src/i18n/zh-CN.ts | 4 + openless-all/app/src/i18n/zh-TW.ts | 4 + openless-all/app/src/lib/appVersion.test.ts | 22 +++ openless-all/app/src/lib/appVersion.ts | 3 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/types.ts | 9 +- .../src/pages/settings/BetaChannelSection.tsx | 5 +- .../src/pages/settings/CheckUpdateButton.tsx | 17 ++- 18 files changed, 268 insertions(+), 43 deletions(-) create mode 100644 openless-all/app/src/lib/appVersion.test.ts 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/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/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2e16703ab..1c2255373 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( @@ -198,7 +213,7 @@ pub async fn set_settings( // 广播给所有 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 主线程拥塞时闭包延迟 → 整场显示旧样式)。 @@ -257,7 +272,7 @@ 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)?; + 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 +312,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 +384,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 +423,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 +567,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 +579,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 +644,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/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/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 28d1118cf..7e0735ff2 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -109,6 +109,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/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/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/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..2668d8d46 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -29,6 +29,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..bbc08e4cb 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,7 @@ export const LLM_LABELS = [ ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], - ['custom', 'custom'], + ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], ].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[]; // 多模态(Omni)模型预设(issue #902):一个模型同时接收「提示词 + 音频」一步输出 @@ -122,7 +123,7 @@ export function ChannelCredentialFields({ kind: 'llm' | 'asr'; providerType: string; channelId: string; - descriptor?: Partial>; + descriptor?: Partial>; /** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */ onTested?: () => void; /** 新建草稿发生用户交互时同步通知外层,避免关闭流程误删。 */ @@ -134,6 +135,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 +164,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 +187,11 @@ export function ChannelCredentialFields({ const codexOAuthSelected = descriptor?.authRequirement === 'o_auth'; return ( <> + {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && ( + + )} {codexOAuthSelected ? (
{t('settings.providers.codexOAuthNotice')} @@ -181,29 +199,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 +231,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 +589,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 +611,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 +621,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested }; const validate = async () => { + if (disabled) return; onUserMutation?.(); setModels([]); setSelectedModel(''); @@ -631,6 +653,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested }; const loadModels = async () => { + if (disabled) return; onUserMutation?.(); setResult('loading', t('settings.providers.loadingModels')); try { @@ -665,15 +688,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 +716,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 +749,7 @@ function providerErrorMessage(error: unknown, t: ReturnType void; label: string; account: string; provider?: string; @@ -738,7 +765,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 +777,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 +850,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 +890,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 +929,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de setCustomModelMode(true); return; } - onUserMutation?.(); + markMutation(); setValue(v); onValueChange?.(v); if (!loaded) return; From e00099beee2517c0b45bfb013a445b6038b40c89 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 8 Sep 2026 19:13:13 +0800 Subject: [PATCH 6/6] feat(llm): integrate OpenCode Zen with shared request formats --- README.md | 2 +- README.zh.md | 2 +- .../openless-core/src/cloud_providers.rs | 1 + .../app/crates/openless-core/src/polish.rs | 75 +++++++++++++++++-- .../openless-core/src/provider_rules.rs | 27 ++++++- .../openless-core/src/provider_service.rs | 9 ++- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/lib/ipc/providers.ts | 5 +- openless-all/app/src/pages/Overview.tsx | 1 + .../pages/settings/LlmProtocolFields.test.ts | 17 ++++- .../pages/settings/ProvidersSection.test.ts | 3 + .../src/pages/settings/ProvidersSection.tsx | 1 + 16 files changed, 133 insertions(+), 15 deletions(-) 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/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index e95d49aea..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,6 +76,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "codingPlanX", "minimax", "stepfun", + "opencode", "custom", "custom_responses", "custom_messages", diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 694fefb74..23857658e 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -187,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "opencode" ) } @@ -1649,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; @@ -2125,7 +2139,10 @@ mod tests { #[tokio::test] async fn all_text_entrypoints_use_the_selected_protocol_over_http() { - for format in LlmRequestFormat::ALL { + 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 || { @@ -2140,7 +2157,7 @@ mod tests { LlmRequestFormat::Responses => "responses", LlmRequestFormat::Messages => "messages", }; - assert!(headers.starts_with(&format!("post /gateway/v1/{path}?tenant=1 "))); + 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")); @@ -2189,9 +2206,9 @@ mod tests { } }); let config = OpenAICompatibleConfig::new( - "custom", + preset, "test", - format!("http://{address}/gateway/v1/chat/completions?tenant=1"), + format!("http://{address}{prefix}/chat/completions?tenant=1"), "fixture-key", "test", ) @@ -2283,6 +2300,52 @@ mod tests { } } + #[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( 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 6ab43ab80..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,6 +62,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), ("stepfun", "stepfun"), + ("opencode", "opencode"), ("custom", "customChatCompletions"), ("custom_responses", "customResponses"), ("custom_messages", "customMessages"), @@ -481,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"), @@ -501,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"), @@ -989,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 1256553f0..a4d1c2d4e 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -892,6 +892,9 @@ mod tests { 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"), ] { @@ -909,13 +912,13 @@ mod tests { 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 enabled { assert_eq!(body["thinking"]["type"], "adaptive"); } + 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, "openai", &[ - (LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{format}")), (LLM_MODEL_ACCOUNT, "test"), + 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)); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 15b0101c8..700a94adc 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1010,6 +1010,7 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', customChatCompletions: "Custom · Chat Completions", customResponses: "Custom · Responses", customMessages: "Custom · Messages", diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b7ef12669..606c5b40f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1012,6 +1012,7 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', customChatCompletions: "カスタム · Chat Completions", customResponses: "カスタム · Responses", customMessages: "カスタム · Messages", diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index fef89e756..0ffc6a580 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1012,6 +1012,7 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', customChatCompletions: "사용자 지정 · Chat Completions", customResponses: "사용자 지정 · Responses", customMessages: "사용자 지정 · Messages", diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 31d7d23d1..b6d39c9ca 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1008,6 +1008,7 @@ export const zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', + opencode: 'OpenCode Zen', customChatCompletions: "自定义 · Chat Completions", customResponses: "自定义 · Responses", customMessages: "自定义 · Messages", diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 61e212577..1c7d5c845 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1010,6 +1010,7 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', customChatCompletions: "自訂 · Chat Completions", customResponses: "自訂 · Responses", customMessages: "自訂 · Messages", diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 2873b3b56..3db537eb8 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -28,11 +28,14 @@ export interface ProviderDescriptor { /** Core owns protocol, defaults, and credential requirements. */ export function listProviderDescriptors(kind: ProviderKind): Promise { 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: null, defaultModel: null, + 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/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/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index 9216eafd7..f6192f9e3 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -18,14 +18,27 @@ assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', ' const descriptors = await listProviderDescriptors('llm'); const presets = presetsFor('llm', 'win', true, undefined, descriptors); -assert(presets.length === 3, 'Browser catalog should expose three compatibility presets'); +assert(presets.filter(p => p.id.startsWith('custom')).length === 3, 'Browser catalog should expose three compatibility presets'); +const opencode = presets.find(p => p.id === 'opencode'); +assert(opencode?.defaultRequestFormat === 'chat_completions' + && opencode.defaultEndpoint === 'https://opencode.ai/zen/v1' + && opencode.defaultModel === 'deepseek-v4-flash', 'OpenCode browser preset must retain Core defaults'); assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults'); for (const preset of presets) assert(preset.supportedRequestFormats?.length === 3, 'All compatibility presets allow switching'); -const first = await createChannel('llm', 'custom', 'first'); +const first = await createChannel('llm', 'opencode', 'first'); const second = await createChannel('llm', 'custom', 'second'); await setCredential('ark.request_format', 'messages', first); await setCredential('ark.api_key', 'fixture-key', first); +await setCredential('ark.endpoint', 'https://opencode.ai/zen/go/v1', first); +await setCredential('ark.model_id', 'deepseek-v4-flash', first); +for (const format of ['chat_completions', 'responses', 'messages']) { + await setCredential('ark.request_format', format, first); + assert(await readCredential('ark.request_format', first) === format, 'OpenCode format must survive reload'); + assert(await readCredential('ark.api_key', first) === 'fixture-key', 'Format changes preserve the key'); + assert(await readCredential('ark.model_id', first) === 'deepseek-v4-flash', 'Format changes preserve the model'); + assert(await readCredential('ark.endpoint', first) === 'https://opencode.ai/zen/go/v1', 'Format changes preserve the endpoint'); +} await recordChannelTest('llm', first, true, 1, null); await setCredential('ark.model_id', 'new-model', first); assert(await readCredential('ark.request_format', first) === 'messages', 'Changing model must preserve format'); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 2668d8d46..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'); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index bbc08e4cb..f50e2e734 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -91,6 +91,7 @@ export const LLM_LABELS = [ ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], + ['opencode', 'opencode'], ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], ].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];