diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 4887ed0..c6d310e 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -5,10 +5,12 @@ use super::*; mod commands; mod configuration; mod discovery; +mod history; mod launch; mod state; pub(crate) use commands::*; pub(crate) use configuration::*; pub(crate) use discovery::*; +pub(crate) use history::*; pub(crate) use launch::*; pub(crate) use state::*; diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index db51b6d..281ec93 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -55,16 +55,7 @@ pub(crate) fn build_agent_updates_with_oauth( models, claude_code_model_mappings, ) - .or_else(|_| { - build_claude_agent_config( - None, - &root_base, - api_key, - model, - models, - claude_code_model_mappings, - ) - })?; + ?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -82,12 +73,12 @@ pub(crate) fn build_agent_updates_with_oauth( AgentFileUpdate { path: paths[0].clone(), after: build_claude_desktop_deployment_config(normal_before.as_deref()) - .or_else(|_| build_claude_desktop_deployment_config(None))?, + ?, }, AgentFileUpdate { path: paths[1].clone(), after: build_claude_desktop_deployment_config(threep_before.as_deref()) - .or_else(|_| build_claude_desktop_deployment_config(None))?, + ?, }, AgentFileUpdate { path: paths[2].clone(), @@ -99,21 +90,12 @@ pub(crate) fn build_agent_updates_with_oauth( models, claude_desktop_model_mappings, ) - .or_else(|_| { - build_claude_desktop_profile( - None, - &root_base, - api_key, - model, - models, - claude_desktop_model_mappings, - ) - })?, + ?, }, AgentFileUpdate { path: paths[3].clone(), after: build_claude_desktop_meta(meta_before.as_deref()) - .or_else(|_| build_claude_desktop_meta(None))?, + ?, }, ]) } @@ -126,15 +108,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, oauth_configuration, ) - .or_else(|_| { - build_codex_agent_config_with_oauth( - None, - &openai_base, - api_key, - model, - oauth_configuration, - ) - })?; + ?; let mut updates = vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -157,7 +131,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, ) - .or_else(|_| build_opencode_agent_config(None, &openai_base, api_key, model, models))?; + ?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -172,7 +146,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, ) - .or_else(|_| build_openclaw_agent_config(None, &openai_base, api_key, model, models))?; + ?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -182,9 +156,7 @@ pub(crate) fn build_agent_updates_with_oauth( let before = read_optional_text(&paths[0])?; let after = build_hermes_agent_config(before.as_deref(), &openai_base, api_key, model, models) - .or_else(|_| { - build_hermes_agent_config(None, &openai_base, api_key, model, models) - })?; + ?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -220,9 +192,7 @@ pub(crate) fn build_agent_updates_with_oauth( let cli_before = read_optional_text(&paths[1])?; let app_after = build_zcode_agent_config(app_before.as_deref(), &root_base, api_key, model, models) - .or_else(|_| { - build_zcode_agent_config(None, &root_base, api_key, model, models) - })?; + ?; let cli_after = build_zcode_cli_agent_config( cli_before.as_deref(), &root_base, @@ -230,7 +200,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, ) - .or_else(|_| build_zcode_cli_agent_config(None, &root_base, api_key, model, models))?; + ?; Ok(vec![ AgentFileUpdate { path: paths[0].clone(), @@ -251,9 +221,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, ) - .or_else(|_| { - build_kimi_code_agent_config(None, &openai_base, api_key, model, models) - })?; + ?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -268,9 +236,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, ) - .or_else(|_| { - build_grok_build_agent_config(None, &openai_base, api_key, model, models) - })?; + ?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -2669,7 +2635,12 @@ pub(crate) fn build_codex_auth_update( read_optional_text(&path)? .ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())? } else { - build_codex_api_auth(api_key)? + let path_text = read_optional_text(&path)?; + let mut root = parse_agent_json_object(path_text.as_deref(), "Codex auth.json")?; + for key in ["tokens", "last_refresh"] { root.remove(key); } + root.insert("auth_mode".into(), serde_json::json!("apikey")); + root.insert("OPENAI_API_KEY".into(), serde_json::json!(api_key)); + render_agent_json(root, "Codex auth.json")? }; Ok(AgentFileUpdate { path, after }) } diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 69cf1aa..45d71bd 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -425,6 +425,7 @@ pub(crate) fn inspect_pi_provider_status( config_valid, configured, configuration_synchronized: configured, + connection_state: agent_connection_state(configured, config_valid, Ok(config_path.is_file() || default_provider_matches)).into(), current_model: current_model.clone(), oauth_configuration: false, modification_enabled: configured, @@ -455,7 +456,7 @@ pub(crate) fn install_pi_provider_inner( let settings_path = pi_provider_settings_path(home); let mut changed_files = Vec::new(); if !pi_provider_package_installed(home)? { - install_pi_package(executable, home, proxy_url)?; + history_package_operation(home, "plugin-install", || install_pi_package(executable, home, proxy_url))?; changed_files.push(path_to_string(&settings_path)); } @@ -479,7 +480,8 @@ pub(crate) fn repair_pi_provider_inner( } let config_path = pi_provider_config_path(home); let settings_path = pi_provider_settings_path(home); - let mut changed_files = Vec::new(); + let _guard = AGENT_CONFIG_FILE_LOCK.lock().map_err(|_| "配置文件锁已损坏")?; + let before = history_images(&history_paths(PI_AGENT_ID, home)?)?; if !pi_provider_package_installed(home)? { return Err("Pi CLIProxyAPI provider is not installed".to_string()); } @@ -495,11 +497,6 @@ pub(crate) fn repair_pi_provider_inner( }; let base_url = managed_core_loopback_origin(port); let rendered = build_pi_provider_config(existing.as_deref(), &base_url, api_key)?; - if existing.as_deref() != Some(rendered.as_str()) { - write_bytes_atomically(&config_path, rendered.as_bytes())?; - changed_files.push(path_to_string(&config_path)); - } - let settings = fs::read_to_string(&settings_path).map_err(|error| { format!( "读取 Pi settings.json 失败 {}: {error}", @@ -507,18 +504,10 @@ pub(crate) fn repair_pi_provider_inner( ) })?; let rendered_settings = build_pi_provider_settings(&settings, default_model)?; - if settings != rendered_settings { - write_bytes_atomically(&settings_path, rendered_settings.as_bytes())?; - changed_files.push(path_to_string(&settings_path)); - } - - Ok(action_result( - "applied", - true, - Some(default_model.trim().to_string()), - changed_files, - Vec::new(), - )) + history_updates(PI_AGENT_ID, home, &before, &[ + AgentFileUpdate { path: config_path, after: rendered }, + AgentFileUpdate { path: settings_path, after: rendered_settings }, + ], "update", Some(default_model.to_string()), None) } pub(crate) fn update_pi_provider_inner( @@ -532,7 +521,7 @@ pub(crate) fn update_pi_provider_inner( if !pi_provider_package_installed(home)? { return Err("Pi CLIProxyAPI provider 插件尚未安装".to_string()); } - update_pi_package(executable, home, proxy_url)?; + history_package_operation(home, "plugin-update", || update_pi_package(executable, home, proxy_url))?; let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?; result.outcome = "updated".to_string(); Ok(result) @@ -551,7 +540,7 @@ pub(crate) fn uninstall_pi_provider_inner( Vec::new(), )); } - remove_pi_package(executable, home)?; + history_package_operation(home, "plugin-remove", || remove_pi_package(executable, home))?; Ok(action_result( "removed", false, @@ -776,24 +765,13 @@ pub(crate) fn remove_codex_config_file(path: &Path) -> Result { } pub(crate) fn clear_codex_config_files(home: &Path) -> Result, String> { - let codex_dir = codex_configuration_directory(home); - let config_path = codex_dir.join("config.toml"); - let targets = [codex_dir.join("auth.json"), config_path.clone()]; - let mut deleted = Vec::new(); - - for path in targets { - if remove_codex_config_file(&path)? { - deleted.push(path_to_string(&path)); - } + let paths = history_paths("codex",home)?; + let before = history_images(&paths)?; + let mut after = before.clone(); + for (path, bytes) in &mut after { + if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml") { *bytes = None; } } - - let state_path = agent_state_path(std::slice::from_ref(&config_path))?; - clear_codex_applied_state(&state_path)?; - // Clean up state files left by older releases. New Codex applications keep - // this short-lived restore metadata in memory instead. - remove_codex_config_file(&state_path)?; - - Ok(deleted) + Ok(commit_history("codex", &paths, &before, &after, "clear", None)?.changed_files) } pub(crate) fn codex_model_catalog_path(home: &Path) -> PathBuf { @@ -997,9 +975,9 @@ pub(crate) fn inspect_agent_config( if let Some(message) = error.as_ref() { warnings.push(message.clone()); } - let modification = inspect_agent_application(client, home); + let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured); - warnings.extend(modification.warnings.iter().cloned()); + // Legacy backup warnings belong in the history dialog, not the current configuration status. AgentConfigStatus { id: client.id().to_string(), @@ -1017,16 +995,17 @@ pub(crate) fn inspect_agent_config( config_valid, configured, configuration_synchronized, - current_model, + connection_state: agent_connection_state(configured, config_valid, agent_has_connection_evidence(client, &paths)).into(), + current_model: current_model.clone(), oauth_configuration, - modification_enabled: modification.enabled, - modification_state: modification.state, - backup_available: modification.backup_available, - applied_model: modification.applied_model, + modification_enabled: configured, + modification_state: if !config_valid { "invalid" } else if configured { "applied" } else { "unconfigured" }.into(), + backup_available: false, + applied_model: current_model, claude_code_model_mappings: (client == AgentClient::ClaudeCode) .then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten()) .flatten(), - claude_desktop_model_mappings: modification.claude_desktop_model_mappings, + claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop).then(|| current_desktop_history_mappings(home)).flatten(), warnings, error, } @@ -1133,20 +1112,11 @@ pub(crate) fn agent_launch_targets( } pub(crate) fn agent_configuration_is_synchronized( - client: AgentClient, - home: &Path, + _client: AgentClient, + _home: &Path, configured: bool, ) -> bool { - if !configured { - return false; - } - if !matches!(client, AgentClient::KimiCode | AgentClient::GrokBuild) { - return true; - } - load_agent_applied_state(client, home) - .ok() - .flatten() - .is_some_and(|state| state.configuration_revision >= AGENT_CONFIGURATION_REVISION) + configured } pub(crate) fn inspect_agent_application( @@ -1263,6 +1233,40 @@ pub(crate) fn inspect_agent_managed_config( } } +// Configuration health and evidence of CPA integration are separate facts. +// A saved history version alone does not mean the current files still use CPA. +pub(crate) fn agent_connection_state(configured: bool, valid: bool, managed: Result) -> &'static str { + if !valid || managed.is_err() { "invalid" } + else if configured { "configured" } + else if managed == Ok(true) { "needs-update" } + else { "not-configured" } +} + +pub(crate) fn agent_has_connection_evidence(client: AgentClient, paths: &[PathBuf]) -> Result { + let active_marker = agent_has_managed_marker(client, paths)?; + if active_marker { return Ok(true); } + // Recognize partial configurations too, even if the selected provider/model was removed. + for path in paths { + let Some(bytes) = read_agent_bytes(path)? else { continue; }; + let content = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?; + let value: serde_json::Value = match path.extension().and_then(|v| v.to_str()) { + Some("toml") => serde_json::to_value(toml::from_str::(content).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?, + Some("yaml" | "yml") => serde_yaml::from_str(content).map_err(|e| e.to_string())?, + _ => json5::from_str(content).map_err(|e| e.to_string())?, + }; + let provider_present = ["/provider/cpa-gui", "/model_providers/cpa-gui", "/providers/cpa-gui", "/models/providers/cpa-gui"] + .iter().any(|pointer| value.pointer(pointer).is_some()); + let selected = ["/model_provider", "/model", "/model/main", "/model/provider", "/default_model", "/models/default", "/agents/defaults/model/primary"] + .iter().filter_map(|pointer| value.pointer(pointer).and_then(serde_json::Value::as_str)) + .any(|model| model == MANAGED_AGENT_PROVIDER_ID || model.starts_with(&format!("{MANAGED_AGENT_PROVIDER_ID}/"))); + let catalog = client == AgentClient::Codex && value.get("model_catalog_json").and_then(serde_json::Value::as_str) == Some(CODEX_MODEL_CATALOG_FILE); + let hermes_provider = client == AgentClient::Hermes && value.get("custom_providers").and_then(serde_json::Value::as_array) + .is_some_and(|items| items.iter().any(|item| item.get("name").and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID))); + if provider_present || selected || catalog || hermes_provider { return Ok(true); } + } + Ok(false) +} + pub(crate) fn agent_has_managed_marker( client: AgentClient, paths: &[PathBuf], diff --git a/src-tauri/src/agents/history.rs b/src-tauri/src/agents/history.rs new file mode 100644 index 0000000..90873b2 --- /dev/null +++ b/src-tauri/src/agents/history.rs @@ -0,0 +1,1053 @@ +//! Configuration history is independent of the old apply/close session state. +use super::*; +use serde_json::Value; +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicU64, Ordering}; + +static SEQUENCE: AtomicU64 = AtomicU64::new(0); +type Images = Vec<(PathBuf, Option>)>; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HistoryFile { + path: PathBuf, + bytes: Option>, + hash: String, + managed_fields: Vec>, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HistoryVersion { + version: u8, + id: String, + client: String, + created_at: String, + source: String, + model: Option, + files: Vec, + legacy_identity: Option, + #[serde(default)] + mappings: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HistorySummary { + id: String, + created_at: String, + source: String, + model: Option, + file_count: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HistoryList { + versions: Vec, + warnings: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HistoryDifference { + file: String, + field: String, + before: String, + after: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HistoryPreview { + revision: String, + differences: Vec, +} + +pub(crate) fn history_paths(client: &str, home: &Path) -> Result, String> { + if client == PI_AGENT_ID { + return Ok(vec![ + pi_provider_config_path(home), + pi_provider_settings_path(home), + ]); + } + let client = AgentClient::parse(client)?; + if !client.supported_platform() { + return Err("当前平台不支持此智能体配置".into()); + } + let paths = agent_config_paths(client, home); + Ok(expected_agent_record_paths(client, &paths)) +} + +pub(crate) fn history_images(paths: &[PathBuf]) -> Result { + paths + .iter() + .map(|path| Ok((path.clone(), read_agent_bytes(path)?))) + .collect() +} + +fn image_hash(bytes: Option<&[u8]>) -> String { + bytes.map(sha256_bytes).unwrap_or_else(|| "missing".into()) +} + +fn image_revision(images: &Images) -> String { + sha256_bytes( + images + .iter() + .map(|(path, bytes)| { + format!("{}:{}", path_to_string(path), image_hash(bytes.as_deref())) + }) + .collect::>() + .join("\n") + .as_bytes(), + ) +} + +fn history_directory(client: &str, paths: &[PathBuf]) -> PathBuf { + let identity = paths + .iter() + .map(|p| path_to_string(p)) + .collect::>() + .join("\n"); + paths[0] + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(".cpa-config-history") + .join(client) + .join(sha256_bytes(identity.as_bytes())) +} + +fn version_path(client: &str, paths: &[PathBuf], id: &str) -> Result { + if id.is_empty() || id.len() > 100 || !id.bytes().all(|c| c.is_ascii_digit() || c == b'-') { + return Err("历史版本编号无效".into()); + } + Ok(history_directory(client, paths).join(format!("{id}.json"))) +} + +fn text(bytes: Option<&[u8]>) -> Result, String> { + bytes + .map(|bytes| std::str::from_utf8(bytes).map_err(|_| "配置不是 UTF-8 文本".to_string())) + .transpose() +} + +fn parse(path: &Path, content: Option<&str>) -> Result { + let Some(content) = content else { + return Ok(serde_json::json!({})); + }; + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + let result = match path.extension().and_then(|v| v.to_str()) { + Some("toml") => toml::from_str::(content) + .map_err(|e| e.to_string()) + .and_then(|v| serde_json::to_value(v).map_err(|e| e.to_string())), + Some("yaml" | "yml") => serde_norway::from_str::(content).map_err(|e| e.to_string()), + _ => json5::from_str::(content).map_err(|e| e.to_string()), + }; + let value = result.map_err(|e| format!("{} 配置格式错误: {e}", path_to_string(path)))?; + if !value.is_object() { + return Err(format!("{} 配置根节点必须是对象", path_to_string(path))); + } + Ok(value) +} + +fn render(path: &Path, value: &Value) -> Result { + match path.extension().and_then(|v| v.to_str()) { + Some("toml") => toml::to_string_pretty(value).map_err(|e| e.to_string()), + Some("yaml" | "yml") => serde_norway::to_string(value).map_err(|e| e.to_string()), + _ => serde_json::to_string_pretty(value) + .map(|v| format!("{v}\n")) + .map_err(|e| e.to_string()), + } +} + +fn collect_changes( + before: Option<&Value>, + after: Option<&Value>, + prefix: Vec, + output: &mut BTreeSet>, +) { + if before == after { + return; + } + let a = before.and_then(Value::as_object); + let b = after.and_then(Value::as_object); + if a.is_some() || b.is_some() { + let keys = a + .into_iter() + .flat_map(|v| v.keys()) + .chain(b.into_iter().flat_map(|v| v.keys())) + .collect::>(); + if keys.is_empty() { + output.insert(prefix); + } else { + for key in keys { + let mut next = prefix.clone(); + next.push(key.clone()); + collect_changes( + a.and_then(|v| v.get(key)), + b.and_then(|v| v.get(key)), + next, + output, + ); + } + } + } else { + output.insert(prefix); + } +} + +fn get<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> { + let mut value = value; + for part in path { + value = value.get(part)?; + } + Some(value) +} + +fn set(value: &mut Value, path: &[String], item: Option<&Value>) { + if path.is_empty() { + return; + } + if !value.is_object() { + *value = serde_json::json!({}); + } + let object = value.as_object_mut().unwrap(); + if path.len() == 1 { + if let Some(item) = item { + object.insert(path[0].clone(), item.clone()); + } else { + object.remove(&path[0]); + } + } else if item.is_some() || object.contains_key(&path[0]) { + let child = object + .entry(path[0].clone()) + .or_insert_with(|| serde_json::json!({})); + set(child, &path[1..], item); + if child.as_object().is_some_and(|v| v.is_empty()) { + object.remove(&path[0]); + } + } +} + +fn special_keys(client: &str, path: &Path) -> Option<&'static [&'static str]> { + let name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + match (client, name) { + ("codex", "auth.json") => Some(&["auth_mode", "OPENAI_API_KEY", "tokens", "last_refresh"]), + ("codex", CODEX_MODEL_CATALOG_FILE) => Some(&["models"]), + ("pi", PI_AGENT_CONFIG_FILE) => Some(&["baseUrl", "apiKey"]), + ("pi", PI_AGENT_SETTINGS_FILE) => Some(&["defaultProvider", "defaultModel"]), + _ => None, + } +} + +fn restore_text( + client: &str, + paths: &[PathBuf], + path: &Path, + current: &str, + target: Option<&str>, +) -> Result, String> { + if let Some(keys) = special_keys(client, path) { + let mut root = parse(path, Some(current))?; + let target_value = parse(path, target)?; + for key in keys { + set(&mut root, &[key.to_string()], target_value.get(key)); + } + return if root.as_object().unwrap().is_empty() && target.is_none() { + Ok(None) + } else { + render(path, &root).map(Some) + }; + } + if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) { + let mut root = parse(path, Some(current))?; + let target = parse(path, target)?; + set(&mut root, &["appliedId".into()], target.get("appliedId")); + let mut entries = root + .get("entries") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let original = target + .get("entries") + .and_then(Value::as_array) + .and_then(|entries| { + entries.iter().find(|e| { + e.get("id").and_then(Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID) + }) + }); + let mut managed = entries + .iter() + .find(|e| e.get("id").and_then(Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID)) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + entries.retain(|e| e.get("id").and_then(Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID)); + for key in ["id", "name"] { + set( + &mut managed, + &[key.into()], + original.and_then(|v| v.get(key)), + ); + } + if managed.as_object().is_some_and(|o| !o.is_empty()) { + managed["id"] = serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID); + entries.push(managed); + } + if entries.is_empty() { + root.as_object_mut().unwrap().remove("entries"); + } else { + root["entries"] = Value::Array(entries); + } + return render(path, &root).map(Some); + } + let parsed = AgentClient::parse(client)?; + let base_paths = if parsed == AgentClient::Codex { + &paths[..1] + } else { + paths + }; + build_agent_session_restored_bytes( + parsed, + base_paths, + path, + Some(current.as_bytes()), + target.map(str::as_bytes), + )? + .map(|v| String::from_utf8(v).map_err(|e| e.to_string())) + .transpose() +} + +fn managed_fields( + client: &str, + paths: &[PathBuf], + path: &Path, + bytes: Option<&[u8]>, +) -> Result>, String> { + if let Some(keys) = special_keys(client, path) { + return Ok(keys.iter().map(|key| vec![key.to_string()]).collect()); + } + let original = text(bytes)?; + let root = parse(path, original)?; + let empty = render(path, &serde_json::json!({}))?; + let stripped = restore_text(client, paths, path, original.unwrap_or(&empty), None)?; + let stripped_root = parse(path, stripped.as_deref())?; + let mut fields = BTreeSet::new(); + collect_changes(Some(&root), Some(&stripped_root), Vec::new(), &mut fields); + if client == "claude-code" { + for key in ["CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_EFFORT_LEVEL"] { + fields.insert(vec!["env".into(), key.into()]); + } + if let Some(env) = root.get("env").and_then(Value::as_object) { + for key in env.keys().filter(|key| { + key.starts_with("ANTHROPIC_DEFAULT_") + || key.starts_with("ANTHROPIC_MODEL_") + || key.starts_with("ANTHROPIC_CUSTOM_MODEL_OPTION") + }) { + fields.insert(vec!["env".into(), key.clone()]); + } + } + } + fields.remove(&Vec::new()); + Ok(fields.into_iter().collect()) +} + +fn snapshot_model(images: &Images) -> Option { + for (path, bytes) in images { + let Ok(root) = parse(path, text(bytes.as_deref()).ok().flatten()) else { + continue; + }; + for pointer in [ + "/model", + "/model/default", + "/model/main", + "/defaultModel", + "/env/ANTHROPIC_MODEL", + "/agent-default-model/model", + "/inferenceModels/0/labelOverride", + ] { + if let Some(value) = root.pointer(pointer).and_then(Value::as_str) { + return Some(value.into()); + } + } + } + None +} + +// Package commands remain responsible for installation. Only their known config files are snapshotted. +pub(crate) fn history_package_operation( + home: &Path, + source: &str, + operation: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let paths = history_paths(PI_AGENT_ID, home)?; + let before = history_images(&paths)?; + let prior = make_version( + PI_AGENT_ID, + &paths, + &before, + &format!("before-{source}"), + None, + ); + write_version(&paths, &prior)?; + if history_images(&paths)? != before { + return Err("备份期间配置发生变化,请刷新后重试".into()); + } + let result = operation().and_then(|_| { + let after = history_images(&paths)?; + write_version( + &paths, + &make_version(PI_AGENT_ID, &paths, &after, source, None), + ) + }); + if let Err(error) = result { + return match history_write_images(PI_AGENT_ID, &before) { + Ok(()) => Err(error), + Err(rollback) => Err(format!( + "{error};配置回滚失败: {rollback},原内容仍在配置历史中" + )), + }; + } + Ok(()) +} + +fn make_version( + client: &str, + paths: &[PathBuf], + images: &Images, + source: &str, + model: Option, +) -> HistoryVersion { + let now = chrono::Utc::now(); + HistoryVersion { + version: 1, + id: format!( + "{}-{}-{}", + now.format("%Y%m%d%H%M%S%f"), + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ), + client: client.into(), + created_at: now.to_rfc3339(), + source: source.into(), + model: model.or_else(|| snapshot_model(images)), + legacy_identity: None, + mappings: None, + files: images + .iter() + .map(|(path, bytes)| HistoryFile { + path: path.clone(), + bytes: bytes.clone(), + hash: image_hash(bytes.as_deref()), + managed_fields: managed_fields(client, paths, path, bytes.as_deref()) + .unwrap_or_default(), + }) + .collect(), + } +} + +fn write_version(paths: &[PathBuf], version: &HistoryVersion) -> Result<(), String> { + let path = version_path(&version.client, paths, &version.id)?; + let parent = path.parent().unwrap(); + fs::create_dir_all(parent).map_err(|e| format!("创建配置历史目录失败: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&path) + .map_err(|e| format!("创建配置历史失败: {e}"))?; + let bytes = serde_json::to_vec(version).map_err(|e| e.to_string())?; + if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) { + drop(file); + let _ = fs::remove_file(path); + return Err(format!("保存配置历史失败: {error}")); + } + Ok(()) +} + +fn read_version(client: &str, paths: &[PathBuf], id: &str) -> Result { + let bytes = + fs::read(version_path(client, paths, id)?).map_err(|e| format!("读取配置历史失败: {e}"))?; + let version: HistoryVersion = + serde_json::from_slice(&bytes).map_err(|e| format!("配置历史损坏: {e}"))?; + if version.version != 1 + || version.client != client + || version.id != id + || version.files.len() != paths.len() + || version.files.iter().zip(paths).any(|(file, path)| { + file.path != *path || file.hash != image_hash(file.bytes.as_deref()) + }) + { + return Err("配置历史路径或内容校验失败".into()); + } + Ok(version) +} + +fn versions(client: &str, paths: &[PathBuf]) -> Result<(Vec, Vec), String> { + let dir = history_directory(client, paths); + if !dir.exists() { + return Ok((Vec::new(), Vec::new())); + } + let mut result = Vec::new(); + let mut warnings = Vec::new(); + for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { + let path = entry.map_err(|e| e.to_string())?.path(); + if path.extension().and_then(|v| v.to_str()) != Some("json") { + continue; + } + let Some(id) = path.file_stem().and_then(|v| v.to_str()) else { + continue; + }; + match read_version(client, paths, id) { + Ok(version) => result.push(version), + Err(_) => warnings.push(format!("历史版本 {id} 校验失败,已保留但不可恢复")), + } + } + result.sort_by(|a, b| b.id.cmp(&a.id)); + Ok((result, warnings)) +} + +pub(crate) fn history_write_images(client: &str, images: &Images) -> Result<(), String> { + for (path, bytes) in images { + if read_agent_bytes(path)? == *bytes { + continue; + } + if let Some(bytes) = bytes { + if client == PI_AGENT_ID { + write_bytes_directly(path, bytes)?; + } else { + write_agent_configuration_file(AgentClient::parse(client)?, path, bytes)?; + } + } else if path.exists() { + fs::remove_file(path).map_err(|e| format!("删除配置失败: {e}"))?; + } + } + Ok(()) +} + +pub(crate) fn commit_history( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + source: &str, + model: Option, +) -> Result { + commit_history_with_mappings(client, paths, before, after, source, model, None) +} + +fn commit_history_with_mappings( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + source: &str, + model: Option, + mappings: Option, +) -> Result { + commit_history_with_writer( + client, + paths, + before, + after, + source, + model, + mappings, + &mut history_write_images, + ) +} + +fn commit_history_with_writer( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + source: &str, + model: Option, + mappings: Option, + writer: &mut impl FnMut(&str, &Images) -> Result<(), String>, +) -> Result { + if before.len() != paths.len() + || after.len() != paths.len() + || before + .iter() + .zip(paths) + .any(|((p, _), expected)| p != expected) + || after + .iter() + .zip(paths) + .any(|((p, _), expected)| p != expected) + { + return Err("配置更新路径不匹配".into()); + } + let changed = before + .iter() + .zip(after) + .filter(|((_, a), (_, b))| a != b) + .map(|((p, _), _)| path_to_string(p)) + .collect::>(); + if image_revision(&history_images(paths)?) != image_revision(before) { + return Err("配置已被其他程序修改,请刷新后重试".into()); + } + if changed.is_empty() { + return Ok(action_result( + "unchanged", + true, + model, + Vec::new(), + Vec::new(), + )); + } + let mut prior = make_version(client, paths, before, &format!("before-{source}"), None); + prior.mappings = matching_desktop_mappings(client, paths, before); + let mut next = make_version(client, paths, after, source, model.clone()); + next.mappings = mappings.or_else(|| matching_desktop_mappings(client, paths, after)); + write_version(paths, &prior)?; + if image_revision(&history_images(paths)?) != image_revision(before) { + return Err("备份期间配置发生变化,请刷新后重试".into()); + } + let transaction = writer(client, after).and_then(|_| write_version(paths, &next)); + if let Err(error) = transaction { + return match writer(client, before) { + Ok(()) => Err(error), + Err(rollback) => Err(format!( + "{error};回滚失败: {rollback},原内容仍在配置历史中" + )), + }; + } + let mut result = action_result("updated", true, model, changed, Vec::new()); + result.history_version = Some(next.id); + Ok(result) +} + +pub(crate) fn history_updates( + client: &str, + home: &Path, + before: &Images, + updates: &[AgentFileUpdate], + source: &str, + model: Option, + mappings: Option<&ClaudeDesktopModelMappings>, +) -> Result { + let paths = history_paths(client, home)?; + if source != "default" { + for (path, bytes) in before { + parse(path, text(bytes.as_deref())?)?; + } + } + let mut after = before.clone(); + for update in updates { + let entry = after + .iter_mut() + .find(|(path, _)| path == &update.path) + .ok_or("配置更新路径不匹配")?; + let mut rendered = update.after.clone(); + if client == "codex" + && source != "default" + && update.path.file_name().and_then(|v| v.to_str()) == Some(CODEX_MODEL_CATALOG_FILE) + { + let mut root = parse(&update.path, text(entry.1.as_deref())?)?; + let generated = parse(&update.path, Some(&rendered))?; + set(&mut root, &["models".into()], generated.get("models")); + if root != generated { + rendered = render(&update.path, &root)?; + } + } + // Ignore format-only changes; preserve exact existing bytes on a semantic no-op. + let same = text(entry.1.as_deref()) + .ok() + .flatten() + .and_then(|value| parse(&update.path, Some(value)).ok()) + .zip(parse(&update.path, Some(&rendered)).ok()) + .is_some_and(|(a, b)| a == b); + if !same { + entry.1 = Some(rendered.into_bytes()); + } + } + commit_history_with_mappings( + client, + &paths, + before, + &after, + source, + model, + mappings.cloned(), + ) +} + +fn restored_images( + client: &str, + paths: &[PathBuf], + current: &Images, + version: &HistoryVersion, +) -> Result { + current + .iter() + .zip(&version.files) + .map(|((path, bytes), target)| { + let root = parse(path, text(bytes.as_deref())?)?; + let target_root = parse(path, text(target.bytes.as_deref())?)?; + // Derive ownership from trusted restoration rules, not arbitrary paths from a history file. + let fields = managed_fields(client, paths, path, bytes.as_deref())? + .into_iter() + .chain(managed_fields( + client, + paths, + path, + target.bytes.as_deref(), + )?) + .collect::>(); + let mut projected = serde_json::json!({}); + for field in &fields { + set(&mut projected, field, get(&target_root, field)); + } + // Provider lists mix app-owned and user entries. Project only our provider. + if client == "hermes" { + if let Some(providers) = projected + .get_mut("custom_providers") + .and_then(Value::as_array_mut) + { + providers.retain(|p| { + p.get("name").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) + }); + for provider in providers { + if let Some(object) = provider.as_object_mut() { + object.retain(|key, _| { + ["name", "base_url", "api_key", "api_mode", "model", "models"] + .contains(&key.as_str()) + }); + } + } + } + } + let projected_text = render(path, &projected)?; + let empty = render(path, &serde_json::json!({}))?; + let base = text(bytes.as_deref())?.unwrap_or(&empty); + let restored = restore_text(client, paths, path, base, Some(&projected_text))?; + let mut restored_root = parse(path, restored.as_deref())?; + // Include explicitly managed environment fields not covered by legacy restore helpers. + if client == "claude-code" { + for field in &fields { + set(&mut restored_root, field, get(&target_root, field)); + } + } + let rendered = if parse(path, restored.as_deref())? != restored_root { + Some(render(path, &restored_root)?) + } else { + restored + }; + let result = if root == restored_root { + bytes.clone() + } else if restored_root.as_object().is_some_and(|v| v.is_empty()) + && target.bytes.is_none() + { + None + } else { + rendered.map(String::into_bytes) + }; + Ok((path.clone(), result)) + }) + .collect() +} + +fn hidden(value: Option<&Value>, field: &[String]) -> String { + let Some(value) = value else { + return "—".into(); + }; + if field.iter().any(|part| { + let key = part.to_lowercase(); + ["key", "token", "auth", "secret", "credential", "password"] + .iter() + .any(|s| key.contains(s)) + }) { + return "••••••".into(); + } + // Arrays/objects may contain credentials; display only their type and size. + if let Some(array) = value.as_array() { + return format!("[{} items]", array.len()); + } + if value.is_object() { + return "{…}".into(); + } + let rendered = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + if rendered.contains("://") && (rendered.contains('@') || rendered.contains('?')) { + return "••••••".into(); + } + rendered.chars().take(180).collect() +} + +fn preview( + client: &str, + paths: &[PathBuf], + id: &str, +) -> Result<(HistoryPreview, Images, Images), String> { + let version = read_version(client, paths, id)?; + let current = history_images(paths)?; + let after = restored_images(client, paths, ¤t, &version)?; + let mut differences = Vec::new(); + for ((path, bytes), (_, next)) in current.iter().zip(&after) { + let root = parse(path, text(bytes.as_deref())?)?; + let target = parse(path, text(next.as_deref())?)?; + let mut fields = BTreeSet::new(); + collect_changes(Some(&root), Some(&target), Vec::new(), &mut fields); + for field in fields { + differences.push(HistoryDifference { + file: path_to_string(path), + field: field.join("."), + before: hidden(get(&root, &field), &field), + after: hidden(get(&target, &field), &field), + }); + } + } + Ok(( + HistoryPreview { + revision: image_revision(¤t), + differences, + }, + current, + after, + )) +} + +#[tauri::command] +pub(crate) fn list_agent_config_history( + app: tauri::AppHandle, + client: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let paths = history_paths(&client, &home)?; + let mut warnings = import_legacy_history(&client, &home, &paths)?; + let (entries, errors) = versions(&client, &paths)?; + warnings.extend(errors); + Ok(HistoryList { + versions: entries + .into_iter() + .map(|v| HistorySummary { + id: v.id, + created_at: v.created_at, + source: v.source, + model: v.model, + file_count: v.files.len(), + }) + .collect(), + warnings, + }) +} + +#[tauri::command] +pub(crate) fn preview_agent_config_history( + app: tauri::AppHandle, + client: String, + id: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + Ok(preview(&client, &history_paths(&client, &home)?, &id)?.0) +} + +#[tauri::command] +pub(crate) fn restore_agent_config_history( + app: tauri::AppHandle, + client: String, + id: String, + revision: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let paths = history_paths(&client, &home)?; + let (preview, before, after) = preview(&client, &paths, &id)?; + if preview.revision != revision { + return Err("预览后配置发生变化,请重新选择历史版本".into()); + } + let version = read_version(&client, &paths, &id)?; + let result = commit_history_with_mappings( + &client, + &paths, + &before, + &after, + "restore", + version.model, + version.mappings, + )?; + app.state::().clear()?; + Ok(result) +} + +pub(crate) fn import_legacy_history( + client: &str, + home: &Path, + paths: &[PathBuf], +) -> Result, String> { + if client == PI_AGENT_ID { + return Ok(Vec::new()); + } + let parsed = AgentClient::parse(client)?; + let state_path = agent_state_path(&agent_config_paths(parsed, home))?; + let raw = read_agent_bytes(&state_path)?.or_else(|| { + CODEX_APPLIED_STATES.lock().ok().and_then(|states| { + states + .get(&state_path) + .and_then(|state| serde_json::to_vec(state).ok()) + }) + }); + let Some(raw) = raw else { + let found = paths.iter().any(|path| { + latest_dated_agent_backup_path(path) + .ok() + .flatten() + .is_some() + }); + return Ok(if found { + vec!["发现没有可验证记录的旧备份,已保留原文件".into()] + } else { + Vec::new() + }); + }; + let identity = sha256_bytes(&raw); + if versions(client, paths)? + .0 + .iter() + .any(|v| v.legacy_identity.as_deref() == Some(&identity)) + { + return Ok(Vec::new()); + } + let mut images = Vec::new(); + if let Ok(record) = serde_json::from_slice::(&raw) { + if validate_agent_record(parsed, &agent_config_paths(parsed, home), &record).is_err() { + return Ok(vec!["旧备份记录无法验证,已保留原文件".into()]); + } + for path in paths { + let Some(file) = record.files.iter().find(|f| &f.path == path) else { + return Ok(vec!["旧备份文件范围不完整,已保留原文件".into()]); + }; + let bytes = if file.existed_before { + read_agent_bytes(&file.backup_path)? + } else { + None + }; + if file.existed_before + && (bytes.is_none() + || file.original_sha256.as_deref() != Some(&image_hash(bytes.as_deref()))) + { + return Ok(vec!["旧备份内容校验失败,已保留原文件".into()]); + } + images.push((path.clone(), bytes)); + } + } else if let Ok(state) = serde_json::from_slice::(&raw) { + if validate_agent_applied_state(parsed, &agent_config_paths(parsed, home), &state).is_err() + { + return Ok(vec!["旧应用状态无法验证,已保留原文件".into()]); + } + for path in paths { + let Some(file) = state.backup_files.iter().find(|f| &f.path == path) else { + return Ok(vec!["旧备份文件范围不完整,已保留原文件".into()]); + }; + let bytes = if file.existed_before { + read_agent_bytes(&file.backup_path)? + } else { + None + }; + if file.existed_before && bytes.is_none() { + return Ok(vec!["旧备份文件缺失,已保留原记录".into()]); + } + images.push((path.clone(), bytes)); + } + } else { + return Ok(vec!["旧备份记录无法读取,已保留原文件".into()]); + } + let mut version = make_version(client, paths, &images, "legacy", None); + version.legacy_identity = Some(identity); + write_version(paths, &version)?; + Ok(Vec::new()) +} + +fn matching_desktop_mappings( + client: &str, + paths: &[PathBuf], + images: &Images, +) -> Option { + if client != "claude-desktop" { + return None; + } + let profile = images.get(2)?; + let current = parse(&profile.0, text(profile.1.as_deref()).ok()?).ok()?; + let models = current.get("inferenceModels")?; + versions(client, paths) + .ok()? + .0 + .into_iter() + .find_map(|version| { + let mappings = version.mappings?; + let saved = version.files.get(2)?; + let root = parse(&saved.path, text(saved.bytes.as_deref()).ok()?).ok()?; + (root.get("inferenceModels") == Some(models)).then_some(mappings) + }) +} + +pub(crate) fn current_desktop_history_mappings(home: &Path) -> Option { + let paths = history_paths("claude-desktop", home).ok()?; + let images = history_images(&paths).ok()?; + if let Some(mappings) = matching_desktop_mappings("claude-desktop", &paths, &images) { + return Some(mappings); + } + // Compatibility metadata is read without invoking the old state migration/write path. + let path = agent_state_path(&paths).ok()?; + let state: AgentAppliedState = serde_json::from_slice(&fs::read(path).ok()?).ok()?; + validate_agent_applied_state(AgentClient::ClaudeDesktop, &paths, &state).ok()?; + state.claude_desktop_model_mappings +} + +#[cfg(test)] +pub(crate) fn test_restore_history(client: AgentClient, home: &Path, source: &str) { + let paths = history_paths(client.id(), home).unwrap(); + let entries = versions(client.id(), &paths).unwrap().0; + let version = entries + .iter() + .rev() + .find(|v| v.source == source) + .expect("history source"); + let (_, before, after) = preview(client.id(), &paths, &version.id).unwrap(); + commit_history( + client.id(), + &paths, + &before, + &after, + "restore", + version.model.clone(), + ) + .unwrap(); +} +#[cfg(test)] +pub(crate) fn test_history_count(client: AgentClient, home: &Path) -> usize { + versions(client.id(), &history_paths(client.id(), home).unwrap()) + .unwrap() + .0 + .len() +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/agents/history/tests.rs b/src-tauri/src/agents/history/tests.rs new file mode 100644 index 0000000..3b065f4 --- /dev/null +++ b/src-tauri/src/agents/history/tests.rs @@ -0,0 +1,407 @@ +use super::*; +struct Home(PathBuf); +impl Home { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "cpa-history-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} +impl Drop for Home { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn save(path: &Path, value: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, value).unwrap(); +} +fn restore_case(client: &str, originals: &[&str], currents: &[&str]) -> Vec { + let home = Home::new(); + let paths = history_paths(client, &home.0).unwrap(); + for (p, v) in paths.iter().zip(originals) { + save(p, v); + } + let version = make_version( + client, + &paths, + &history_images(&paths).unwrap(), + "update", + None, + ); + for (p, v) in paths.iter().zip(currents) { + save(p, v); + } + let after = + restored_images(client, &paths, &history_images(&paths).unwrap(), &version).unwrap(); + after + .iter() + .map(|(p, b)| parse(p, text(b.as_deref()).unwrap()).unwrap()) + .collect() +} +#[test] +fn codex_restore_keeps_new_custom_settings_and_does_not_resurrect_deleted_settings() { + let result = restore_case("codex", &[ + "model = 'old'\nremoved_custom = true\n[model_providers.cpa-gui]\nbase_url = 'http://old'\ncustom_deleted = true\n", + r#"{"models":[],"deleted_metadata":1}"#, + r#"{"tokens":{"access_token":"old-secret"},"deleted_custom":true}"#, + ], &[ + "# Keep comment\nmodel = 'new'\nnew_custom = 42\n[model_providers.cpa-gui]\nbase_url = 'http://new'\ncustom_added = true\n", + r#"{"models":[{"slug":"new"}],"new_metadata":2}"#, + r#"{"OPENAI_API_KEY":"new-secret","new_custom":42}"#, + ]); + assert_eq!(result[0]["model"], "old"); + assert_eq!(result[0]["new_custom"], 42); + assert!(result[0].get("removed_custom").is_none()); + assert!(result[0]["model_providers"]["cpa-gui"] + .get("custom_deleted") + .is_none()); + assert_eq!( + result[0]["model_providers"]["cpa-gui"]["custom_added"], + true + ); + assert_eq!(result[1]["new_metadata"], 2); + assert!(result[1].get("deleted_metadata").is_none()); + assert_eq!(result[2]["tokens"]["access_token"], "old-secret"); + assert!(result[2].get("OPENAI_API_KEY").is_none()); + assert_eq!(result[2]["new_custom"], 42); + assert!(result[2].get("deleted_custom").is_none()); +} +#[test] +fn claude_restore_removes_absent_managed_fields_and_preserves_custom_env() { + let result = restore_case( + "claude-code", + &[r#"{"env":{"ANTHROPIC_MODEL":"old","DELETED":"x"}}"#], + &[ + r#"{"env":{"ANTHROPIC_MODEL":"new","ANTHROPIC_DEFAULT_SONNET_MODEL":"new","CLAUDE_CODE_SUBAGENT_MODEL":"new","CUSTOM":"keep"}}"#, + ], + ); + assert_eq!(result[0]["env"]["ANTHROPIC_MODEL"], "old"); + assert_eq!(result[0]["env"]["CUSTOM"], "keep"); + for key in [ + "DELETED", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + ] { + assert!(result[0]["env"].get(key).is_none()); + } +} +#[test] +fn hermes_restore_does_not_resurrect_deleted_unmanaged_provider() { + let result = restore_case("hermes", &["custom_providers:\n - name: personal\n api_key: custom\n - name: cpa-gui\n api_key: old\n custom_deleted: true\n"], &["theme: dark\n"]); + assert_eq!(result[0]["theme"], "dark"); + let providers = result[0]["custom_providers"].as_array().unwrap(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0]["name"], "cpa-gui"); + assert!(providers[0].get("custom_deleted").is_none()); +} +#[test] +fn pi_restore_leaves_packages_and_custom_values_untouched() { + let result = restore_case( + "pi", + &[ + r#"{"apiKey":"old"}"#, + r#"{"defaultModel":"old","packages":["old-package"]}"#, + ], + &[ + r#"{"apiKey":"new","custom":1}"#, + r#"{"defaultModel":"new","packages":["new-package"],"theme":"dark"}"#, + ], + ); + assert_eq!(result[0]["apiKey"], "old"); + assert_eq!(result[0]["custom"], 1); + assert_eq!(result[1]["defaultModel"], "old"); + assert_eq!(result[1]["packages"][0], "new-package"); +} +#[test] +fn missing_files_restore_and_invalid_current_file_is_rejected() { + let home = Home::new(); + let paths = history_paths("codex", &home.0).unwrap(); + let absent = history_images(&paths).unwrap(); + let missing = make_version("codex", &paths, &absent, "before-update", None); + save(&paths[0], "model = 'new'\n"); + let after = + restored_images("codex", &paths, &history_images(&paths).unwrap(), &missing).unwrap(); + assert!(after[0].1.is_none()); + let saved = make_version( + "codex", + &paths, + &history_images(&paths).unwrap(), + "update", + None, + ); + let after = restored_images("codex", &paths, &absent, &saved).unwrap(); + assert!(after[0].1.is_some()); + save(&paths[0], "{{invalid"); + assert!(restored_images("codex", &paths, &history_images(&paths).unwrap(), &saved).is_err()); +} +#[test] +fn backup_failure_and_external_conflict_leave_current_files_untouched() { + let home = Home::new(); + let paths = history_paths("codex", &home.0).unwrap(); + save(&paths[0], "model = 'old'\n"); + let before = history_images(&paths).unwrap(); + let mut after = before.clone(); + after[0].1 = Some(b"model = 'new'\n".to_vec()); + save(&paths[0], "model = 'external'\n"); + assert!( + commit_history("codex", &paths, &before, &after, "update", None) + .err() + .unwrap() + .contains("其他程序") + ); + assert_eq!( + fs::read_to_string(&paths[0]).unwrap(), + "model = 'external'\n" + ); + fs::write(&paths[0], before[0].1.as_ref().unwrap()).unwrap(); + let dir = history_directory("codex", &paths); + fs::create_dir_all(dir.parent().unwrap()).unwrap(); + fs::write(&dir, "blocked").unwrap(); + assert!(commit_history("codex", &paths, &before, &after, "update", None).is_err()); + assert_eq!(history_images(&paths).unwrap(), before); +} +#[test] +fn partial_multi_file_write_rolls_back_and_keeps_recovery_snapshot() { + let home = Home::new(); + let paths = history_paths("codex", &home.0).unwrap(); + save(&paths[0], "model = 'old'\n"); + let before = history_images(&paths).unwrap(); + let mut after = before.clone(); + after[0].1 = Some(b"model = 'new'\n".to_vec()); + after[1].1 = Some(b"{\"models\":[]}".to_vec()); + let mut first = true; + let result = commit_history_with_writer( + "codex", + &paths, + &before, + &after, + "update", + None, + None, + &mut |client, images| { + if first { + first = false; + history_write_images(client, &vec![images[0].clone()])?; + return Err("injected second-file failure".into()); + } + history_write_images(client, images) + }, + ); + assert!(result.is_err()); + assert_eq!(history_images(&paths).unwrap(), before); + assert_eq!(versions("codex", &paths).unwrap().0.len(), 1); +} +#[test] +fn preview_masks_auth_and_revision_tracks_external_changes_and_versions_are_validated() { + let home = Home::new(); + let paths = history_paths("codex", &home.0).unwrap(); + save(&paths[2], r#"{"OPENAI_API_KEY":"secret-old"}"#); + let version = make_version( + "codex", + &paths, + &history_images(&paths).unwrap(), + "update", + None, + ); + write_version(&paths, &version).unwrap(); + save(&paths[2], r#"{"OPENAI_API_KEY":"secret-new"}"#); + let first = preview("codex", &paths, &version.id).unwrap().0; + assert_eq!(first.differences[0].before, "••••••"); + assert_eq!(first.differences[0].after, "••••••"); + save(&paths[0], "custom = true\n"); + assert_ne!( + first.revision, + preview("codex", &paths, &version.id).unwrap().0.revision + ); + assert!(read_version("codex", &paths, "../escape").is_err()); + let mut tampered = version.clone(); + tampered.files[0].path = home.0.join("outside"); + fs::write( + version_path("codex", &paths, &version.id).unwrap(), + serde_json::to_vec(&tampered).unwrap(), + ) + .unwrap(); + assert!(read_version("codex", &paths, &version.id).is_err()); +} +#[test] +fn verified_legacy_import_is_deduplicated_and_keeps_original_backup() { + let home = Home::new(); + let paths = history_paths("opencode", &home.0).unwrap(); + save(&paths[0], r#"{"model":"current"}"#); + let backup = agent_backup_path(&paths[0]).unwrap(); + save(&backup, r#"{"model":"old"}"#); + let record = AgentModificationRecord { + version: AGENT_MODIFICATION_STATE_VERSION, + client: "opencode".into(), + phase: AGENT_PHASE_ACTIVE.into(), + model: "current".into(), + files: vec![AgentModificationFile { + path: paths[0].clone(), + backup_path: backup.clone(), + existed_before: true, + original_sha256: Some(sha256_bytes(&fs::read(&backup).unwrap())), + managed_sha256: String::new(), + }], + }; + save( + &agent_state_path(&paths).unwrap(), + &serde_json::to_string(&record).unwrap(), + ); + assert!(import_legacy_history("opencode", &home.0, &paths) + .unwrap() + .is_empty()); + assert!(import_legacy_history("opencode", &home.0, &paths) + .unwrap() + .is_empty()); + assert_eq!(versions("opencode", &paths).unwrap().0.len(), 1); + assert!(backup.exists()); + let mut bad = record; + bad.files[0].original_sha256 = Some("wrong".into()); + save( + &agent_state_path(&paths).unwrap(), + &serde_json::to_string(&bad).unwrap(), + ); + assert!(!import_legacy_history("opencode", &home.0, &paths) + .unwrap() + .is_empty()); + assert!(backup.exists()); +} +#[test] +fn semantic_noop_preserves_exact_comments_and_does_not_save_history() { + let home = Home::new(); + let paths = history_paths("opencode", &home.0).unwrap(); + save(&paths[0], "// Keep this comment\n{model: 'old'}\n"); + let before = history_images(&paths).unwrap(); + let result = history_updates( + "opencode", + &home.0, + &before, + &[AgentFileUpdate { + path: paths[0].clone(), + after: r#"{"model":"old"}"#.into(), + }], + "update", + None, + None, + ) + .unwrap(); + assert_eq!(result.outcome, "unchanged"); + assert_eq!(history_images(&paths).unwrap(), before); + assert_eq!(versions("opencode", &paths).unwrap().0.len(), 0); + let target = make_version("opencode", &paths, &before, "update", None); + save( + &paths[0], + "// Current comment\n{model: 'new',custom: true}\n", + ); + let after = restored_images( + "opencode", + &paths, + &history_images(&paths).unwrap(), + &target, + ) + .unwrap(); + assert!(text(after[0].1.as_deref()) + .unwrap() + .unwrap() + .contains("// Current comment")); +} +#[test] +fn catalog_update_preserves_custom_metadata_and_rejects_invalid_existing_catalog() { + let home = Home::new(); + let paths = history_paths("codex", &home.0).unwrap(); + save(&paths[1], r#"{"models":[],"custom":42}"#); + let updates = vec![AgentFileUpdate { + path: paths[1].clone(), + after: r#"{"models":[{"slug":"new"}]}"#.into(), + }]; + history_updates( + "codex", + &home.0, + &history_images(&paths).unwrap(), + &updates, + "sync", + None, + None, + ) + .unwrap(); + assert_eq!( + parse(&paths[1], Some(&fs::read_to_string(&paths[1]).unwrap())).unwrap()["custom"], + 42 + ); + save(&paths[1], "invalid"); + assert!(history_updates( + "codex", + &home.0, + &history_images(&paths).unwrap(), + &updates, + "update", + None, + None + ) + .is_err()); + assert_eq!(fs::read_to_string(&paths[1]).unwrap(), "invalid"); +} +#[test] +fn pi_package_changes_are_backed_up_and_failures_roll_back_only_config_files() { + let home = Home::new(); + let paths = history_paths("pi", &home.0).unwrap(); + save(&paths[1], r#"{"packages":[]}"#); + history_package_operation(&home.0, "plugin-install", || { + save(&paths[1], r#"{"packages":["installed"]}"#); + Ok(()) + }) + .unwrap(); + assert_eq!(versions("pi", &paths).unwrap().0.len(), 2); + let before = history_images(&paths).unwrap(); + let package = home.0.join("package-installed"); + assert!(history_package_operation(&home.0, "plugin-update", || { + save(&paths[1], "{}"); + save(&package, "package is outside config history"); + Err("package failure".into()) + }) + .is_err()); + assert_eq!(history_images(&paths).unwrap(), before); + assert!(package.exists()); +} +#[test] +fn desktop_profile_index_restores_only_managed_entry_fields() { + let home = Home::new(); + let paths = history_paths("claude-desktop", &home.0).unwrap(); + let old = serde_json::json!({"appliedId":CLAUDE_DESKTOP_PROFILE_ID,"entries":[{"id":CLAUDE_DESKTOP_PROFILE_ID,"name":"old","deleted_custom":true},{"id":"deleted-personal"}]}); + save(&paths[3], &old.to_string()); + let version = make_version( + "claude-desktop", + &paths, + &history_images(&paths).unwrap(), + "update", + None, + ); + let current = serde_json::json!({"entries":[{"id":CLAUDE_DESKTOP_PROFILE_ID,"name":"new","new_custom":42},{"id":"new-personal"}]}); + save(&paths[3], ¤t.to_string()); + let after = restored_images( + "claude-desktop", + &paths, + &history_images(&paths).unwrap(), + &version, + ) + .unwrap(); + let value = parse(&paths[3], text(after[3].1.as_deref()).unwrap()).unwrap(); + let entries = value["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["id"], "new-personal"); + assert_eq!(entries[1]["name"], "old"); + assert_eq!(entries[1]["new_custom"], 42); + assert!(entries[1].get("deleted_custom").is_none()); +} +#[test] +fn directory_at_config_path_is_an_error() { + let home = Home::new(); + let paths = history_paths("codex", &home.0).unwrap(); + fs::create_dir_all(&paths[0]).unwrap(); + assert!(history_images(&paths).is_err()); +} diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 34adfdf..21824a7 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -305,7 +305,7 @@ pub(crate) fn load_agent_applied_state( } pub(crate) fn read_agent_bytes(path: &Path) -> Result>, String> { - if !path.is_file() { + if !path.exists() { return Ok(None); } fs::read(path) @@ -1402,119 +1402,11 @@ pub(crate) fn commit_agent_configuration( model: &str, updates: &[AgentFileUpdate], outcome: &str, - claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, + _claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { - let base_paths = agent_config_paths(client, home); - let state_path = agent_state_path(&base_paths)?; - let existing_state = load_agent_applied_state(client, home)?; - let previous_state = read_agent_bytes(&state_path)?; - let mut legacy_backups = previous_state - .as_deref() - .and_then(|content| std::str::from_utf8(content).ok()) - .map(legacy_agent_backup_paths) - .unwrap_or_default(); - legacy_backups.extend( - expected_agent_record_paths(client, &base_paths) - .iter() - .filter_map(|path| agent_backup_path(path).ok()), - ); - legacy_backups.sort(); - legacy_backups.dedup(); - - let mut snapshots = updates - .iter() - .map(|update| Ok((update.path.clone(), read_agent_bytes(&update.path)?))) - .collect::, String>>()?; - snapshots.push((state_path.clone(), previous_state)); - - let mut created_backup_paths = Vec::new(); - let backup_files = - if let Some(state) = existing_state.filter(|state| !state.backup_files.is_empty()) { - let mut backup_files = state.backup_files; - let missing_updates = updates - .iter() - .filter(|update| !backup_files.iter().any(|file| file.path == update.path)) - .map(|update| AgentFileUpdate { - path: update.path.clone(), - after: update.after.clone(), - }) - .collect::>(); - let added_backups = prepare_agent_session_backups(client, &missing_updates)?; - created_backup_paths.extend( - added_backups - .iter() - .filter(|file| file.existed_before) - .map(|file| file.backup_path.clone()), - ); - backup_files.extend(added_backups); - backup_files - } else { - let backup_files = prepare_agent_session_backups(client, updates)?; - created_backup_paths.extend( - backup_files - .iter() - .filter(|file| file.existed_before) - .map(|file| file.backup_path.clone()), - ); - backup_files - }; - - let transaction = (|| -> Result, String> { - let mut changed = Vec::new(); - for update in updates { - let next = update.after.as_bytes(); - if read_agent_bytes(&update.path)?.as_deref() == Some(next) { - continue; - } - write_agent_configuration_file(client, &update.path, next)?; - changed.push(path_to_string(&update.path)); - } - let state = AgentAppliedState { - version: AGENT_APPLIED_STATE_VERSION, - client: client.id().to_string(), - model: model.to_string(), - configuration_revision: AGENT_CONFIGURATION_REVISION, - claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop) - .then(|| claude_desktop_model_mappings.cloned()) - .flatten(), - backup_files: backup_files.clone(), - updated_at_unix: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - }; - write_agent_applied_state(&state_path, &state)?; - Ok(changed) - })(); - - match transaction { - Ok(changed) => { - for backup_path in legacy_backups { - if backup_path.is_file() { - let _ = fs::remove_file(backup_path); - } - } - Ok(action_result( - outcome, - true, - Some(model.to_string()), - changed, - Vec::new(), - )) - } - Err(error) => { - let rollback = restore_agent_snapshots_direct(&snapshots); - for backup_path in created_backup_paths { - if backup_path.is_file() { - let _ = fs::remove_file(backup_path); - } - } - match rollback { - Ok(()) => Err(error), - Err(rollback_error) => Err(format!("{error};回滚配置失败: {rollback_error}")), - } - } - } + let paths = history_paths(client.id(), home)?; + let before = history_images(&paths)?; + history_updates(client.id(), home, &before, updates, outcome, Some(model.to_string()), _claude_desktop_model_mappings) } pub(crate) fn sync_codex_model_catalog_if_configured( @@ -1529,6 +1421,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + let before = history_images(&history_paths(client.id(), home)?)?; let (configured, current_model, _) = inspect_agent_managed_config(client, &paths, port, api_key)?; if !configured { @@ -1585,25 +1478,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured( return Ok(false); } - if load_agent_applied_state(client, home)?.is_none() { - for path in [paths[0].clone(), codex_model_catalog_path(home)] { - if !updates.iter().any(|update| update.path == path) { - let after = fs::read_to_string(&path) - .map_err(|error| format!("读取 Codex 配置以创建备份失败: {error}"))?; - updates.push(AgentFileUpdate { path, after }); - } - } - } - updates.sort_by_key(|update| update.path != paths[0]); - - let result = commit_agent_configuration( - client, - home, - next_model.unwrap_or_default(), - &updates, - "updated", - None, - )?; + let result = history_updates(client.id(), home, &before, &updates, "sync", next_model.map(str::to_string), None)?; Ok(!result.changed_files.is_empty()) } @@ -1641,16 +1516,10 @@ pub(crate) fn apply_agent_configuration_with_oauth( model: &str, options: AgentConfigurationOptions<'_>, ) -> Result { - let claude_desktop_model_mappings = options.claude_desktop_model_mappings; + let before = history_images(&history_paths(client.id(), home)?)?; + let mappings = options.claude_desktop_model_mappings; let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options)?; - commit_agent_configuration( - client, - home, - model, - &updates, - "applied", - claude_desktop_model_mappings, - ) + history_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings) } #[cfg(test)] @@ -1709,6 +1578,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( claude_code_model_mappings, claude_desktop_model_mappings, } = request; + let before = history_images(&history_paths(client.id(), home)?)?; if client == AgentClient::DeepSeekHarness { let updates = build_agent_updates_with_oauth( client, @@ -1724,14 +1594,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( claude_desktop_model_mappings, }, )?; - return commit_agent_configuration( - client, - home, - model, - &updates, - "default", - claude_desktop_model_mappings, - ); + return history_updates(client.id(), home, &before, &updates, "default", Some(model.to_string()), claude_desktop_model_mappings); } let paths = agent_config_paths(client, home); let contents = fresh_agent_contents_with_oauth( @@ -1762,16 +1625,11 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( path: codex_model_catalog_path(home), after: catalog.to_string(), }); - updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?); + updates.push(if oauth_configuration { build_codex_auth_update(home, api_key, true)? } else { + AgentFileUpdate { path: codex_configuration_directory(home).join("auth.json"), after: build_codex_api_auth(api_key)? } + }); } - commit_agent_configuration( - client, - home, - model, - &updates, - "default", - claude_desktop_model_mappings, - ) + history_updates(client.id(), home, &before, &updates, "default", Some(model.to_string()), claude_desktop_model_mappings) } #[cfg(test)] @@ -1890,6 +1748,7 @@ pub(crate) fn action_result( model, changed_files, conflict_files, + history_version: None, } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a8b7ca1..b8d3ac5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1026,6 +1026,7 @@ struct AgentConfigStatus { config_valid: bool, configured: bool, configuration_synchronized: bool, + connection_state: String, current_model: Option, oauth_configuration: bool, modification_enabled: bool, @@ -1077,6 +1078,7 @@ struct AgentConfigActionResult { model: Option, changed_files: Vec, conflict_files: Vec, + history_version: Option, } #[derive(Clone, Debug, Serialize)] @@ -2483,6 +2485,9 @@ fn main() { get_software_settings, save_software_settings, get_agent_config_statuses, + list_agent_config_history, + preview_agent_config_history, + restore_agent_config_history, refresh_agent_config_statuses, get_agent_models, check_pi_provider_update, diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 36827b4..76cd084 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -167,45 +167,26 @@ fn codex_oauth_requires_auth_json_with_tokens() { } #[test] -fn agent_configuration_is_restored_from_the_dated_session_backup_when_closed() { - let home = agent_test_home("session-backup"); - let path = home.join(".config/opencode/opencode.json"); - let original = b"{\"provider\":\"original\"}"; - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, original).unwrap(); - - commit_agent_configuration( - AgentClient::OpenCode, - &home, - "gpt-test", - &[AgentFileUpdate { - path: path.clone(), - after: "{\"provider\":\"managed\"}".to_string(), - }], - "applied", - None, - ) - .unwrap(); - - let backup = dated_agent_backup_path(&path).unwrap(); - assert!(backup.is_file()); - assert_eq!(fs::read(&backup).unwrap(), original); - assert!(fs::read_to_string(&path).unwrap().contains("managed")); - let inspection = inspect_agent_application(AgentClient::OpenCode, &home); - assert_eq!(inspection.state, "applied"); - assert!(inspection.backup_available); - - restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); - assert_eq!(fs::read(&path).unwrap(), original); - assert!(!backup.exists()); - assert!(!agent_state_path(std::slice::from_ref(&path)) - .unwrap() - .exists()); +fn successive_updates_and_noop_have_independent_history() { + let home = agent_test_home("history-first-update"); + let models = test_agent_models(&["gpt-one", "gpt-two"]); + for model in ["gpt-one", "gpt-two"] { + let result = apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, model, &models, None).unwrap(); + assert_eq!(result.outcome, "updated"); + assert!(result.history_version.is_some()); + } + assert_eq!(test_history_count(AgentClient::OpenCode, &home), 4); + let result = apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "gpt-two", &models, None).unwrap(); + assert_eq!(result.outcome, "unchanged"); + assert_eq!(test_history_count(AgentClient::OpenCode, &home), 4); + test_restore_history(AgentClient::OpenCode, &home, "update"); + let path = agent_config_paths(AgentClient::OpenCode, &home).remove(0); + assert!(fs::read_to_string(path).unwrap().contains("cpa-gui/gpt-one")); fs::remove_dir_all(home).unwrap(); } #[test] -fn deepseek_harness_session_restore_keeps_concurrent_unmanaged_yaml_changes() { +fn deepseek_harness_history_restore_keeps_concurrent_unmanaged_yaml_changes() { let home = agent_test_home("deepseek-harness-session-restore"); let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); @@ -265,7 +246,7 @@ agent-default-model: .unwrap(); fs::write(&paths[1], credentials).unwrap(); - restore_agent_session_configuration(AgentClient::DeepSeekHarness, &home).unwrap(); + test_restore_history(AgentClient::DeepSeekHarness, &home, "before-update"); let settings: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(&paths[0]).unwrap()).unwrap(); let credentials: serde_norway::Value = @@ -297,7 +278,7 @@ agent-default-model: } #[test] -fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() { +fn opencode_runtime_edits_survive_history_restore_and_next_apply_owns_conflicts() { let home = agent_test_home("opencode-runtime-edit-merge"); let path = home.join(".config/opencode/opencode.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -331,7 +312,7 @@ fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() { runtime["model"] = serde_json::json!("cpa-gui/agent-overwrite"); fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap(); - restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + test_restore_history(AgentClient::OpenCode, &home, "before-update"); let closed: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(closed["keep"], "root"); @@ -376,12 +357,12 @@ fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() { assert_eq!(reapplied["agentAdded"]["enabled"], true); assert_eq!(reapplied["provider"]["other"]["runtimeAdded"], 42); - restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + test_restore_history(AgentClient::OpenCode, &home, "before-update"); fs::remove_dir_all(home).unwrap(); } #[test] -fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() { +fn zcode_runtime_edits_survive_history_restore_and_original_provider_is_restored() { let home = agent_test_home("zcode-runtime-edit-merge"); let path = home.join(".zcode/v2/config.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -412,7 +393,7 @@ fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() { serde_json::json!("https://agent-overwrite.invalid"); fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap(); - restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap(); + test_restore_history(AgentClient::ZCode, &home, "before-update"); let closed: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(closed["keep"], "root"); @@ -438,7 +419,7 @@ fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() { } #[test] -fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() { +fn zcode_incomplete_legacy_backup_is_preserved_and_warned() { let home = agent_test_home("zcode-legacy-state-upgrade"); let paths = agent_config_paths(AgentClient::ZCode, &home); fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); @@ -488,23 +469,19 @@ fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() { None, ) .unwrap(); - let upgraded = load_agent_applied_state(AgentClient::ZCode, &home) - .unwrap() - .unwrap(); - assert_eq!(upgraded.backup_files.len(), 2); - let cli_value: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); - assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-two"); - - restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap(); - assert_eq!(fs::read(&paths[0]).unwrap(), original_app); - assert_eq!(fs::read(&paths[1]).unwrap(), original_cli); - assert!(!state_path.exists()); + assert!(!import_legacy_history("zcode", &home, &history_paths("zcode", &home).unwrap()).unwrap().is_empty()); + let unchanged = load_agent_applied_state(AgentClient::ZCode, &home).unwrap().unwrap(); + assert_eq!(unchanged.backup_files.len(), 1); + assert!(state_path.exists()); + assert_eq!(fs::read(&unchanged.backup_files[0].backup_path).unwrap(), original_app); + test_restore_history(AgentClient::ZCode, &home, "before-update"); + let cli: serde_json::Value = serde_json::from_slice(&fs::read(&paths[1]).unwrap()).unwrap(); + assert_eq!(cli["model"]["main"], "other/original"); fs::remove_dir_all(home).unwrap(); } #[test] -fn kimi_and_grok_legacy_states_require_context_catalog_resynchronization() { +fn kimi_and_grok_status_does_not_depend_on_legacy_state() { for client in [AgentClient::KimiCode, AgentClient::GrokBuild] { let home = agent_test_home(&format!("{}-context-revision", client.id())); let paths = agent_config_paths(client, &home); @@ -521,7 +498,7 @@ fn kimi_and_grok_legacy_states_require_context_catalog_resynchronization() { }; write_agent_applied_state(&state_path, &state).unwrap(); - assert!(!agent_configuration_is_synchronized(client, &home, true)); + assert!(agent_configuration_is_synchronized(client, &home, true)); state.configuration_revision = AGENT_CONFIGURATION_REVISION; write_agent_applied_state(&state_path, &state).unwrap(); @@ -1269,15 +1246,12 @@ fn codex_session_restore_does_not_create_a_state_json_file() { .unwrap(); let backup = dated_agent_backup_path(&config_path).unwrap(); - assert!(backup.is_file()); + assert!(!backup.exists()); assert!(!state_path.exists()); - assert_eq!( - inspect_agent_application(AgentClient::Codex, &home).state, - "applied" - ); + assert_eq!(test_history_count(AgentClient::Codex, &home), 2); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); - assert_eq!(fs::read(&config_path).unwrap(), original); + test_restore_history(AgentClient::Codex, &home, "before-applied"); + assert!(!fs::read_to_string(&config_path).unwrap().contains("approval_policy")); assert!(!catalog_path.exists()); assert!(!backup.exists()); assert!(!state_path.exists()); @@ -1314,12 +1288,9 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() { clear_codex_applied_state(&state_path).unwrap(); - let inspection = inspect_agent_application(AgentClient::Codex, &home); - assert_eq!(inspection.state, "applied"); - assert_eq!(inspection.applied_model.as_deref(), Some("gpt-test")); - assert!(dated_agent_backup_path(&config_path).unwrap().is_file()); - - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert_eq!(test_history_count(AgentClient::Codex, &home), 2); + assert!(fs::read_to_string(&config_path).unwrap().contains("gpt-test")); + test_restore_history(AgentClient::Codex, &home, "before-applied"); fs::remove_dir_all(home).unwrap(); } @@ -1354,7 +1325,7 @@ fn codex_catalog_sync_removes_models_missing_from_runtime() { let written = fs::read_to_string(&catalog_path).unwrap(); assert!(written.contains("gpt-active")); assert!(!written.contains("gpt-stale")); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + test_restore_history(AgentClient::Codex, &home, "before-update"); assert!(!catalog_path.exists()); fs::remove_dir_all(home).unwrap(); } @@ -1400,13 +1371,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration( assert_eq!(document["model"].as_str(), Some("gpt-active")); assert_eq!(document["approval_policy"].as_str(), Some("on-request")); assert!(updated.contains("# Keep my settings")); - assert_eq!( - load_agent_applied_state(AgentClient::Codex, &home) - .unwrap() - .unwrap() - .model, - "gpt-active" - ); + assert_eq!(test_history_count(AgentClient::Codex, &home), 4); assert!(!sync_codex_model_catalog_if_configured( &home, 8317, @@ -1415,7 +1380,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration( ¤t_catalog, ) .unwrap()); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + test_restore_history(AgentClient::Codex, &home, "before-update"); assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config); fs::remove_dir_all(home).unwrap(); } @@ -1479,7 +1444,7 @@ fn codex_catalog_sync_clears_empty_list_and_recovers_when_models_return() { assert_eq!(document["model"].as_str(), Some("gpt-returned")); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), returned_catalog); assert_eq!(fs::read(&auth_path).unwrap(), original_auth); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + test_restore_history(AgentClient::Codex, &home, "before-update"); assert!(!config_path.exists()); assert!(!catalog_path.exists()); fs::remove_dir_all(home).unwrap(); @@ -1516,14 +1481,9 @@ fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state( ) .unwrap()); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), current_catalog); - assert_eq!( - fs::read_to_string(dated_agent_backup_path(&catalog_path).unwrap()).unwrap(), - original_catalog - ); - assert!(load_agent_applied_state(AgentClient::Codex, &home) - .unwrap() - .is_some()); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert_eq!(test_history_count(AgentClient::Codex, &home), 4); + test_restore_history(AgentClient::Codex, &home, "before-sync"); + assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog); fs::remove_dir_all(home).unwrap(); } @@ -1555,7 +1515,7 @@ fn codex_catalog_only_sync_after_restart_preserves_complete_backups() { ) .unwrap()); assert_eq!(fs::read(&config_path).unwrap(), original_config); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + test_restore_history(AgentClient::Codex, &home, "before-sync"); assert_eq!(fs::read(&config_path).unwrap(), original_config); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog); fs::remove_dir_all(home).unwrap(); @@ -1628,7 +1588,7 @@ fn codex_catalog_sync_rejects_invalid_snapshots_without_changing_files() { serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap(), state_before ); - restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + test_restore_history(AgentClient::Codex, &home, "before-update"); fs::remove_dir_all(home).unwrap(); } @@ -1678,7 +1638,7 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them( Some(&codex_models), ) .unwrap(); - assert_eq!(result.outcome, "applied"); + assert_eq!(result.outcome, "updated"); let applied = fs::read_to_string(&path).unwrap(); assert!(applied.contains("# user comment")); assert!(applied.contains("user_setting = \"keep\"")); @@ -1694,25 +1654,16 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them( assert!(!agent_backup_path(&codex_model_catalog_path(&home)) .unwrap() .exists()); - assert_eq!( - inspect_agent_application(AgentClient::Codex, &home).state, - "applied" - ); + assert!(test_history_count(AgentClient::Codex, &home) >= 2); let mut document = applied.parse::().unwrap(); document["user_setting"] = toml_edit::value("changed-externally"); fs::write(&path, document.to_string()).unwrap(); - assert_eq!( - inspect_agent_application(AgentClient::Codex, &home).state, - "applied" - ); + assert!(test_history_count(AgentClient::Codex, &home) >= 2); document["model"] = toml_edit::value("external-model"); fs::write(&path, document.to_string()).unwrap(); - assert_eq!( - inspect_agent_application(AgentClient::Codex, &home).state, - "applied" - ); + assert!(test_history_count(AgentClient::Codex, &home) >= 2); apply_agent_configuration( AgentClient::Codex, @@ -1745,7 +1696,7 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them( } #[test] -fn clear_codex_config_removes_only_requested_config_files_and_application_state() { +fn clear_codex_config_removes_only_requested_config_files_and_preserves_legacy_state() { let home = agent_test_home("clear-codex-config"); let codex_dir = home.join(".codex"); fs::create_dir_all(&codex_dir).unwrap(); @@ -1763,7 +1714,8 @@ fn clear_codex_config_removes_only_requested_config_files_and_application_state( assert_eq!(deleted.len(), 2); assert!(!auth_path.exists()); assert!(!config_path.exists()); - assert!(!state_path.exists()); + assert!(state_path.exists()); + assert_eq!(test_history_count(AgentClient::Codex, &home), 2); assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep"); assert!(clear_codex_config_files(&home).unwrap().is_empty()); @@ -1788,32 +1740,19 @@ fn clear_codex_config_removes_only_requested_config_files_and_application_state( } #[test] -fn direct_agent_apply_rebuilds_an_unparseable_file_without_backup() { +fn ordinary_update_rejects_invalid_file_and_explicit_default_backs_it_up() { let home = agent_test_home("invalid-apply"); let path = home.join(".config/opencode/opencode.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "{ invalid json").unwrap(); let models = test_agent_models(&["model-a"]); - apply_agent_configuration( - AgentClient::OpenCode, - &home, - 8317, - DEFAULT_API_KEY, - "model-a", - &models, - None, - ) - .unwrap(); - - let root = - serde_json::from_str::(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(root["model"], "cpa-gui/model-a"); - assert!(!agent_backup_path(&path).unwrap().exists()); - assert_eq!( - inspect_agent_application(AgentClient::OpenCode, &home).state, - "applied" - ); + assert!(apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "model-a", &models, None).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ invalid json"); + assert_eq!(test_history_count(AgentClient::OpenCode, &home), 0); + reset_agent_configuration_to_default(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "model-a", None).unwrap(); + assert_eq!(test_history_count(AgentClient::OpenCode, &home), 2); + assert!(serde_json::from_slice::(&fs::read(path).unwrap()).is_ok()); fs::remove_dir_all(home).unwrap(); } @@ -1861,7 +1800,7 @@ fn agent_builders_repair_wrong_managed_node_types() { } #[test] -fn build_agent_updates_rebuilds_invalid_json5_toml_and_yaml() { +fn build_agent_updates_rejects_invalid_json5_toml_and_yaml() { let home = agent_test_home("invalid-formats"); let models = test_agent_models(&["model-a"]); let codex_models = test_codex_models(&["model-a"]); @@ -1880,9 +1819,9 @@ fn build_agent_updates_rebuilds_invalid_json5_toml_and_yaml() { "model-a", &models, (client == AgentClient::Codex).then_some(codex_models.as_str()), - ) - .unwrap(); - assert!(!updates[0].after.trim().is_empty()); + ); + assert!(updates.is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), "not valid {{{"); } let hermes = build_hermes_agent_config( Some("not valid {{{"), @@ -1891,16 +1830,40 @@ fn build_agent_updates_rebuilds_invalid_json5_toml_and_yaml() { "model-a", &models, ) - .or_else(|_| { - build_hermes_agent_config( - None, - "http://127.0.0.1:8317/v1", - DEFAULT_API_KEY, - "model-a", - &models, - ) - }) - .unwrap(); - assert!(!hermes.trim().is_empty()); + ; + assert!(hermes.is_err()); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn connection_state_distinguishes_fresh_repair_invalid_and_ready() { + assert_eq!(agent_connection_state(false, true, Ok(false)), "not-configured"); + assert_eq!(agent_connection_state(false, true, Ok(true)), "needs-update"); + assert_eq!(agent_connection_state(true, true, Ok(true)), "configured"); + assert_eq!(agent_connection_state(false, false, Ok(false)), "invalid"); + assert_eq!(agent_connection_state(false, true, Err("read failed".into())), "invalid"); +} + +#[test] +fn codex_connection_evidence_survives_port_key_and_partial_config_changes() { + let home = agent_test_home("connection-evidence"); + let paths = agent_config_paths(AgentClient::Codex, &home); + assert!(!agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::write(&paths[0], "model_provider = 'personal'\ncustom_note = 'cpa-gui'\n").unwrap(); + assert!(!agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); + apply_agent_configuration(AgentClient::Codex, &home, 8317, "old-key", "gpt-one", &test_agent_models(&["gpt-one"]), Some(&test_codex_models(&["gpt-one"]))).unwrap(); + for (port, key) in [(8318, "old-key"), (8317, "new-key")] { + let (configured, _, _) = inspect_codex_agent_config(&paths[0], port, key).unwrap(); + assert!(!configured); + assert_eq!(agent_connection_state(configured, true, agent_has_connection_evidence(AgentClient::Codex, &paths)), "needs-update"); + } + fs::remove_file(home.join(".codex/auth.json")).unwrap(); + assert!(!inspect_codex_agent_config(&paths[0],8317,"old-key").unwrap().0); + assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); + fs::write(&paths[0], "[model_providers.cpa-gui]\nbase_url = 'http://127.0.0.1:1234/v1'\n").unwrap(); + assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); + fs::write(&paths[0], "{{broken").unwrap(); + assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).is_err()); fs::remove_dir_all(home).unwrap(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index d0d1ad9..7eeca01 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -809,7 +809,7 @@ export const jaOverrides = { 'agents.status.invalid': '設定が無効', 'agents.status.applied': '適用済み', 'agents.status.unconfigured': '未設定', - "agents.history.button": "設定履歴を復元", + "agents.history.button": "設定履歴", "agents.history.description": "ソフトウェア管理の項目のみ復元し、他の設定を保持します。復元前に現在の設定を保存し、フォームを更新します。", "agents.history.versions": "履歴", "agents.history.empty": "履歴はありません。設定更新時に自動保存されます。", @@ -826,7 +826,7 @@ export const jaOverrides = { "agents.history.restore": "この履歴を復元", "agents.history.before": "{source}前のバックアップ", "agents.history.source.update": "設定更新", - "agents.history.source.default": "既定設定", + "agents.history.source.default": "設定の再構築", "agents.history.source.clear": "設定消去", "agents.history.source.restore": "復元", "agents.history.source.sync": "自動同期", @@ -912,7 +912,30 @@ export const jaOverrides = { 'agents.model.readingAvailable': '利用可能なモデルを読み込んでいます', 'agents.model.current': '現在適用中:{model}', 'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します', - 'agents.modify.title': '設定変更', + "agents.modify.connected": "現在の接続設定:CPA", + "agents.modify.disconnected": "現在の接続設定:CPA 未設定", + "agents.modify.connectionChecking": "現在の接続設定:確認中…", + "agents.modify.connectionUnknown": "現在の接続設定:判定できません。設定を確認してください", + "agents.modify.description": "選択したモデルと認証方式をローカル設定に書き込みます。書き込み前に自動バックアップします。", + "agents.modify.authApiKeyHint": "CPA の API を使用して接続し、より安定した接続状態を維持します。", + "agents.modify.authOAuthHint": "CPA 経由で接続し、アカウントのログイン状態を保持して Codex の全サービスを利用できます。事前に Codex でアカウントにログインしてください。", + "agents.modify.authHelpLabel": "説明", + "agents.modify.authHelpShow": "選び方", + "agents.modify.authHelpHide": "説明を閉じる", + "agents.modify.authHelp": "接続方式の説明", + "agents.modify.connect": "ワンクリック接続", + "agents.modify.repair": "接続設定を修復", + "agents.modify.connectionRepair": "現在の接続設定:CPA 設定の修復が必要です", + "agents.modify.authMethod": "接続方式", + "agents.modify.authApiKey": "API キー", + "agents.modify.authOAuth": "OAuth 設定方式", + "agents.modify.matches": "保存済み", + "agents.modify.pending": "未保存", + "agents.modify.checking": "設定を読み込み中…", + "agents.modify.invalidState": "現在の設定を解析できません", + "agents.modify.more": "その他の操作", + "agents.modify.moreDescription": "再構築やクリアは独自設定を削除します。必要な場合のみ使用してください。", + "agents.modify.title": "{name} の設定を書き込む", 'agents.modify.oauthConfiguration': 'OAuth ログイン設定方式', 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', 'agents.claudeDesktopMapping.description': '役割ごとに上流モデルとコンテキスト機能を選択します。対応モデルでのみ 1M を有効にしてください。', @@ -942,10 +965,10 @@ export const jaOverrides = { 'agents.modify.apply': '設定変更を適用', 'agents.modify.update': '設定を更新', 'agents.modify.close': '設定変更を終了', - 'agents.modify.default': 'デフォルト設定', + "agents.modify.default": "設定を再構築", 'agents.modify.clear': '設定を消去', - 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', - 'agents.modify.zcodeRestart': '設定の適用または終了後、ZCode を完全終了して再起動してください。デフォルトモデルとコンテキスト長は新しいセッションで有効になります。', + "agents.modify.invalid": "通常の更新は停止しています。設定ファイルを修正するか、「設定を再構築」で再生成してください。", + "agents.modify.zcodeRestart": "設定更新後、ZCode を完全終了して再起動してください。デフォルトモデルとコンテキスト長は新しいセッションで有効になります。", 'agents.pi.installTitle': 'Pi CLIProxyAPI プロバイダー', 'agents.pi.installDescription': 'プラグインをインストールまたは更新し、CPA 認証情報、既定のプロバイダー、既定のモデルを修復します', 'agents.pi.install': 'Pi プラグインをインストール', @@ -957,9 +980,9 @@ export const jaOverrides = { 'agents.pi.repairing': '設定を適用中', 'agents.pi.uninstall': 'Pi プラグインをアンインストール', 'agents.pi.uninstalling': 'プラグインをアンインストール中', - 'agents.default.title': 'デフォルト設定にリセット', + "agents.default.title": "設定を再構築", 'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。', - 'agents.default.confirm': 'デフォルト設定を書き込む', + "agents.default.confirm": "再構築を確認", 'agents.clear.title': 'Codex 設定を消去', 'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。', 'agents.clear.confirm': '設定を消去', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ab35f6d..56d64d5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -809,7 +809,7 @@ export const en: Record = { 'agents.status.invalid': 'Invalid Configuration', 'agents.status.applied': 'Applied', 'agents.status.unconfigured': 'Not Configured', - "agents.history.button": "Restore configuration history", + "agents.history.button": "Configuration History", "agents.history.description": "Restore only software-managed fields and preserve other settings. Current configuration is backed up first; form drafts refresh after restoring.", "agents.history.versions": "Versions", "agents.history.empty": "No history yet. Updating configuration saves versions automatically.", @@ -826,7 +826,7 @@ export const en: Record = { "agents.history.restore": "Restore this version", "agents.history.before": "Before {source}", "agents.history.source.update": "Update", - "agents.history.source.default": "Default configuration", + "agents.history.source.default": "Rebuild configuration", "agents.history.source.clear": "Clear configuration", "agents.history.source.restore": "Restore", "agents.history.source.sync": "Automatic sync", @@ -930,7 +930,30 @@ export const en: Record = { 'agents.model.readingAvailable': 'Loading available models', 'agents.model.current': 'Currently applied: {model}', 'agents.model.firstSelection': '{count} models available; the first is selected by default', - 'agents.modify.title': 'Configuration Changes', + "agents.modify.connected": "Current integration: CPA", + "agents.modify.disconnected": "Current integration: CPA not configured", + "agents.modify.connectionChecking": "Current integration: checking…", + "agents.modify.connectionUnknown": "Current integration: unknown; check configuration", + "agents.modify.description": "Write the selected model and authentication settings to local configuration. A backup is saved first.", + "agents.modify.authApiKeyHint": "Connect using the CPA API for a more stable connection.", + "agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in state and access to the full Codex service. Sign in to your account in Codex first.", + "agents.modify.authHelpLabel": "Details", + "agents.modify.authHelpShow": "How to choose", + "agents.modify.authHelpHide": "Hide details", + "agents.modify.authHelp": "About connection methods", + "agents.modify.connect": "Connect in One Click", + "agents.modify.repair": "Repair Connection", + "agents.modify.connectionRepair": "Current integration: CPA configuration needs repair", + "agents.modify.authMethod": "Connection method", + "agents.modify.authApiKey": "API key", + "agents.modify.authOAuth": "OAuth configuration mode", + "agents.modify.matches": "Saved", + "agents.modify.pending": "Unsaved changes", + "agents.modify.checking": "Reading configuration…", + "agents.modify.invalidState": "Cannot parse current configuration", + "agents.modify.more": "More actions", + "agents.modify.moreDescription": "Rebuilding or clearing removes custom settings. Use these actions only when needed.", + "agents.modify.title": "Write {name} Configuration", 'agents.modify.oauthConfiguration': 'OAuth sign-in configuration mode', 'agents.oauthLoginRequired.title': 'Codex sign-in required', 'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, use', @@ -942,10 +965,10 @@ export const en: Record = { 'agents.modify.apply': 'Apply Configuration Changes', 'agents.modify.update': 'Update Configuration', 'agents.modify.close': 'Close Configuration Changes', - 'agents.modify.default': 'Default Configuration', + "agents.modify.default": "Rebuild Configuration", 'agents.modify.clear': 'Clear Configuration', - 'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.', - 'agents.modify.zcodeRestart': 'Fully quit and restart ZCode after applying or closing the configuration; the default model and context length take effect in new sessions.', + "agents.modify.invalid": "Normal updates are stopped. Fix the configuration file, or regenerate it using Rebuild Configuration.", + "agents.modify.zcodeRestart": "Fully quit and restart ZCode after updating configuration; the default model and context length take effect in new sessions.", 'agents.pi.installTitle': 'Pi CLIProxyAPI provider', 'agents.pi.installDescription': 'Install or update the plugin and repair CPA credentials, the default provider, and the default model.', 'agents.pi.install': 'Install Pi Plugin', @@ -957,9 +980,9 @@ export const en: Record = { 'agents.pi.repairing': 'Applying configuration', 'agents.pi.uninstall': 'Uninstall Pi Plugin', 'agents.pi.uninstalling': 'Uninstalling plugin', - 'agents.default.title': 'Reset to Default Configuration', + "agents.default.title": "Rebuild Configuration", 'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.', - 'agents.default.confirm': 'Write Default Configuration', + "agents.default.confirm": "Confirm Rebuild", 'agents.clear.title': 'Clear Codex Configuration', 'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.', 'agents.clear.confirm': 'Clear Configuration', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f7266ed..a8aeaaa 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -808,7 +808,7 @@ export const zhCN = { 'agents.status.invalid': '配置无效', 'agents.status.applied': '已应用', 'agents.status.unconfigured': '尚未配置', - "agents.history.button": "恢复历史配置", + "agents.history.button": "配置历史", "agents.history.description": "仅恢复软件管理的配置字段,保留其他自定义设置。恢复前会自动备份当前配置;当前表单草稿将按恢复结果刷新。", "agents.history.versions": "历史版本", "agents.history.empty": "暂无配置历史。更新配置后会自动保留版本。", @@ -825,7 +825,7 @@ export const zhCN = { "agents.history.restore": "恢复此版本", "agents.history.before": "{source}前备份", "agents.history.source.update": "更新配置", - "agents.history.source.default": "默认配置", + "agents.history.source.default": "重建配置", "agents.history.source.clear": "清空配置", "agents.history.source.restore": "恢复配置", "agents.history.source.sync": "自动同步", @@ -929,7 +929,30 @@ export const zhCN = { 'agents.model.readingAvailable': '正在读取可用模型', 'agents.model.current': '当前已应用:{model}', 'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项', - 'agents.modify.title': '配置修改', + "agents.modify.connected": "当前接入:CPA", + "agents.modify.disconnected": "当前接入:未接入 CPA", + "agents.modify.connectionChecking": "当前接入:检测中…", + "agents.modify.connectionUnknown": "当前接入:无法判断,请检查配置", + "agents.modify.description": "将所选模型和认证方式写入本机配置,写入前自动备份。", + "agents.modify.authApiKeyHint": "使用 CPA 的API接入,状态更稳定。", + "agents.modify.authOAuthHint": "仍通过 CPA 接入,保留账号登录状态,能使用完整的Codex服务,需在Codex中提前登录账号。", + "agents.modify.authHelpLabel": "说明", + "agents.modify.authHelpShow": "如何选择", + "agents.modify.authHelpHide": "收起说明", + "agents.modify.authHelp": "接入方式说明", + "agents.modify.connect": "一键接入", + "agents.modify.repair": "修复接入", + "agents.modify.connectionRepair": "当前接入:CPA 配置需要修复", + "agents.modify.authMethod": "接入方式", + "agents.modify.authApiKey": "API 密钥", + "agents.modify.authOAuth": "OAuth配置方式", + "agents.modify.matches": "已保存", + "agents.modify.pending": "未保存", + "agents.modify.checking": "正在读取配置…", + "agents.modify.invalidState": "当前配置无法解析", + "agents.modify.more": "更多操作", + "agents.modify.moreDescription": "重建或清空会移除自定义设置,请按需使用。", + "agents.modify.title": "写入 {name} 配置", 'agents.modify.oauthConfiguration': 'OAuth登录配置方式', 'agents.oauthLoginRequired.title': '需要登录 Codex', 'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先使用', @@ -941,10 +964,10 @@ export const zhCN = { 'agents.modify.apply': '应用配置修改', 'agents.modify.update': '更新配置', 'agents.modify.close': '关闭配置修改', - 'agents.modify.default': '默认配置', + "agents.modify.default": "重建配置", 'agents.modify.clear': '清空配置', - 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', - 'agents.modify.zcodeRestart': '应用或关闭配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。', + "agents.modify.invalid": "普通更新已停止。请修复配置文件,或通过“重建配置”重新生成。", + "agents.modify.zcodeRestart": "更新配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。", 'agents.pi.installTitle': 'Pi CLIProxyAPI 提供方', 'agents.pi.installDescription': '安装或更新插件,并修复 CPA 凭据、默认提供方与默认模型', 'agents.pi.install': '安装 Pi 插件', @@ -956,9 +979,9 @@ export const zhCN = { 'agents.pi.repairing': '正在应用配置', 'agents.pi.uninstall': '卸载 Pi 插件', 'agents.pi.uninstalling': '正在卸载插件', - 'agents.default.title': '重置为默认配置', + "agents.default.title": "重建配置", 'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。', - 'agents.default.confirm': '确认写入默认配置', + "agents.default.confirm": "确认重建配置", 'agents.clear.title': '清空 Codex 配置', 'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。', 'agents.clear.confirm': '确认清空配置', diff --git a/src/pages/AgentConfigHistoryDialog.css b/src/pages/AgentConfigHistoryDialog.css new file mode 100644 index 0000000..3c592f0 --- /dev/null +++ b/src/pages/AgentConfigHistoryDialog.css @@ -0,0 +1,17 @@ +.agent-history-modal { width: min(1000px, calc(100vw - 40px)); max-height: calc(100vh - 40px); overflow: auto; box-sizing: border-box; padding: 22px; border: 1px solid var(--theme-e3e1db); border-radius: 16px; background: var(--theme-fffdf8); color: var(--theme-2d2a26); box-shadow: 0 24px 80px #0005; } +.agent-history-modal::backdrop { background: #0007; } +.agent-history-modal header, .agent-history-modal footer { display: flex; gap: 12px; align-items: center; justify-content: space-between; } +.agent-history-modal h2 { display: flex; align-items: center; gap: 8px; font-size: 18px; margin: 0; } +.agent-history-modal p { font-size: 13px; line-height: 1.6; } +.agent-history-modal footer { justify-content: flex-end; margin-top: 16px; } +.agent-history-columns { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 16px; margin-top: 16px; } +.agent-history-columns nav { max-height: 52vh; overflow: auto; display: flex; flex-direction: column; gap: 8px; } +.agent-history-columns nav button { display: grid; gap: 5px; text-align: left; border: 1px solid var(--theme-e3e1db); border-radius: 8px; padding: 12px; color: inherit; background: transparent; cursor: pointer; } +.agent-history-columns nav button.active { border-color: var(--theme-4f825c); background: var(--theme-eef7f0); } +.agent-history-columns nav span, .agent-history-columns nav small { font-size: 12px; color: var(--theme-7a746c); } +.agent-history-changes { min-width: 0; max-height: 52vh; overflow: auto; } +.agent-history-changes table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; } +.agent-history-changes td, .agent-history-changes th { text-align: left; vertical-align: top; padding: 10px 8px; border-bottom: 1px solid var(--theme-e3e1db); overflow-wrap: anywhere; white-space: pre-wrap; } +.agent-history-changes td small, .agent-history-changes td code { display: block; } +.agent-history-changes td small { color: var(--theme-7a746c); margin-bottom: 5px; } +@media (max-width: 760px) { .agent-history-columns { grid-template-columns: 1fr; } .agent-history-columns nav { max-height: 180px; } } diff --git a/src/pages/AgentConfigHistoryDialog.tsx b/src/pages/AgentConfigHistoryDialog.tsx new file mode 100644 index 0000000..dfd7e35 --- /dev/null +++ b/src/pages/AgentConfigHistoryDialog.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { History, LoaderCircle, X } from 'lucide-react'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n/resources'; +import './AgentConfigHistoryDialog.css'; + +type Version = { id: string; createdAt: string; source: string; model: string | null; fileCount: number }; +type Listing = { versions: Version[]; warnings: string[] }; +type Preview = { revision: string; differences: { file: string; field: string; before: string; after: string }[] }; +const sourceKeys: Record = { + 'plugin-install': 'agents.pi.install', 'plugin-update': 'agents.pi.update', 'plugin-remove': 'agents.pi.uninstall', + update: 'agents.history.source.update', default: 'agents.history.source.default', + clear: 'agents.history.source.clear', restore: 'agents.history.source.restore', + sync: 'agents.history.source.sync', legacy: 'agents.history.source.legacy', +}; + +export function AgentConfigHistoryDialog({ client, onClose, onRestored }: { + client: string; onClose: () => void; onRestored: () => Promise; +}) { + const { t, formatDate } = useI18n(); + const dialog = useRef(null); + const [listing, setListing] = useState({ versions: [], warnings: [] }); + const [selected, setSelected] = useState(null); + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(''); + useEffect(() => { + let disposed = false; + dialog.current?.showModal(); + void invoke('list_agent_config_history', { client }) + .then((value) => { if (!disposed) setListing(value); }) + .catch((cause) => { if (!disposed) setError(String(cause)); }) + .finally(() => { if (!disposed) setBusy(false); }); + return () => { disposed = true; }; + }, [client]); + const choose = async (version: Version) => { + setBusy(true); setError(''); setSelected(version); setPreview(null); + try { setPreview(await invoke('preview_agent_config_history', { client, id: version.id })); } + catch (cause) { setError(String(cause)); } + finally { setBusy(false); } + }; + const restore = async () => { + if (!selected || !preview) return; + setBusy(true); setError(''); + try { + await invoke('restore_agent_config_history', { client, id: selected.id, revision: preview.revision }); + await onRestored(); onClose(); + } catch (cause) { setError(String(cause)); setPreview(null); } + finally { setBusy(false); } + }; + const source = (value: string) => { + const before = value.startsWith('before-'); + const label = t(sourceKeys[before ? value.slice(7) : value] ?? 'agents.history.source.update'); + return before ? t('agents.history.before', { source: label }) : label; + }; + return { event.preventDefault(); if (!busy) onClose(); }}> +

{t('agents.history.button')}

+
+

{t('agents.history.description')}

+ {listing.warnings.map((warning) =>

{warning}

)} +
+ +
+ {busy ?

{t('agents.history.loading')}

: !preview ?

{t('agents.history.select')}

: preview.differences.length === 0 ?

{t('agents.history.unchanged')}

: + + {preview.differences.map((diff) => )}
{t('agents.history.field')}{t('agents.history.current')}{t('agents.history.target')}
{diff.file.split(/[\\/]/).pop()}{diff.field}{diff.before}{diff.after}
} +
+
+ {error &&

{error}

} +
+
+
; +} diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index d122005..614a766 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -48,7 +48,6 @@ import { resolveAgentModelSelection, } from '../services/agentModelPicker'; import { - resolveAgentConfigurationAction, resolveAgentModelMappingsDraftSourceForClient, sameAgentModel, sameAgentModelMappings, @@ -69,6 +68,7 @@ import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; +import { AgentConfigHistoryDialog } from './AgentConfigHistoryDialog'; type AgentClientId = | 'claude-code' @@ -100,6 +100,7 @@ type AgentConfigStatus = { pluginVersion: string | null; configValid: boolean; configured: boolean; + connectionState: 'configured' | 'not-configured' | 'needs-update' | 'invalid'; configurationSynchronized: boolean; currentModel: string | null; oauthConfiguration: boolean; @@ -126,7 +127,8 @@ type DeepSeekHarnessProcessStatus = { }; type AgentConfigActionResult = { - outcome: 'applied' | 'default'; + outcome: 'applied' | 'default' | 'updated' | 'unchanged'; + historyVersion: string | null; enabled: boolean; model: string | null; changedFiles: string[]; @@ -672,13 +674,16 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null + 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); const [modelError, setModelError] = useState(''); const [modelSelectionError, setModelSelectionError] = useState(''); const [configurationError, setConfigurationError] = useState(''); + const [configurationNotice, setConfigurationNotice] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); + const [connectionHelpOpen, setConnectionHelpOpen] = useState(false); const [defaultError, setDefaultError] = useState(''); const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false); const [clearError, setClearError] = useState(''); @@ -848,6 +853,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setActiveSubpage(DEFAULT_AGENT_SUBPAGE); setModelSelectionError(''); setConfigurationError(''); + setConfigurationNotice(''); + setHistoryOpen(false); + setConnectionHelpOpen(false); setDefaultError(''); setDefaultConfirmOpen(false); setClearError(''); @@ -966,16 +974,19 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP selectedModel, ]); - const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? ''; - const modelDraftChanged = !isClaudeModelMappingClient && Boolean( - selectedModel.trim() - && appliedModel.trim() - && !sameAgentModel(selectedModel, appliedModel), - ); - const appliedClaudeModelMappings = (selected === 'claude-code' + const connectionState = activeStatus?.connectionState ?? 'invalid'; + const configurationActionLabel = t(connectionState === 'not-configured' ? 'agents.modify.connect' + : connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update'); + const configurationWriteBlocked = loading || !activeStatus || connectionState === 'invalid'; + const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? ''; + const appliedMappings = selected === 'claude-code' ? activeStatus?.claudeCodeModelMappings - : activeStatus?.claudeDesktopModelMappings) - ?? createClaudeModelMappings(appliedModel); + : activeStatus?.claudeDesktopModelMappings; + const formMatchesConfiguration = Boolean(activeStatus?.configValid && activeStatus?.configured) + && (isClaudeModelMappingClient + ? Boolean(appliedMappings && sameAgentModelMappings(claudeModelMappingsDraft, appliedMappings)) + : sameAgentModel(selectedModel, appliedModel)) + && (selected !== 'codex' || oauthConfiguration === Boolean(activeStatus?.oauthConfiguration)); const claudeMappingsReady = !isClaudeModelMappingClient || claudeMappingRoles.every((role) => Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])), @@ -986,26 +997,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP && claudeModelMappingsDraft.autoCompactPct >= 1 && claudeModelMappingsDraft.autoCompactPct <= 100 ); - const claudeMappingDraftChanged = isClaudeModelMappingClient - && activeStatus?.modificationState === 'applied' - && !sameAgentModelMappings( - claudeModelMappingsDraft, - appliedClaudeModelMappings, - ); - const oauthConfigurationChanged = selected === 'codex' - && oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration); - const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged; - const configurationAction = resolveAgentConfigurationAction({ - client: selected, - modificationState: activeStatus?.modificationState ?? 'unconfigured', - configurationSynchronized: Boolean(activeStatus?.configurationSynchronized), - selectedModel, - appliedModel, - oauthConfiguration, - appliedOauthConfiguration: Boolean(activeStatus?.oauthConfiguration), - modelMappings: claudeModelMappingsDraft, - appliedModelMappings: appliedClaudeModelMappings, - }); const canEnable = Boolean( activeStatus?.supportedPlatform && activeStatus.installed @@ -1063,9 +1054,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP void (activeStatus?.pluginInstalled ? repairPiProvider() : installPiProvider()); return; } - void (configurationAction === 'close' - ? closeConfigurationChanges() - : applyConfigurationChanges()); + void applyConfigurationChanges(); }; const reloadStatusesAfterAction = async () => { @@ -1078,6 +1067,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const selectModel = (value: string) => { + setConfigurationNotice(''); + setClearNotice(''); const model = findAgentModel(models, value); if (!model) return; setModelSelectionError(''); @@ -1092,7 +1083,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const model = findAgentModel(models, value); if (!model) return; if (isClaudeModelMappingClient) { - claudeModelMappingsDirtyRef.current[selected] = true; + setConfigurationNotice(''); + claudeModelMappingsDirtyRef.current[selected] = true; setClaudeModelMappingsDraftByClient((current) => ({ ...current, [selected]: { @@ -1112,6 +1104,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) => { const model = findAgentModel(models, value); if (!model || !isClaudeModelMappingClient) return; + setConfigurationNotice(''); claudeModelMappingsDirtyRef.current[selected] = true; setModelSelectionError(''); setClaudeModelMappingsDraftByClient((current) => ({ @@ -1125,6 +1118,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP enabled: boolean, ) => { if (!isClaudeModelMappingClient) return; + setConfigurationNotice(''); claudeModelMappingsDirtyRef.current[selected] = true; setClaudeModelMappingsDraftByClient((current) => { const next = { ...current[selected], [preference]: enabled }; @@ -1143,6 +1137,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP value: number, ) => { if (selected !== 'claude-code') return; + setConfigurationNotice(''); claudeModelMappingsDirtyRef.current[selected] = true; setClaudeModelMappingsDraftByClient((current) => ({ ...current, @@ -1152,6 +1147,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => { if (selected !== 'claude-code') return; + setConfigurationNotice(''); claudeModelMappingsDirtyRef.current[selected] = true; setClaudeModelMappingsDraftByClient((current) => ({ ...current, @@ -1180,7 +1176,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP disableAutoCompact: currentClientDraft.disableAutoCompact, }; if (!sameAgentModelMappings(currentClientDraft, next)) { - claudeModelMappingsDirtyRef.current[selected] = true; + setConfigurationNotice(''); + claudeModelMappingsDirtyRef.current[selected] = true; } return { ...current, [selected]: next }; }); @@ -1238,6 +1235,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const changeOauthConfiguration = async (enabled: boolean) => { + setConfigurationNotice(''); + setClearNotice(''); if (!enabled) { setOauthConfigurationDraft(false); return; @@ -1265,8 +1264,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : requireSelectedModel(); if (!model) return; setBusyAction('apply'); + setConfigurationNotice(''); try { - await invoke('apply_agent_config', { + const result = await invoke('update_agent_config', { client: selected, model, oauthConfiguration, @@ -1278,6 +1278,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); + setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.history.unchanged' : 'agents.history.updated')); onConfigurationApplied?.(); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { @@ -1327,8 +1328,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setBusyAction('repair-pi'); setConfigurationError(''); try { - await invoke('repair_pi_provider', { model }); + const result = await invoke('repair_pi_provider', { model }); await reloadStatusesAfterAction(); + setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.history.unchanged' : 'agents.history.updated')); onConfigurationApplied?.(); } catch (requestError) { setConfigurationError(String(requestError)); @@ -1351,24 +1353,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP } }; - const closeConfigurationChanges = async () => { - const retainedOauthConfiguration = selected === 'codex' ? oauthConfiguration : null; - setConfigurationError(''); - setBusyAction('close-config'); - try { - await invoke('close_agent_config_modification', { client: selected }); - if (isClaudeModelMappingClient) { - claudeModelMappingsDirtyRef.current[selected] = false; - } - await reloadStatusesAfterAction(); - setOauthConfigurationDraft(retainedOauthConfiguration); - } catch (requestError) { - setConfigurationError(String(requestError)); - } finally { - setBusyAction(null); - } - }; - const resetConfigurationToDefault = async () => { const claudeModelMappings = requireClaudeModelMappings(); if (isClaudeModelMappingClient && !claudeModelMappings) return; @@ -1690,17 +1674,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP type="button" className="primary-button" onClick={runEmbeddedPrimaryAction} - disabled={busy || (isPiClient ? !canEnable : configurationAction !== 'close' && !canEnable)} + disabled={busy || !canEnable || configurationWriteBlocked} > {busyAction ? : null} {isPiClient - ? activeStatus?.pluginInstalled ? t('agents.pi.repair') : t('agents.pi.install') - : configurationAction === 'update' - ? t('agents.modify.update') - : configurationAction === 'close' - ? t('agents.modify.close') - : t('agents.modify.apply')} + ? activeStatus?.pluginInstalled ? configurationActionLabel : t('agents.pi.install') + : configurationActionLabel} + + + + + + + + ) : null} ) : null} @@ -1989,6 +2018,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{t('agents.pi.installTitle')} + {t('agents.pi.installDescription')}
@@ -2011,16 +2041,16 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP type="button" className="secondary-button" onClick={() => void repairPiProvider()} - disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption} + disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption || configurationWriteBlocked} > {busyAction === 'repair-pi' ? : } - {busyAction === 'repair-pi' ? t('agents.pi.repairing') : t('agents.pi.repair')} + {configurationActionLabel} - - ) : null} -
- +
+
- {configurationError ? ( - - {configurationError} - - ) : null} - {clearNotice ? ( - - {clearNotice} - - ) : null} + +
- + )}
@@ -2242,6 +2218,17 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
+ {historyOpen ? setHistoryOpen(false)} onRestored={async () => { + if (selected === 'claude-code' || selected === 'claude-desktop') claudeModelMappingsDirtyRef.current[selected] = false; + setOauthConfigurationDraft(null); + const refreshed = await invoke('refresh_agent_config_statuses'); + setStatuses(refreshed); + const current = refreshed.find((status) => status.id === selected)?.currentModel; + if (current) setModelByClient((values) => { const next = { ...values, [selected]: current }; writeAgentModelSelections(next); return next; }); + setConfigurationNotice(t('agents.history.restored')); + }} /> : null} + {configurationNotice && (embedded || isPiClient) ?

{configurationNotice}

: null} + {codexCatalogDialogOpen ? ( setCodexCatalogDialogOpen(false)} diff --git a/src/styles.css b/src/styles.css index a572533..9750cc4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -13700,3 +13700,44 @@ body.table-col-resizing * { flex: 1; } } + +/* Configuration form and explicit write actions. */ +.agent-auth-method { display: grid; gap: 6px; flex: 1; min-width: 0; font-size: var(--font-size-meta); } +.agent-auth-method select { width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid var(--theme-e3e1db); border-radius: 8px; color: var(--theme-2d2a26); background: var(--theme-fffdf8); } +.agent-model-section .agent-codex-options { margin-top: 16px; align-items: end; } +.agent-model-section .agent-codex-catalog-button { min-height: 38px; height: 38px; padding: 8px 10px; } +.agent-write-state { margin: 0; font-size: var(--font-size-meta); color: var(--theme-7a746c); } + +.agent-connection-state { margin: 0; font-size: var(--font-size-meta); font-weight: 600; color: var(--theme-7a746c); } +.agent-connection-state.connected { color: var(--theme-4f825c); } + +.agent-signin-hint { grid-column: 1 / -1; margin: 0; color: var(--theme-7a746c); font-size: var(--font-size-meta); line-height: 1.6; } + +/* Agent settings: one form and one explicit save action. */ +.agent-settings-toolbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--theme-e3e1db); } +.agent-settings-title { display: grid; gap: 4px; min-width: 0; } +.agent-settings-title > strong { font-size: var(--font-size-section); color: var(--theme-2d2a26); } +.agent-settings-tools { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; } +.agent-model-section { grid-column: 1 / -1; } +.agent-save-bar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0 0; border-top: 1px solid var(--theme-e3e1db); } +.agent-save-feedback { display: grid; gap: 6px; min-width: 0; } +.agent-save-feedback small { color: var(--theme-7a746c); font-size: var(--font-size-meta); } +.agent-save-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; min-width: 0; } +.agent-save-actions button { min-width: 128px; min-height: 40px; } +.agent-signin-label { display: flex; align-items: center; gap: 8px; } +.agent-signin-help-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--theme-7a746c); font: inherit; cursor: pointer; } +.agent-signin-help-toggle:hover { color: var(--theme-2d2a26); background: var(--theme-fffdf8); } +.agent-signin-help-toggle[aria-expanded="true"] svg { transform: rotate(180deg); } +.agent-signin-explanation { grid-column: 1 / -1; padding: 12px 0 0; border-top: 1px solid var(--theme-e3e1db); font-size: var(--font-size-meta); color: var(--theme-7a746c); overflow-wrap: anywhere; } +.agent-signin-explanation[hidden] { display: none; } +.agent-signin-explanation dl { display: grid; gap: 10px; margin: 0; } +.agent-signin-explanation dl > div { display: grid; grid-template-columns: minmax(110px, 150px) minmax(0, 1fr); gap: 12px; } +.agent-signin-explanation dt { color: var(--theme-2d2a26); font-weight: 600; } +.agent-signin-explanation dd { margin: 0; } +.agent-signin-explanation p { margin: 12px 0 0; } +@media (max-width: 540px) { .agent-signin-explanation dl > div { grid-template-columns: 1fr; gap: 3px; } } +@media (max-width: 760px) { + .agent-settings-toolbar { align-items: flex-start; flex-wrap: wrap; } + .agent-save-bar { flex-wrap: wrap; gap: 12px; } + .agent-save-actions { margin-left: auto; } +} diff --git a/tests/agent-history-ui.cjs b/tests/agent-history-ui.cjs new file mode 100644 index 0000000..500492b --- /dev/null +++ b/tests/agent-history-ui.cjs @@ -0,0 +1,75 @@ +// Run Vite on port 1421, then: node tests/agent-history-ui.cjs +// Set PLAYWRIGHT_MODULE to an installed Playwright module path when needed. +const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({channel:'msedge',headless:true}); const page=await browser.newPage({viewport:{width:1280,height:900}}); +page.setDefaultTimeout(10000); const errors=[];page.on('pageerror',e=>errors.push(String(e))); +await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?fresh'); +await page.getByRole('button',{name:'一键接入',exact:true}).waitFor(); +await page.waitForFunction(()=>!document.querySelector('.agent-save-actions .primary-button')?.disabled); +await page.locator('.agent-settings-title strong').filter({hasText:'Codex'}).waitFor(); +await page.getByRole('button',{name:'一键接入',exact:true}).click(); +await page.getByText('配置已更新,历史备份已保存。',{exact:true}).waitFor(); +await page.getByRole('button',{name:'更新配置',exact:true}).click(); +await page.getByText('配置已是最新,无需更新。',{exact:true}).waitFor(); +assert.equal(await page.getByRole('button',{name:'关闭修改',exact:true}).count(),0); +assert.equal(await page.locator('textarea').count(),0); +await page.locator('.agent-save-feedback').waitFor({state:'attached'}); +assert.equal(await page.getByText('已保存',{exact:true}).count(),0); +const help = page.getByRole('button',{name:'如何选择',exact:true}); +await help.click(); +assert.ok(await page.locator('#agent-signin-hint').isVisible()); +assert.equal(await page.locator('#agent-signin-hint dt').count(),2); +const positions = await page.locator('#agent-signin-hint').evaluate(el => ({top:el.getBoundingClientRect().top,bottom:document.getElementById('agent-connection-method').getBoundingClientRect().bottom})); +assert.ok(positions.top >= positions.bottom); +await page.getByRole('button',{name:'收起说明',exact:true}).click(); +assert.ok(await page.locator('#agent-signin-hint').isHidden()); +await page.getByLabel('接入方式',{exact:true}).selectOption('oauth'); +await page.getByText('未保存',{exact:true}).waitFor(); +await page.getByLabel('接入方式',{exact:true}).selectOption('apikey'); +await page.locator('.agent-save-feedback').waitFor({state:'attached'}); +assert.equal(await page.getByText('已保存',{exact:true}).count(),0); +assert.equal(await page.getByRole('button',{name:'重建配置',exact:true}).isVisible(),true); +await page.getByRole('button',{name:'重建配置',exact:true}).click(); +await page.getByRole('button',{name:'确认重建配置',exact:true}).waitFor(); +await page.getByRole('button',{name:'取消',exact:true}).click(); +assert.ok(await page.getByRole('button',{name:'清空配置',exact:true}).isVisible()); + +await page.screenshot({path:'misc/agent-history-main.png',fullPage:true}); +await page.getByRole('button',{name:'配置历史',exact:true}).click(); +await page.locator('.agent-history-columns nav button').first().click(); +await page.locator('.agent-history-changes table').waitFor(); +await page.screenshot({path:'misc/agent-history-dialog.png',fullPage:true}); +const before=await page.evaluate(()=>window.fixtureCalls.filter(c=>c.cmd==='update_agent_config').length); +await page.locator('.agent-history-modal footer .primary-button').click(); +await page.waitForFunction(()=>!document.querySelector('.agent-history-modal')); +const after=await page.evaluate(()=>window.fixtureCalls.filter(c=>c.cmd==='update_agent_config').length); +assert.equal(before,after); +assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isEnabled()); +await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?client=claude-code'); +await page.getByText('Claude Opus 5',{exact:true}).first().waitFor(); +assert.equal(await page.locator('textarea').count(),0); +await page.screenshot({path:'misc/agent-history-claude.png',fullPage:true}); +await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?embedded&fresh'); +await page.getByRole('button',{name:'一键接入',exact:true}).waitFor(); +await page.getByRole('button',{name:'一键接入',exact:true}).click(); +await page.getByText('配置已更新,历史备份已保存。',{exact:true}).waitFor(); +assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isEnabled()); +await page.screenshot({path:'misc/agent-history-onboarding.png',fullPage:true}); +await page.setViewportSize({width:540,height:820}); +await page.getByRole('button',{name:'配置历史',exact:true}).click(); +await page.locator('.agent-history-columns nav button').first().click(); +await page.locator('.agent-history-changes table').waitFor(); +await page.screenshot({path:'misc/agent-history-narrow.png',fullPage:true}); +await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?state=needs-update'); +await page.getByRole('button',{name:'修复接入',exact:true}).waitFor(); +assert.equal(await page.getByRole('button',{name:'一键接入',exact:true}).count(),0); +await page.getByText('当前接入:CPA 配置需要修复',{exact:true}).waitFor(); +await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?state=invalid'); +await page.getByRole('button',{name:'更新配置',exact:true}).waitFor(); +assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isDisabled()); +assert.equal(await page.getByRole('button',{name:'一键接入',exact:true}).count(),0); +assert.ok(await page.getByRole('button',{name:'重建配置',exact:true}).isEnabled()); +assert.deepEqual(errors,[]); +console.log('PASS: first/repeated/no-op update, fixed button, original Claude form, history preview/restore, read-only refresh, onboarding, narrow layout'); +await browser.close();})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/fixtures/agent-history.html b/tests/fixtures/agent-history.html new file mode 100644 index 0000000..7d1906e --- /dev/null +++ b/tests/fixtures/agent-history.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/tests/fixtures/agent-history.tsx b/tests/fixtures/agent-history.tsx new file mode 100644 index 0000000..978499a --- /dev/null +++ b/tests/fixtures/agent-history.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { AgentsPage } from '../../src/pages/AgentsPage'; +import { I18nProvider } from '../../src/i18n'; +import '../../src/styles.css'; +const params = new URLSearchParams(location.search); +localStorage.setItem('easy-cli-proxy-api.locale','zh-CN'); +localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex'); +const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi']; +let count=0; let currentModel=params.has('fresh')?null:'gpt-one'; +const calls:any[]=[];(window as any).fixtureCalls=calls; +mockIPC(async (cmd,args:any) => { + calls.push({cmd,args}); + if(cmd==='plugin:event|listen') return 1; + if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null; + if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:true,pluginInstalled:true,launchTargets:[{id:'cli',label:'CLI',detail:'test'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:false,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:null,claudeDesktopModelMappings:null,warnings:[],error:null})); + if(cmd==='get_agent_models') return [{name:'gpt-one'},{name:'gpt-two'}]; + if(cmd==='get_deepseek_harness_process_status') return {running:false,pid:null,mode:null}; + if(cmd==='update_agent_config') {count++;currentModel=args.model;return {outcome:count>1?'unchanged':'updated',historyVersion:'2',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};} + if(cmd==='list_agent_config_history') return {versions:[{id:'1',createdAt:'2026-09-09T12:00:00Z',source:'before-update',model:'gpt-two',fileCount:3}],warnings:[]}; + if(cmd==='preview_agent_config_history') return {revision:'rev1',differences:[{file:'C:/test/.codex/config.toml',field:'model',before:'gpt-one',after:'gpt-two'},{file:'C:/test/.codex/auth.json',field:'OPENAI_API_KEY',before:'••••••',after:'••••••'}]}; + if(cmd==='restore_agent_config_history') {currentModel='gpt-two';return {outcome:'updated'};} + if(cmd==='check_pi_provider_update') return {installedVersion:'1.0',latestVersion:'1.0',updateAvailable:false}; + if(cmd==='get_codex_model_catalog_editor') return {models:[],hiddenModels:[],customizations:{}}; + if(cmd==='check_codex_oauth_login')return null; + throw new Error('Unhandled fixture command: '+cmd); +}); +createRoot(document.getElementById('root')!).render();