mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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.
This commit is contained in:
@@ -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<String> {
|
||||
.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<String> {
|
||||
pub(crate) fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
|
||||
let doc = config_text.parse::<DocumentMut>().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<Item = &'a str>,
|
||||
) -> Result<String, AppError> {
|
||||
fn normalize_codex_live_config_model_provider(config_text: &str) -> Result<String, AppError> {
|
||||
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")
|
||||
|
||||
@@ -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<String>,
|
||||
pub migrated_jsonl_files: usize,
|
||||
pub migrated_state_rows: usize,
|
||||
pub skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub fn maybe_migrate_codex_third_party_history_provider_bucket(
|
||||
db: &Database,
|
||||
) -> Result<CodexHistoryProviderBucketMigrationOutcome, AppError> {
|
||||
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<String> = 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<BTreeSet<String>, 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<String>,
|
||||
backup_root: &Path,
|
||||
) -> Result<usize, AppError> {
|
||||
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<String> = 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<PathBuf>, 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<String>,
|
||||
backup_root: &Path,
|
||||
) -> Result<bool, AppError> {
|
||||
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<SystemTime>,
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
backup_root: &Path,
|
||||
) -> Result<usize, AppError> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let doc = config_text.parse::<DocumentMut>().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<String>,
|
||||
backup_root: &Path,
|
||||
) -> Result<usize, AppError> {
|
||||
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::<Vec<_>>()
|
||||
.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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<bool, String> {
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<CodexThirdPartyHistoryProviderBucketMigration>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[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<String>,
|
||||
|
||||
// ===== 本机自动迁移状态 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_migrations: Option<LocalMigrations>,
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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<typeof settingsSchema>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user