From a9ee22f3340c9a10880951675f9eaf788f3c552e Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 8 Sep 2026 15:41:36 +0800 Subject: [PATCH 1/3] =?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 | 132 ++++++++++++++++-- .../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, 269 insertions(+), 44 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 6266a809..eb0ab1b2 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 93621dfb..46e9fe79 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 347d85a2..81385425 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 2fe83328..072d7b67 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 2e16703a..1c225537 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()) } -pub(crate) fn persist_strict_settings( +fn persist_settings_preserving_update_channel( 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()); + persist_settings_with_host_lock_held(coord, prefs) +} + +pub(crate) fn persist_strict_settings( + coord: &Coordinator, + 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 11faeead..ee06ee61 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 e4051d9d..b527318e 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 28d1118c..7e0735ff 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 97f6c299..91e96449 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 02f341e8..ea3958ec 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 4d558779..a799a5dc 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 07fa719a..2668d8d4 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 4a032e5f..bbc08e4c 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 a3fc7171d2efa0b30958fa3b37a683a2ea312499 Mon Sep 17 00:00:00 2001 From: zhou zhichao <69954637+zhou-zhichao@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:54:01 +0200 Subject: [PATCH 3/3] feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash (#1023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * fix(macos): 避免 Fn 长按与功能键误触发听写 (#1008) Auto 和 Toggle 模式只在 Fn 短按松开且未叠加功能层操作时触发听写;长按及亮度、音量等 systemDefined 事件留给 macOS。 保留显式 Hold 模式的按住说话语义,并跳过 Fn 短按释放后的冗余仲裁延迟。 Co-authored-by: Chris233 * fix(asr): 百炼 WebSocket 优先走 IPv4,避开本机 IPv6 黑洞 (#1036) * 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 * fix(updater): Beta 构建默认跟随 Beta 渠道 (#1041) * fix(updater): default beta builds to beta channel (#1034) * fix(updater): persist channel switches atomically * feat(llm): 支持渠道级 Chat Completions、Responses 和 Messages (#1042) * feat(llm): add channel request formats and compatibility presets * fix(llm): harden channel protocol handling * feat(llm): integrate OpenCode Zen with shared request formats --------- Co-authored-by: Mark Co-authored-by: Chris233 Co-authored-by: 浩彬(^-^) <146098469+HKLHaoBin@users.noreply.github.com> Co-authored-by: Cursor --- 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 4298c5f9..be2cc935 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 14e93de1..052e317a 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 e95d49ae..54113e64 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 694fefb7..23857658 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 6ab43ab8..e79f9e20 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 1256553f..a4d1c2d4 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 15b0101c..700a94ad 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 b7ef1266..606c5b40 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 fef89e75..0ffc6a58 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 31d7d23d..b6d39c9c 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 61e21257..1c7d5c84 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 2873b3b5..3db537eb 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 95ba9168..6faee63a 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 9216eafd..f6192f9e 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 2668d8d4..7e027d7d 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 bbc08e4c..f50e2e73 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 }[];