From b44f83f7c5e8d08c7382d7a768ddd38a30920fa2 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 20 May 2026 17:10:38 +0800 Subject: [PATCH] feat: unify Codex third-party providers into stable "custom" history bucket Codex filters resume history by `model_provider`, so switching between provider-specific ids like `rightcode` and `aihubmix` made past sessions appear to vanish. Collapse all third-party providers into a single stable bucket so cross-switch history stays visible. - Normalize live `model_provider` to "custom" on every Codex write (reserved built-in ids like openai/ollama are preserved). - Add device-level one-shot migration that rewrites historical JSONL session files and the `state_5.sqlite` threads table from legacy provider ids into the "custom" bucket. Backs up originals under `~/.cc-switch/backups/codex-history-provider-migration-v1/` and uses the SQLite Backup API for the state DB. - Record completion in `settings.json` under `localMigrations` so the migration is strictly idempotent across launches. - Update Codex provider preset templates to emit `model_provider = "custom"` out of the box. --- src-tauri/src/codex_config.rs | 126 ++--- src-tauri/src/codex_history_migration.rs | 559 +++++++++++++++++++++++ src-tauri/src/commands/settings.rs | 53 ++- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/lib.rs | 26 ++ src-tauri/src/services/proxy.rs | 13 +- src-tauri/src/settings.rs | 52 +++ src-tauri/tests/provider_service.rs | 6 +- src/config/codexProviderPresets.ts | 29 +- src/lib/schemas/settings.ts | 15 + src/types.ts | 11 + 11 files changed, 771 insertions(+), 121 deletions(-) create mode 100644 src-tauri/src/codex_history_migration.rs diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 230bf249e..089e8d5eb 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -12,7 +12,7 @@ use std::path::Path; use std::process::Command; use toml_edit::DocumentMut; -pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch"; +pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5"; @@ -166,7 +166,7 @@ fn active_codex_model_provider_id(doc: &DocumentMut) -> Option { .map(str::to_string) } -fn is_custom_codex_model_provider_id(id: &str) -> bool { +pub(crate) fn is_custom_codex_model_provider_id(id: &str) -> bool { let id = id.trim(); !id.is_empty() && !CODEX_RESERVED_MODEL_PROVIDER_IDS @@ -174,7 +174,7 @@ fn is_custom_codex_model_provider_id(id: &str) -> bool { .any(|reserved| reserved.eq_ignore_ascii_case(id)) } -fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option { +pub(crate) fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; let provider_id = active_codex_model_provider_id(&doc)?; @@ -208,10 +208,7 @@ fn codex_model_provider_id_with_table_from_config( Ok(has_provider_table.then_some(provider_id)) } -fn normalize_codex_live_config_model_provider_with_anchors<'a>( - config_text: &str, - anchor_config_texts: impl IntoIterator, -) -> Result { +fn normalize_codex_live_config_model_provider(config_text: &str) -> Result { if config_text.trim().is_empty() { return Ok(config_text.to_string()); } @@ -232,15 +229,11 @@ fn normalize_codex_live_config_model_provider_with_anchors<'a>( if !has_source_provider_table { return Ok(config_text.to_string()); } + if !is_custom_codex_model_provider_id(&source_provider_id) { + return Ok(config_text.to_string()); + } - let stable_provider_id = anchor_config_texts - .into_iter() - .find_map(stable_codex_model_provider_id_from_config) - .or_else(|| { - is_custom_codex_model_provider_id(&source_provider_id) - .then(|| source_provider_id.clone()) - }) - .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + let stable_provider_id = CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(); if stable_provider_id == source_provider_id { return Ok(config_text.to_string()); @@ -297,11 +290,10 @@ fn rewrite_codex_profile_model_provider_refs( /// /// Codex stores and filters resume history by `model_provider`, so switching between /// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move. -/// We preserve an existing custom provider id when possible and only rewrite the -/// live config text that Codex sees at provider-driven write boundaries. +/// CC Switch-managed third-party providers share one stable bucket while official +/// built-in providers such as `openai` keep their original identity. pub fn normalize_codex_settings_config_model_provider( settings: &mut Value, - anchor_config_text: Option<&str>, ) -> Result<(), AppError> { let Some(config_text) = settings .get("config") @@ -311,12 +303,7 @@ pub fn normalize_codex_settings_config_model_provider( return Ok(()); }; - let current_config_text = read_codex_config_text().ok(); - let anchors = anchor_config_text - .into_iter() - .chain(current_config_text.as_deref()); - let normalized = - normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?; + let normalized = normalize_codex_live_config_model_provider(&config_text)?; if let Some(obj) = settings.as_object_mut() { obj.insert("config".to_string(), Value::String(normalized)); @@ -409,7 +396,7 @@ pub fn write_codex_live_atomic_with_stable_provider( let mut settings = serde_json::Map::new(); settings.insert("config".to_string(), Value::String(config_text.to_string())); let mut settings = Value::Object(settings); - normalize_codex_settings_config_model_provider(&mut settings, None)?; + normalize_codex_settings_config_model_provider(&mut settings)?; let config_text = settings .get("config") .and_then(|value| value.as_str()) @@ -798,14 +785,7 @@ mod tests { use super::*; #[test] - fn normalize_live_config_preserves_current_custom_model_provider_id() { - let current = r#"model_provider = "rightcode" - -[model_providers.rightcode] -name = "RightCode" -base_url = "https://rightcode.example/v1" -wire_api = "responses" -"#; + fn normalize_live_config_uses_custom_for_third_party_model_provider_id() { let target = r#"model_provider = "aihubmix" model = "gpt-5.4" @@ -819,13 +799,12 @@ requires_openai_auth = true command = "npx" "#; - let result = - normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), - Some("rightcode") + Some("custom") ); let model_providers = parsed @@ -838,7 +817,7 @@ command = "npx" ); let stable_provider = model_providers - .get("rightcode") + .get("custom") .expect("stable provider table should exist"); assert_eq!( stable_provider.get("base_url").and_then(|v| v.as_str()), @@ -851,8 +830,7 @@ command = "npx" } #[test] - fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() { - let current = r#"model_provider = "openai""#; + fn normalize_live_config_uses_custom_for_custom_provider_even_without_anchor() { let target = r#"model_provider = "aihubmix" [model_providers.aihubmix] @@ -861,46 +839,31 @@ base_url = "https://aihubmix.example/v1" wire_api = "responses" "#; - let result = - normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), - Some("aihubmix") + Some("custom") ); assert!( parsed .get("model_providers") - .and_then(|v| v.get("aihubmix")) + .and_then(|v| v.get("custom")) .is_some(), - "target provider id should be kept when there is no reusable live custom id" + "third-party provider id should be normalized to custom" ); } #[test] fn normalize_live_config_leaves_official_empty_config_unchanged() { - let current = r#"model_provider = "rightcode" - -[model_providers.rightcode] -base_url = "https://rightcode.example/v1" -"#; - - let result = - normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap(); + let result = normalize_codex_live_config_model_provider("").unwrap(); assert_eq!(result, ""); } #[test] fn normalize_live_config_rewrites_matching_profile_model_provider_refs() { - let current = r#"model_provider = "session_anchor" - -[model_providers.session_anchor] -name = "Session Anchor" -base_url = "https://anchor.example/v1" -wire_api = "responses" -"#; let target = r#"model_provider = "vendor_alpha" model = "gpt-5.4" profile = "work" @@ -915,13 +878,12 @@ model_provider = "vendor_alpha" model = "gpt-5.4" "#; - let result = - normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), - Some("session_anchor") + Some("custom") ); assert_eq!( parsed @@ -929,20 +891,13 @@ model = "gpt-5.4" .and_then(|v| v.get("work")) .and_then(|v| v.get("model_provider")) .and_then(|v| v.as_str()), - Some("session_anchor"), + Some("custom"), "profile override matching the rewritten provider should stay valid" ); } #[test] fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() { - let current = r#"model_provider = "session_anchor" - -[model_providers.session_anchor] -name = "Session Anchor" -base_url = "https://anchor.example/v1" -wire_api = "responses" -"#; let target = r#"model_provider = "vendor_alpha" model = "gpt-5.4" @@ -961,8 +916,7 @@ model_provider = "local_profile" model = "local-model" "#; - let result = - normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( @@ -984,14 +938,7 @@ model = "local-model" } #[test] - fn normalize_live_config_keeps_stable_provider_across_repeated_switches() { - let anchor = r#"model_provider = "session_anchor" - -[model_providers.session_anchor] -name = "Session Anchor" -base_url = "https://anchor.example/v1" -wire_api = "responses" -"#; + fn normalize_live_config_keeps_custom_across_repeated_switches() { let first_target = r#"model_provider = "vendor_alpha" [model_providers.vendor_alpha] @@ -1007,25 +954,24 @@ base_url = "https://beta.example/v1" wire_api = "responses" "#; - let first = - normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor)) - .unwrap(); - let second = normalize_codex_live_config_model_provider_with_anchors( - second_target, - Some(first.as_str()), - ) - .unwrap(); + let first = normalize_codex_live_config_model_provider(first_target).unwrap(); + let second = normalize_codex_live_config_model_provider(second_target).unwrap(); + let first_parsed: toml::Value = toml::from_str(&first).unwrap(); let parsed: toml::Value = toml::from_str(&second).unwrap(); + assert_eq!( + first_parsed.get("model_provider").and_then(|v| v.as_str()), + Some("custom") + ); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), - Some("session_anchor"), + Some("custom"), "stable provider id should not drift across repeated switches" ); assert_eq!( parsed .get("model_providers") - .and_then(|v| v.get("session_anchor")) + .and_then(|v| v.get("custom")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("https://beta.example/v1") diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs new file mode 100644 index 000000000..bc0cf0a7e --- /dev/null +++ b/src-tauri/src/codex_history_migration.rs @@ -0,0 +1,559 @@ +//! Codex 第三方历史会话归桶迁移。 +//! +//! 只迁移本机 `~/.codex` 历史数据;完成标记写入设备级 `settings.json`, +//! 失败时不写标记,下一次启动自动重试。 + +use crate::codex_config::{ + get_codex_config_dir, is_custom_codex_model_provider_id, read_codex_config_text, + stable_codex_model_provider_id_from_config, CC_SWITCH_CODEX_MODEL_PROVIDER_ID, +}; +use crate::config::{atomic_write, copy_file, get_app_config_dir}; +use crate::database::{is_official_seed_id, Database}; +use crate::error::AppError; +use crate::settings::CodexThirdPartyHistoryProviderBucketMigration; +use chrono::{Local, Utc}; +use rusqlite::{backup::Backup, params_from_iter, Connection}; +use serde_json::Value; +use std::collections::{BTreeSet, HashSet}; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; +use toml_edit::DocumentMut; + +const MIGRATION_NAME: &str = "codex-history-provider-migration-v1"; +const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; + +#[derive(Debug, Clone, Default)] +pub struct CodexHistoryProviderBucketMigrationOutcome { + pub source_provider_ids: Vec, + pub migrated_jsonl_files: usize, + pub migrated_state_rows: usize, + pub skipped_reason: Option, +} + +pub fn maybe_migrate_codex_third_party_history_provider_bucket( + db: &Database, +) -> Result { + if crate::settings::is_codex_third_party_history_provider_bucket_migrated() { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("already_migrated".to_string()), + ..Default::default() + }); + } + + let source_provider_ids = collect_source_model_provider_ids(db)?; + if source_provider_ids.is_empty() { + crate::settings::mark_codex_third_party_history_provider_bucket_migrated( + CodexThirdPartyHistoryProviderBucketMigration { + completed_at: Utc::now().to_rfc3339(), + target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(), + source_provider_ids: Vec::new(), + migrated_jsonl_files: 0, + migrated_state_rows: 0, + }, + )?; + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("no_third_party_provider_ids".to_string()), + ..Default::default() + }); + } + + let backup_root = migration_backup_root(); + let codex_dir = get_codex_config_dir(); + let migrated_jsonl_files = + migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?; + let migrated_state_rows = + migrate_codex_state_dbs(&codex_dir, &source_provider_ids, &backup_root)?; + + let source_provider_ids_vec: Vec = source_provider_ids.iter().cloned().collect(); + crate::settings::mark_codex_third_party_history_provider_bucket_migrated( + CodexThirdPartyHistoryProviderBucketMigration { + completed_at: Utc::now().to_rfc3339(), + target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(), + source_provider_ids: source_provider_ids_vec.clone(), + migrated_jsonl_files, + migrated_state_rows, + }, + )?; + + Ok(CodexHistoryProviderBucketMigrationOutcome { + source_provider_ids: source_provider_ids_vec, + migrated_jsonl_files, + migrated_state_rows, + skipped_reason: None, + }) +} + +fn collect_source_model_provider_ids(db: &Database) -> Result, AppError> { + let providers = db.get_all_providers("codex")?; + let mut ids = BTreeSet::new(); + + for provider in providers.values() { + if provider.category.as_deref() == Some("official") + || is_official_seed_id(&provider.id) + || provider.is_codex_oauth() + { + continue; + } + + if is_migratable_source_id(&provider.id) { + ids.insert(provider.id.clone()); + } + + let Some(config_text) = provider + .settings_config + .get("config") + .and_then(|value| value.as_str()) + else { + continue; + }; + + if let Some(provider_id) = stable_codex_model_provider_id_from_config(config_text) { + if is_migratable_source_id(&provider_id) { + ids.insert(provider_id); + } + } + } + + Ok(ids) +} + +fn is_migratable_source_id(provider_id: &str) -> bool { + let trimmed = provider_id.trim(); + !trimmed.is_empty() + && trimmed != CC_SWITCH_CODEX_MODEL_PROVIDER_ID + && is_custom_codex_model_provider_id(trimmed) +} + +fn migration_backup_root() -> PathBuf { + get_app_config_dir() + .join("backups") + .join(MIGRATION_NAME) + .join(Local::now().format("%Y%m%d_%H%M%S").to_string()) +} + +fn migrate_codex_jsonl_files( + codex_dir: &Path, + source_provider_ids: &BTreeSet, + backup_root: &Path, +) -> Result { + let mut files = Vec::new(); + collect_jsonl_files(&codex_dir.join("sessions"), &mut files, 0, 8); + collect_jsonl_files(&codex_dir.join("archived_sessions"), &mut files, 0, 4); + + let source_provider_ids: HashSet = source_provider_ids.iter().cloned().collect(); + let mut migrated = 0; + for file_path in files { + if rewrite_codex_session_file_for_provider_bucket( + &file_path, + codex_dir, + &source_provider_ids, + backup_root, + )? { + migrated += 1; + } + } + Ok(migrated) +} + +fn collect_jsonl_files(dir: &Path, files: &mut Vec, depth: u8, max_depth: u8) { + if depth > max_depth || !dir.is_dir() { + return; + } + + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(err) => { + log::debug!( + "Failed to read Codex session directory {}: {err}", + dir.display() + ); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files, depth + 1, max_depth); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + files.push(path); + } + } +} + +fn rewrite_codex_session_file_for_provider_bucket( + path: &Path, + codex_dir: &Path, + source_provider_ids: &HashSet, + backup_root: &Path, +) -> Result { + let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?; + let modified_before = metadata_before.modified().ok(); + let len_before = metadata_before.len(); + let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; + + let mut rewritten = String::with_capacity(content.len()); + let mut changed = false; + for segment in content.split_inclusive('\n') { + let (line, newline) = segment + .strip_suffix('\n') + .map(|line| (line, "\n")) + .unwrap_or((segment, "")); + if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) { + rewritten.push_str(&next_line); + changed = true; + } else { + rewritten.push_str(line); + } + rewritten.push_str(newline); + } + + if !changed { + return Ok(false); + } + + ensure_codex_session_file_unchanged(path, modified_before, len_before)?; + backup_codex_jsonl_file(path, codex_dir, backup_root)?; + ensure_codex_session_file_unchanged(path, modified_before, len_before)?; + atomic_write(path, rewritten.as_bytes())?; + Ok(true) +} + +fn ensure_codex_session_file_unchanged( + path: &Path, + modified_before: Option, + len_before: u64, +) -> Result<(), AppError> { + let metadata_after = fs::metadata(path).map_err(|e| AppError::io(path, e))?; + if metadata_after.modified().ok() != modified_before || metadata_after.len() != len_before { + return Err(AppError::Message(format!( + "Codex session file changed during migration: {}", + path.display() + ))); + } + Ok(()) +} + +fn rewrite_codex_session_meta_line( + line: &str, + source_provider_ids: &HashSet, +) -> Option { + if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") { + return None; + } + + let mut value: Value = serde_json::from_str(line).ok()?; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + return None; + } + + let payload = value.get_mut("payload")?.as_object_mut()?; + let current_provider = payload.get("model_provider")?.as_str()?; + if !source_provider_ids.contains(current_provider) { + return None; + } + + payload.insert( + "model_provider".to_string(), + Value::String(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()), + ); + serde_json::to_string(&value).ok() +} + +fn migrate_codex_state_dbs( + codex_dir: &Path, + source_provider_ids: &BTreeSet, + backup_root: &Path, +) -> Result { + let config_text = read_codex_config_text().unwrap_or_default(); + let mut migrated = 0; + for db_path in codex_state_db_paths(codex_dir, &config_text) { + migrated += migrate_codex_state_db_provider_bucket( + &db_path, + codex_dir, + source_provider_ids, + backup_root, + )?; + } + Ok(migrated) +} + +fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec { + let mut paths = vec![codex_dir.join(CODEX_STATE_DB_FILENAME)]; + if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) { + let db_path = sqlite_home.join(CODEX_STATE_DB_FILENAME); + if !paths.contains(&db_path) { + paths.push(db_path); + } + } + paths +} + +fn sqlite_home_from_codex_config(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + let raw = doc.get("sqlite_home")?.as_str()?.trim(); + if raw.is_empty() { + return None; + } + Some(resolve_user_path(raw)) +} + +fn resolve_user_path(raw: &str) -> PathBuf { + if raw == "~" { + return crate::config::get_home_dir(); + } + if let Some(rest) = raw.strip_prefix("~/") { + return crate::config::get_home_dir().join(rest); + } + if let Some(rest) = raw.strip_prefix("~\\") { + return crate::config::get_home_dir().join(rest); + } + PathBuf::from(raw) +} + +fn migrate_codex_state_db_provider_bucket( + db_path: &Path, + codex_dir: &Path, + source_provider_ids: &BTreeSet, + backup_root: &Path, +) -> Result { + if !db_path.exists() || source_provider_ids.is_empty() { + return Ok(0); + } + + let conn = Connection::open(db_path) + .map_err(|e| AppError::Database(format!("打开 Codex state DB 失败: {e}")))?; + conn.busy_timeout(Duration::from_secs(5)) + .map_err(|e| AppError::Database(format!("设置 Codex state DB busy_timeout 失败: {e}")))?; + + if !Database::table_exists(&conn, "threads")? + || !Database::has_column(&conn, "threads", "model_provider")? + { + return Ok(0); + } + + let placeholders = placeholders(source_provider_ids.len()); + let count_sql = + format!("SELECT COUNT(*) FROM threads WHERE model_provider IN ({placeholders})"); + let matching_rows: i64 = conn + .query_row( + &count_sql, + params_from_iter(source_provider_ids.iter()), + |row| row.get(0), + ) + .map_err(|e| AppError::Database(format!("统计 Codex state DB 待迁移行失败: {e}")))?; + if matching_rows == 0 { + return Ok(0); + } + + backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?; + + let update_sql = + format!("UPDATE threads SET model_provider = ? WHERE model_provider IN ({placeholders})"); + let mut values = Vec::with_capacity(source_provider_ids.len() + 1); + values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + values.extend(source_provider_ids.iter().cloned()); + let changed = conn + .execute(&update_sql, params_from_iter(values.iter())) + .map_err(|e| AppError::Database(format!("迁移 Codex state DB provider 失败: {e}")))?; + Ok(changed) +} + +fn placeholders(count: usize) -> String { + std::iter::repeat_n("?", count) + .collect::>() + .join(", ") +} + +fn backup_codex_jsonl_file( + path: &Path, + codex_dir: &Path, + backup_root: &Path, +) -> Result<(), AppError> { + let backup_path = backup_root + .join("jsonl") + .join(relative_backup_path(path, codex_dir)); + copy_existing_file(path, &backup_path) +} + +fn backup_codex_state_db( + db_path: &Path, + codex_dir: &Path, + backup_root: &Path, + source_conn: &Connection, +) -> Result<(), AppError> { + let backup_path = backup_root + .join("state") + .join(relative_backup_path(db_path, codex_dir)); + if let Some(parent) = backup_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let mut backup_conn = Connection::open(&backup_path) + .map_err(|e| AppError::Database(format!("创建 Codex state DB 备份失败: {e}")))?; + let backup = Backup::new(source_conn, &mut backup_conn) + .map_err(|e| AppError::Database(format!("初始化 Codex state DB 备份失败: {e}")))?; + backup + .run_to_completion(5, Duration::from_millis(25), None) + .map_err(|e| AppError::Database(format!("写入 Codex state DB 备份失败: {e}")))?; + Ok(()) +} + +fn copy_existing_file(source: &Path, target: &Path) -> Result<(), AppError> { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + copy_file(source, target) +} + +fn relative_backup_path(path: &Path, root: &Path) -> PathBuf { + if let Ok(relative) = path.strip_prefix(root) { + return relative.to_path_buf(); + } + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + path.hash(&mut hasher); + let hash = hasher.finish(); + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".to_string()); + PathBuf::from("external").join(format!("{hash:016x}-{file_name}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::Provider; + use tempfile::tempdir; + + fn source_ids(values: &[&str]) -> BTreeSet { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn rewrites_only_codex_session_meta_provider_ids() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let backup_root = dir.path().join("backup"); + let session_dir = codex_dir.join("sessions/2026/05/20"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let path = session_dir.join("rollout-test.jsonl"); + fs::write( + &path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"rightcode\"}}\n", + "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hi\"}}\n" + ), + ) + .expect("write session"); + + let changed = rewrite_codex_session_file_for_provider_bucket( + &path, + &codex_dir, + &HashSet::from(["rightcode".to_string()]), + &backup_root, + ) + .expect("rewrite"); + + assert!(changed); + let next = fs::read_to_string(&path).expect("read rewritten"); + assert!(next.contains("\"model_provider\":\"custom\"")); + assert!(backup_root + .join("jsonl/sessions/2026/05/20/rollout-test.jsonl") + .exists()); + } + + #[test] + fn updates_codex_state_db_thread_provider_ids() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let db_path = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + ); + INSERT INTO threads (id, model_provider) VALUES + ('a', 'rightcode'), + ('b', 'openai'), + ('c', 'aihubmix');", + ) + .expect("seed db"); + drop(conn); + + let backup_root = dir.path().join("backup"); + let changed = migrate_codex_state_db_provider_bucket( + &db_path, + &codex_dir, + &source_ids(&["rightcode", "aihubmix"]), + &backup_root, + ) + .expect("migrate state db"); + + assert_eq!(changed, 2); + let conn = Connection::open(&db_path).expect("reopen db"); + let custom_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider = 'custom'", + [], + |row| row.get(0), + ) + .expect("count custom"); + let openai_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider = 'openai'", + [], + |row| row.get(0), + ) + .expect("count openai"); + assert_eq!(custom_count, 2); + assert_eq!(openai_count, 1); + + let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); + let backup_conn = Connection::open(&backup_path).expect("open backup db"); + let backed_up_source_count: i64 = backup_conn + .query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider IN ('rightcode', 'aihubmix')", + [], + |row| row.get(0), + ) + .expect("count backed up source providers"); + assert_eq!(backed_up_source_count, 2); + } + + #[test] + fn collects_third_party_provider_ids_from_codex_providers() { + let db = Database::memory().expect("memory db"); + let third_party = Provider::with_id( + "rightcode".to_string(), + "RightCode".to_string(), + serde_json::json!({ + "auth": {}, + "config": "model_provider = \"aihubmix\"\n\n[model_providers.aihubmix]\nname = \"AIHubMix\"\nbase_url = \"https://example.com/v1\"" + }), + None, + ); + let mut official = Provider::with_id( + "codex-official".to_string(), + "OpenAI Official".to_string(), + serde_json::json!({"auth": {}, "config": "model_provider = \"openai\""}), + None, + ); + official.category = Some("official".to_string()); + + db.save_provider("codex", &third_party) + .expect("save third-party"); + db.save_provider("codex", &official).expect("save official"); + + let ids = collect_source_model_provider_ids(&db).expect("collect ids"); + assert!(ids.contains("rightcode")); + assert!(ids.contains("aihubmix")); + assert!(!ids.contains("openai")); + assert!(!ids.contains("codex-official")); + } +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index f67fbf227..1ecf98593 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -21,6 +21,20 @@ fn merge_settings_for_save( } _ => {} } + if incoming.local_migrations.is_none() { + incoming.local_migrations = existing.local_migrations.clone(); + } else if let (Some(incoming_migrations), Some(existing_migrations)) = + (&mut incoming.local_migrations, &existing.local_migrations) + { + if incoming_migrations + .codex_third_party_history_provider_bucket_v1 + .is_none() + { + incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations + .codex_third_party_history_provider_bucket_v1 + .clone(); + } + } incoming } @@ -83,7 +97,10 @@ pub async fn set_auto_launch(enabled: bool) -> Result { #[cfg(test)] mod tests { use super::merge_settings_for_save; - use crate::settings::{AppSettings, WebDavSyncSettings}; + use crate::settings::{ + AppSettings, CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, + WebDavSyncSettings, + }; #[test] fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() { @@ -204,6 +221,40 @@ mod tests { Some("") ); } + + #[test] + fn save_settings_should_preserve_local_migrations_when_payload_omits_it() { + let existing = AppSettings { + local_migrations: Some(LocalMigrations { + codex_third_party_history_provider_bucket_v1: Some( + CodexThirdPartyHistoryProviderBucketMigration { + completed_at: "2026-05-20T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + source_provider_ids: vec!["rightcode".to_string()], + migrated_jsonl_files: 2, + migrated_state_rows: 3, + }, + ), + }), + ..AppSettings::default() + }; + + let incoming = AppSettings::default(); + let merged = merge_settings_for_save(incoming, &existing); + + let migration = merged + .local_migrations + .as_ref() + .and_then(|migrations| { + migrations + .codex_third_party_history_provider_bucket_v1 + .as_ref() + }) + .expect("local migration marker should be preserved"); + assert_eq!(migration.target_provider_id, "custom"); + assert_eq!(migration.migrated_jsonl_files, 2); + assert_eq!(migration.migrated_state_rows, 3); + } } /// 获取开机自启状态 diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 9a2ac314c..65958fcde 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -32,7 +32,7 @@ mod schema; mod tests; // DAO 类型导出供外部使用 -pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID; +pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID}; pub(crate) use dao::proxy::{ validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 312c4835e..c14c03738 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod claude_desktop_config; mod claude_mcp; mod claude_plugin; mod codex_config; +mod codex_history_migration; mod commands; mod config; mod database; @@ -535,6 +536,31 @@ pub fn run() { Err(e) => log::warn!("✗ Failed to seed official providers: {e}"), } + { + let db_for_codex_history_migration = app_state.db.clone(); + tauri::async_runtime::spawn_blocking(move || { + match crate::codex_history_migration::maybe_migrate_codex_third_party_history_provider_bucket( + &db_for_codex_history_migration, + ) { + Ok(outcome) => { + if let Some(reason) = outcome.skipped_reason { + log::debug!("○ Codex history provider bucket migration skipped: {reason}"); + } else { + log::info!( + "✓ Codex history provider bucket migration completed: sources={}, jsonl_files={}, state_rows={}", + outcome.source_provider_ids.len(), + outcome.migrated_jsonl_files, + outcome.migrated_state_rows + ); + } + } + Err(e) => { + log::warn!("✗ Codex history provider bucket migration failed: {e}"); + } + } + }); + } + // 老用户 / 已确认的路径由 `fresh_install_at_startup` 自行拦截,这里不做写入。 // 字段只由前端在用户点击"我知道了"时 save_settings 回写,语义是"用户显式确认过"。 if !first_run_already_confirmed && fresh_install_at_startup { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 2e52a1c91..9746553ba 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1735,13 +1735,8 @@ impl ProxyService { )?; } - let anchor_config_text = existing_backup_value - .as_ref() - .and_then(|value| value.get("config")) - .and_then(|value| value.as_str()); crate::codex_config::normalize_codex_settings_config_model_provider( &mut effective_settings, - anchor_config_text, ) .map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?; } @@ -3388,7 +3383,7 @@ requires_openai_auth = true toml::from_str(backup_config).expect("parse backup config"); assert_eq!( parsed_backup.get("model_provider").and_then(|v| v.as_str()), - Some("rightcode"), + Some("custom"), "provider-derived restore backup should retain stable Codex model_provider" ); let backup_model_providers = parsed_backup @@ -3398,7 +3393,7 @@ requires_openai_auth = true assert!(backup_model_providers.get("aihubmix").is_none()); assert_eq!( backup_model_providers - .get("rightcode") + .get("custom") .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("https://aihubmix.example/v1"), @@ -3418,7 +3413,7 @@ requires_openai_auth = true let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config"); assert_eq!( parsed_live.get("model_provider").and_then(|v| v.as_str()), - Some("rightcode"), + Some("custom"), "restored Codex live config should not switch history buckets" ); assert_eq!( @@ -3527,7 +3522,7 @@ requires_openai_auth = true assert_eq!( parsed_live.get("model_provider").and_then(|v| v.as_str()), - Some("stable") + Some("custom") ); assert_eq!( parsed_live.get("model").and_then(|v| v.as_str()), diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 161e7bc6a..c31044f59 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -176,6 +176,30 @@ impl WebDavSyncSettings { } } +/// 本机自动迁移状态。 +/// +/// 这里记录的是设备级操作(例如修改本机 `~/.codex` 文件),不随数据库同步。 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct LocalMigrations { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_third_party_history_provider_bucket_v1: + Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexThirdPartyHistoryProviderBucketMigration { + pub completed_at: String, + pub target_provider_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_provider_ids: Vec, + #[serde(default)] + pub migrated_jsonl_files: usize, + #[serde(default)] + pub migrated_state_rows: usize, +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 @@ -301,6 +325,10 @@ pub struct AppSettings { /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_terminal: Option, + + // ===== 本机自动迁移状态 ===== + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_migrations: Option, } fn default_show_in_tray() -> bool { @@ -351,6 +379,7 @@ impl Default for AppSettings { backup_interval_hours: None, backup_retain_count: None, preferred_terminal: None, + local_migrations: None, } } } @@ -556,6 +585,29 @@ where Ok(()) } +pub fn is_codex_third_party_history_provider_bucket_migrated() -> bool { + get_settings() + .local_migrations + .as_ref() + .and_then(|migrations| { + migrations + .codex_third_party_history_provider_bucket_v1 + .as_ref() + }) + .is_some() +} + +pub fn mark_codex_third_party_history_provider_bucket_migrated( + migration: CodexThirdPartyHistoryProviderBucketMigration, +) -> Result<(), AppError> { + mutate_settings(|settings| { + let migrations = settings + .local_migrations + .get_or_insert_with(Default::default); + migrations.codex_third_party_history_provider_bucket_v1 = Some(migration); + }) +} + /// 从文件重新加载设置到内存缓存 /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index bb963be6d..97f92eea1 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -309,8 +309,8 @@ requires_openai_auth = true assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), - Some("rightcode"), - "live Codex model_provider should stay stable so resume history remains visible" + Some("custom"), + "live Codex third-party model_provider should use the CC Switch history bucket" ); let model_providers = parsed @@ -323,7 +323,7 @@ requires_openai_auth = true ); assert_eq!( model_providers - .get("rightcode") + .get("custom") .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("https://aihubmix.example/v1"), diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 6be4c6200..471abdad0 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -46,21 +46,16 @@ export function generateThirdPartyConfig( baseUrl: string, modelName = "gpt-5.4", ): string { - // 清理供应商名称,确保符合TOML键名规范 - const cleanProviderName = - providerName - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_") - .replace(/^_+|_+$/g, "") || "custom"; + const tomlString = (value: string) => JSON.stringify(value); - return `model_provider = "${cleanProviderName}" -model = "${modelName}" + return `model_provider = "custom" +model = ${tomlString(modelName)} model_reasoning_effort = "high" disable_response_storage = true -[model_providers.${cleanProviderName}] -name = "${cleanProviderName}" -base_url = "${baseUrl}" +[model_providers.custom] +name = ${tomlString(providerName)} +base_url = ${tomlString(baseUrl)} wire_api = "responses" requires_openai_auth = true`; } @@ -120,12 +115,12 @@ export const codexProviderPresets: CodexProviderPreset[] = [ category: "third_party", isOfficial: true, auth: generateThirdPartyAuth(""), - config: `model_provider = "azure" + config: `model_provider = "custom" model = "gpt-5.4" model_reasoning_effort = "high" disable_response_storage = true -[model_providers.azure] +[model_providers.custom] name = "Azure OpenAI" base_url = "https://YOUR_RESOURCE_NAME.openai.azure.com/openai" env_key = "OPENAI_API_KEY" @@ -438,14 +433,14 @@ requires_openai_auth = true`, auth: { OPENAI_API_KEY: "", }, - config: `model_provider = "e-flowcode" + config: `model_provider = "custom" model = "gpt-5.4" model_reasoning_effort = "high" disable_response_storage = true personality = "pragmatic" -[model_providers.e-flowcode] -name = "e-flowcode" +[model_providers.custom] +name = "E-FlowCode" base_url = "https://e-flowcode.cc/v1" wire_api = "responses" requires_openai_auth = true @@ -485,7 +480,7 @@ model_reasoning_effort = "medium" disable_response_storage = true [model_providers.custom] -name = "custom" +name = "PIPELLM" wire_api = "responses" requires_openai_auth = true base_url = "https://cc-api.pipellm.ai/v1"`, diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 82e9a510d..9b42d787e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -56,6 +56,21 @@ export const settingsSchema = z.object({ .optional(), }) .optional(), + + // 本机自动迁移状态(后端维护,前端保存设置时应透传) + localMigrations: z + .object({ + codexThirdPartyHistoryProviderBucketV1: z + .object({ + completedAt: z.string(), + targetProviderId: z.string(), + sourceProviderIds: z.array(z.string()).optional(), + migratedJsonlFiles: z.number().optional(), + migratedStateRows: z.number().optional(), + }) + .optional(), + }) + .optional(), }); export type SettingsFormData = z.infer; diff --git a/src/types.ts b/src/types.ts index 48ac9b973..1bbd374d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,6 +354,17 @@ export interface Settings { // Windows: "cmd" | "powershell" | "wt" // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" preferredTerminal?: string; + + // ===== 本机自动迁移状态 ===== + localMigrations?: { + codexThirdPartyHistoryProviderBucketV1?: { + completedAt: string; + targetProviderId: string; + sourceProviderIds?: string[]; + migratedJsonlFiles?: number; + migratedStateRows?: number; + }; + }; } export interface SessionMeta {