From 948d7627923b4eb5cc2f7bdeb82185b770e429e3 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Jun 2026 10:40:51 +0800 Subject: [PATCH] feat(codex): add unified session history toggle for official providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex buckets resume history by the model_provider id recorded in each session: official runs (no key, built-in "openai") and cc-switch third-party runs (shared "custom") are mutually invisible in the resume picker. Add an opt-in setting that runs official providers under the shared "custom" id so future official sessions land in the same history bucket as third-party ones. Forward-only by design: existing sessions are not migrated. When enabled, official live config.toml gets model_provider = "custom" plus a [model_providers.custom] entry that mirrors the built-in openai provider (requires_openai_auth routes auth to the ChatGPT login in auth.json, name "OpenAI" keeps is_openai() feature gates, explicit supports_websockets/wire_api restore built-in defaults). auth.json is untouched. Key invariants: - Injection lives only in the live config: switch-away backfill strips the exact injected shape, so stored provider configs stay clean and turning the toggle off fully reverts on the next write. - Toggle changes apply immediately via a takeover-aware reapply: when the proxy owns the live config (backup/placeholder present), only the live backup is updated, mirroring the provider-switch path. - The takeover backup path runs the same injection so a takeover release restores a config that still carries the unified routing. - Injection refuses to activate a foreign [model_providers.custom] table (e.g. stale entry with a third-party base_url) to avoid routing ChatGPT OAuth traffic to an unknown backend. The toggle lives under Settings → Codex App Enhancements; the description warns that resuming old sessions across providers may fail because encrypted_content reasoning only decrypts on the backend that created it (upstream treats cross-provider resume as unsupported). --- src-tauri/src/codex_config.rs | 284 ++++++++++++++++++ src-tauri/src/commands/settings.rs | 17 +- src-tauri/src/services/provider/live.rs | 13 + src-tauri/src/services/provider/mod.rs | 42 +++ src-tauri/src/services/proxy.rs | 8 + src-tauri/src/settings.rs | 17 ++ src/components/settings/CodexAuthSettings.tsx | 12 +- src/hooks/useSettingsForm.ts | 3 + src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/schemas/settings.ts | 1 + src/types.ts | 3 + 14 files changed, 406 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 965d5848a..f2735f8ce 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1043,16 +1043,197 @@ pub fn read_codex_live_settings() -> Result { Ok(json!({ "auth": auth, "config": cfg_text })) } +/// `[model_providers.custom]` entry that makes an official (ChatGPT OAuth) +/// provider behave like Codex's built-in `openai` entry while running under +/// the shared custom id: `requires_openai_auth` routes auth to the ChatGPT +/// login in `auth.json` (base_url then defaults to the official Codex +/// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates +/// (web search, remote compaction), and `supports_websockets` restores the +/// built-in default that custom entries otherwise lose. +fn codex_unified_official_provider_table() -> toml_edit::Table { + let mut table = toml_edit::Table::new(); + table["name"] = toml_edit::value("OpenAI"); + table["requires_openai_auth"] = toml_edit::value(true); + table["supports_websockets"] = toml_edit::value(true); + table["wire_api"] = toml_edit::value("responses"); + table +} + +fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool { + table.len() == 4 + && table.get("name").and_then(|item| item.as_str()) == Some("OpenAI") + && table + .get("requires_openai_auth") + .and_then(|item| item.as_bool()) + == Some(true) + && table + .get("supports_websockets") + .and_then(|item| item.as_bool()) + == Some(true) + && table.get("wire_api").and_then(|item| item.as_str()) == Some("responses") +} + +/// 统一 Codex 会话历史:把官方供应商的 live 配置改写为以共享的 +/// `custom` model_provider 标识运行(认证仍走 `auth.json` 的 ChatGPT 登录), +/// 使开关开启后创建的官方会话与第三方会话共用同一个 resume 历史桶。 +/// +/// 两种情况拒绝注入、原样返回: +/// - 配置已有显式 `model_provider`:用户手工指定的路由不被覆盖; +/// - 配置已有形态不同的 `[model_providers.custom]` 表:设置 `model_provider` +/// 会激活这张我们不认识的表(可能带第三方 base_url/token,会把 ChatGPT +/// OAuth 流量路由到错误后端),宁可让开关对该配置不生效。 +pub fn inject_codex_unified_session_bucket(config_text: &str) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + if doc.get("model_provider").is_some() { + return Ok(config_text.to_string()); + } + + let existing_custom_conflicts = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)) + .and_then(|item| item.as_table()) + .is_some_and(|table| !table_matches_codex_unified_official_provider(table)); + if existing_custom_conflicts { + log::warn!( + "官方 Codex 配置已存在自定义 [model_providers.custom],跳过统一会话路由注入以避免激活未知路由" + ); + return Ok(config_text.to_string()); + } + + doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID); + + if doc.get("model_providers").is_none() { + let mut parent = toml_edit::Table::new(); + parent.set_implicit(true); + doc["model_providers"] = toml_edit::Item::Table(parent); + } + if let Some(providers) = doc["model_providers"].as_table_mut() { + if !providers.contains_key(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) { + providers.insert( + CC_SWITCH_CODEX_MODEL_PROVIDER_ID, + toml_edit::Item::Table(codex_unified_official_provider_table()), + ); + } + } + Ok(doc.to_string()) +} + +/// `inject_codex_unified_session_bucket` 的反向操作:从配置文本里剥掉注入的 +/// 统一会话路由,保证切换回填不会把它带进数据库的存储配置(关闭开关后 +/// 切换即可完全还原)。仅当形态与注入产物完全一致时才剥离;第三方模板和 +/// 用户自定义的 `custom` 条目(带 base_url 等差异字段)原样保留。 +pub fn strip_codex_unified_session_bucket(config_text: &str) -> Result { + if !config_text.contains("model_provider") { + return Ok(config_text.to_string()); + } + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + if doc.get("model_provider").and_then(|item| item.as_str()) + != Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + { + return Ok(config_text.to_string()); + } + let matches_injected = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)) + .and_then(|item| item.as_table()) + .is_some_and(table_matches_codex_unified_official_provider); + if !matches_injected { + return Ok(config_text.to_string()); + } + + doc.as_table_mut().remove("model_provider"); + let providers_empty = doc["model_providers"] + .as_table_mut() + .map(|providers| { + providers.remove(CC_SWITCH_CODEX_MODEL_PROVIDER_ID); + providers.is_empty() + }) + .unwrap_or(false); + if providers_empty { + doc.as_table_mut().remove("model_providers"); + } + Ok(doc.to_string()) +} + +/// 统一会话开关开启时,把官方供应商 `{ auth, config }` 设置对象中的 +/// config 文本注入共享 custom 路由;开关关闭或非官方供应商时不做改动。 +/// +/// 普通 live 写入(`write_codex_live_for_provider`)与代理接管备份 +/// (`update_live_backup_from_provider`)两条落盘路径共用:接管期间 +/// live 归代理所有,注入必须进备份,接管释放恢复的 live 才带统一路由。 +pub fn apply_codex_unified_session_bucket_to_settings( + category: Option<&str>, + settings: &mut Value, +) -> Result<(), AppError> { + if category != Some("official") || !crate::settings::unify_codex_session_history() { + return Ok(()); + } + let config_text = settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + let injected = inject_codex_unified_session_bucket(&config_text)?; + if injected != config_text { + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(injected)); + } + } + Ok(()) +} + +/// Backfill helper: strip the unified-session injection from a live +/// `{ auth, config }` settings object before it is stored back to the DB. +pub fn strip_codex_unified_session_bucket_from_settings( + settings: &mut Value, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + let stripped = strip_codex_unified_session_bucket(&config_text)?; + if stripped != config_text { + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(stripped)); + } + } + Ok(()) +} + /// Route a Codex live write between full auth+config or config-only. /// /// Official providers with usable login material own `auth.json`. Third-party /// providers only touch `config.toml` when the compatibility setting is enabled /// so the user's ChatGPT login cache survives provider switches. +/// +/// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由 +/// (见 `inject_codex_unified_session_bucket`)。 pub fn write_codex_live_for_provider( category: Option<&str>, auth: &Value, config_text: Option<&str>, ) -> Result<(), AppError> { + let unified_official_config = + if category == Some("official") && crate::settings::unify_codex_session_history() { + Some(inject_codex_unified_session_bucket( + config_text.unwrap_or(""), + )?) + } else { + None + }; + let config_text = unified_official_config.as_deref().or(config_text); + let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth)) || (category != Some("official") && !crate::settings::preserve_codex_official_auth_on_switch()); @@ -1257,6 +1438,109 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn unified_session_bucket_injects_for_empty_official_config() { + let injected = inject_codex_unified_session_bucket("").expect("inject"); + let doc: toml::Table = toml::from_str(&injected).expect("parse injected config"); + + assert_eq!( + doc.get("model_provider").and_then(|v| v.as_str()), + Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + ); + let custom = doc["model_providers"][CC_SWITCH_CODEX_MODEL_PROVIDER_ID] + .as_table() + .expect("custom provider table"); + assert_eq!(custom.get("name").and_then(|v| v.as_str()), Some("OpenAI")); + assert_eq!( + custom.get("requires_openai_auth").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + custom.get("supports_websockets").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + custom.get("wire_api").and_then(|v| v.as_str()), + Some("responses") + ); + } + + #[test] + fn unified_session_bucket_preserves_other_keys_and_explicit_routing() { + let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n"; + let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject"); + assert!(injected.contains("model_catalog_json")); + assert!(injected.contains("model_provider = \"custom\"")); + + // 用户显式指定过 model_provider 的官方配置不被覆盖 + let explicit = "model_provider = \"openai_https\"\n"; + let unchanged = inject_codex_unified_session_bucket(explicit).expect("inject"); + assert_eq!(unchanged, explicit); + } + + #[test] + fn unified_session_bucket_skips_conflicting_custom_table() { + // 残留的非注入形态 custom 表:设置 model_provider 会把官方流量 + // 路由到表里的第三方端点,必须整体拒绝注入。 + let stale = r#"[model_providers.custom] +name = "Relay" +base_url = "https://relay.example/v1" +"#; + let unchanged = inject_codex_unified_session_bucket(stale).expect("inject"); + assert_eq!(unchanged, stale); + + // 已是注入形态的 custom 表(如重复注入)则照常补上 model_provider + let injected_once = inject_codex_unified_session_bucket("").expect("inject"); + let reinjected = inject_codex_unified_session_bucket(&injected_once).expect("re-inject"); + assert_eq!(reinjected, injected_once); + } + + #[test] + fn unified_session_bucket_strip_round_trips_injection() { + let injected = inject_codex_unified_session_bucket("").expect("inject"); + let stripped = strip_codex_unified_session_bucket(&injected).expect("strip"); + assert_eq!(stripped.trim(), ""); + + let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n"; + let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject"); + let stripped = strip_codex_unified_session_bucket(&injected).expect("strip"); + assert_eq!(stripped, with_catalog); + } + + #[test] + fn unified_session_bucket_strip_keeps_third_party_custom_entry() { + // 第三方模板同样用 custom 路由,但条目带 base_url 等差异字段, + // 形态不等于注入产物,必须原样保留。 + let third_party = r#"model_provider = "custom" + +[model_providers.custom] +name = "Relay" +base_url = "https://relay.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + let untouched = strip_codex_unified_session_bucket(third_party).expect("strip"); + assert_eq!(untouched, third_party); + } + + #[test] + fn unified_session_bucket_strip_from_settings_only_touches_config() { + let injected = inject_codex_unified_session_bucket("").expect("inject"); + let mut settings = json!({ + "auth": { "tokens": { "access_token": "secret" } }, + "config": injected, + }); + strip_codex_unified_session_bucket_from_settings(&mut settings).expect("strip settings"); + assert_eq!( + settings + .get("config") + .and_then(|v| v.as_str()) + .map(str::trim), + Some("") + ); + assert!(settings.pointer("/auth/tokens/access_token").is_some()); + } + #[test] fn extract_base_url_prefers_active_provider_section() { let input = r#"model_provider = "azure" diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 0996f5311..d49828170 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -65,10 +65,25 @@ pub async fn get_settings() -> Result { /// 保存设置 #[tauri::command] -pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { +pub async fn save_settings( + state: tauri::State<'_, crate::store::AppState>, + settings: crate::settings::AppSettings, +) -> Result { let existing = crate::settings::get_settings(); let merged = merge_settings_for_save(settings, &existing); + let unify_codex_changed = + merged.unify_codex_session_history != existing.unify_codex_session_history; crate::settings::update_settings(merged).map_err(|e| e.to_string())?; + + // 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置, + // 不必等下一次切换才生效。 + if unify_codex_changed { + if let Err(err) = + crate::services::provider::reapply_current_codex_official_live(state.inner()) + { + log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败: {err}"); + } + } Ok(true) } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e3ce46b83..945a7cf5f 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill( ); } + // 统一会话开关注入的共享 `custom` 路由只属于 live 配置;切换回填时 + // 必须剥掉,否则官方供应商的存储配置被污染,关闭开关后无法还原。 + if provider.category.as_deref() == Some("official") { + if let Err(err) = + crate::codex_config::strip_codex_unified_session_bucket_from_settings(&mut settings) + { + log::warn!( + "Failed to strip unified session bucket while backfilling '{}': {err}", + provider.id + ); + } + } + // `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's // `config.toml` only carries a lossy projection (`model_catalog_json` → // generated catalog file) that proxy takeover/restore cycles and Codex.app diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index f41fb7827..d7eb15144 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -42,6 +42,48 @@ use live::{ }; use usage::validate_usage_script; +/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 +/// live 配置,使开关即时生效(无需等下一次切换)。 +/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置, +/// 第三方 live 配置不受开关影响。 +pub fn reapply_current_codex_official_live(state: &AppState) -> Result { + let current_id = ProviderService::current(state, AppType::Codex)?; + if current_id.is_empty() { + return Ok(false); + } + let providers = state.db.get_all_providers(AppType::Codex.as_str())?; + let Some(provider) = providers.get(¤t_id) else { + return Ok(false); + }; + if provider.category.as_deref() != Some("official") { + return Ok(false); + } + + // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截, + // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号, + // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。 + let has_live_backup = + futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str())) + .ok() + .flatten() + .is_some(); + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&AppType::Codex); + if has_live_backup || live_taken_over { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(AppType::Codex.as_str(), provider), + ) + .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + return Ok(true); + } + + live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?; + Ok(true) +} + /// Provider business logic service pub struct ProviderService; diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index d77c7169e..b99bbf5a4 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -2033,6 +2033,14 @@ impl ProxyService { )?; Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?; } + + // 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的 + // 共享 custom 路由注入必须落在备份里,否则恢复后开关失效。 + crate::codex_config::apply_codex_unified_session_bucket_to_settings( + provider.category.as_deref(), + &mut effective_settings, + ) + .map_err(|e| format!("注入统一会话路由失败: {e}"))?; } let backup_json = match app_type_enum { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2d32a9401..cc11aa59a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -357,6 +357,12 @@ pub struct AppSettings { /// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json. #[serde(default)] pub preserve_codex_official_auth_on_switch: bool, + /// Run official Codex providers under the shared "custom" model_provider id + /// so future official sessions share one resume-history bucket with + /// third-party providers. Forward-only: existing sessions are not migrated. + /// Opt-in: defaults to false. + #[serde(default)] + pub unify_codex_session_history: bool, /// User has confirmed the failover toggle first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub failover_confirmed: Option, @@ -475,6 +481,7 @@ impl Default for AppSettings { stream_check_confirmed: None, enable_failover_toggle: false, preserve_codex_official_auth_on_switch: false, + unify_codex_session_history: false, failover_confirmed: None, first_run_notice_confirmed: None, common_config_confirmed: None, @@ -829,6 +836,16 @@ pub fn preserve_codex_official_auth_on_switch() -> bool { .preserve_codex_official_auth_on_switch } +pub fn unify_codex_session_history() -> bool { + settings_store() + .read() + .unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }) + .unify_codex_session_history +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) diff --git a/src/components/settings/CodexAuthSettings.tsx b/src/components/settings/CodexAuthSettings.tsx index 0472acfdc..5b2ad3826 100644 --- a/src/components/settings/CodexAuthSettings.tsx +++ b/src/components/settings/CodexAuthSettings.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { KeyRound } from "lucide-react"; +import { History, KeyRound } from "lucide-react"; import type { SettingsFormState } from "@/hooks/useSettings"; import { ToggleRow } from "@/components/ui/toggle-row"; @@ -30,6 +30,16 @@ export function CodexAuthSettings({ onChange({ preserveCodexOfficialAuthOnSwitch: value }) } /> + + } + title={t("settings.unifyCodexSessionHistory")} + description={t("settings.unifyCodexSessionHistoryDescription")} + checked={settings.unifyCodexSessionHistory ?? false} + onCheckedChange={(value) => + onChange({ unifyCodexSessionHistory: value }) + } + /> ); } diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 245b7a66d..03c7e34a3 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -118,6 +118,7 @@ export function useSettingsForm(): UseSettingsFormResult { skipClaudeOnboarding: data.skipClaudeOnboarding ?? false, preserveCodexOfficialAuthOnSwitch: data.preserveCodexOfficialAuthOnSwitch ?? false, + unifyCodexSessionHistory: data.unifyCodexSessionHistory ?? false, claudeConfigDir: sanitizeDir(data.claudeConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), @@ -143,6 +144,7 @@ export function useSettingsForm(): UseSettingsFormResult { enableClaudePluginIntegration: false, skipClaudeOnboarding: false, preserveCodexOfficialAuthOnSwitch: false, + unifyCodexSessionHistory: false, language: readPersistedLanguage(), } as SettingsFormState); @@ -182,6 +184,7 @@ export function useSettingsForm(): UseSettingsFormResult { skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false, preserveCodexOfficialAuthOnSwitch: serverData.preserveCodexOfficialAuthOnSwitch ?? false, + unifyCodexSessionHistory: serverData.unifyCodexSessionHistory ?? false, claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 54bc37ea8..77dd67da9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -670,6 +670,8 @@ "codexAuth": "Codex App Enhancements", "preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers", "preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.", + "unifyCodexSessionHistory": "Unified Codex session history", + "unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id, so future official sessions appear in the same history list as third-party sessions (existing sessions are not migrated). Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.", "appVisibility": { "title": "Homepage Display", "description": "Choose which apps to show on the homepage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f56ddbd71..47d87ae80 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -670,6 +670,8 @@ "codexAuth": "Codex アプリ拡張", "preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持", "preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。", + "unifyCodexSessionHistory": "Codex セッション履歴を統一", + "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、今後の公式セッションはサードパーティのセッションと同じ履歴リストに表示されます(既存セッションは移行しません)。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 71db22265..d4a0c7adc 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -670,6 +670,8 @@ "codexAuth": "Codex 應用增強", "preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入", "preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能", + "unifyCodexSessionHistory": "統一 Codex 會話歷史", + "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,未來的官方會話與第三方會話出現在同一歷史清單中(不遷移既有會話)。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", "appVisibility": { "title": "主頁面顯示", "description": "選擇在主頁面顯示的應用程式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7a8427cf4..e81ab1563 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -670,6 +670,8 @@ "codexAuth": "Codex 应用增强", "preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录", "preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能", + "unifyCodexSessionHistory": "统一 Codex 会话历史", + "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,未来的官方会话与第三方会话出现在同一历史列表中(不迁移既有会话)。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用", diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 33ee0eb66..3e52a5cb6 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -16,6 +16,7 @@ export const settingsSchema = z.object({ launchOnStartup: z.boolean().optional(), enableLocalProxy: z.boolean().optional(), preserveCodexOfficialAuthOnSwitch: z.boolean().optional(), + unifyCodexSessionHistory: z.boolean().optional(), language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(), // 设备级目录覆盖 diff --git a/src/types.ts b/src/types.ts index a09238110..954eecc8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -351,6 +351,9 @@ export interface Settings { enableFailoverToggle?: boolean; // Preserve Codex ChatGPT login in auth.json when switching third-party providers preserveCodexOfficialAuthOnSwitch?: boolean; + // Run official Codex under the shared "custom" provider id so future + // sessions share one resume-history bucket with third-party providers + unifyCodexSessionHistory?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; // User has confirmed the first-run welcome notice