mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(codex): add unified session history toggle for official providers
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).
This commit is contained in:
@@ -1043,16 +1043,197 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
||||
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<String, AppError> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.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<String, AppError> {
|
||||
if !config_text.contains("model_provider") {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.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"
|
||||
|
||||
@@ -65,10 +65,25 @@ pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||
pub async fn save_settings(
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
settings: crate::settings::AppSettings,
|
||||
) -> Result<bool, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool, AppError> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<bool>,
|
||||
@@ -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 读取)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<History className="h-4 w-4 text-sky-500" />}
|
||||
title={t("settings.unifyCodexSessionHistory")}
|
||||
description={t("settings.unifyCodexSessionHistoryDescription")}
|
||||
checked={settings.unifyCodexSessionHistory ?? false}
|
||||
onCheckedChange={(value) =>
|
||||
onChange({ unifyCodexSessionHistory: value })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -670,6 +670,8 @@
|
||||
"codexAuth": "Codex アプリ拡張",
|
||||
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
|
||||
"unifyCodexSessionHistory": "Codex セッション履歴を統一",
|
||||
"unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、今後の公式セッションはサードパーティのセッションと同じ履歴リストに表示されます(既存セッションは移行しません)。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。",
|
||||
"appVisibility": {
|
||||
"title": "ホームページ表示",
|
||||
"description": "ホームページに表示するアプリを選択",
|
||||
|
||||
@@ -670,6 +670,8 @@
|
||||
"codexAuth": "Codex 應用增強",
|
||||
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
|
||||
"unifyCodexSessionHistory": "統一 Codex 會話歷史",
|
||||
"unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,未來的官方會話與第三方會話出現在同一歷史清單中(不遷移既有會話)。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗",
|
||||
"appVisibility": {
|
||||
"title": "主頁面顯示",
|
||||
"description": "選擇在主頁面顯示的應用程式",
|
||||
|
||||
@@ -670,6 +670,8 @@
|
||||
"codexAuth": "Codex 应用增强",
|
||||
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
|
||||
"unifyCodexSessionHistory": "统一 Codex 会话历史",
|
||||
"unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,未来的官方会话与第三方会话出现在同一历史列表中(不迁移既有会话)。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败",
|
||||
"appVisibility": {
|
||||
"title": "主页面显示",
|
||||
"description": "选择在主页面显示的应用",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
// 设备级目录覆盖
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user