From eab6bfd20c2dc4c59387e66d159495ee7474b818 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Jun 2026 23:35:01 +0800 Subject: [PATCH] feat(codex): add opt-in migration and ledger-based restore for unified session history - Enable dialog gains a checkbox (default off) to migrate existing official sessions from the built-in "openai" bucket into the shared "custom" bucket, with per-generation backups; failed migrations retry at startup - Disable dialog offers a precise restore driven by the backup ledger: only sessions recorded as "openai" in backups are flipped back, and sessions created while the toggle was on are never touched - Completion marker and backup generations are bound to the canonical Codex config dir; migrate/restore serialize on an op lock and the marker is written conditionally inside the settings write lock - save_settings rolls back the toggle and fails the save when the live rewrite fails; migration additionally requires the live config to actually route to the shared bucket (skips with live_not_unified so refused injection or proxy takeover can't split history) - Restore refuses to run while the toggle is (re-)enabled and reports nothing_to_restore instead of a zero-count success; local migration markers are now backend-owned in merge_settings_for_save so stale frontend payloads can't resurrect them - Settings autosave reverts optimistic form state on failure so a failed toggle change can't be replayed by an unrelated save - ConfirmDialog supports an optional checkbox; all four locales updated --- src-tauri/src/codex_history_migration.rs | 871 +++++++++++++++++- src-tauri/src/commands/settings.rs | 190 +++- src-tauri/src/lib.rs | 21 + src-tauri/src/settings.rs | 80 +- src/components/ConfirmDialog.tsx | 33 +- src/components/settings/CodexAuthSettings.tsx | 103 ++- src/components/settings/ProxyTabContent.tsx | 2 +- src/components/settings/SettingsPage.tsx | 19 +- src/i18n/locales/en.json | 18 +- src/i18n/locales/ja.json | 18 +- src/i18n/locales/zh-TW.json | 18 +- src/i18n/locales/zh.json | 18 +- src/lib/api/settings.ts | 17 + src/lib/schemas/settings.ts | 2 +- src/types.ts | 2 + 15 files changed, 1369 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index 30443eda6..240ce5e72 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -10,7 +10,8 @@ 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::{ - CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, + CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration, + CodexThirdPartyHistoryProviderBucketMigration, }; use chrono::{Local, Utc}; use rusqlite::{backup::Backup, params_from_iter, Connection}; @@ -24,7 +25,25 @@ use std::time::{Duration, SystemTime}; use toml_edit::DocumentMut; const MIGRATION_NAME: &str = "codex-history-provider-migration-v1"; +const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1"; +/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。 +const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1"; const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; +/// SQLite 变量上限保守值,IN 列表按此分块。 +const STATE_DB_ID_CHUNK: usize = 500; + +/// 串行化官方历史的迁移与还原:开启迁移(启动重试 + 设置保存后台任务)和 +/// 关闭还原可能在毫秒级先后被触发,对同一批 jsonl / state DB 双向改写。 +static CODEX_OFFICIAL_HISTORY_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn lock_codex_official_history_op() -> std::sync::MutexGuard<'static, ()> { + CODEX_OFFICIAL_HISTORY_OP_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} +/// Codex 内建默认 provider id:config.toml 没有 `model_provider` 键时会话归入此桶。 +/// 官方订阅(ChatGPT OAuth / OpenAI API key)的历史会话都记录这个 id。 +const OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID: &str = "openai"; const LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch"; // If a Codex preset ever used a temporary routing key, keep that old key here // so local history can be bucketed under the current custom provider id. @@ -120,7 +139,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket( }); } - let backup_root = migration_backup_root(); + let backup_root = migration_backup_root(MIGRATION_NAME); let codex_dir = get_codex_config_dir(); let migrated_jsonl_files = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?; @@ -157,7 +176,7 @@ pub fn maybe_migrate_codex_provider_template_bucket( }); } - let backup_root = migration_backup_root(); + let backup_root = migration_backup_root(MIGRATION_NAME); let outcome = migrate_codex_provider_templates_to_custom(db, &backup_root)?; crate::settings::mark_codex_provider_template_migrated(CodexProviderTemplateMigration { completed_at: Utc::now().to_rfc3339(), @@ -167,6 +186,475 @@ pub fn maybe_migrate_codex_provider_template_bucket( Ok(outcome) } +/// 统一会话开关的存量迁移:把官方会话(内建 "openai" 桶)迁入共享 "custom" 桶。 +/// +/// 仅当用户在开启弹窗里勾选了"迁入既有官方会话"(`unify_codex_migrate_existing`) +/// 且本轮未完成时执行;开关关闭时标记与勾选意愿都会被清除(见 `save_settings`), +/// 重新开启并再次勾选即可补迁关闭期间产生的官方会话。 +/// custom 桶里官方与第三方会话无法区分,自动逻辑绝不反向搬回; +/// 用户可在关闭开关时选择按备份账本精确还原(见 `restore_codex_official_history_from_backups`)。 +/// 迁移前 jsonl / state DB 均备份到 `~/.cc-switch/backups/codex-official-history-unify-v1/`。 +pub fn maybe_migrate_codex_official_history_to_unified_bucket( +) -> Result { + if !crate::settings::unify_codex_session_history() { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("unify_toggle_off".to_string()), + ..Default::default() + }); + } + if !crate::settings::unify_codex_migrate_existing_requested() { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("stock_migration_not_requested".to_string()), + ..Default::default() + }); + } + let _op_guard = lock_codex_official_history_op(); + let codex_dir = get_codex_config_dir(); + // marker 绑定迁移时的 Codex 目录:切换 codex_config_dir 后旧 marker 不再 + // 挡住新目录的迁移(迁移幂等,重跑无害)。 + let codex_dir_key = canonical_dir_string(&codex_dir); + if crate::settings::is_codex_official_history_unify_migrated_for_dir(&codex_dir_key) { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("already_migrated".to_string()), + ..Default::default() + }); + } + // live 必须已实际路由到共享 custom 桶才允许迁移:官方配置的注入可能被拒 + // (已有显式 model_provider / 形态冲突的 custom 表,见 + // `inject_codex_unified_session_bucket`),代理接管期间的 live 也不带统一 + // 路由(注入只进备份)。这些状态下新会话仍落 "openai" 桶,迁移只会把 + // 历史搬进当前 live 看不见的桶里。开关与迁移意愿保持不动,待 live 真正 + // 统一后(下次切换 / 接管释放后的启动重试)再迁。 + if !codex_config_text_routes_custom(&read_codex_config_text().unwrap_or_default()) { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("live_not_unified".to_string()), + ..Default::default() + }); + } + + let source_provider_ids: BTreeSet = + std::iter::once(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()).collect(); + let backup_root = migration_backup_root(OFFICIAL_UNIFY_MIGRATION_NAME); + 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)?; + // 备份代际记录来源目录,restore 据此只取当前目录的账本。 + write_backup_generation_meta(&backup_root, &codex_dir_key)?; + + let outcome = CodexHistoryProviderBucketMigrationOutcome { + source_provider_ids: source_provider_ids.into_iter().collect(), + migrated_jsonl_files, + migrated_state_rows, + skipped_reason: None, + }; + + // 条件写入在 settings 写锁内原子完成:"迁移期间开关被关掉"时不写完成标记, + // 避免下一次开启被标记挡住而漏迁"关闭期间"新产生的 openai 桶会话。 + // 与关闭路径(update_settings + 清标记)共用同一把锁,无检查-写入窗口。 + let marker_written = crate::settings::mark_codex_official_history_unify_migrated_if_enabled( + CodexOfficialHistoryUnifyMigration { + completed_at: Utc::now().to_rfc3339(), + target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(), + migrated_jsonl_files, + migrated_state_rows, + codex_config_dir: Some(codex_dir_key), + }, + )?; + if !marker_written { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("toggle_disabled_during_migration".to_string()), + ..outcome + }); + } + + Ok(outcome) +} + +/// live config.toml 是否路由到共享 custom 桶(会话分桶只看这个实态: +/// base_url / 接管与否都不影响 session_meta 记录的 model_provider)。 +fn codex_config_text_routes_custom(config_text: &str) -> bool { + config_text + .parse::() + .ok() + .and_then(|doc| { + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(|id| id.trim() == CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + }) + .unwrap_or(false) +} + +/// 目录的规范化字符串形式,用作 marker / 备份代际的目录身份。 +/// canonicalize 失败(目录尚不存在等)时退回原始路径字符串。 +fn canonical_dir_string(dir: &Path) -> String { + fs::canonicalize(dir) + .unwrap_or_else(|_| dir.to_path_buf()) + .to_string_lossy() + .to_string() +} + +/// 在备份代际根目录写入 meta.json,记录这批备份来自哪个 Codex 目录。 +/// 代际目录不存在(本轮没有任何文件被迁移)时跳过。 +fn write_backup_generation_meta(backup_root: &Path, codex_dir_key: &str) -> Result<(), AppError> { + if !backup_root.exists() { + return Ok(()); + } + let payload = serde_json::json!({ "codexConfigDir": codex_dir_key }); + let bytes = + serde_json::to_vec_pretty(&payload).map_err(|e| AppError::JsonSerialize { source: e })?; + atomic_write(&backup_root.join("meta.json"), &bytes) +} + +#[derive(Debug, Clone, Default)] +pub struct CodexOfficialHistoryRestoreOutcome { + pub restored_jsonl_files: usize, + pub restored_state_rows: usize, + pub skipped_reason: Option, +} + +/// 统一会话开关迁移备份的父目录(其下每次迁移一个时间戳代际目录)。 +fn official_history_unify_backup_parent() -> PathBuf { + get_app_config_dir() + .join("backups") + .join(OFFICIAL_UNIFY_MIGRATION_NAME) +} + +/// 是否存在可用于还原的迁移备份(给前端决定要不要显示"恢复备份"勾选)。 +/// 与 restore 的账本收集共用同一目录匹配口径:只认属于当前 Codex 目录的 +/// 代际,避免切换 codex_config_dir 后弹出注定空跑的勾选。 +/// 精确账本内容仍在真正还原时才解析。 +pub fn has_codex_official_history_unify_backup() -> bool { + has_official_history_unify_backup_for_dir( + &official_history_unify_backup_parent(), + &canonical_dir_string(&get_codex_config_dir()), + ) +} + +fn has_official_history_unify_backup_for_dir(ledger_parent: &Path, codex_dir_key: &str) -> bool { + let Ok(entries) = fs::read_dir(ledger_parent) else { + return false; + }; + entries.flatten().any(|entry| { + let generation = entry.path(); + generation.is_dir() && backup_generation_matches_dir(&generation, codex_dir_key) + }) +} + +/// 关闭统一会话开关时的可选还原:按迁移备份账本,把当时迁入共享 custom 桶的 +/// 官方会话精确翻回 "openai" 桶。 +/// +/// 备份是唯一可信的归属证据:备份里 model_provider=="openai" 的会话必定源自 +/// 官方桶。开启期间新产生的会话不在任何备份里,**永不触碰**——它们可能来自 +/// 第三方,方向无法判定(产品决策:宁可留在第三方历史)。 +/// 扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话; +/// 还原前改动目标先备份到独立的 restore 目录(保持迁移账本目录纯净), +/// 且只改写当前仍为 custom 的目标,重复执行无害。 +pub fn restore_codex_official_history_from_backups( +) -> Result { + let _op_guard = lock_codex_official_history_op(); + // 开关已(重新)开启时拒绝还原:live 正路由 custom,把账本会话翻回 + // openai 桶等于亲手制造分裂。覆盖"关闭保存成功后用户立刻重新开启, + // 还原排在重开迁移之后才拿到 op lock"的时序。 + if crate::settings::unify_codex_session_history() { + return Ok(CodexOfficialHistoryRestoreOutcome { + skipped_reason: Some("unify_toggle_on".to_string()), + ..Default::default() + }); + } + let config_text = read_codex_config_text().unwrap_or_default(); + restore_codex_official_history_inner( + &get_codex_config_dir(), + &official_history_unify_backup_parent(), + &migration_backup_root(OFFICIAL_UNIFY_RESTORE_BACKUP_NAME), + &config_text, + ) +} + +fn restore_codex_official_history_inner( + codex_dir: &Path, + ledger_parent: &Path, + restore_backup_root: &Path, + config_text: &str, +) -> Result { + let codex_dir_key = canonical_dir_string(codex_dir); + let (official_session_ids, official_thread_ids) = + collect_official_ledger(ledger_parent, &codex_dir_key)?; + if official_session_ids.is_empty() && official_thread_ids.is_empty() { + return Ok(CodexOfficialHistoryRestoreOutcome { + skipped_reason: Some("no_backup_ledger".to_string()), + ..Default::default() + }); + } + + 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 mut restored_jsonl_files = 0; + for file_path in files { + if rewrite_codex_session_file_lines(&file_path, codex_dir, restore_backup_root, |line| { + rewrite_codex_session_meta_line_for_restore(line, &official_session_ids) + })? { + restored_jsonl_files += 1; + } + } + + let mut restored_state_rows = 0; + for db_path in codex_state_db_paths(codex_dir, config_text) { + restored_state_rows += restore_codex_state_db_official_threads( + &db_path, + codex_dir, + &official_thread_ids, + restore_backup_root, + )?; + } + + if restored_jsonl_files == 0 && restored_state_rows == 0 { + // 账本非空但没有任何"当前仍为 custom"的目标(如重复还原): + // 以 reason 告知前端,避免误报"已还原 0 项"为成功。 + return Ok(CodexOfficialHistoryRestoreOutcome { + skipped_reason: Some("nothing_to_restore".to_string()), + ..Default::default() + }); + } + + Ok(CodexOfficialHistoryRestoreOutcome { + restored_jsonl_files, + restored_state_rows, + skipped_reason: None, + }) +} + +/// 从备份代际收集官方会话账本:jsonl 备份里 session_meta 为 "openai" 的 +/// 会话 id + state DB 备份里 model_provider 为 "openai" 的 thread id。 +/// 只采纳 meta.json 目录与当前 Codex 目录一致的代际,避免切换 +/// codex_config_dir 后拿旧目录的账本作用到新目录。 +/// 还原操作自身的备份(restore 目录)天然不会混入:那些副本里的 id 都是 +/// custom,解析后贡献为空。 +fn collect_official_ledger( + ledger_parent: &Path, + codex_dir_key: &str, +) -> Result<(HashSet, BTreeSet), AppError> { + let mut session_ids = HashSet::new(); + let mut thread_ids = BTreeSet::new(); + let entries = match fs::read_dir(ledger_parent) { + Ok(entries) => entries, + Err(_) => return Ok((session_ids, thread_ids)), + }; + for entry in entries.flatten() { + let generation = entry.path(); + if !generation.is_dir() { + continue; + } + if !backup_generation_matches_dir(&generation, codex_dir_key) { + continue; + } + let mut backup_files = Vec::new(); + collect_jsonl_files(&generation.join("jsonl"), &mut backup_files, 0, 10); + for backup_file in backup_files { + collect_official_session_ids_from_backup(&backup_file, &mut session_ids); + } + let mut backup_dbs = Vec::new(); + collect_files_with_extension(&generation.join("state"), "sqlite", &mut backup_dbs, 0, 4); + for backup_db in backup_dbs { + collect_official_thread_ids_from_backup(&backup_db, &mut thread_ids); + } + } + Ok((session_ids, thread_ids)) +} + +/// 备份代际是否属于指定 Codex 目录。无 meta.json 或解析失败时宽容接受: +/// 早期版本的备份没有 meta,而那个时期不存在切目录场景;误纳的代价也被 +/// "按会话 id 精确匹配 + 仅改写 custom"双重条件兜底。 +fn backup_generation_matches_dir(generation: &Path, codex_dir_key: &str) -> bool { + let Ok(text) = fs::read_to_string(generation.join("meta.json")) else { + return true; + }; + serde_json::from_str::(&text) + .ok() + .and_then(|value| { + value + .get("codexConfigDir") + .and_then(Value::as_str) + .map(|dir| dir == codex_dir_key) + }) + .unwrap_or(true) +} + +fn collect_official_session_ids_from_backup(path: &Path, session_ids: &mut HashSet) { + let Ok(content) = fs::read_to_string(path) else { + log::debug!("Failed to read unify backup file {}", path.display()); + return; + }; + for line in content.lines() { + if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + continue; + } + let Some(payload) = value.get("payload") else { + continue; + }; + if payload.get("model_provider").and_then(Value::as_str) + != Some(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID) + { + continue; + } + if let Some(session_id) = payload.get("id").and_then(Value::as_str) { + session_ids.insert(session_id.to_string()); + } + } +} + +fn collect_official_thread_ids_from_backup(db_path: &Path, thread_ids: &mut BTreeSet) { + let conn = + match Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(conn) => conn, + Err(err) => { + log::debug!( + "Failed to open unify backup state DB {}: {err}", + db_path.display() + ); + return; + } + }; + let has_threads = Database::table_exists(&conn, "threads").unwrap_or(false) + && Database::has_column(&conn, "threads", "model_provider").unwrap_or(false); + if !has_threads { + return; + } + let Ok(mut stmt) = conn.prepare("SELECT id FROM threads WHERE model_provider = ?1") else { + return; + }; + let Ok(rows) = stmt.query_map([OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID], |row| { + row.get::<_, String>(0) + }) else { + return; + }; + for thread_id in rows.flatten() { + thread_ids.insert(thread_id); + } +} + +fn collect_files_with_extension( + dir: &Path, + extension: &str, + files: &mut Vec, + depth: u8, + max_depth: u8, +) { + if depth > max_depth || !dir.is_dir() { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_with_extension(&path, extension, files, depth + 1, max_depth); + } else if path.extension().and_then(|ext| ext.to_str()) == Some(extension) { + files.push(path); + } + } +} + +fn rewrite_codex_session_meta_line_for_restore( + line: &str, + official_session_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()?; + if payload.get("model_provider")?.as_str()? != CC_SWITCH_CODEX_MODEL_PROVIDER_ID { + return None; + } + let session_id = payload.get("id")?.as_str()?; + if !official_session_ids.contains(session_id) { + return None; + } + payload.insert( + "model_provider".to_string(), + Value::String(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()), + ); + serde_json::to_string(&value).ok() +} + +fn restore_codex_state_db_official_threads( + db_path: &Path, + codex_dir: &Path, + official_thread_ids: &BTreeSet, + backup_root: &Path, +) -> Result { + if !db_path.exists() || official_thread_ids.is_empty() { + return Ok(0); + } + + let mut 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 ids: Vec<&String> = official_thread_ids.iter().collect(); + let mut matching_rows: i64 = 0; + for chunk in ids.chunks(STATE_DB_ID_CHUNK) { + let placeholders = placeholders(chunk.len()); + let count_sql = format!( + "SELECT COUNT(*) FROM threads WHERE model_provider = ? AND id IN ({placeholders})" + ); + let mut values = Vec::with_capacity(chunk.len() + 1); + values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + values.extend(chunk.iter().map(|id| (*id).clone())); + let count: i64 = conn + .query_row(&count_sql, params_from_iter(values.iter()), |row| { + row.get(0) + }) + .map_err(|e| AppError::Database(format!("统计 Codex state DB 待还原行失败: {e}")))?; + matching_rows += count; + } + if matching_rows == 0 { + return Ok(0); + } + + backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?; + + let tx = conn + .transaction() + .map_err(|e| AppError::Database(format!("开启 Codex state DB 还原事务失败: {e}")))?; + let mut changed = 0; + for chunk in ids.chunks(STATE_DB_ID_CHUNK) { + let placeholders = placeholders(chunk.len()); + let update_sql = format!( + "UPDATE threads SET model_provider = ? WHERE model_provider = ? AND id IN ({placeholders})" + ); + let mut values = Vec::with_capacity(chunk.len() + 2); + values.push(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()); + values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + values.extend(chunk.iter().map(|id| (*id).clone())); + changed += tx + .execute(&update_sql, params_from_iter(values.iter())) + .map_err(|e| AppError::Database(format!("还原 Codex state DB provider 失败: {e}")))?; + } + tx.commit() + .map_err(|e| AppError::Database(format!("提交 Codex state DB 还原事务失败: {e}")))?; + Ok(changed) +} + fn migrate_codex_provider_templates_to_custom( db: &Database, backup_root: &Path, @@ -257,10 +745,10 @@ fn insert_known_cc_switch_legacy_source_id(ids: &mut BTreeSet, provider_ } } -fn migration_backup_root() -> PathBuf { +fn migration_backup_root(migration_name: &str) -> PathBuf { get_app_config_dir() .join("backups") - .join(MIGRATION_NAME) + .join(migration_name) .join(Local::now().format("%Y%m%d_%H%M%S").to_string()) } @@ -524,6 +1012,17 @@ fn rewrite_codex_session_file_for_provider_bucket( codex_dir: &Path, source_provider_ids: &HashSet, backup_root: &Path, +) -> Result { + rewrite_codex_session_file_lines(path, codex_dir, backup_root, |line| { + rewrite_codex_session_meta_line(line, source_provider_ids) + }) +} + +fn rewrite_codex_session_file_lines( + path: &Path, + codex_dir: &Path, + backup_root: &Path, + rewrite_line: impl Fn(&str) -> Option, ) -> Result { let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?; let modified_before = metadata_before.modified().ok(); @@ -537,7 +1036,7 @@ fn rewrite_codex_session_file_for_provider_bucket( .strip_suffix('\n') .map(|line| (line, "\n")) .unwrap_or((segment, "")); - if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) { + if let Some(next_line) = rewrite_line(line) { rewritten.push_str(&next_line); changed = true; } else { @@ -820,6 +1319,39 @@ mod tests { values.iter().map(|value| value.to_string()).collect() } + #[test] + fn detects_custom_routed_codex_config_for_unify_gate() { + // 注入产物(官方 + 统一开关) + assert!(codex_config_text_routes_custom( + r#"model_provider = "custom" + +[model_providers.custom] +name = "OpenAI" +requires_openai_auth = true +supports_websockets = true +wire_api = "responses" +"# + )); + // 第三方供应商的常规 custom 路由(带 base_url)同样算已统一 + assert!(codex_config_text_routes_custom( + r#"model_provider = "custom" + +[model_providers.custom] +name = "AIHubMix" +base_url = "https://aihubmix.example/v1" +"# + )); + // 注入被拒的形态:显式 openai 路由 / 无 model_provider(接管期间、空配置) + assert!(!codex_config_text_routes_custom( + "model_provider = \"openai\"\n" + )); + assert!(!codex_config_text_routes_custom( + "base_url = \"http://127.0.0.1:15721/codex\"\n" + )); + assert!(!codex_config_text_routes_custom("")); + assert!(!codex_config_text_routes_custom("not toml [")); + } + fn migrate_provider_templates_for_test( db: &Database, ) -> ( @@ -1092,6 +1624,333 @@ base_url = "https://proxy.example/v1" ); } + #[test] + fn simulates_official_history_unify_migration_end_to_end() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let backup_root = dir.path().join("backup"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + + let source_provider_ids = source_ids(&[OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID]); + + let session_dir = codex_dir.join("sessions/2026/06/12"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let session_path = session_dir.join("official-sim.jsonl"); + fs::write( + &session_path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n", + "{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}\n", + ), + ) + .expect("write session"); + + let migrated_jsonl = + migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root) + .expect("migrate jsonl"); + assert_eq!(migrated_jsonl, 1); + let session_text = fs::read_to_string(&session_path).expect("read session"); + assert_eq!( + session_text + .matches("\"model_provider\":\"custom\"") + .count(), + 2 + ); + assert!(!session_text.contains("\"model_provider\":\"openai\"")); + assert!(session_text.contains("\"model_provider\":\"my-private-relay\"")); + assert!( + session_text.contains("{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}") + ); + assert!(backup_root + .join("jsonl/sessions/2026/06/12/official-sim.jsonl") + .exists()); + + // 第二次执行应当无事可做(幂等) + let rerun = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root) + .expect("rerun migrate jsonl"); + assert_eq!(rerun, 0); + + let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db_path).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + ); + INSERT INTO threads (id, model_provider) VALUES + ('openai-thread', 'openai'), + ('custom-thread', 'custom'), + ('manual-thread', 'my-private-relay');", + ) + .expect("seed state db"); + drop(conn); + + let migrated_state_rows = migrate_codex_state_db_provider_bucket( + &state_db_path, + &codex_dir, + &source_provider_ids, + &backup_root, + ) + .expect("migrate state db"); + assert_eq!(migrated_state_rows, 1); + + let conn = Connection::open(&state_db_path).expect("reopen state db"); + let count_provider = |provider_id: &str| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider = ?1", + [provider_id], + |row| row.get(0), + ) + .expect("count provider") + }; + assert_eq!(count_provider("custom"), 2); + assert_eq!(count_provider("openai"), 0); + assert_eq!(count_provider("my-private-relay"), 1); + } + + #[test] + fn restores_only_ledgered_official_sessions_from_backups() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let ledger_parent = dir.path().join("ledger"); + let restore_backup_root = dir.path().join("restore-backup"); + + // 备份账本:一个代际,jsonl 备份里 s1 是 openai;state 备份里 t1 是 openai + let generation = ledger_parent.join("20260612_010101"); + let backup_session_dir = generation.join("jsonl/sessions/2026/06/01"); + fs::create_dir_all(&backup_session_dir).expect("create backup session dir"); + fs::write( + backup_session_dir.join("official.jsonl"), + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n", + ) + .expect("write backup session"); + let backup_state_dir = generation.join("state"); + fs::create_dir_all(&backup_state_dir).expect("create backup state dir"); + let backup_db = Connection::open(backup_state_dir.join(CODEX_STATE_DB_FILENAME)) + .expect("open backup db"); + backup_db + .execute_batch( + "CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL); + INSERT INTO threads (id, model_provider) VALUES ('t1', 'openai');", + ) + .expect("seed backup db"); + drop(backup_db); + + // 当前数据:s1(账本内,custom)应还原;s2(开启期间新会话,不在账本) + // 与 s3(手工 relay)必须原样保留 + let session_dir = codex_dir.join("sessions/2026/06/01"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let official_path = session_dir.join("official.jsonl"); + fs::write( + &official_path, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n", + ) + .expect("write official session"); + let on_period_dir = codex_dir.join("sessions/2026/06/12"); + fs::create_dir_all(&on_period_dir).expect("create on-period dir"); + let on_period_path = on_period_dir.join("on-period.jsonl"); + fs::write( + &on_period_path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n", + ), + ) + .expect("write on-period session"); + + let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db_path).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL); + INSERT INTO threads (id, model_provider) VALUES + ('t1', 'custom'), + ('t2', 'custom'), + ('t3', 'openai');", + ) + .expect("seed state db"); + drop(conn); + + // 代际 meta 指向当前 Codex 目录:精确匹配分支生效(而非无 meta 的宽容分支) + fs::write( + generation.join("meta.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "codexConfigDir": canonical_dir_string(&codex_dir) + })) + .expect("serialize meta"), + ) + .expect("write meta"); + + let outcome = restore_codex_official_history_inner( + &codex_dir, + &ledger_parent, + &restore_backup_root, + "", + ) + .expect("restore"); + assert_eq!(outcome.restored_jsonl_files, 1); + assert_eq!(outcome.restored_state_rows, 1); + assert!(outcome.skipped_reason.is_none()); + + let official_text = fs::read_to_string(&official_path).expect("read official"); + assert!(official_text.contains("\"model_provider\":\"openai\"")); + let on_period_text = fs::read_to_string(&on_period_path).expect("read on-period"); + assert!(on_period_text.contains("\"id\":\"s2\",\"model_provider\":\"custom\"")); + assert!(on_period_text.contains("\"model_provider\":\"my-private-relay\"")); + + let conn = Connection::open(&state_db_path).expect("reopen state db"); + let provider_of = |thread_id: &str| -> String { + conn.query_row( + "SELECT model_provider FROM threads WHERE id = ?1", + [thread_id], + |row| row.get(0), + ) + .expect("thread provider") + }; + assert_eq!(provider_of("t1"), "openai"); + assert_eq!(provider_of("t2"), "custom"); + assert_eq!(provider_of("t3"), "openai"); + drop(conn); + + // 还原前的现场已备份到独立目录 + assert!(restore_backup_root + .join("jsonl/sessions/2026/06/01/official.jsonl") + .exists()); + assert!(restore_backup_root + .join("state") + .join(CODEX_STATE_DB_FILENAME) + .exists()); + + // 幂等:第二次还原无事可做 + let rerun = restore_codex_official_history_inner( + &codex_dir, + &ledger_parent, + &dir.path().join("restore-backup-2"), + "", + ) + .expect("rerun restore"); + assert_eq!(rerun.restored_jsonl_files, 0); + assert_eq!(rerun.restored_state_rows, 0); + assert_eq!(rerun.skipped_reason.as_deref(), Some("nothing_to_restore")); + } + + #[test] + fn restore_ignores_backup_generations_from_other_codex_dirs() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let ledger_parent = dir.path().join("ledger"); + + // 账本代际属于另一个 Codex 目录 + let generation = ledger_parent.join("20260612_010101"); + let backup_session_dir = generation.join("jsonl/sessions/2026/06/01"); + fs::create_dir_all(&backup_session_dir).expect("create backup session dir"); + fs::write( + backup_session_dir.join("official.jsonl"), + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n", + ) + .expect("write backup session"); + fs::write( + generation.join("meta.json"), + "{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}", + ) + .expect("write meta"); + + let session_dir = codex_dir.join("sessions/2026/06/01"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let session_path = session_dir.join("official.jsonl"); + fs::write( + &session_path, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n", + ) + .expect("write session"); + + let outcome = restore_codex_official_history_inner( + &codex_dir, + &ledger_parent, + &dir.path().join("restore-backup"), + "", + ) + .expect("restore"); + assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger")); + let text = fs::read_to_string(&session_path).expect("read session"); + assert!(text.contains("\"model_provider\":\"custom\"")); + } + + #[test] + fn backup_probe_only_counts_generations_for_current_dir() { + let dir = tempdir().expect("tempdir"); + let ledger_parent = dir.path().join("ledger"); + let codex_dir_key = "/current/codex-dir"; + + // 空父目录 / 父目录不存在:无备份 + assert!(!has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + + // 只有其他目录的代际:不算有备份 + let other = ledger_parent.join("20260612_010101"); + fs::create_dir_all(&other).expect("create generation"); + fs::write( + other.join("meta.json"), + "{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}", + ) + .expect("write meta"); + assert!(!has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + + // 无 meta 的早期代际:宽容接受(与 restore 的账本口径一致) + fs::create_dir_all(ledger_parent.join("20260612_020202")).expect("create legacy gen"); + assert!(has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + + // 精确匹配当前目录的代际 + fs::remove_dir_all(ledger_parent.join("20260612_020202")).expect("remove legacy gen"); + let matched = ledger_parent.join("20260612_030303"); + fs::create_dir_all(&matched).expect("create matched gen"); + fs::write( + matched.join("meta.json"), + format!("{{\n \"codexConfigDir\": \"{codex_dir_key}\"\n}}"), + ) + .expect("write matched meta"); + assert!(has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + } + + #[test] + fn restore_skips_when_no_backup_ledger_exists() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let session_dir = codex_dir.join("sessions/2026/06/01"); + fs::create_dir_all(&session_dir).expect("create session dir"); + fs::write( + session_dir.join("session.jsonl"), + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n", + ) + .expect("write session"); + + let outcome = restore_codex_official_history_inner( + &codex_dir, + &dir.path().join("missing-ledger"), + &dir.path().join("restore-backup"), + "", + ) + .expect("restore"); + assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger")); + assert_eq!(outcome.restored_jsonl_files, 0); + assert_eq!(outcome.restored_state_rows, 0); + + let text = fs::read_to_string(session_dir.join("session.jsonl")).expect("read session"); + assert!(text.contains("\"model_provider\":\"custom\"")); + } + #[test] fn rewrites_only_codex_session_meta_provider_ids() { let dir = tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d49828170..8cceccb4f 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -36,24 +36,11 @@ 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(); - } - if incoming_migrations.codex_provider_template_v1.is_none() { - incoming_migrations.codex_provider_template_v1 = - existing_migrations.codex_provider_template_v1.clone(); - } - } + // local_migrations 是纯后端状态(迁移完成标记),前端没有合法的修改场景, + // 无条件取现有值。若按 incoming 透传:后端清掉 marker(如关闭统一会话 + // 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来, + // 重新开启时被"复活"的标记挡住而漏迁。 + incoming.local_migrations = existing.local_migrations.clone(); incoming } @@ -73,20 +60,109 @@ pub async fn save_settings( let merged = merge_settings_for_save(settings, &existing); let unify_codex_changed = merged.unify_codex_session_history != existing.unify_codex_session_history; + let unify_codex_enabled = merged.unify_codex_session_history; crate::settings::update_settings(merged).map_err(|e| e.to_string())?; // 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置, // 不必等下一次切换才生效。 if unify_codex_changed { + // live 重写失败时回滚设置并把保存整体报失败:若设置保持已切换状态, + // live 仍跑旧桶,后续的历史迁移/还原会让会话再次分裂(开启=历史 + // 迁走而新会话仍写 openai 桶;关闭=会话还原而 live 仍写 custom)。 + // 报错让前端 saved=false 短路还原;回滚是整次保存的事务语义 + // (本开关的保存只携带开关相关字段)。 if let Err(err) = crate::services::provider::reapply_current_codex_official_live(state.inner()) { - log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败: {err}"); + log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}"); + if let Err(rollback_err) = crate::settings::update_settings(existing) { + log::error!("回滚统一会话开关设置失败: {rollback_err}"); + } + return Err(format!( + "统一 Codex 会话历史开关未生效(live 配置重写失败): {err}" + )); + } + + if unify_codex_enabled { + // 后台执行存量迁移(openai 桶 → custom 桶;仅当用户勾选了迁入既有 + // 会话,函数内部自门控)。大会话目录可能要读数秒,不能阻塞设置保存; + // 失败时不写完成标记,下次启动自动重试。 + tauri::async_runtime::spawn_blocking(|| { + match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() { + Ok(outcome) => { + if let Some(reason) = outcome.skipped_reason { + log::debug!("○ Codex official history unify migration skipped: {reason}"); + } else { + log::info!( + "✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}", + outcome.migrated_jsonl_files, + outcome.migrated_state_rows + ); + } + } + Err(e) => { + log::warn!("✗ Codex official history unify migration failed: {e}"); + } + } + }); + } else { + // 清除标记与迁移意愿,让重新开启并再次勾选时能补迁 + // 关闭期间落入 openai 桶的官方会话。 + if let Err(err) = crate::settings::clear_codex_official_history_unify_migration() { + log::warn!("清除统一会话迁移标记失败: {err}"); + } + if let Err(err) = crate::settings::clear_codex_unify_migrate_existing() { + log::warn!("清除统一会话迁移意愿失败: {err}"); + } } } Ok(true) } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexUnifyHistoryRestoreResult { + pub restored_jsonl_files: usize, + pub restored_state_rows: usize, + /// 还原被跳过的原因(如当前目录没有账本),前端据此提示而非报"成功 0 项"。 + #[serde(skip_serializing_if = "Option::is_none")] + pub skipped_reason: Option, +} + +/// 是否存在统一会话开关的迁移备份(决定关闭弹窗里是否显示"恢复备份"勾选)。 +#[tauri::command] +pub async fn has_codex_unify_history_backup() -> Result { + Ok(crate::codex_history_migration::has_codex_official_history_unify_backup()) +} + +/// 按迁移备份账本把当时迁入共享桶的官方会话还原回 "openai" 桶。 +/// 由关闭统一会话开关的确认弹窗触发;幂等,可安全重试。 +#[tauri::command] +pub async fn restore_codex_unified_history() -> Result { + let outcome = tauri::async_runtime::spawn_blocking(|| { + crate::codex_history_migration::restore_codex_official_history_from_backups() + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + if let Some(reason) = &outcome.skipped_reason { + log::debug!("○ Codex official history restore skipped: {reason}"); + } else { + log::info!( + "✓ Codex official history restored from backups: jsonl_files={}, state_rows={}", + outcome.restored_jsonl_files, + outcome.restored_state_rows + ); + } + + Ok(CodexUnifyHistoryRestoreResult { + restored_jsonl_files: outcome.restored_jsonl_files, + restored_state_rows: outcome.restored_state_rows, + skipped_reason: outcome.skipped_reason, + }) +} + /// 重启应用程序(当 app_config_dir 变更后使用) #[tauri::command] pub async fn restart_app(app: AppHandle) -> Result { @@ -201,8 +277,9 @@ pub async fn set_auto_launch(enabled: bool) -> Result { mod tests { use super::merge_settings_for_save; use crate::settings::{ - AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, - LocalMigrations, S3SyncSettings, WebDavSyncSettings, + AppSettings, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration, + CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings, + WebDavSyncSettings, }; #[test] @@ -401,6 +478,13 @@ mod tests { completed_at: "2026-05-20T00:01:00Z".to_string(), migrated_provider_ids: vec!["legacy".to_string()], }), + codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration { + completed_at: "2026-06-12T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + migrated_jsonl_files: 5, + migrated_state_rows: 7, + codex_config_dir: None, + }), }), ..AppSettings::default() }; @@ -430,6 +514,70 @@ mod tests { template_migration.migrated_provider_ids, vec!["legacy".to_string()] ); + + let unify_migration = merged + .local_migrations + .as_ref() + .and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref()) + .expect("official unify migration marker should be preserved"); + assert_eq!(unify_migration.migrated_jsonl_files, 5); + assert_eq!(unify_migration.migrated_state_rows, 7); + } + + /// incoming 带有 local_migrations(哪怕是空的)也不能覆盖后端维护的标记。 + #[test] + fn save_settings_should_keep_backend_migration_markers_over_incoming() { + let existing = AppSettings { + local_migrations: Some(LocalMigrations { + codex_third_party_history_provider_bucket_v1: None, + codex_provider_template_v1: None, + codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration { + completed_at: "2026-06-12T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + migrated_jsonl_files: 1, + migrated_state_rows: 2, + codex_config_dir: None, + }), + }), + ..AppSettings::default() + }; + + let incoming = AppSettings { + local_migrations: Some(LocalMigrations::default()), + ..AppSettings::default() + }; + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged + .local_migrations + .as_ref() + .and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref()) + .is_some()); + } + + /// 后端清掉 marker 后(如关闭统一会话开关)、前端缓存刷新前的全量保存 + /// 会携带旧 marker;merge 必须忽略它,否则被"复活"的标记会让重新开启 + /// 时误判已迁移而漏迁。 + #[test] + fn save_settings_should_ignore_stale_incoming_migration_markers() { + let existing = AppSettings::default(); + + let incoming = AppSettings { + local_migrations: Some(LocalMigrations { + codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration { + completed_at: "2026-06-12T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + migrated_jsonl_files: 1, + migrated_state_rows: 2, + codex_config_dir: None, + }), + ..LocalMigrations::default() + }), + ..AppSettings::default() + }; + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged.local_migrations.is_none()); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eeebed147..863bb21a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -600,6 +600,25 @@ pub fn run() { log::warn!("✗ Codex provider template bucket migration failed: {e}"); } } + + // 统一会话开关的官方历史迁移:开关开启但上次未完成(如文件被占用 + // 中途失败)时在启动期重试;函数内部自门控,开关关闭时直接跳过。 + match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() { + Ok(outcome) => { + if let Some(reason) = outcome.skipped_reason { + log::debug!("○ Codex official history unify migration skipped: {reason}"); + } else { + log::info!( + "✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}", + outcome.migrated_jsonl_files, + outcome.migrated_state_rows + ); + } + } + Err(e) => { + log::warn!("✗ Codex official history unify migration failed: {e}"); + } + } }); } @@ -1156,6 +1175,8 @@ pub fn run() { commands::read_live_provider_settings, commands::get_settings, commands::save_settings, + commands::has_codex_unify_history_backup, + commands::restore_codex_unified_history, commands::get_rectifier_config, commands::set_rectifier_config, commands::get_optimizer_config, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index cc11aa59a..ffa00909f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -287,6 +287,10 @@ pub struct LocalMigrations { Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub codex_provider_template_v1: Option, + /// 统一会话开关的官方历史迁移标记。开关关闭时会被清除, + /// 这样重新开启能把"关闭期间"落入 openai 桶的官方会话补迁进来。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_official_history_unify_v1: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -312,6 +316,21 @@ pub struct CodexProviderTemplateMigration { pub migrated_provider_ids: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexOfficialHistoryUnifyMigration { + pub completed_at: String, + pub target_provider_id: String, + #[serde(default)] + pub migrated_jsonl_files: usize, + #[serde(default)] + pub migrated_state_rows: usize, + /// 迁移时的规范化 Codex 目录。标记只对同一目录生效: + /// 切换 codex_config_dir 后旧标记不会挡住新目录的迁移。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_config_dir: Option, +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 @@ -358,11 +377,15 @@ pub struct AppSettings { #[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. + /// so official sessions share one resume-history bucket with third-party + /// providers. Opt-in: defaults to false. #[serde(default)] pub unify_codex_session_history: bool, + /// User opted in (via the enable dialog checkbox) to migrate existing + /// official sessions ("openai" bucket) into the shared bucket. Persisted so + /// a failed migration retries at startup; cleared when the toggle turns off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unify_codex_migrate_existing: Option, /// User has confirmed the failover toggle first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub failover_confirmed: Option, @@ -482,6 +505,7 @@ impl Default for AppSettings { enable_failover_toggle: false, preserve_codex_official_auth_on_switch: false, unify_codex_session_history: false, + unify_codex_migrate_existing: None, failover_confirmed: None, first_run_notice_confirmed: None, common_config_confirmed: None, @@ -766,6 +790,56 @@ pub fn mark_codex_provider_template_migrated( }) } +/// 统一会话迁移标记是否覆盖指定目录。标记里没记目录(不应出现的旧格式) +/// 视为不匹配——重跑迁移是幂等的,宁可重迁也不漏迁。 +pub fn is_codex_official_history_unify_migrated_for_dir(codex_dir: &str) -> bool { + get_settings() + .local_migrations + .as_ref() + .and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref()) + .is_some_and(|migration| migration.codex_config_dir.as_deref() == Some(codex_dir)) +} + +/// 条件写入迁移完成标记:仅当此刻开关仍开启且迁移意愿仍在时才写。 +/// 检查与写入在 settings 写锁内原子完成,与关闭开关路径 +/// (`update_settings` / 清标记)串行,消除"迁移线程复查开关后、写标记前 +/// 用户恰好关闭开关"的竞态窗口。返回是否实际写入。 +pub fn mark_codex_official_history_unify_migrated_if_enabled( + migration: CodexOfficialHistoryUnifyMigration, +) -> Result { + let mut written = false; + mutate_settings(|settings| { + if settings.unify_codex_session_history + && settings.unify_codex_migrate_existing.unwrap_or(false) + { + settings + .local_migrations + .get_or_insert_with(Default::default) + .codex_official_history_unify_v1 = Some(migration); + written = true; + } + })?; + Ok(written) +} + +pub fn clear_codex_official_history_unify_migration() -> Result<(), AppError> { + mutate_settings(|settings| { + if let Some(migrations) = settings.local_migrations.as_mut() { + migrations.codex_official_history_unify_v1 = None; + } + }) +} + +pub fn unify_codex_migrate_existing_requested() -> bool { + get_settings().unify_codex_migrate_existing.unwrap_or(false) +} + +pub fn clear_codex_unify_migrate_existing() -> Result<(), AppError> { + mutate_settings(|settings| { + settings.unify_codex_migrate_existing = None; + }) +} + /// 从文件重新加载设置到内存缓存 /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index c2eb952b3..788056d9a 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Dialog, DialogContent, @@ -7,6 +8,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { AlertTriangle, Info } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -18,7 +20,10 @@ interface ConfirmDialogProps { cancelText?: string; variant?: "destructive" | "info"; zIndex?: "base" | "nested" | "alert" | "top"; - onConfirm: () => void; + /** 可选勾选项:提供 label 即显示,勾选状态经 onConfirm 参数回传 */ + checkboxLabel?: string; + checkboxDefaultChecked?: boolean; + onConfirm: (checkboxChecked: boolean) => void; onCancel: () => void; } @@ -30,10 +35,21 @@ export function ConfirmDialog({ cancelText, variant = "destructive", zIndex = "alert", + checkboxLabel, + checkboxDefaultChecked = false, onConfirm, onCancel, }: ConfirmDialogProps) { const { t } = useTranslation(); + const [checkboxChecked, setCheckboxChecked] = useState( + checkboxDefaultChecked, + ); + + useEffect(() => { + if (isOpen) { + setCheckboxChecked(checkboxDefaultChecked); + } + }, [isOpen, checkboxDefaultChecked]); const IconComponent = variant === "info" ? Info : AlertTriangle; const iconClass = @@ -58,13 +74,26 @@ export function ConfirmDialog({ {message} + {checkboxLabel ? ( + + ) : null} diff --git a/src/components/settings/CodexAuthSettings.tsx b/src/components/settings/CodexAuthSettings.tsx index 5b2ad3826..f58b373d5 100644 --- a/src/components/settings/CodexAuthSettings.tsx +++ b/src/components/settings/CodexAuthSettings.tsx @@ -1,11 +1,18 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { History, KeyRound } from "lucide-react"; +import { toast } from "sonner"; import type { SettingsFormState } from "@/hooks/useSettings"; import { ToggleRow } from "@/components/ui/toggle-row"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { settingsApi } from "@/lib/api"; interface CodexAuthSettingsProps { settings: SettingsFormState; - onChange: (updates: Partial) => void; + /** 返回 false(或 resolve 为 false)表示保存失败;其余返回值视为成功 */ + onChange: ( + updates: Partial, + ) => void | boolean | Promise; } export function CodexAuthSettings({ @@ -13,6 +20,73 @@ export function CodexAuthSettings({ onChange, }: CodexAuthSettingsProps) { const { t } = useTranslation(); + const [showEnableConfirm, setShowEnableConfirm] = useState(false); + const [showDisableConfirm, setShowDisableConfirm] = useState(false); + const [hasUnifyBackup, setHasUnifyBackup] = useState(false); + + const handleUnifyHistoryChange = (checked: boolean) => { + if (checked) { + setShowEnableConfirm(true); + return; + } + // 先探测有无迁移备份,决定关闭弹窗是否提供"恢复备份"勾选 + void settingsApi + .hasCodexUnifyHistoryBackup() + .catch(() => false) + .then((hasBackup) => { + setHasUnifyBackup(hasBackup); + setShowDisableConfirm(true); + }); + }; + + const handleEnableConfirm = (migrateExisting: boolean) => { + setShowEnableConfirm(false); + void onChange({ + unifyCodexSessionHistory: true, + unifyCodexMigrateExisting: migrateExisting, + }); + }; + + // 备份探测可能落后于正在后台进行的迁移(刚勾选迁入就立刻关闭时, + // 备份尚未产出)。只要本轮勾选过"迁入既有会话",就必须提供恢复入口; + // 真正有没有账本交给后端 restore 的 skippedReason 判定。 + const showRestoreOption = + hasUnifyBackup || (settings.unifyCodexMigrateExisting ?? false); + + const handleDisableConfirm = async (restoreBackup: boolean) => { + setShowDisableConfirm(false); + const saved = await onChange({ + unifyCodexSessionHistory: false, + unifyCodexMigrateExisting: false, + }); + // 关闭保存失败时绝不还原:否则开关仍开着(live 仍统一路由), + // 已迁移会话却被翻回 openai 桶,历史被拆成两半。 + if (saved === false) return; + // 不再以探测结果短路:还原命令会在迁移锁上排队,等到迁移落盘后 + // 拿到完整账本;确实无账本时由 skippedReason 提示。 + if (!restoreBackup) return; + try { + const result = await settingsApi.restoreCodexUnifiedHistory(); + if (result.skippedReason) { + // unify_toggle_on:还原排队期间开关被重新开启,后端拒绝还原 + toast.info( + result.skippedReason === "unify_toggle_on" + ? t("settings.unifyCodexHistoryRestoreSkippedToggleOn") + : t("settings.unifyCodexHistoryRestoreNothing"), + ); + return; + } + toast.success( + t("settings.unifyCodexHistoryRestoreCompleted", { + files: result.restoredJsonlFiles, + rows: result.restoredStateRows, + }), + ); + } catch (error) { + console.error("Failed to restore codex unified history:", error); + toast.error(t("settings.unifyCodexHistoryRestoreFailed")); + } + }; return (
@@ -36,9 +110,32 @@ export function CodexAuthSettings({ title={t("settings.unifyCodexSessionHistory")} description={t("settings.unifyCodexSessionHistoryDescription")} checked={settings.unifyCodexSessionHistory ?? false} - onCheckedChange={(value) => - onChange({ unifyCodexSessionHistory: value }) + onCheckedChange={handleUnifyHistoryChange} + /> + + setShowEnableConfirm(false)} + /> + + void handleDisableConfirm(restoreBackup)} + onCancel={() => setShowDisableConfirm(false)} />
); diff --git a/src/components/settings/ProxyTabContent.tsx b/src/components/settings/ProxyTabContent.tsx index a89a7a535..cfd332782 100644 --- a/src/components/settings/ProxyTabContent.tsx +++ b/src/components/settings/ProxyTabContent.tsx @@ -22,7 +22,7 @@ import type { SettingsFormState } from "@/hooks/useSettings"; interface ProxyTabContentProps { settings: SettingsFormState; - onAutoSave: (updates: Partial) => Promise; + onAutoSave: (updates: Partial) => Promise; } export function ProxyTabContent({ diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 22441aed5..748c926ad 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -163,19 +163,34 @@ export function SettingsPage({ // 通用设置即时保存(无需手动点击) // 使用 autoSaveSettings 避免误触发系统 API(开机自启、Claude 插件等) + // 返回保存是否成功:需要在保存成功后追加动作的调用方(如统一会话历史 + // 关闭后的备份还原)据此短路,其余调用方可忽略返回值。 const handleAutoSave = useCallback( - async (updates: Partial) => { - if (!settings) return; + async (updates: Partial): Promise => { + if (!settings) return false; + // 乐观更新前捕获旧值:autoSaveSettings 发送的是全量表单状态,后端按 + // diff 触发副作用(如统一会话开关的 live 重写与历史迁移)。保存失败 + // 不回滚的话,失败的变更会滞留在表单里,被之后任意一次无关保存原样 + // 重放,绕过确认弹窗。 + const previousValues = Object.fromEntries( + Object.keys(updates).map((key) => [ + key, + settings[key as keyof SettingsFormState], + ]), + ) as Partial; updateSettings(updates); try { await autoSaveSettings(updates); + return true; } catch (error) { console.error("[SettingsPage] Failed to autosave settings", error); + updateSettings(previousValues); toast.error( t("settings.saveFailedGeneric", { defaultValue: "保存失败,请重试", }), ); + return false; } }, [autoSaveSettings, settings, t, updateSettings], diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 77dd67da9..9afe4c699 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -279,6 +279,18 @@ "message": "Failover is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend configuring provider priorities in the failover queue first.", "confirm": "I understand, enable" }, + "unifyCodexHistory": { + "title": "Unified Codex session history", + "message": "When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.\n\nYou can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).", + "migrateExisting": "Also migrate existing official session history", + "confirm": "I understand, enable" + }, + "unifyCodexHistoryOff": { + "title": "Turn off unified session history", + "message": "After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.", + "restoreBackup": "Restore the official sessions migrated at enable time back to the official history (exact restore from backup)", + "confirm": "Turn off" + }, "usage": { "title": "Configure Usage Query", "message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.", @@ -671,7 +683,11 @@ "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.", + "unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.", + "unifyCodexHistoryRestoreCompleted": "Official session history restored from backup ({{files}} session files, {{rows}} index rows)", + "unifyCodexHistoryRestoreFailed": "Failed to restore official session history, please try again", + "unifyCodexHistoryRestoreNothing": "No restorable migration backup for the current Codex directory", + "unifyCodexHistoryRestoreSkippedToggleOn": "Unified session history was re-enabled; restore skipped", "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 47d87ae80..cebf6bf25 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -279,6 +279,18 @@ "message": "フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。", "confirm": "理解しました、有効にする" }, + "unifyCodexHistory": { + "title": "Codex セッション履歴を統一", + "message": "オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。\n\n既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。", + "migrateExisting": "既存の公式セッション履歴も移行する", + "confirm": "理解しました、オンにする" + }, + "unifyCodexHistoryOff": { + "title": "セッション履歴の統一をオフにする", + "message": "オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。", + "restoreBackup": "オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)", + "confirm": "オフにする" + }, "usage": { "title": "使用量クエリの設定", "message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持", "preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。", "unifyCodexSessionHistory": "Codex セッション履歴を統一", - "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、今後の公式セッションはサードパーティのセッションと同じ履歴リストに表示されます(既存セッションは移行しません)。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", + "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", + "unifyCodexHistoryRestoreCompleted": "バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)", + "unifyCodexHistoryRestoreFailed": "公式セッション履歴の復元に失敗しました。もう一度お試しください", + "unifyCodexHistoryRestoreNothing": "現在の Codex ディレクトリに復元可能な移行バックアップはありません", + "unifyCodexHistoryRestoreSkippedToggleOn": "統一セッション履歴が再度有効化されたため、復元をスキップしました", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d4a0c7adc..66caee2a7 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -279,6 +279,18 @@ "message": "故障轉移是一項進階功能,啟用前請確保您已了解其運作原理。\n\n建議先在故障轉移佇列中設定好供應商優先順序。", "confirm": "我已了解,繼續啟用" }, + "unifyCodexHistory": { + "title": "統一 Codex 會話歷史", + "message": "開啟後,官方訂閱與第三方將共用同一個會話歷史清單。注意:跨供應商繼續舊會話時,可能因對方後端無法解密 encrypted_content 推理內容而失敗。\n\n可選擇同時把現有官方會話歷史遷入共享清單(遷移前自動備份到 ~/.cc-switch/backups,關閉開關時可選擇恢復)。", + "migrateExisting": "同時遷入現有官方會話歷史", + "confirm": "我已了解,繼續開啟" + }, + "unifyCodexHistoryOff": { + "title": "關閉統一會話歷史", + "message": "關閉後,官方訂閱與第三方將恢復各自獨立的會話歷史清單。開啟期間產生的會話因無法區分來源,將留在第三方歷史中,官方訂閱將看不到它們。", + "restoreBackup": "把開啟時遷入的官方會話還原回官方歷史(按備份精確還原)", + "confirm": "關閉" + }, "usage": { "title": "設定用量查詢", "message": "用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\n\n如不確定如何設定,請先查閱供應商文件。", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入", "preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能", "unifyCodexSessionHistory": "統一 Codex 會話歷史", - "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,未來的官方會話與第三方會話出現在同一歷史清單中(不遷移既有會話)。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", + "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", + "unifyCodexHistoryRestoreCompleted": "已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)", + "unifyCodexHistoryRestoreFailed": "還原官方會話歷史失敗,請重試", + "unifyCodexHistoryRestoreNothing": "目前 Codex 目錄沒有可恢復的遷移備份", + "unifyCodexHistoryRestoreSkippedToggleOn": "統一會話歷史開關已重新開啟,已跳過還原", "appVisibility": { "title": "主頁面顯示", "description": "選擇在主頁面顯示的應用程式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e81ab1563..7f7689f78 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -279,6 +279,18 @@ "message": "故障转移是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先在故障转移队列中配置好供应商优先级。", "confirm": "我已了解,继续启用" }, + "unifyCodexHistory": { + "title": "统一 Codex 会话历史", + "message": "开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。\n\n可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。", + "migrateExisting": "同时迁入现有官方会话历史", + "confirm": "我已了解,继续开启" + }, + "unifyCodexHistoryOff": { + "title": "关闭统一会话历史", + "message": "关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。", + "restoreBackup": "把开启时迁入的官方会话还原回官方历史(按备份精确还原)", + "confirm": "关闭" + }, "usage": { "title": "配置用量查询", "message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录", "preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能", "unifyCodexSessionHistory": "统一 Codex 会话历史", - "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,未来的官方会话与第三方会话出现在同一历史列表中(不迁移既有会话)。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", + "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", + "unifyCodexHistoryRestoreCompleted": "已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)", + "unifyCodexHistoryRestoreFailed": "还原官方会话历史失败,请重试", + "unifyCodexHistoryRestoreNothing": "当前 Codex 目录没有可恢复的迁移备份", + "unifyCodexHistoryRestoreSkippedToggleOn": "统一会话历史开关已重新开启,已跳过还原", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 813ea929a..9c394c00b 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -19,6 +19,13 @@ export interface WebDavTestResult { message?: string; } +export interface CodexUnifyHistoryRestoreResult { + restoredJsonlFiles: number; + restoredStateRows: number; + /** 还原被跳过的原因(如当前目录没有账本);存在时不应报成功 */ + skippedReason?: string; +} + export interface WebDavSyncResult { status: string; } @@ -32,6 +39,16 @@ export const settingsApi = { return await invoke("save_settings", { settings }); }, + /** 是否存在统一 Codex 会话历史的迁移备份(关闭弹窗据此显示"恢复备份"勾选) */ + async hasCodexUnifyHistoryBackup(): Promise { + return await invoke("has_codex_unify_history_backup"); + }, + + /** 按迁移备份账本把当时迁入共享桶的官方会话还原回 openai 桶(幂等) */ + async restoreCodexUnifiedHistory(): Promise { + return await invoke("restore_codex_unified_history"); + }, + async restart(): Promise { return await invoke("restart_app"); }, diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 3e52a5cb6..dbdff5e1e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -59,7 +59,7 @@ export const settingsSchema = z.object({ }) .optional(), - // 本机自动迁移状态(后端维护,前端保存设置时应透传) + // 本机自动迁移状态(后端维护且保存时后端忽略前端值,仅供读取展示) localMigrations: z .object({ codexThirdPartyHistoryProviderBucketV1: z diff --git a/src/types.ts b/src/types.ts index 954eecc8e..6fccc84fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,6 +354,8 @@ export interface Settings { // Run official Codex under the shared "custom" provider id so future // sessions share one resume-history bucket with third-party providers unifyCodexSessionHistory?: boolean; + // User opted in (enable dialog checkbox) to migrate existing official sessions + unifyCodexMigrateExisting?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; // User has confirmed the first-run welcome notice