17 files changed
+5771
-433
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
7.2.99
|
||||
7.2.101
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cpa-gui",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpa-gui"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cpa-gui"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
description = "CPA desktop management shell"
|
||||
authors = ["lzt404"]
|
||||
edition = "2021"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+307
-30
@@ -1,5 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod codex_sessions;
|
||||
mod usage;
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
@@ -439,6 +440,7 @@ struct GuiConfigFile {
|
||||
usage_statistics_enabled: bool,
|
||||
plugins_enabled: bool,
|
||||
routing_strategy: String,
|
||||
codex_session_repair_on_launch: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
@@ -501,6 +503,7 @@ impl Default for GuiConfigFile {
|
||||
usage_statistics_enabled: true,
|
||||
plugins_enabled: false,
|
||||
routing_strategy: "round-robin".to_string(),
|
||||
codex_session_repair_on_launch: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,6 +518,7 @@ struct GuiConfigPresence {
|
||||
auth_dir: Option<String>,
|
||||
api_keys: Option<Vec<GuiApiKeyInput>>,
|
||||
management_secret_key: Option<String>,
|
||||
codex_session_repair_on_launch: Option<bool>,
|
||||
usage_statistics_enabled: Option<bool>,
|
||||
plugins_enabled: Option<bool>,
|
||||
routing_strategy: Option<String>,
|
||||
@@ -1154,6 +1158,13 @@ impl GuiConfigState {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_codex_session_repair_on_launch(&self, enabled: bool) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.codex_session_repair_on_launch = enabled;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locale(&self, locale: String) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.locale = normalize_app_locale(&locale).to_string();
|
||||
@@ -1577,6 +1588,7 @@ async fn apply_agent_config(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
model: String,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let client = AgentClient::parse(&client)?;
|
||||
let home = app
|
||||
@@ -1596,7 +1608,7 @@ async fn apply_agent_config(
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
apply_agent_configuration(
|
||||
apply_agent_configuration_with_oauth(
|
||||
client,
|
||||
&home,
|
||||
config.port,
|
||||
@@ -1604,6 +1616,7 @@ async fn apply_agent_config(
|
||||
&model,
|
||||
&available_models,
|
||||
codex_models.as_deref(),
|
||||
oauth_configuration,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1613,6 +1626,7 @@ async fn reset_agent_config_to_default(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
model: String,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let client = AgentClient::parse(&client)?;
|
||||
let home = app
|
||||
@@ -1632,16 +1646,29 @@ async fn reset_agent_config_to_default(
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
reset_agent_configuration_to_default(
|
||||
reset_agent_configuration_to_default_with_oauth(
|
||||
client,
|
||||
&home,
|
||||
config.port,
|
||||
api_key,
|
||||
&model,
|
||||
codex_models.as_deref(),
|
||||
oauth_configuration,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_codex_config(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
clear_codex_config_files(&home)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_agent_config_enabled(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1728,7 +1755,7 @@ async fn update_agent_config(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn launch_agent(
|
||||
async fn launch_agent(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
@@ -1750,6 +1777,14 @@ fn launch_agent(
|
||||
status.modification_enabled,
|
||||
&status.modification_state,
|
||||
)?;
|
||||
if client == AgentClient::Codex && config.codex_session_repair_on_launch && status.configured {
|
||||
let repair_home = home.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
codex_sessions::repair_before_codex_launch(&repair_home)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("启动前会话修复任务失败: {error}"))??;
|
||||
}
|
||||
let requested_target = target
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
@@ -1796,7 +1831,7 @@ fn validate_agent_launch_modification(
|
||||
enabled: bool,
|
||||
state: &str,
|
||||
) -> Result<(), String> {
|
||||
if enabled && state == "applied" {
|
||||
if client == AgentClient::Codex || (enabled && state == "applied") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
@@ -2118,6 +2153,37 @@ fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_codex_config_file(path: &Path) -> Result<bool, String> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(format!(
|
||||
"删除 Codex 配置文件失败 {}: {error}",
|
||||
path_to_string(path)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_codex_config_files(home: &Path) -> Result<Vec<String>, String> {
|
||||
let codex_dir = home.join(".codex");
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// The state file belongs to this app. Remove it after the Codex files are
|
||||
// cleared so the UI does not report the deliberate deletion as an external edit.
|
||||
let state_path = agent_state_path(std::slice::from_ref(&config_path))?;
|
||||
remove_codex_config_file(&state_path)?;
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
fn codex_model_catalog_path(home: &Path) -> PathBuf {
|
||||
home.join(".codex").join(CODEX_MODEL_CATALOG_FILE)
|
||||
}
|
||||
@@ -3231,6 +3297,15 @@ fn inspect_codex_agent_config(
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID))
|
||||
.and_then(toml::Value::as_table);
|
||||
let uses_api_key = provider
|
||||
.and_then(|provider| provider.get("experimental_bearer_token"))
|
||||
.and_then(toml::Value::as_str)
|
||||
== Some(api_key);
|
||||
let uses_oauth = provider
|
||||
.and_then(|provider| provider.get("requires_openai_auth"))
|
||||
.and_then(toml::Value::as_bool)
|
||||
== Some(true)
|
||||
&& provider.is_some_and(|provider| !provider.contains_key("experimental_bearer_token"));
|
||||
let configured = root.get("model_provider").and_then(toml::Value::as_str)
|
||||
== Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
&& root.get("model_catalog_json").and_then(toml::Value::as_str)
|
||||
@@ -3243,10 +3318,7 @@ fn inspect_codex_agent_config(
|
||||
.and_then(|provider| provider.get("base_url"))
|
||||
.and_then(toml::Value::as_str)
|
||||
== Some(expected_base.as_str())
|
||||
&& provider
|
||||
.and_then(|provider| provider.get("experimental_bearer_token"))
|
||||
.and_then(toml::Value::as_str)
|
||||
== Some(api_key)
|
||||
&& (uses_api_key || uses_oauth)
|
||||
&& provider
|
||||
.and_then(|provider| provider.get("wire_api"))
|
||||
.and_then(toml::Value::as_str)
|
||||
@@ -3382,6 +3454,7 @@ fn inspect_hermes_agent_config(
|
||||
Ok((configured, model))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_agent_updates(
|
||||
client: AgentClient,
|
||||
home: &Path,
|
||||
@@ -3390,6 +3463,28 @@ fn build_agent_updates(
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
) -> Result<Vec<AgentFileUpdate>, String> {
|
||||
build_agent_updates_with_oauth(
|
||||
client,
|
||||
home,
|
||||
port,
|
||||
api_key,
|
||||
model,
|
||||
models,
|
||||
codex_models,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_agent_updates_with_oauth(
|
||||
client: AgentClient,
|
||||
home: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<Vec<AgentFileUpdate>, String> {
|
||||
let paths = agent_config_paths(client, home);
|
||||
let root_base = format!("http://127.0.0.1:{port}");
|
||||
@@ -3445,9 +3540,22 @@ fn build_agent_updates(
|
||||
}
|
||||
AgentClient::Codex => {
|
||||
let before = read_optional_text(&paths[0])?;
|
||||
let after =
|
||||
build_codex_agent_config(before.as_deref(), &openai_base, api_key, model)
|
||||
.or_else(|_| build_codex_agent_config(None, &openai_base, api_key, model))?;
|
||||
let after = build_codex_agent_config_with_oauth(
|
||||
before.as_deref(),
|
||||
&openai_base,
|
||||
api_key,
|
||||
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,
|
||||
@@ -3717,11 +3825,22 @@ fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String, String> {
|
||||
render_agent_json(root, "Claude Desktop 配置索引")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_codex_agent_config(
|
||||
existing: Option<&str>,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
) -> Result<String, String> {
|
||||
build_codex_agent_config_with_oauth(existing, base_url, api_key, model, false)
|
||||
}
|
||||
|
||||
fn build_codex_agent_config_with_oauth(
|
||||
existing: Option<&str>,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<String, String> {
|
||||
use toml_edit::{value, Document, Item, Table};
|
||||
|
||||
@@ -3766,7 +3885,13 @@ fn build_codex_agent_config(
|
||||
set_codex_table_item(provider, "name", value("EasyCLIProxyAPI"));
|
||||
set_codex_table_item(provider, "base_url", value(base_url));
|
||||
set_codex_table_item(provider, "wire_api", value("responses"));
|
||||
set_codex_table_item(provider, "experimental_bearer_token", value(api_key));
|
||||
if oauth_configuration {
|
||||
provider.remove("experimental_bearer_token");
|
||||
set_codex_table_item(provider, "requires_openai_auth", value(true));
|
||||
} else {
|
||||
provider.remove("requires_openai_auth");
|
||||
set_codex_table_item(provider, "experimental_bearer_token", value(api_key));
|
||||
}
|
||||
let rendered = document.to_string();
|
||||
toml::from_str::<toml::Value>(&rendered)
|
||||
.map_err(|error| format!("验证 Codex 配置失败: {error}"))?;
|
||||
@@ -3776,8 +3901,13 @@ fn build_codex_agent_config(
|
||||
#[cfg(test)]
|
||||
const CODEX_MANAGED_ROOT_KEYS: [&str; 3] = ["model_provider", "model", "model_catalog_json"];
|
||||
#[cfg(test)]
|
||||
const CODEX_MANAGED_PROVIDER_KEYS: [&str; 4] =
|
||||
["name", "base_url", "wire_api", "experimental_bearer_token"];
|
||||
const CODEX_MANAGED_PROVIDER_KEYS: [&str; 5] = [
|
||||
"name",
|
||||
"base_url",
|
||||
"wire_api",
|
||||
"experimental_bearer_token",
|
||||
"requires_openai_auth",
|
||||
];
|
||||
|
||||
fn set_codex_table_item(table: &mut toml_edit::Table, key: &str, mut item: toml_edit::Item) {
|
||||
if let (Some(current), Some(next)) = (
|
||||
@@ -4415,16 +4545,22 @@ fn agent_managed_projection(
|
||||
.get("model_providers")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
|
||||
let provider_projection = ["name", "base_url", "wire_api", "experimental_bearer_token"]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let value = provider
|
||||
.and_then(|provider| provider.get(key))
|
||||
.and_then(|value| serde_json::to_value(value).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
(key.to_string(), value)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let provider_projection = [
|
||||
"name",
|
||||
"base_url",
|
||||
"wire_api",
|
||||
"experimental_bearer_token",
|
||||
"requires_openai_auth",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let value = provider
|
||||
.and_then(|provider| provider.get(key))
|
||||
.and_then(|value| serde_json::to_value(value).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
(key.to_string(), value)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let root_value = |key: &str| {
|
||||
root.get(key)
|
||||
.and_then(|value| serde_json::to_value(value).ok())
|
||||
@@ -5005,11 +5141,22 @@ fn inspect_agent_modification(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn fresh_agent_contents(
|
||||
client: AgentClient,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
fresh_agent_contents_with_oauth(client, port, api_key, model, false)
|
||||
}
|
||||
|
||||
fn fresh_agent_contents_with_oauth(
|
||||
client: AgentClient,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let root_base = format!("http://127.0.0.1:{port}");
|
||||
let openai_base = format!("{root_base}/v1");
|
||||
@@ -5027,11 +5174,12 @@ fn fresh_agent_contents(
|
||||
build_claude_desktop_profile(None, &root_base, api_key, model, &models)?,
|
||||
build_claude_desktop_meta(None)?,
|
||||
]),
|
||||
AgentClient::Codex => Ok(vec![build_codex_agent_config(
|
||||
AgentClient::Codex => Ok(vec![build_codex_agent_config_with_oauth(
|
||||
None,
|
||||
&openai_base,
|
||||
api_key,
|
||||
model,
|
||||
oauth_configuration,
|
||||
)?]),
|
||||
AgentClient::OpenCode => Ok(vec![build_opencode_agent_config(
|
||||
None,
|
||||
@@ -5591,10 +5739,42 @@ fn apply_agent_configuration(
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let updates = build_agent_updates(client, home, port, api_key, model, models, codex_models)?;
|
||||
apply_agent_configuration_with_oauth(
|
||||
client,
|
||||
home,
|
||||
port,
|
||||
api_key,
|
||||
model,
|
||||
models,
|
||||
codex_models,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_agent_configuration_with_oauth(
|
||||
client: AgentClient,
|
||||
home: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let updates = build_agent_updates_with_oauth(
|
||||
client,
|
||||
home,
|
||||
port,
|
||||
api_key,
|
||||
model,
|
||||
models,
|
||||
codex_models,
|
||||
oauth_configuration,
|
||||
)?;
|
||||
commit_agent_configuration(client, home, model, &updates, "applied")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reset_agent_configuration_to_default(
|
||||
client: AgentClient,
|
||||
home: &Path,
|
||||
@@ -5602,9 +5782,30 @@ fn reset_agent_configuration_to_default(
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
reset_agent_configuration_to_default_with_oauth(
|
||||
client,
|
||||
home,
|
||||
port,
|
||||
api_key,
|
||||
model,
|
||||
codex_models,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_agent_configuration_to_default_with_oauth(
|
||||
client: AgentClient,
|
||||
home: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let paths = agent_config_paths(client, home);
|
||||
let contents = fresh_agent_contents(client, port, api_key, model)?;
|
||||
let contents =
|
||||
fresh_agent_contents_with_oauth(client, port, api_key, model, oauth_configuration)?;
|
||||
if paths.len() != contents.len() {
|
||||
return Err("智能体默认配置文件数量不匹配".to_string());
|
||||
}
|
||||
@@ -10214,6 +10415,9 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
if presence.locale.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
if presence.codex_session_repair_on_launch.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
changed |= sanitize_gui_config(&mut config)?;
|
||||
validate_gui_config(&config)?;
|
||||
if changed {
|
||||
@@ -10521,6 +10725,10 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul
|
||||
),
|
||||
("plugins-enabled", value(config.plugins_enabled)),
|
||||
("routing-strategy", value(config.routing_strategy.as_str())),
|
||||
(
|
||||
"codex-session-repair-on-launch",
|
||||
value(config.codex_session_repair_on_launch),
|
||||
),
|
||||
] {
|
||||
set_codex_table_item(root, key, item);
|
||||
}
|
||||
@@ -12756,6 +12964,7 @@ fn main() {
|
||||
delete_thinking_alias,
|
||||
apply_agent_config,
|
||||
reset_agent_config_to_default,
|
||||
clear_codex_config,
|
||||
set_agent_config_enabled,
|
||||
update_agent_config,
|
||||
launch_agent,
|
||||
@@ -12792,7 +13001,15 @@ fn main() {
|
||||
usage::get_usage_events,
|
||||
start_core_process,
|
||||
stop_core_process,
|
||||
restart_core_process
|
||||
restart_core_process,
|
||||
codex_sessions::list_codex_sessions,
|
||||
codex_sessions::delete_codex_sessions,
|
||||
codex_sessions::repair_codex_session_metadata,
|
||||
codex_sessions::preview_codex_session_index_cleanup,
|
||||
codex_sessions::apply_codex_session_index_cleanup,
|
||||
codex_sessions::get_codex_session_repair_on_launch,
|
||||
codex_sessions::set_codex_session_repair_on_launch,
|
||||
codex_sessions::close_chatgpt_app
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build app");
|
||||
@@ -12960,6 +13177,31 @@ mod tests {
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_codex_config_removes_only_requested_config_files_and_application_state() {
|
||||
let home = agent_test_home("clear-codex-config");
|
||||
let codex_dir = home.join(".codex");
|
||||
fs::create_dir_all(&codex_dir).unwrap();
|
||||
let auth_path = codex_dir.join("auth.json");
|
||||
let config_path = codex_dir.join("config.toml");
|
||||
let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap();
|
||||
let preserved_path = codex_dir.join("history.jsonl");
|
||||
fs::write(&auth_path, "{}").unwrap();
|
||||
fs::write(&config_path, "model = \"gpt-test\"\n").unwrap();
|
||||
fs::write(&state_path, "{}").unwrap();
|
||||
fs::write(&preserved_path, "keep").unwrap();
|
||||
|
||||
let deleted = clear_codex_config_files(&home).unwrap();
|
||||
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!auth_path.exists());
|
||||
assert!(!config_path.exists());
|
||||
assert!(!state_path.exists());
|
||||
assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep");
|
||||
assert!(clear_codex_config_files(&home).unwrap().is_empty());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_agent_apply_rebuilds_an_unparseable_file_without_backup() {
|
||||
let home = agent_test_home("invalid-apply");
|
||||
@@ -13092,6 +13334,7 @@ mod tests {
|
||||
auth_dir: path_to_string(&home.join("custom-auth")),
|
||||
management_secret_key: "custom-secret".to_string(),
|
||||
usage_statistics_enabled: false,
|
||||
codex_session_repair_on_launch: true,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
@@ -13104,6 +13347,7 @@ mod tests {
|
||||
assert!(content.contains("host = \"0.0.0.0\""));
|
||||
assert!(content.contains("management-secret-key = \"custom-secret\""));
|
||||
assert!(content.contains("usage-statistics-enabled = false"));
|
||||
assert!(content.contains("codex-session-repair-on-launch = true"));
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
@@ -13169,6 +13413,7 @@ mod tests {
|
||||
assert!(content.contains("management-secret-key = \"123456\""));
|
||||
assert!(content.contains("plugins-enabled = false"));
|
||||
assert!(content.contains("routing-strategy = \"round-robin\""));
|
||||
assert!(content.contains("codex-session-repair-on-launch = false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -13277,6 +13522,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_configuration_uses_openai_auth_without_bearer_token() {
|
||||
let rendered = build_codex_agent_config_with_oauth(
|
||||
Some("[model_providers.cpa-gui]\nexperimental_bearer_token = \"old-key\"\n"),
|
||||
"http://127.0.0.1:8317/v1",
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-test",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let value = toml::from_str::<toml::Value>(&rendered).unwrap();
|
||||
let provider = value["model_providers"][MANAGED_AGENT_PROVIDER_ID]
|
||||
.as_table()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.get("requires_openai_auth")
|
||||
.and_then(toml::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
assert!(!provider.contains_key("experimental_bearer_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_config_builds_gateway_profile_and_index() {
|
||||
let models = test_agent_models(&["claude-sonnet-test", "claude-opus-test"]);
|
||||
@@ -14960,6 +15229,7 @@ custom_option = "keep-original"
|
||||
usage_statistics_enabled: false,
|
||||
plugins_enabled: true,
|
||||
routing_strategy: "fill-first".to_string(),
|
||||
codex_session_repair_on_launch: false,
|
||||
};
|
||||
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
|
||||
|
||||
@@ -15331,13 +15601,20 @@ custom_option = "keep-original"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_launch_requires_an_active_managed_configuration() {
|
||||
fn codex_launch_is_independent_from_managed_configuration_state() {
|
||||
assert!(validate_agent_launch_modification(AgentClient::Codex, true, "applied").is_ok());
|
||||
assert!(validate_agent_launch_modification(AgentClient::Codex, false, "applied").is_err());
|
||||
assert!(
|
||||
validate_agent_launch_modification(AgentClient::Codex, false, "unconfigured").is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_agent_launch_modification(AgentClient::Codex, true, "external-changed",)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_agent_launch_modification(AgentClient::OpenCode, false, "unconfigured")
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_agent_launch_modification(AgentClient::OpenCode, true, "applied").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1385,6 +1385,7 @@ mod tests {
|
||||
usage_statistics_enabled: true,
|
||||
plugins_enabled: false,
|
||||
routing_strategy: "round-robin".to_string(),
|
||||
codex_session_repair_on_launch: false,
|
||||
};
|
||||
let record = normalize_usage_record(
|
||||
serde_json::json!({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "EasyCLIProxyAPI",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"identifier": "com.cpa.gui",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -14,8 +14,8 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "EasyCLIProxyAPI",
|
||||
"width": 1200,
|
||||
"height": 810,
|
||||
"width": 1380,
|
||||
"height": 815,
|
||||
"minWidth": 640,
|
||||
"minHeight": 600
|
||||
}
|
||||
|
||||
+86
-14
@@ -360,15 +360,7 @@ export const jaOverrides = {
|
||||
'agents.error.modelsLoading': 'モデル一覧を読み込み中です。しばらくしてから再試行してください',
|
||||
'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません',
|
||||
'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください',
|
||||
'agents.notice.enabled': '{name} の設定変更を適用しました',
|
||||
'agents.notice.applied': '{name} の設定変更を適用しました',
|
||||
'agents.notice.defaultWritten': '{name} のデフォルト設定を書き込みました',
|
||||
'agents.notice.updated': '{name} の設定変更を更新しました',
|
||||
'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください',
|
||||
'agents.notice.appliedLaunch': '{name} にモデルを適用して起動しました',
|
||||
'agents.notice.syncedLaunch': '{name} の管理対象設定を同期して起動しました',
|
||||
'agents.notice.managedLaunch': '{name} を設定変更が適用された状態で起動しました',
|
||||
'agents.notice.launched': '{name} を起動しました',
|
||||
'agents.status.detecting': '検出中',
|
||||
'agents.status.unsupported': 'プラットフォーム非対応',
|
||||
'agents.status.notInstalled': '未インストール',
|
||||
@@ -381,6 +373,77 @@ export const jaOverrides = {
|
||||
'agents.status.unconfigured': '未設定',
|
||||
'agents.title': 'エージェント設定',
|
||||
'agents.redetect': '再検出',
|
||||
'agents.tabs.label': 'エージェント設定ページ',
|
||||
'agents.tabs.core': 'コア設定',
|
||||
'agents.tabs.sessions': 'セッション管理',
|
||||
'agents.sessions.title': 'Codex セッション管理',
|
||||
'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。',
|
||||
'agents.sessions.refresh': '更新',
|
||||
'agents.sessions.currentPage': '現在のページ',
|
||||
'agents.sessions.active': '未アーカイブ',
|
||||
'agents.sessions.archived': 'アーカイブ済み',
|
||||
'agents.sessions.database': 'データベース',
|
||||
'agents.sessions.databaseMissing': 'セッションデータベースが見つかりません',
|
||||
'agents.sessions.repairTitle': '過去のセッションを復元',
|
||||
'agents.sessions.repairDescription': '他の Provider のセッションを現在のセッション Provider に同期します。',
|
||||
'agents.sessions.autoRepair': 'Codex 起動前に過去のセッションを自動復元',
|
||||
'agents.sessions.autoRepairEnabled': '起動前の自動復元を有効にしました。',
|
||||
'agents.sessions.autoRepairDisabled': '起動前の自動復元を無効にしました。',
|
||||
'agents.sessions.settingLoading': '読み込み中',
|
||||
'agents.sessions.settingOn': 'オン',
|
||||
'agents.sessions.settingOff': 'オフ',
|
||||
'agents.sessions.settingLoadFailed': '自動復元設定の読み込みに失敗しました:{error}',
|
||||
'agents.sessions.applyManagedFirst': '先に Codex の CPA 管理設定を適用してください',
|
||||
'agents.sessions.repairNow': '今すぐ復元',
|
||||
'agents.sessions.repairing': '復元中',
|
||||
'agents.sessions.loadFailed': 'Codex セッションの読み込みに失敗しました:{error}',
|
||||
'agents.sessions.localSessions': 'ローカルセッション',
|
||||
'agents.sessions.pageDescription': '{page} ページ目・1 ページ最大 {size} 件',
|
||||
'agents.sessions.selected': '{count} 件選択中',
|
||||
'agents.sessions.selectAll': 'このページをすべて選択',
|
||||
'agents.sessions.clearSelection': '選択を解除',
|
||||
'agents.sessions.deleteSelected': '選択項目を削除',
|
||||
'agents.sessions.multiSelect': '複数選択',
|
||||
'agents.sessions.loading': 'ローカルセッションをスキャンしています…',
|
||||
'agents.sessions.empty': 'Codex セッションが見つかりません。',
|
||||
'agents.sessions.selectSession': 'セッション {title} を選択',
|
||||
'agents.sessions.untitled': '無題のセッション',
|
||||
'agents.sessions.noProject': 'プロジェクトパスなし',
|
||||
'agents.sessions.noProvider': '不明な Provider',
|
||||
'agents.sessions.noTime': '更新時刻不明',
|
||||
'agents.sessions.activeBadge': '未アーカイブ',
|
||||
'agents.sessions.archivedBadge': 'アーカイブ済み',
|
||||
'agents.sessions.previousPage': '前のページ',
|
||||
'agents.sessions.nextPage': '次のページ',
|
||||
'agents.sessions.pageNumber': '{page} ページ目',
|
||||
'agents.sessions.deleteOneTitle': 'このセッションを削除しますか?',
|
||||
'agents.sessions.deleteManyTitle': '{count} 件のセッションを削除しますか?',
|
||||
'agents.sessions.deleteOneDescription': 'バックアップを作成してから「{title}」と既知の関連記録を削除します。アプリ内で元に戻すことはできません。',
|
||||
'agents.sessions.deleteManyDescription': 'バックアップを作成してから、次の {count} 件と既知の関連記録を削除します:{preview}{extra}',
|
||||
'agents.sessions.deleteMore': '、ほか {count} 件',
|
||||
'agents.sessions.confirmDelete': 'バックアップして削除',
|
||||
'agents.sessions.deleteResult': '{deleted} 件を削除、{failed} 件失敗。{details}{backup}',
|
||||
'agents.sessions.deleteFailed': 'セッションの削除に失敗しました:{error}',
|
||||
'agents.sessions.backupAt': 'バックアップ先:{path}',
|
||||
'agents.sessions.settingFailed': '自動復元設定の保存に失敗しました:{error}',
|
||||
'agents.sessions.repairResult': '過去のセッションを {provider} に復元しました:rollout {files} ファイルを移行、データベース {rows} 行を更新、使用中の {skipped} ファイルをスキップしました。',
|
||||
'agents.sessions.repairFailed': '過去のセッションの復元に失敗しました:{error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': 'セッションをスキャン中',
|
||||
'agents.sessions.progress.backingUp': 'バックアップを作成中',
|
||||
'agents.sessions.progress.rewriting': '過去のセッションを移行中',
|
||||
'agents.sessions.progress.updatingDatabase': 'データベースを同期中',
|
||||
'agents.sessions.progress.complete': '復元完了',
|
||||
'agents.sessions.scanIndex': '無効なセッション記録を確認',
|
||||
'agents.sessions.scanningIndex': '無効な記録を確認中',
|
||||
'agents.sessions.cleanupNoCandidates': '安全に削除できる無効なセッション記録はありません。',
|
||||
'agents.sessions.cleanupPreviewFailed': '無効なセッション記録の確認に失敗しました:{error}',
|
||||
'agents.sessions.cleanupTitle': '削除可能な無効な索引',
|
||||
'agents.sessions.cleanupDescription': 'session_index.jsonl のみに存在し、実際のセッション元がなくなった記録が {count} 件見つかりました。初期状態では未選択です。Codex と ChatGPT を終了し、項目ごとに確認して削除してください。不明な形式の記録は保持されます。',
|
||||
'agents.sessions.cleanupSelectAll': '候補をすべて選択',
|
||||
'agents.sessions.cleanupConfirm': '選択した {count} 件を削除',
|
||||
'agents.sessions.cleanupResult': '無効なセッション索引を {count} 件削除しました。{backup}',
|
||||
'agents.sessions.cleanupFailed': '無効なセッション索引の削除に失敗しました:{error}',
|
||||
'agents.localClients': 'ローカルクライアント',
|
||||
'agents.selectClient': '管理するエージェントを選択',
|
||||
'agents.installStatus': 'インストール状態',
|
||||
@@ -389,37 +452,46 @@ export const jaOverrides = {
|
||||
'agents.clientVersion': 'クライアントバージョン',
|
||||
'agents.notFetched': '未取得',
|
||||
'agents.useModel': '使用モデル',
|
||||
'agents.useModelDescription': 'コアで公開中のモデルを同期し、選択したモデルを既定に設定します',
|
||||
'agents.pending': '未適用',
|
||||
'agents.model.readingAvailable': '利用可能なモデルを読み込んでいます',
|
||||
'agents.model.cannotConfigure': '選択可能なモデルがないため、設定変更を適用できません',
|
||||
'agents.model.current': '現在適用中:{model}',
|
||||
'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します',
|
||||
'agents.modify.title': '設定変更',
|
||||
'agents.modify.oauthConfiguration': 'OAuth 設定方式',
|
||||
'agents.modify.apply': '設定変更を適用',
|
||||
'agents.modify.default': 'デフォルト設定',
|
||||
'agents.modify.clear': '設定を消去',
|
||||
'agents.modify.externalChanged': '外部の設定変更を検出し、その内容を保持しています。「設定変更を適用」をクリックするまで管理対象フィールドは書き換えません。',
|
||||
'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
|
||||
'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。',
|
||||
'agents.modify.unconfigured': 'モデルを選択して設定変更を適用してください。設定のコピーやバックアップは作成しません。',
|
||||
'agents.footer.changed': 'モデル選択が変更されました。先に「設定変更を適用」をクリックしてください',
|
||||
'agents.footer.changed': 'モデル選択が変更されました。設定変更を適用すると新しいモデルが使用されます。',
|
||||
'agents.footer.applied': '設定変更は適用済みです。アプリは関連フィールドだけを管理します。',
|
||||
'agents.footer.externalChanged': '外部変更を保持しています。再適用するまでこのクライアントは起動できません。',
|
||||
'agents.footer.externalChanged': '外部変更を保持しています。設定変更を適用すると管理対象フィールドが再度書き込まれます。',
|
||||
'agents.footer.invalidManaged': '設定が無効です。変更を適用して修復するか、デフォルト設定にリセットしてください。',
|
||||
'agents.footer.unsupported': '現在のシステムではこのクライアントを設定できません',
|
||||
'agents.footer.installFirst': 'クライアントをインストールしてから再検出してください',
|
||||
'agents.footer.noCommand': '設定ファイルは検出しましたが、クライアントコマンドが見つかりません',
|
||||
'agents.footer.invalidConfig': '元の設定形式が無効なため、安全に変更できません',
|
||||
'agents.footer.enableFirst': '先に「設定変更を適用」をクリックし、CPA モデル設定を反映してからクライアントを起動してください',
|
||||
'agents.launchMethods': 'Codex の起動方法',
|
||||
'agents.launch.enableFirst': '先に設定変更を適用してください',
|
||||
'agents.launch.applyFirst': '先に選択中のモデルを適用してください',
|
||||
'agents.launch.starting': '起動中',
|
||||
'agents.launch.start': '{target} を起動',
|
||||
'agents.launch.unavailable': '起動できません',
|
||||
'agents.launch.closeChatgpt': 'ChatGPT App を終了',
|
||||
'agents.launch.closingChatgpt': '終了中',
|
||||
'agents.default.title': 'デフォルト設定にリセット',
|
||||
'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。バックアップは作成されず、元に戻せません。',
|
||||
'agents.default.confirm': 'デフォルト設定を書き込む',
|
||||
'agents.clear.title': 'Codex 設定を消去',
|
||||
'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除し、このアプリが記録した適用状態をリセットします。バックアップは作成されず、元に戻せません。',
|
||||
'agents.clear.confirm': '設定を消去',
|
||||
'agents.clear.success': 'Codex 設定を消去しました。',
|
||||
'agents.closeApp.title': 'ChatGPT App を終了',
|
||||
'agents.closeApp.description': 'このコンピューターで実行中の ChatGPT App プロセスとその子プロセスをすべて終了します。生成中の応答や未保存の状態が失われる可能性があります。',
|
||||
'agents.closeApp.confirm': 'App を終了',
|
||||
'agents.closeApp.success': 'ChatGPT App を終了しました({count} プロセス)。',
|
||||
'agents.closeApp.notRunning': 'ChatGPT App は実行されていません。',
|
||||
'apiAccess.compatibleName': 'OpenAI 互換 {number}',
|
||||
'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません',
|
||||
'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です',
|
||||
|
||||
+172
-28
@@ -363,15 +363,7 @@ export const zhCN = {
|
||||
'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试',
|
||||
'agents.error.noModels': '当前没有可选模型,无法应用配置修改',
|
||||
'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择',
|
||||
'agents.notice.enabled': '{name} 的配置修改已应用',
|
||||
'agents.notice.applied': '{name} 的配置修改已应用',
|
||||
'agents.notice.defaultWritten': '{name} 的默认配置已写入',
|
||||
'agents.notice.updated': '{name} 的配置修改已更新',
|
||||
'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”',
|
||||
'agents.notice.appliedLaunch': '{name} 已应用模型并启动',
|
||||
'agents.notice.syncedLaunch': '{name} 已同步受管配置并启动',
|
||||
'agents.notice.managedLaunch': '{name} 已启动,配置修改仍然生效',
|
||||
'agents.notice.launched': '{name} 已启动',
|
||||
'agents.status.detecting': '检测中',
|
||||
'agents.status.unsupported': '平台不支持',
|
||||
'agents.status.notInstalled': '未安装',
|
||||
@@ -384,6 +376,77 @@ export const zhCN = {
|
||||
'agents.status.unconfigured': '尚未配置',
|
||||
'agents.title': '智能体配置',
|
||||
'agents.redetect': '重新检测',
|
||||
'agents.tabs.label': '智能体配置子页面',
|
||||
'agents.tabs.core': '核心配置',
|
||||
'agents.tabs.sessions': '会话管理',
|
||||
'agents.sessions.title': 'Codex 会话管理',
|
||||
'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。',
|
||||
'agents.sessions.refresh': '刷新',
|
||||
'agents.sessions.currentPage': '当前页',
|
||||
'agents.sessions.active': '未归档',
|
||||
'agents.sessions.archived': '已归档',
|
||||
'agents.sessions.database': '数据库来源',
|
||||
'agents.sessions.databaseMissing': '未发现会话数据库',
|
||||
'agents.sessions.repairTitle': '恢复历史会话',
|
||||
'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话',
|
||||
'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话',
|
||||
'agents.sessions.autoRepairEnabled': '已开启启动前自动恢复历史会话。',
|
||||
'agents.sessions.autoRepairDisabled': '已关闭启动前自动恢复历史会话。',
|
||||
'agents.sessions.settingLoading': '读取中',
|
||||
'agents.sessions.settingOn': '已开启',
|
||||
'agents.sessions.settingOff': '已关闭',
|
||||
'agents.sessions.settingLoadFailed': '读取自动恢复设置失败:{error}',
|
||||
'agents.sessions.applyManagedFirst': '请先应用 Codex 的 CPA 托管配置',
|
||||
'agents.sessions.repairNow': '立即恢复',
|
||||
'agents.sessions.repairing': '正在恢复',
|
||||
'agents.sessions.loadFailed': '读取 Codex 会话失败:{error}',
|
||||
'agents.sessions.localSessions': '本地会话',
|
||||
'agents.sessions.pageDescription': '第 {page} 页 · 每页最多 {size} 条',
|
||||
'agents.sessions.selected': '已选 {count} 条',
|
||||
'agents.sessions.selectAll': '全选本页',
|
||||
'agents.sessions.clearSelection': '清空选择',
|
||||
'agents.sessions.deleteSelected': '删除所选',
|
||||
'agents.sessions.multiSelect': '多选',
|
||||
'agents.sessions.loading': '正在扫描本地会话…',
|
||||
'agents.sessions.empty': '没有找到可显示的 Codex 会话。',
|
||||
'agents.sessions.selectSession': '选择会话 {title}',
|
||||
'agents.sessions.untitled': '未命名会话',
|
||||
'agents.sessions.noProject': '无项目路径',
|
||||
'agents.sessions.noProvider': '未知 Provider',
|
||||
'agents.sessions.noTime': '更新时间未知',
|
||||
'agents.sessions.activeBadge': '未归档',
|
||||
'agents.sessions.archivedBadge': '已归档',
|
||||
'agents.sessions.previousPage': '上一页',
|
||||
'agents.sessions.nextPage': '下一页',
|
||||
'agents.sessions.pageNumber': '第 {page} 页',
|
||||
'agents.sessions.deleteOneTitle': '删除这个会话?',
|
||||
'agents.sessions.deleteManyTitle': '删除 {count} 个会话?',
|
||||
'agents.sessions.deleteOneDescription': '将先创建备份,再删除“{title}”及其已知关联记录。此操作不提供界面内撤销。',
|
||||
'agents.sessions.deleteManyDescription': '将先创建备份,再删除以下 {count} 个会话及其已知关联记录:{preview}{extra}',
|
||||
'agents.sessions.deleteMore': ',另有 {count} 个',
|
||||
'agents.sessions.confirmDelete': '备份并删除',
|
||||
'agents.sessions.deleteResult': '已删除 {deleted} 个,失败 {failed} 个。{details}{backup}',
|
||||
'agents.sessions.deleteFailed': '删除会话失败:{error}',
|
||||
'agents.sessions.backupAt': '备份位置:{path}',
|
||||
'agents.sessions.settingFailed': '保存自动恢复设置失败:{error}',
|
||||
'agents.sessions.repairResult': '历史会话已恢复到 {provider}:迁移 {files} 个 rollout 文件、更新 {rows} 条数据库记录、跳过 {skipped} 个占用文件。',
|
||||
'agents.sessions.repairFailed': '恢复历史会话失败:{error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': '正在扫描会话',
|
||||
'agents.sessions.progress.backingUp': '正在创建备份',
|
||||
'agents.sessions.progress.rewriting': '正在迁移历史会话',
|
||||
'agents.sessions.progress.updatingDatabase': '正在同步数据库',
|
||||
'agents.sessions.progress.complete': '恢复完成',
|
||||
'agents.sessions.scanIndex': '检查无效会话记录',
|
||||
'agents.sessions.scanningIndex': '正在检查无效记录',
|
||||
'agents.sessions.cleanupNoCandidates': '未发现可安全清理的无效会话记录。',
|
||||
'agents.sessions.cleanupPreviewFailed': '检查无效会话记录失败:{error}',
|
||||
'agents.sessions.cleanupTitle': '可清理的无效索引',
|
||||
'agents.sessions.cleanupDescription': '发现 {count} 条只存在于 session_index.jsonl、但已没有真实会话来源的记录。默认不选择;确认 Codex 和 ChatGPT 已退出后,再逐项清理。未知格式记录会保留。',
|
||||
'agents.sessions.cleanupSelectAll': '选择全部候选',
|
||||
'agents.sessions.cleanupConfirm': '清理所选 {count} 条',
|
||||
'agents.sessions.cleanupResult': '已清理 {count} 条无效会话索引。{backup}',
|
||||
'agents.sessions.cleanupFailed': '清理无效会话索引失败:{error}',
|
||||
'agents.localClients': '本机客户端',
|
||||
'agents.selectClient': '选择需要管理的智能体',
|
||||
'agents.installStatus': '安装状态',
|
||||
@@ -392,37 +455,46 @@ export const zhCN = {
|
||||
'agents.clientVersion': '客户端版本',
|
||||
'agents.notFetched': '未获取',
|
||||
'agents.useModel': '使用模型',
|
||||
'agents.useModelDescription': '同步内核当前开放的模型,并将所选项设为默认模型',
|
||||
'agents.pending': '尚未应用',
|
||||
'agents.model.readingAvailable': '正在读取可用模型',
|
||||
'agents.model.cannotConfigure': '无可选模型,无法应用配置修改',
|
||||
'agents.model.current': '当前已应用:{model}',
|
||||
'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项',
|
||||
'agents.modify.title': '配置修改',
|
||||
'agents.modify.oauthConfiguration': 'Oauth配置方式',
|
||||
'agents.modify.apply': '应用配置修改',
|
||||
'agents.modify.default': '默认配置',
|
||||
'agents.modify.clear': '清空配置',
|
||||
'agents.modify.externalChanged': '检测到外部配置变化。软件会保留这些内容;点击“应用配置修改”后才会重新写入受管字段。',
|
||||
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
|
||||
'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。',
|
||||
'agents.modify.unconfigured': '选择模型后可应用配置修改;不会创建配置副本或备份。',
|
||||
'agents.footer.changed': '模型选择已变化,请先点击“应用配置修改”',
|
||||
'agents.footer.changed': '模型选择已变化;应用配置修改后将使用新模型。',
|
||||
'agents.footer.applied': '配置修改已应用;软件只管理相关字段。',
|
||||
'agents.footer.externalChanged': '外部配置已保留。重新应用前无法启动该客户端。',
|
||||
'agents.footer.externalChanged': '外部配置已保留;应用配置修改后才会重新写入受管字段。',
|
||||
'agents.footer.invalidManaged': '配置当前无效,请应用修改进行修复,或重置为默认配置。',
|
||||
'agents.footer.unsupported': '当前系统无法配置该客户端',
|
||||
'agents.footer.installFirst': '请先安装客户端并重新检测',
|
||||
'agents.footer.noCommand': '检测到配置文件,但没有找到客户端命令',
|
||||
'agents.footer.invalidConfig': '原配置格式异常,无法安全修改',
|
||||
'agents.footer.enableFirst': '请先点击“应用配置修改”,确保 CPA 模型配置生效后再启动客户端',
|
||||
'agents.launchMethods': 'Codex 启动方式',
|
||||
'agents.launch.enableFirst': '请先应用配置修改',
|
||||
'agents.launch.applyFirst': '请先应用当前模型选择',
|
||||
'agents.launch.starting': '启动中',
|
||||
'agents.launch.start': '启动 {target}',
|
||||
'agents.launch.unavailable': '无法启动',
|
||||
'agents.launch.closeChatgpt': '关闭 ChatGPT App',
|
||||
'agents.launch.closingChatgpt': '关闭中',
|
||||
'agents.default.title': '重置为默认配置',
|
||||
'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。该操作没有备份且无法恢复。',
|
||||
'agents.default.confirm': '确认写入默认配置',
|
||||
'agents.clear.title': '清空 Codex 配置',
|
||||
'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml,并重置软件记录的应用状态。该操作没有备份且无法恢复。',
|
||||
'agents.clear.confirm': '确认清空配置',
|
||||
'agents.clear.success': 'Codex 配置已清空。',
|
||||
'agents.closeApp.title': '关闭 ChatGPT App',
|
||||
'agents.closeApp.description': '这会关闭本机所有正在运行的 ChatGPT App 进程及其子进程。正在生成的回复或尚未保存的状态可能丢失。',
|
||||
'agents.closeApp.confirm': '确认关闭 App',
|
||||
'agents.closeApp.success': 'ChatGPT App 已关闭({count} 个进程)。',
|
||||
'agents.closeApp.notRunning': 'ChatGPT App 当前没有运行。',
|
||||
'apiAccess.compatibleName': 'OpenAI 兼容 {number}',
|
||||
'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号',
|
||||
'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效',
|
||||
@@ -1078,15 +1150,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.error.modelsLoading': 'The model list is still loading. Try again shortly.',
|
||||
'agents.error.noModels': 'No models are available, so configuration changes cannot be applied',
|
||||
'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.',
|
||||
'agents.notice.enabled': 'Configuration changes for {name} were applied.',
|
||||
'agents.notice.applied': 'Configuration changes for {name} were applied.',
|
||||
'agents.notice.defaultWritten': 'The default configuration for {name} was written.',
|
||||
'agents.notice.updated': 'Configuration changes for {name} were updated.',
|
||||
'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.',
|
||||
'agents.notice.appliedLaunch': '{name} applied the model and started.',
|
||||
'agents.notice.syncedLaunch': '{name} synchronized the managed configuration and started.',
|
||||
'agents.notice.managedLaunch': '{name} started with the configuration changes still applied.',
|
||||
'agents.notice.launched': '{name} started',
|
||||
'agents.status.detecting': 'Detecting',
|
||||
'agents.status.unsupported': 'Unsupported Platform',
|
||||
'agents.status.notInstalled': 'Not Installed',
|
||||
@@ -1099,6 +1163,77 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.status.unconfigured': 'Not Configured',
|
||||
'agents.title': 'Agent Configuration',
|
||||
'agents.redetect': 'Detect Again',
|
||||
'agents.tabs.label': 'Agent configuration sections',
|
||||
'agents.tabs.core': 'Core Configuration',
|
||||
'agents.tabs.sessions': 'Session Management',
|
||||
'agents.sessions.title': 'Codex Session Management',
|
||||
'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.',
|
||||
'agents.sessions.refresh': 'Refresh',
|
||||
'agents.sessions.currentPage': 'Current page',
|
||||
'agents.sessions.active': 'Active',
|
||||
'agents.sessions.archived': 'Archived',
|
||||
'agents.sessions.database': 'Database source',
|
||||
'agents.sessions.databaseMissing': 'No session database found',
|
||||
'agents.sessions.repairTitle': 'Restore Historical Sessions',
|
||||
'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.',
|
||||
'agents.sessions.autoRepair': 'Restore historical sessions before launching Codex',
|
||||
'agents.sessions.autoRepairEnabled': 'Automatic restoration before launch is enabled.',
|
||||
'agents.sessions.autoRepairDisabled': 'Automatic restoration before launch is disabled.',
|
||||
'agents.sessions.settingLoading': 'Loading',
|
||||
'agents.sessions.settingOn': 'On',
|
||||
'agents.sessions.settingOff': 'Off',
|
||||
'agents.sessions.settingLoadFailed': 'Failed to load the automatic-restoration setting: {error}',
|
||||
'agents.sessions.applyManagedFirst': 'Apply the managed CPA configuration for Codex first',
|
||||
'agents.sessions.repairNow': 'Restore now',
|
||||
'agents.sessions.repairing': 'Restoring',
|
||||
'agents.sessions.loadFailed': 'Failed to load Codex sessions: {error}',
|
||||
'agents.sessions.localSessions': 'Local sessions',
|
||||
'agents.sessions.pageDescription': 'Page {page} · up to {size} items per page',
|
||||
'agents.sessions.selected': '{count} selected',
|
||||
'agents.sessions.selectAll': 'Select this page',
|
||||
'agents.sessions.clearSelection': 'Clear selection',
|
||||
'agents.sessions.deleteSelected': 'Delete selected',
|
||||
'agents.sessions.multiSelect': 'Select multiple',
|
||||
'agents.sessions.loading': 'Scanning local sessions…',
|
||||
'agents.sessions.empty': 'No Codex sessions were found.',
|
||||
'agents.sessions.selectSession': 'Select session {title}',
|
||||
'agents.sessions.untitled': 'Untitled session',
|
||||
'agents.sessions.noProject': 'No project path',
|
||||
'agents.sessions.noProvider': 'Unknown provider',
|
||||
'agents.sessions.noTime': 'Unknown update time',
|
||||
'agents.sessions.activeBadge': 'Active',
|
||||
'agents.sessions.archivedBadge': 'Archived',
|
||||
'agents.sessions.previousPage': 'Previous page',
|
||||
'agents.sessions.nextPage': 'Next page',
|
||||
'agents.sessions.pageNumber': 'Page {page}',
|
||||
'agents.sessions.deleteOneTitle': 'Delete this session?',
|
||||
'agents.sessions.deleteManyTitle': 'Delete {count} sessions?',
|
||||
'agents.sessions.deleteOneDescription': 'A backup will be created before “{title}” and its known related records are deleted. There is no in-app undo.',
|
||||
'agents.sessions.deleteManyDescription': 'A backup will be created before these {count} sessions and their known related records are deleted: {preview}{extra}',
|
||||
'agents.sessions.deleteMore': ', plus {count} more',
|
||||
'agents.sessions.confirmDelete': 'Back up and delete',
|
||||
'agents.sessions.deleteResult': 'Deleted {deleted}; failed {failed}. {details}{backup}',
|
||||
'agents.sessions.deleteFailed': 'Failed to delete sessions: {error}',
|
||||
'agents.sessions.backupAt': 'Backup: {path}',
|
||||
'agents.sessions.settingFailed': 'Failed to save the automatic-restoration setting: {error}',
|
||||
'agents.sessions.repairResult': 'Historical sessions restored to {provider}: migrated {files} rollout files, updated {rows} database rows, and skipped {skipped} locked files.',
|
||||
'agents.sessions.repairFailed': 'Failed to restore historical sessions: {error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': 'Scanning sessions',
|
||||
'agents.sessions.progress.backingUp': 'Creating backup',
|
||||
'agents.sessions.progress.rewriting': 'Migrating historical sessions',
|
||||
'agents.sessions.progress.updatingDatabase': 'Synchronizing databases',
|
||||
'agents.sessions.progress.complete': 'Restoration complete',
|
||||
'agents.sessions.scanIndex': 'Check invalid session records',
|
||||
'agents.sessions.scanningIndex': 'Checking invalid records',
|
||||
'agents.sessions.cleanupNoCandidates': 'No safely removable invalid session records were found.',
|
||||
'agents.sessions.cleanupPreviewFailed': 'Failed to check invalid session records: {error}',
|
||||
'agents.sessions.cleanupTitle': 'Removable invalid index entries',
|
||||
'agents.sessions.cleanupDescription': 'Found {count} records that exist only in session_index.jsonl and no longer have a real session source. Nothing is selected by default. Exit Codex and ChatGPT, then confirm each item to remove. Unknown record formats are preserved.',
|
||||
'agents.sessions.cleanupSelectAll': 'Select all candidates',
|
||||
'agents.sessions.cleanupConfirm': 'Remove {count} selected',
|
||||
'agents.sessions.cleanupResult': 'Removed {count} invalid session index entries. {backup}',
|
||||
'agents.sessions.cleanupFailed': 'Failed to remove invalid session index entries: {error}',
|
||||
'agents.localClients': 'Local Clients',
|
||||
'agents.selectClient': 'Select an agent to manage',
|
||||
'agents.installStatus': 'Installation Status',
|
||||
@@ -1107,37 +1242,46 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.clientVersion': 'Client Version',
|
||||
'agents.notFetched': 'Not available',
|
||||
'agents.useModel': 'Model',
|
||||
'agents.useModelDescription': 'Synchronize models currently exposed by the core and set the selection as the default',
|
||||
'agents.pending': 'Not Applied',
|
||||
'agents.model.readingAvailable': 'Loading available models',
|
||||
'agents.model.cannotConfigure': 'No models are available, so configuration changes cannot be applied',
|
||||
'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.oauthConfiguration': 'OAuth configuration mode',
|
||||
'agents.modify.apply': 'Apply Configuration Changes',
|
||||
'agents.modify.default': 'Default Configuration',
|
||||
'agents.modify.clear': 'Clear Configuration',
|
||||
'agents.modify.externalChanged': 'External configuration changes were detected and preserved. Managed fields are rewritten only after you click Apply Configuration Changes.',
|
||||
'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.applied': 'The fields managed by the app are applied; all other user content remains unchanged.',
|
||||
'agents.modify.unconfigured': 'Select a model and apply the configuration. No configuration copy or backup is created.',
|
||||
'agents.footer.changed': 'The model selection changed. Click “Apply Configuration Changes” first.',
|
||||
'agents.footer.changed': 'The model selection changed. Apply the configuration to use the new model.',
|
||||
'agents.footer.applied': 'Configuration changes are applied; only the relevant fields are managed by the app.',
|
||||
'agents.footer.externalChanged': 'External changes were preserved. Apply them before launching this client.',
|
||||
'agents.footer.externalChanged': 'External changes were preserved. Managed fields are rewritten when you apply the configuration.',
|
||||
'agents.footer.invalidManaged': 'The configuration is invalid. Apply changes to repair it or reset it to the default configuration.',
|
||||
'agents.footer.unsupported': 'This client cannot be configured on the current system',
|
||||
'agents.footer.installFirst': 'Install the client, then detect again',
|
||||
'agents.footer.noCommand': 'A configuration file was found, but no client command is available',
|
||||
'agents.footer.invalidConfig': 'The original configuration is invalid and cannot be modified safely',
|
||||
'agents.footer.enableFirst': 'Click “Apply Configuration Changes” before launching so the CPA model configuration is active',
|
||||
'agents.launchMethods': 'Codex launch methods',
|
||||
'agents.launch.enableFirst': 'Apply the configuration changes first',
|
||||
'agents.launch.applyFirst': 'Apply the selected model first',
|
||||
'agents.launch.starting': 'Starting',
|
||||
'agents.launch.start': 'Start {target}',
|
||||
'agents.launch.unavailable': 'Cannot Start',
|
||||
'agents.launch.closeChatgpt': 'Close ChatGPT App',
|
||||
'agents.launch.closingChatgpt': 'Closing',
|
||||
'agents.default.title': 'Reset to Default Configuration',
|
||||
'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. No backup is created and the operation cannot be undone.',
|
||||
'agents.default.confirm': 'Write Default Configuration',
|
||||
'agents.clear.title': 'Clear Codex Configuration',
|
||||
'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory and resets the application state tracked by this app. No backup is created and the operation cannot be undone.',
|
||||
'agents.clear.confirm': 'Clear Configuration',
|
||||
'agents.clear.success': 'The Codex configuration has been cleared.',
|
||||
'agents.closeApp.title': 'Close ChatGPT App',
|
||||
'agents.closeApp.description': 'This closes all running ChatGPT App processes and their child processes on this computer. In-progress responses or unsaved state may be lost.',
|
||||
'agents.closeApp.confirm': 'Close App',
|
||||
'agents.closeApp.success': 'ChatGPT App was closed ({count} processes).',
|
||||
'agents.closeApp.notRunning': 'ChatGPT App is not running.',
|
||||
'apiAccess.compatibleName': 'OpenAI Compatible {number}',
|
||||
'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon',
|
||||
'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid',
|
||||
|
||||
@@ -2,6 +2,7 @@ const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
|
||||
['繁体中文', '繁體中文'],
|
||||
['简体中文', '簡體中文'],
|
||||
['智能体', '智慧代理'],
|
||||
['会话', '工作階段'],
|
||||
['使用记录', '使用記錄'],
|
||||
['认证文件', '認證檔案'],
|
||||
['配置文件', '設定檔'],
|
||||
|
||||
+483
-200
@@ -18,9 +18,11 @@ import {
|
||||
ChevronDown,
|
||||
LoaderCircle,
|
||||
Play,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
@@ -36,6 +38,8 @@ import {
|
||||
} from '../services/agentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
import { CodexSessionAutoRestoreCard } from './CodexSessionAutoRestoreCard';
|
||||
import { CodexSessionsPanel } from './CodexSessionsPanel';
|
||||
|
||||
type AgentClientId =
|
||||
| 'claude-code'
|
||||
@@ -80,6 +84,11 @@ type AgentConfigActionResult = {
|
||||
conflictFiles: string[];
|
||||
};
|
||||
|
||||
type ChatGptCloseResult = {
|
||||
wasRunning: boolean;
|
||||
closedProcesses: number;
|
||||
};
|
||||
|
||||
type AgentDefinition = {
|
||||
id: AgentClientId;
|
||||
name: string;
|
||||
@@ -88,6 +97,14 @@ type AgentDefinition = {
|
||||
descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes';
|
||||
};
|
||||
|
||||
type AgentSubpageId = 'core' | 'sessions';
|
||||
|
||||
type AgentSubpageDefinition = {
|
||||
id: AgentSubpageId;
|
||||
labelKey: 'agents.tabs.core' | 'agents.tabs.sessions';
|
||||
clients?: readonly AgentClientId[];
|
||||
};
|
||||
|
||||
const agentDefinitions: AgentDefinition[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
@@ -127,6 +144,20 @@ const agentDefinitions: AgentDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const agentSubpages: AgentSubpageDefinition[] = [
|
||||
{
|
||||
id: 'core',
|
||||
labelKey: 'agents.tabs.core',
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
labelKey: 'agents.tabs.sessions',
|
||||
clients: ['codex'],
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_AGENT_SUBPAGE: AgentSubpageId = 'core';
|
||||
|
||||
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
|
||||
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
|
||||
|
||||
@@ -455,6 +486,7 @@ function AgentModelPicker({
|
||||
export function AgentsPage() {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
|
||||
const [activeSubpage, setActiveSubpage] = useState<AgentSubpageId>(DEFAULT_AGENT_SUBPAGE);
|
||||
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
|
||||
@@ -463,12 +495,22 @@ export function AgentsPage() {
|
||||
const [launchTargetByClient, setLaunchTargetByClient] = useState<Partial<Record<AgentClientId, string>>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'launch' | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | null>(null);
|
||||
const busy = busyAction !== null;
|
||||
const [error, setError] = useState('');
|
||||
const [detectionError, setDetectionError] = useState('');
|
||||
const [modelError, setModelError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [modelSelectionError, setModelSelectionError] = useState('');
|
||||
const [configurationError, setConfigurationError] = useState('');
|
||||
const [launchError, setLaunchError] = useState('');
|
||||
const [defaultError, setDefaultError] = useState('');
|
||||
const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false);
|
||||
const [clearError, setClearError] = useState('');
|
||||
const [clearNotice, setClearNotice] = useState('');
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const [closeAppError, setCloseAppError] = useState('');
|
||||
const [closeAppNotice, setCloseAppNotice] = useState('');
|
||||
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
|
||||
const [oauthConfiguration, setOauthConfiguration] = useState(false);
|
||||
|
||||
const loadStatuses = useCallback(async (forceRefresh = false) => {
|
||||
const command = forceRefresh
|
||||
@@ -484,6 +526,7 @@ export function AgentsPage() {
|
||||
try {
|
||||
const nextModels = await invoke<ModelOption[]>('get_agent_models');
|
||||
setModels(nextModels);
|
||||
setModelSelectionError('');
|
||||
setModelByClient((current) => {
|
||||
const next = reconcileAgentModelSelections(current, nextModels);
|
||||
writeAgentModelSelections(next);
|
||||
@@ -498,11 +541,11 @@ export function AgentsPage() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setDetectionError('');
|
||||
try {
|
||||
await Promise.all([loadStatuses(true), loadModels()]);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
setDetectionError(String(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -510,9 +553,9 @@ export function AgentsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setDetectionError('');
|
||||
void Promise.all([loadStatuses(), loadModels()])
|
||||
.catch((requestError) => setError(String(requestError)))
|
||||
.catch((requestError) => setDetectionError(String(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadModels, loadStatuses]);
|
||||
|
||||
@@ -521,8 +564,9 @@ export function AgentsPage() {
|
||||
let stop: (() => void) | null = null;
|
||||
void listen('config-files-changed', () => {
|
||||
if (disposed) return;
|
||||
setDetectionError('');
|
||||
void Promise.all([loadStatuses(true), loadModels()]).catch((requestError) => {
|
||||
if (!disposed) setError(String(requestError));
|
||||
if (!disposed) setDetectionError(String(requestError));
|
||||
});
|
||||
}).then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
@@ -538,6 +582,21 @@ export function AgentsPage() {
|
||||
writeSelectedAgentClient(selected);
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSubpage(DEFAULT_AGENT_SUBPAGE);
|
||||
setModelSelectionError('');
|
||||
setConfigurationError('');
|
||||
setLaunchError('');
|
||||
setDefaultError('');
|
||||
setDefaultConfirmOpen(false);
|
||||
setClearError('');
|
||||
setClearNotice('');
|
||||
setClearConfirmOpen(false);
|
||||
setCloseAppError('');
|
||||
setCloseAppNotice('');
|
||||
setCloseAppConfirmOpen(false);
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
setLaunchTargetByClient((current) => agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>(
|
||||
(next, definition) => {
|
||||
@@ -578,18 +637,59 @@ export function AgentsPage() {
|
||||
const canLaunch = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& activeStatus.modificationEnabled
|
||||
&& activeStatus.modificationState === 'applied'
|
||||
&& selectedLaunchTarget,
|
||||
&& selectedLaunchTarget
|
||||
&& (selected === 'codex'
|
||||
|| (activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')),
|
||||
);
|
||||
const modelHint = modelSelectionError
|
||||
|| modelError
|
||||
|| (modelLoading
|
||||
? t('agents.model.readingAvailable')
|
||||
: models.length === 0
|
||||
? ''
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? t('agents.model.current', { model: appliedModel || '—' })
|
||||
: t('agents.model.firstSelection', { count: models.length }));
|
||||
const modificationDescription = activeStatus?.modificationState === 'external-changed'
|
||||
? t('agents.modify.externalChanged')
|
||||
: activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.modify.invalid')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? t('agents.modify.applied')
|
||||
: '';
|
||||
const footerMessage = activeStatus?.modificationState === 'applied'
|
||||
? draftChanged
|
||||
? t('agents.footer.changed')
|
||||
: t('agents.footer.applied')
|
||||
: activeStatus?.modificationState === 'external-changed'
|
||||
? t('agents.footer.externalChanged')
|
||||
: activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.footer.invalidManaged')
|
||||
: !activeStatus?.supportedPlatform
|
||||
? t('agents.footer.unsupported')
|
||||
: !activeStatus.installed
|
||||
? t('agents.footer.installFirst')
|
||||
: activeStatus.launchTargets.length === 0
|
||||
? t('agents.footer.noCommand')
|
||||
: '';
|
||||
|
||||
const refreshModels = () => {
|
||||
void loadModels();
|
||||
};
|
||||
|
||||
const reloadStatusesAfterAction = async () => {
|
||||
setDetectionError('');
|
||||
try {
|
||||
await loadStatuses(true);
|
||||
} catch (requestError) {
|
||||
setDetectionError(String(requestError));
|
||||
}
|
||||
};
|
||||
|
||||
const selectModel = (value: string) => {
|
||||
const model = findAgentModel(models, value);
|
||||
if (!model) return;
|
||||
setModelSelectionError('');
|
||||
setModelByClient((current) => {
|
||||
const next = { ...current, [selected]: model.name };
|
||||
writeAgentModelSelections(next);
|
||||
@@ -599,36 +699,36 @@ export function AgentsPage() {
|
||||
|
||||
const requireSelectedModel = () => {
|
||||
if (modelLoading) {
|
||||
setError(t('agents.error.modelsLoading'));
|
||||
setModelSelectionError(t('agents.error.modelsLoading'));
|
||||
return null;
|
||||
}
|
||||
if (models.length === 0) {
|
||||
setError(modelError || t('agents.error.noModels'));
|
||||
setModelSelectionError(modelError || t('agents.error.noModels'));
|
||||
return null;
|
||||
}
|
||||
const model = findAgentModel(models, selectedModel);
|
||||
if (!model) {
|
||||
setError(t('agents.error.selectionGone'));
|
||||
setModelSelectionError(t('agents.error.selectionGone'));
|
||||
return null;
|
||||
}
|
||||
setModelSelectionError('');
|
||||
return model.name;
|
||||
};
|
||||
|
||||
const applyConfigurationChanges = async () => {
|
||||
setConfigurationError('');
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('apply');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('apply_agent_config', {
|
||||
client: selected,
|
||||
model,
|
||||
oauthConfiguration,
|
||||
});
|
||||
setNotice(t('agents.notice.applied', { name: activeDefinition.name }));
|
||||
await loadStatuses(true);
|
||||
await reloadStatusesAfterAction();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
setConfigurationError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
@@ -638,18 +738,33 @@ export function AgentsPage() {
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('default');
|
||||
setError('');
|
||||
setNotice('');
|
||||
setDefaultError('');
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('reset_agent_config_to_default', {
|
||||
client: selected,
|
||||
model,
|
||||
oauthConfiguration,
|
||||
});
|
||||
setDefaultConfirmOpen(false);
|
||||
setNotice(t('agents.notice.defaultWritten', { name: activeDefinition.name }));
|
||||
await loadStatuses(true);
|
||||
await reloadStatusesAfterAction();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
setDefaultError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCodexConfiguration = async () => {
|
||||
setBusyAction('clear');
|
||||
setClearError('');
|
||||
setClearNotice('');
|
||||
try {
|
||||
await invoke<string[]>('clear_codex_config');
|
||||
setClearConfirmOpen(false);
|
||||
setClearNotice(t('agents.clear.success'));
|
||||
await reloadStatusesAfterAction();
|
||||
} catch (requestError) {
|
||||
setClearError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
@@ -657,41 +772,69 @@ export function AgentsPage() {
|
||||
|
||||
const launchAgent = async () => {
|
||||
setBusyAction('launch');
|
||||
setError('');
|
||||
setNotice('');
|
||||
setLaunchError('');
|
||||
try {
|
||||
if (draftChanged) {
|
||||
if (selected !== 'codex' && draftChanged) {
|
||||
throw new Error(t('agents.error.applyFirst'));
|
||||
}
|
||||
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
|
||||
setNotice(t('agents.notice.managedLaunch', { name: activeDefinition.name }));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
setLaunchError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = loading
|
||||
? t('agents.status.detecting')
|
||||
: !activeStatus?.supportedPlatform
|
||||
? t('agents.status.unsupported')
|
||||
: !activeStatus.installed
|
||||
? t('agents.status.notInstalled')
|
||||
: activeStatus.modificationState === 'external-changed'
|
||||
? t('agents.status.externalChanged')
|
||||
: activeStatus.modificationState === 'invalid'
|
||||
? t('agents.status.invalid')
|
||||
: activeStatus.modificationState === 'applied'
|
||||
? t('agents.status.applied')
|
||||
: t('agents.status.unconfigured');
|
||||
const statusTone = activeStatus?.modificationState === 'external-changed'
|
||||
|| activeStatus?.modificationState === 'invalid'
|
||||
|| !activeStatus?.supportedPlatform
|
||||
? 'error'
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? 'success'
|
||||
: '';
|
||||
const closeChatGptApp = async () => {
|
||||
setBusyAction('close-app');
|
||||
setCloseAppError('');
|
||||
setCloseAppNotice('');
|
||||
try {
|
||||
const result = await invoke<ChatGptCloseResult>('close_chatgpt_app');
|
||||
setCloseAppConfirmOpen(false);
|
||||
setCloseAppNotice(result.wasRunning
|
||||
? t('agents.closeApp.success', { count: result.closedProcesses })
|
||||
: t('agents.closeApp.notRunning'));
|
||||
} catch (requestError) {
|
||||
setCloseAppError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openDefaultConfirmation = () => {
|
||||
setDefaultError('');
|
||||
setDefaultConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeDefaultConfirmation = () => {
|
||||
setDefaultError('');
|
||||
setDefaultConfirmOpen(false);
|
||||
};
|
||||
|
||||
const openClearConfirmation = () => {
|
||||
setClearError('');
|
||||
setClearConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeClearConfirmation = () => {
|
||||
setClearError('');
|
||||
setClearConfirmOpen(false);
|
||||
};
|
||||
|
||||
const openCloseAppConfirmation = () => {
|
||||
setCloseAppError('');
|
||||
setCloseAppConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeCloseAppConfirmation = () => {
|
||||
setCloseAppError('');
|
||||
setCloseAppConfirmOpen(false);
|
||||
};
|
||||
|
||||
const availableSubpages = agentSubpages.filter(
|
||||
(subpage) => !subpage.clients || subpage.clients.includes(selected),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page management-page agents-page">
|
||||
@@ -700,17 +843,19 @@ export function AgentsPage() {
|
||||
<span>Agent Clients</span>
|
||||
<h1>{t('agents.title')}</h1>
|
||||
</div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refresh()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
{t('agents.redetect')}
|
||||
</button>
|
||||
<div className="agent-header-actions">
|
||||
{detectionError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{detectionError}
|
||||
</span>
|
||||
) : null}
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refresh()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
{t('agents.redetect')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="agent-feedback-slot" aria-live="polite">
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-workbench">
|
||||
<aside className="panel agent-client-list">
|
||||
<div className="agent-list-heading">
|
||||
@@ -725,7 +870,10 @@ export function AgentsPage() {
|
||||
type="button"
|
||||
className={selected === agent.id ? 'active' : ''}
|
||||
key={agent.id}
|
||||
onClick={() => setSelected(agent.id)}
|
||||
onClick={() => {
|
||||
setActiveSubpage(DEFAULT_AGENT_SUBPAGE);
|
||||
setSelected(agent.id);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
|
||||
@@ -741,154 +889,238 @@ export function AgentsPage() {
|
||||
</aside>
|
||||
|
||||
<section className="panel agent-config-panel">
|
||||
<div className="agent-config-heading">
|
||||
<div className="agent-config-title">
|
||||
<span className="agent-logo"><AgentMark definition={activeDefinition} size={24} /></span>
|
||||
<div><h2>{activeDefinition.name}</h2><span>{t(activeDefinition.descriptionKey)}</span></div>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`}>{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="agent-config-message-slot">
|
||||
{activeStatus?.error ? <div className="management-alert error">{activeStatus.error}</div> : null}
|
||||
{activeStatus?.warnings.length && !activeStatus.error ? (
|
||||
<div className="agent-warning-line">{activeStatus.warnings.join(';')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-status-grid">
|
||||
<div>
|
||||
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
|
||||
<strong>{activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('agents.clientVersion')}</span>
|
||||
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? t('agents.notFetched')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>{t('agents.useModel')}</strong><span>{t('agents.useModelDescription')}</span></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
loading={modelLoading}
|
||||
error={modelError}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
onChange={selectModel}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
<span className="agent-model-hint">
|
||||
{modelLoading
|
||||
? t('agents.model.readingAvailable')
|
||||
: modelError && models.length === 0
|
||||
? modelError
|
||||
: models.length === 0
|
||||
? t('agents.model.cannotConfigure')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? t('agents.model.current', { model: appliedModel || '—' })
|
||||
: t('agents.model.firstSelection', { count: models.length })}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className={`agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
|
||||
<div className="agent-modification-copy">
|
||||
<strong>{t('agents.modify.title')}</strong>
|
||||
<span>{activeStatus?.modificationState === 'external-changed'
|
||||
? t('agents.modify.externalChanged')
|
||||
: activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.modify.invalid')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? t('agents.modify.applied')
|
||||
: t('agents.modify.unconfigured')}</span>
|
||||
</div>
|
||||
<div className="agent-modification-buttons">
|
||||
<div className="agent-subpage-tabs" role="tablist" aria-label={t('agents.tabs.label')}>
|
||||
{availableSubpages.map((subpage) => (
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void applyConfigurationChanges()}
|
||||
disabled={
|
||||
busy
|
||||
|| !canEnable
|
||||
}
|
||||
id={`agent-subpage-tab-${subpage.id}`}
|
||||
role="tab"
|
||||
className={activeSubpage === subpage.id ? 'active' : ''}
|
||||
aria-selected={activeSubpage === subpage.id}
|
||||
aria-controls={`agent-subpage-panel-${subpage.id}`}
|
||||
tabIndex={activeSubpage === subpage.id ? 0 : -1}
|
||||
key={subpage.id}
|
||||
onClick={() => setActiveSubpage(subpage.id)}
|
||||
>
|
||||
{busyAction === 'apply' ? <LoaderCircle size={16} className="spin" /> : <Sparkles size={16} />}
|
||||
{t('agents.modify.apply')}
|
||||
{t(subpage.labelKey)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setDefaultConfirmOpen(true)}
|
||||
disabled={busy || !canEnable}
|
||||
>
|
||||
{busyAction === 'default' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
|
||||
{t('agents.modify.default')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="agent-config-footer">
|
||||
<div>
|
||||
{activeStatus?.modificationState === 'applied' ? <Check size={16} /> : <Sparkles size={16} />}
|
||||
<span>{activeStatus?.modificationState === 'applied'
|
||||
? draftChanged
|
||||
? t('agents.footer.changed')
|
||||
: t('agents.footer.applied')
|
||||
: activeStatus?.modificationState === 'external-changed'
|
||||
? t('agents.footer.externalChanged')
|
||||
: activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.footer.invalidManaged')
|
||||
: !activeStatus?.supportedPlatform
|
||||
? t('agents.footer.unsupported')
|
||||
: !activeStatus.installed
|
||||
? t('agents.footer.installFirst')
|
||||
: activeStatus.launchTargets.length === 0
|
||||
? t('agents.footer.noCommand')
|
||||
: t('agents.footer.enableFirst')}</span>
|
||||
</div>
|
||||
<div className="agent-launch-actions">
|
||||
{activeLaunchTargets.length > 1 ? (
|
||||
<div className="agent-launch-targets" aria-label={t('agents.launchMethods')}>
|
||||
{activeLaunchTargets.map((target) => (
|
||||
<button
|
||||
type="button"
|
||||
className={target.id === selectedLaunchTarget?.id ? 'active' : ''}
|
||||
key={target.id}
|
||||
onClick={() => setLaunchTargetByClient((current) => ({
|
||||
...current,
|
||||
[selected]: target.id,
|
||||
}))}
|
||||
disabled={busy}
|
||||
title={target.detail}
|
||||
>
|
||||
{target.label.replace('Codex ', '')}
|
||||
</button>
|
||||
))}
|
||||
{activeSubpage === 'core' ? (
|
||||
<div
|
||||
className="agent-core-config"
|
||||
id="agent-subpage-panel-core"
|
||||
role="tabpanel"
|
||||
aria-labelledby="agent-subpage-tab-core"
|
||||
>
|
||||
<div className="agent-status-grid">
|
||||
<div>
|
||||
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
|
||||
<strong>{activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('agents.clientVersion')}</span>
|
||||
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? t('agents.notFetched')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeStatus?.error || activeStatus?.warnings.length ? (
|
||||
<div className="agent-status-messages" aria-live="polite">
|
||||
{activeStatus.error ? (
|
||||
<span className="agent-inline-message error" role="alert">{activeStatus.error}</span>
|
||||
) : (
|
||||
<span className="agent-inline-message warning">{activeStatus.warnings.join(';')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void launchAgent()}
|
||||
disabled={
|
||||
busy
|
||||
|| !canLaunch
|
||||
|| draftChanged
|
||||
}
|
||||
title={draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? selectedLaunchTarget?.detail
|
||||
: t('agents.launch.enableFirst')}
|
||||
>
|
||||
{busyAction === 'launch' ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
|
||||
{busyAction === 'launch' ? t('agents.launch.starting') : selectedLaunchTarget ? t('agents.launch.start', { target: selectedLaunchTarget.label }) : t('agents.launch.unavailable')}
|
||||
</button>
|
||||
|
||||
<section className="agent-core-setting-section agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>{t('agents.useModel')}</strong></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
loading={modelLoading}
|
||||
error={modelError}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
onChange={selectModel}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
{modelHint ? (
|
||||
<span
|
||||
className={`agent-model-hint ${modelSelectionError || modelError ? 'error' : ''}`}
|
||||
role={modelSelectionError || modelError ? 'alert' : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
{modelHint}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
|
||||
<div className="agent-section-heading">
|
||||
<div>
|
||||
<strong>{t('agents.modify.title')}</strong>
|
||||
{modificationDescription ? <span>{modificationDescription}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-modification-control">
|
||||
{selected === 'codex' ? (
|
||||
<label
|
||||
className="agent-oauth-configuration"
|
||||
title={t('agents.modify.oauthConfiguration')}
|
||||
>
|
||||
<span>{t('agents.modify.oauthConfiguration')}</span>
|
||||
<span className="switch-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={oauthConfiguration}
|
||||
onChange={(event) => setOauthConfiguration(event.currentTarget.checked)}
|
||||
disabled={busy}
|
||||
aria-label={t('agents.modify.oauthConfiguration')}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
<div className={`agent-modification-buttons ${selected === 'codex' ? 'codex' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void applyConfigurationChanges()}
|
||||
disabled={
|
||||
busy
|
||||
|| !canEnable
|
||||
}
|
||||
>
|
||||
{busyAction === 'apply' ? <LoaderCircle size={16} className="spin" /> : <Sparkles size={16} />}
|
||||
{t('agents.modify.apply')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={openDefaultConfirmation}
|
||||
disabled={busy || !canEnable}
|
||||
>
|
||||
{busyAction === 'default' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
|
||||
{t('agents.modify.default')}
|
||||
</button>
|
||||
{selected === 'codex' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={openClearConfirmation}
|
||||
disabled={busy}
|
||||
>
|
||||
{busyAction === 'clear' ? <LoaderCircle size={16} className="spin" /> : <Trash2 size={16} />}
|
||||
{t('agents.modify.clear')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{configurationError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{configurationError}
|
||||
</span>
|
||||
) : null}
|
||||
{clearNotice ? (
|
||||
<span className="agent-inline-message" role="status" aria-live="polite">
|
||||
{clearNotice}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected === 'codex' ? <CodexSessionAutoRestoreCard /> : null}
|
||||
|
||||
<div className="agent-config-footer">
|
||||
{footerMessage ? (
|
||||
<div className="agent-config-summary">
|
||||
{activeStatus?.modificationState === 'applied' ? <Check size={16} /> : <Sparkles size={16} />}
|
||||
<span>{footerMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="agent-launch-control">
|
||||
<div className="agent-launch-actions">
|
||||
{activeLaunchTargets.length > 1 ? (
|
||||
<div className="agent-launch-targets" aria-label={t('agents.launchMethods')}>
|
||||
{activeLaunchTargets.map((target) => (
|
||||
<button
|
||||
type="button"
|
||||
className={target.id === selectedLaunchTarget?.id ? 'active' : ''}
|
||||
key={target.id}
|
||||
onClick={() => setLaunchTargetByClient((current) => ({
|
||||
...current,
|
||||
[selected]: target.id,
|
||||
}))}
|
||||
disabled={busy}
|
||||
title={target.detail}
|
||||
>
|
||||
{target.label.replace('Codex ', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void launchAgent()}
|
||||
disabled={
|
||||
busy
|
||||
|| !canLaunch
|
||||
|| (selected !== 'codex' && draftChanged)
|
||||
}
|
||||
title={selected === 'codex'
|
||||
? selectedLaunchTarget?.detail
|
||||
: draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: activeStatus?.modificationState === 'applied'
|
||||
? selectedLaunchTarget?.detail
|
||||
: t('agents.launch.enableFirst')}
|
||||
>
|
||||
{busyAction === 'launch' ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
|
||||
{busyAction === 'launch' ? t('agents.launch.starting') : selectedLaunchTarget ? t('agents.launch.start', { target: selectedLaunchTarget.label }) : t('agents.launch.unavailable')}
|
||||
</button>
|
||||
{selected === 'codex' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button agent-close-app-button"
|
||||
onClick={openCloseAppConfirmation}
|
||||
disabled={busy}
|
||||
>
|
||||
{busyAction === 'close-app' ? <LoaderCircle size={16} className="spin" /> : <Power size={16} />}
|
||||
{busyAction === 'close-app' ? t('agents.launch.closingChatgpt') : t('agents.launch.closeChatgpt')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{launchError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{launchError}
|
||||
</span>
|
||||
) : null}
|
||||
{closeAppNotice ? (
|
||||
<span className="agent-inline-message" role="status" aria-live="polite">
|
||||
{closeAppNotice}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selected === 'codex' && activeSubpage === 'sessions' ? (
|
||||
<div
|
||||
className="agent-sessions-page"
|
||||
id="agent-subpage-panel-sessions"
|
||||
role="tabpanel"
|
||||
aria-labelledby="agent-subpage-tab-sessions"
|
||||
>
|
||||
<CodexSessionsPanel managedProviderActive={activeStatus?.modificationState === 'applied'} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -901,8 +1133,13 @@ export function AgentsPage() {
|
||||
<p>
|
||||
{t('agents.default.description', { name: activeDefinition.name })}
|
||||
</p>
|
||||
{defaultError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{defaultError}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setDefaultConfirmOpen(false)} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" className="secondary-button" onClick={closeDefaultConfirmation} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" onClick={() => void resetConfigurationToDefault()} disabled={busy}>
|
||||
{busyAction === 'default' ? <LoaderCircle size={16} className="spin" /> : null}
|
||||
{t('agents.default.confirm')}
|
||||
@@ -911,6 +1148,52 @@ export function AgentsPage() {
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{clearConfirmOpen ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-clear-title">
|
||||
<div className="config-dialog-heading">
|
||||
<div><AlertTriangle size={19} /><h2 id="agent-clear-title">{t('agents.clear.title')}</h2></div>
|
||||
</div>
|
||||
<p>{t('agents.clear.description')}</p>
|
||||
{clearError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{clearError}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={closeClearConfirmation} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" onClick={() => void clearCodexConfiguration()} disabled={busy}>
|
||||
{busyAction === 'clear' ? <LoaderCircle size={16} className="spin" /> : <Trash2 size={16} />}
|
||||
{t('agents.clear.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{closeAppConfirmOpen ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-close-app-title">
|
||||
<div className="config-dialog-heading">
|
||||
<div><AlertTriangle size={19} /><h2 id="agent-close-app-title">{t('agents.closeApp.title')}</h2></div>
|
||||
</div>
|
||||
<p>{t('agents.closeApp.description')}</p>
|
||||
{closeAppError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{closeAppError}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={closeCloseAppConfirmation} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" onClick={() => void closeChatGptApp()} disabled={busy}>
|
||||
{busyAction === 'close-app' ? <LoaderCircle size={16} className="spin" /> : <Power size={16} />}
|
||||
{t('agents.closeApp.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type RepairOnLaunchSetting = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type StatusMessage = {
|
||||
kind: 'success' | 'error';
|
||||
text: string;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function CodexSessionAutoRestoreCard() {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void invoke<RepairOnLaunchSetting>('get_codex_session_repair_on_launch')
|
||||
.then((result) => {
|
||||
if (!disposed) setEnabled(result.enabled);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
text: t('agents.sessions.settingLoadFailed', { error: errorMessage(error) }),
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const updateSetting = async (nextEnabled: boolean) => {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const result = await invoke<RepairOnLaunchSetting>('set_codex_session_repair_on_launch', {
|
||||
request: { enabled: nextEnabled },
|
||||
});
|
||||
setEnabled(result.enabled);
|
||||
setStatus({
|
||||
kind: 'success',
|
||||
text: result.enabled
|
||||
? t('agents.sessions.autoRepairEnabled')
|
||||
: t('agents.sessions.autoRepairDisabled'),
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
text: t('agents.sessions.settingFailed', { error: errorMessage(error) }),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`agent-core-setting-section codex-auto-restore-card ${enabled ? 'enabled' : ''}`}
|
||||
aria-busy={loading || saving}
|
||||
>
|
||||
<div className="agent-section-heading">
|
||||
<div>
|
||||
<strong>{t('agents.sessions.autoRepair')}</strong>
|
||||
</div>
|
||||
<label className="codex-auto-restore-toggle">
|
||||
{loading || saving ? <LoaderCircle size={14} className="spin" aria-hidden="true" /> : null}
|
||||
<strong>
|
||||
{loading
|
||||
? t('agents.sessions.settingLoading')
|
||||
: enabled
|
||||
? t('agents.sessions.settingOn')
|
||||
: t('agents.sessions.settingOff')}
|
||||
</strong>
|
||||
<span className="switch-control codex-auto-restore-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={t('agents.sessions.autoRepair')}
|
||||
checked={enabled}
|
||||
disabled={loading || saving}
|
||||
onChange={(event) => void updateSetting(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{status ? (
|
||||
<small
|
||||
className={`codex-auto-restore-message ${status.kind}`}
|
||||
role={status.kind === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{status.text}
|
||||
</small>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Archive,
|
||||
Database,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import {
|
||||
codexSessionPageCounts,
|
||||
retainVisibleCodexSessionIds,
|
||||
type CodexSessionDeleteBatchResult,
|
||||
type CodexSessionPage,
|
||||
type CodexSessionRepairProgress,
|
||||
type CodexSessionRepairResult,
|
||||
type CodexSessionSummary,
|
||||
type SessionIndexCleanupPreview,
|
||||
type SessionIndexCleanupResult,
|
||||
} from '../services/codexSessionState';
|
||||
|
||||
type Notice = {
|
||||
kind: 'success' | 'warning' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type DeleteConfirmation = {
|
||||
ids: string[];
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function CodexSessionsPanel({ managedProviderActive }: { managedProviderActive: boolean }) {
|
||||
const { formatDate, t } = useI18n();
|
||||
const [page, setPage] = useState<CodexSessionPage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [operation, setOperation] = useState<'delete' | 'repair' | 'preview' | 'cleanup' | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation | null>(null);
|
||||
const [cleanupPreview, setCleanupPreview] = useState<SessionIndexCleanupPreview | null>(null);
|
||||
const [cleanupSelectedIds, setCleanupSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [repairProgress, setRepairProgress] = useState<CodexSessionRepairProgress>({
|
||||
phase: 'scanning',
|
||||
percent: 0,
|
||||
processed: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const loadPage = useCallback(async (offset = 0, silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const result = await invoke<CodexSessionPage>('list_codex_sessions', {
|
||||
request: { offset, limit: PAGE_SIZE },
|
||||
});
|
||||
if (result.sessions.length === 0 && result.offset > 0) {
|
||||
return await loadPage(Math.max(0, result.offset - result.limit), silent);
|
||||
}
|
||||
setPage(result);
|
||||
setSelectedIds((current) => retainVisibleCodexSessionIds(current, result.sessions));
|
||||
if (!silent && result.warnings.length > 0) {
|
||||
setNotice({ kind: 'warning', message: result.warnings.join(';') });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.loadFailed', { error: errorMessage(error) }) });
|
||||
return null;
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let stop: (() => void) | undefined;
|
||||
void listen<CodexSessionRepairProgress>('codex-session-repair-progress', (event) => {
|
||||
if (!disposed) setRepairProgress(event.payload);
|
||||
}).then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
else stop = unlisten;
|
||||
}).catch(() => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
stop?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessions = page?.sessions ?? [];
|
||||
const pageCounts = codexSessionPageCounts(sessions);
|
||||
const currentPage = Math.floor((page?.offset ?? 0) / (page?.limit ?? PAGE_SIZE)) + 1;
|
||||
const selectedSessions = useMemo(
|
||||
() => sessions.filter((session) => selectedIds.has(session.id)),
|
||||
[selectedIds, sessions],
|
||||
);
|
||||
const allSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
|
||||
const busy = operation !== null;
|
||||
|
||||
const toggleSelected = (id: string, selected: boolean) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (selected) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const requestDelete = (targets: CodexSessionSummary[]) => {
|
||||
if (targets.length === 0) return;
|
||||
const preview = targets
|
||||
.slice(0, 6)
|
||||
.map((session) => session.title || session.id)
|
||||
.join('、');
|
||||
const extra = Math.max(0, targets.length - 6);
|
||||
setDeleteConfirmation({
|
||||
ids: targets.map((session) => session.id),
|
||||
title: targets.length === 1
|
||||
? t('agents.sessions.deleteOneTitle')
|
||||
: t('agents.sessions.deleteManyTitle', { count: targets.length }),
|
||||
description: targets.length === 1
|
||||
? t('agents.sessions.deleteOneDescription', { title: preview })
|
||||
: t('agents.sessions.deleteManyDescription', {
|
||||
count: targets.length,
|
||||
preview,
|
||||
extra: extra > 0 ? t('agents.sessions.deleteMore', { count: extra }) : '',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteConfirmation) return;
|
||||
const request = deleteConfirmation;
|
||||
setDeleteConfirmation(null);
|
||||
setOperation('delete');
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await invoke<CodexSessionDeleteBatchResult>('delete_codex_sessions', {
|
||||
request: { sessionIds: request.ids },
|
||||
});
|
||||
const failed = result.results.filter((item) => !['deleted', 'partial'].includes(item.status));
|
||||
const partial = result.results.filter((item) => item.status === 'partial');
|
||||
const backupPaths = result.results
|
||||
.map((item) => item.backupPath)
|
||||
.filter((path): path is string => Boolean(path));
|
||||
const details = [...partial, ...failed].map((item) => `${item.sessionId}: ${item.message}`).join(';');
|
||||
setNotice({
|
||||
kind: failed.length > 0 ? (result.deletedCount > 0 ? 'warning' : 'error') : partial.length > 0 ? 'warning' : 'success',
|
||||
message: t('agents.sessions.deleteResult', {
|
||||
deleted: result.deletedCount,
|
||||
failed: result.failedCount,
|
||||
details: details ? ` ${details}` : '',
|
||||
backup: backupPaths[0] ? t('agents.sessions.backupAt', { path: backupPaths[0] }) : '',
|
||||
}),
|
||||
});
|
||||
setSelectedIds(new Set());
|
||||
await loadPage(page?.offset ?? 0, true);
|
||||
} catch (error) {
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.deleteFailed', { error: errorMessage(error) }) });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const repairSessions = async () => {
|
||||
setOperation('repair');
|
||||
setNotice(null);
|
||||
setRepairProgress({ phase: 'scanning', percent: 4, processed: 0, total: 0 });
|
||||
try {
|
||||
const result = await invoke<CodexSessionRepairResult>('repair_codex_session_metadata');
|
||||
const messages = [
|
||||
t('agents.sessions.repairResult', {
|
||||
provider: result.targetProvider,
|
||||
files: result.changedRolloutFiles,
|
||||
rows: result.sqliteRowsUpdated,
|
||||
skipped: result.skippedLockedFiles.length,
|
||||
}),
|
||||
];
|
||||
if (result.encryptedContentWarning) messages.push(result.encryptedContentWarning);
|
||||
if (result.warnings.length > 0) messages.push(result.warnings.join(';'));
|
||||
if (result.backupPath) messages.push(t('agents.sessions.backupAt', { path: result.backupPath }));
|
||||
setNotice({
|
||||
kind: result.encryptedContentWarning || result.warnings.length > 0 ? 'warning' : 'success',
|
||||
message: messages.join(' '),
|
||||
});
|
||||
await loadPage(page?.offset ?? 0, true);
|
||||
} catch (error) {
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.repairFailed', { error: errorMessage(error) }) });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const previewCleanup = async () => {
|
||||
setOperation('preview');
|
||||
setNotice(null);
|
||||
try {
|
||||
const preview = await invoke<SessionIndexCleanupPreview>('preview_codex_session_index_cleanup');
|
||||
setCleanupSelectedIds(new Set());
|
||||
if (preview.candidates.length === 0) {
|
||||
setCleanupPreview(null);
|
||||
setNotice({ kind: 'success', message: t('agents.sessions.cleanupNoCandidates') });
|
||||
} else {
|
||||
setCleanupPreview(preview);
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
message: t('agents.sessions.cleanupPreviewFailed', { error: errorMessage(error) }),
|
||||
});
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCleanup = async () => {
|
||||
if (!cleanupPreview || cleanupSelectedIds.size === 0) return;
|
||||
setOperation('cleanup');
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await invoke<SessionIndexCleanupResult>('apply_codex_session_index_cleanup', {
|
||||
request: {
|
||||
snapshotSha256: cleanupPreview.snapshotSha256,
|
||||
threadIds: Array.from(cleanupSelectedIds),
|
||||
},
|
||||
});
|
||||
setCleanupPreview(null);
|
||||
setCleanupSelectedIds(new Set());
|
||||
setNotice({
|
||||
kind: 'success',
|
||||
message: t('agents.sessions.cleanupResult', {
|
||||
count: result.prunedEntries,
|
||||
backup: result.backupPath ? t('agents.sessions.backupAt', { path: result.backupPath }) : '',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
setCleanupPreview(null);
|
||||
setCleanupSelectedIds(new Set());
|
||||
setNotice({ kind: 'error', message: t('agents.sessions.cleanupFailed', { error: errorMessage(error) }) });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const repairPhaseText = t(`agents.sessions.progress.${repairProgress.phase}` as
|
||||
| 'agents.sessions.progress.scanning'
|
||||
| 'agents.sessions.progress.backingUp'
|
||||
| 'agents.sessions.progress.rewriting'
|
||||
| 'agents.sessions.progress.updatingDatabase'
|
||||
| 'agents.sessions.progress.complete');
|
||||
|
||||
return (
|
||||
<div className="codex-sessions-page">
|
||||
<section className="codex-session-overview">
|
||||
<div className="codex-session-overview-head">
|
||||
<div>
|
||||
<strong>{t('agents.sessions.title')}</strong>
|
||||
<span>{t('agents.sessions.description')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void loadPage(page?.offset ?? 0)}
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'spin' : ''} />
|
||||
{t('agents.sessions.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="codex-session-metrics">
|
||||
<div><span>{t('agents.sessions.currentPage')}</span><strong>{sessions.length}</strong></div>
|
||||
<div><span>{t('agents.sessions.active')}</span><strong>{pageCounts.active}</strong></div>
|
||||
<div><span>{t('agents.sessions.archived')}</span><strong>{pageCounts.archived}</strong></div>
|
||||
<div
|
||||
className="codex-session-database-metric"
|
||||
title={page?.databasePaths.join('\n') || page?.codexHome}
|
||||
>
|
||||
<span><Database size={13} />{t('agents.sessions.database')}</span>
|
||||
<strong>{page?.databasePaths[0] || t('agents.sessions.databaseMissing')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="codex-session-repair-panel">
|
||||
<div className="codex-session-repair-copy">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>{t('agents.sessions.repairTitle')}</strong>
|
||||
<span>{t('agents.sessions.repairDescription')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="codex-session-repair-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || loading}
|
||||
onClick={() => void previewCleanup()}
|
||||
>
|
||||
{operation === 'preview' ? <LoaderCircle size={15} className="spin" /> : <ScanSearch size={15} />}
|
||||
{operation === 'preview' ? t('agents.sessions.scanningIndex') : t('agents.sessions.scanIndex')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || !managedProviderActive}
|
||||
title={!managedProviderActive ? t('agents.sessions.applyManagedFirst') : undefined}
|
||||
onClick={() => void repairSessions()}
|
||||
>
|
||||
{operation === 'repair' ? <LoaderCircle size={15} className="spin" /> : <ShieldCheck size={15} />}
|
||||
{operation === 'repair' ? t('agents.sessions.repairing') : t('agents.sessions.repairNow')}
|
||||
</button>
|
||||
</div>
|
||||
{operation === 'repair' ? (
|
||||
<div className="codex-session-repair-progress">
|
||||
<div>
|
||||
<span>
|
||||
{repairPhaseText}
|
||||
{repairProgress.total > 0
|
||||
? ` · ${t('agents.sessions.progressCount', { processed: repairProgress.processed, total: repairProgress.total })}`
|
||||
: ''}
|
||||
</span>
|
||||
<strong>{repairProgress.percent}%</strong>
|
||||
</div>
|
||||
<div
|
||||
className="codex-session-progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={repairProgress.percent}
|
||||
>
|
||||
<i style={{ width: `${repairProgress.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div className={`codex-session-notice ${notice.kind}`} role={notice.kind === 'error' ? 'alert' : 'status'}>
|
||||
{notice.kind === 'error' ? <TriangleAlert size={16} /> : notice.kind === 'warning' ? <Info size={16} /> : <ShieldCheck size={16} />}
|
||||
<span>{notice.message}</span>
|
||||
<button type="button" aria-label={t('common.close')} onClick={() => setNotice(null)}><X size={14} /></button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="codex-session-list-section">
|
||||
<div className="codex-session-list-head">
|
||||
<div>
|
||||
<strong>{t('agents.sessions.localSessions')}</strong>
|
||||
<span>{t('agents.sessions.pageDescription', { page: currentPage, size: page?.limit ?? PAGE_SIZE })}</span>
|
||||
</div>
|
||||
<div className="codex-session-selection-actions">
|
||||
{selectionMode ? <span>{t('agents.sessions.selected', { count: selectedSessions.length })}</span> : null}
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<button type="button" className="secondary-button compact-button" disabled={busy || allSelected} onClick={() => setSelectedIds(new Set(sessions.map((session) => session.id)))}>{t('agents.sessions.selectAll')}</button>
|
||||
<button type="button" className="secondary-button compact-button" disabled={busy || selectedIds.size === 0} onClick={() => setSelectedIds(new Set())}>{t('agents.sessions.clearSelection')}</button>
|
||||
<button type="button" className="danger-button compact-button" disabled={busy || selectedSessions.length === 0} onClick={() => requestDelete(selectedSessions)}><Trash2 size={14} />{t('agents.sessions.deleteSelected')}</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || sessions.length === 0}
|
||||
onClick={() => {
|
||||
setSelectionMode((current) => !current);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
{selectionMode ? t('common.cancel') : t('agents.sessions.multiSelect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="codex-session-empty"><LoaderCircle size={22} className="spin" /><span>{t('agents.sessions.loading')}</span></div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="codex-session-empty"><Archive size={22} /><span>{t('agents.sessions.empty')}</span></div>
|
||||
) : (
|
||||
<div className="codex-session-list">
|
||||
{sessions.map((session) => (
|
||||
<article className={`codex-session-row ${selectedIds.has(session.id) ? 'selected' : ''}`} key={session.id}>
|
||||
{selectionMode ? (
|
||||
<label className="codex-session-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(session.id)}
|
||||
aria-label={t('agents.sessions.selectSession', { title: session.title || session.id })}
|
||||
onChange={(event) => toggleSelected(session.id, event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="codex-session-main">
|
||||
<strong>{session.title || t('agents.sessions.untitled')}</strong>
|
||||
<code>{session.id}</code>
|
||||
<span title={session.cwd}>{session.cwd || t('agents.sessions.noProject')}</span>
|
||||
</div>
|
||||
<div className="codex-session-meta">
|
||||
<span className={session.archived ? 'archived' : 'active'}>{session.archived ? t('agents.sessions.archivedBadge') : t('agents.sessions.activeBadge')}</span>
|
||||
<small>{session.modelProvider || t('agents.sessions.noProvider')}</small>
|
||||
<time>{session.updatedAtMs ? formatDate(session.updatedAtMs, { dateStyle: 'medium', timeStyle: 'short' }) : t('agents.sessions.noTime')}</time>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button compact-button codex-session-delete-button"
|
||||
disabled={busy}
|
||||
onClick={() => requestDelete([session])}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="codex-session-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
aria-label={t('agents.sessions.previousPage')}
|
||||
title={t('agents.sessions.previousPage')}
|
||||
disabled={busy || loading || !page || page.offset === 0}
|
||||
onClick={() => void loadPage(Math.max(0, (page?.offset ?? 0) - (page?.limit ?? PAGE_SIZE)))}
|
||||
><ArrowLeft size={15} /></button>
|
||||
<span>{t('agents.sessions.pageNumber', { page: currentPage })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
aria-label={t('agents.sessions.nextPage')}
|
||||
title={t('agents.sessions.nextPage')}
|
||||
disabled={busy || loading || !page?.hasMore}
|
||||
onClick={() => void loadPage((page?.offset ?? 0) + (page?.limit ?? PAGE_SIZE))}
|
||||
><ArrowRight size={15} /></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{deleteConfirmation ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog codex-session-dialog" role="alertdialog" aria-modal="true" aria-labelledby="codex-session-delete-title">
|
||||
<div className="config-dialog-heading"><div><TriangleAlert size={19} /><h2 id="codex-session-delete-title">{deleteConfirmation.title}</h2></div></div>
|
||||
<p>{deleteConfirmation.description}</p>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setDeleteConfirmation(null)}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" onClick={() => void confirmDelete()}><Trash2 size={15} />{t('agents.sessions.confirmDelete')}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cleanupPreview ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog codex-session-cleanup-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-session-cleanup-title">
|
||||
<div className="config-dialog-heading">
|
||||
<div><TriangleAlert size={19} /><h2 id="codex-session-cleanup-title">{t('agents.sessions.cleanupTitle')}</h2></div>
|
||||
<button type="button" className="codex-session-dialog-close" aria-label={t('common.close')} onClick={() => setCleanupPreview(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<p>{t('agents.sessions.cleanupDescription', { count: cleanupPreview.candidates.length })}</p>
|
||||
<label className="codex-session-cleanup-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupSelectedIds.size === cleanupPreview.candidates.length && cleanupPreview.candidates.length > 0}
|
||||
onChange={(event) => setCleanupSelectedIds(event.currentTarget.checked ? new Set(cleanupPreview.candidates.map((candidate) => candidate.id)) : new Set())}
|
||||
/>
|
||||
<span>{t('agents.sessions.cleanupSelectAll')}</span>
|
||||
</label>
|
||||
<div className="codex-session-cleanup-list">
|
||||
{cleanupPreview.candidates.map((candidate) => (
|
||||
<label key={candidate.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupSelectedIds.has(candidate.id)}
|
||||
onChange={(event) => {
|
||||
setCleanupSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.currentTarget.checked) next.add(candidate.id);
|
||||
else next.delete(candidate.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span><strong>{candidate.threadName || t('agents.sessions.untitled')}</strong><code>{candidate.id}</code><small>{candidate.updatedAt}</small></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" disabled={operation === 'cleanup'} onClick={() => setCleanupPreview(null)}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" disabled={operation === 'cleanup' || cleanupSelectedIds.size === 0} onClick={() => void applyCleanup()}>
|
||||
{operation === 'cleanup' ? <LoaderCircle size={15} className="spin" /> : <Trash2 size={15} />}
|
||||
{t('agents.sessions.cleanupConfirm', { count: cleanupSelectedIds.size })}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type CodexSessionSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
modelProvider: string;
|
||||
archived: boolean;
|
||||
updatedAtMs: number | null;
|
||||
databasePath: string;
|
||||
};
|
||||
|
||||
export type CodexSessionPage = {
|
||||
codexHome: string;
|
||||
databasePaths: string[];
|
||||
sessions: CodexSessionSummary[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
repairOnLaunch: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type CodexSessionDeleteResult = {
|
||||
sessionId: string;
|
||||
status: 'deleted' | 'partial' | 'notFound' | 'failed';
|
||||
message: string;
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
export type CodexSessionDeleteBatchResult = {
|
||||
results: CodexSessionDeleteResult[];
|
||||
deletedCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
|
||||
export type CodexSessionRepairProgress = {
|
||||
phase: 'scanning' | 'backingUp' | 'rewriting' | 'updatingDatabase' | 'complete';
|
||||
percent: number;
|
||||
processed: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CodexSessionRepairResult = {
|
||||
targetProvider: string;
|
||||
changedRolloutFiles: number;
|
||||
sqliteRowsUpdated: number;
|
||||
skippedLockedFiles: string[];
|
||||
backupPath: string | null;
|
||||
encryptedContentWarning: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type SessionIndexCleanupCandidate = {
|
||||
id: string;
|
||||
threadName: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type SessionIndexCleanupPreview = {
|
||||
snapshotSha256: string;
|
||||
candidates: SessionIndexCleanupCandidate[];
|
||||
};
|
||||
|
||||
export type SessionIndexCleanupResult = {
|
||||
prunedEntries: number;
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
export type CodexSessionPageCounts = {
|
||||
active: number;
|
||||
archived: number;
|
||||
};
|
||||
|
||||
export function codexSessionPageCounts(
|
||||
sessions: readonly Pick<CodexSessionSummary, 'archived'>[],
|
||||
): CodexSessionPageCounts {
|
||||
const archived = sessions.reduce(
|
||||
(count, session) => count + Number(session.archived),
|
||||
0,
|
||||
);
|
||||
return { active: sessions.length - archived, archived };
|
||||
}
|
||||
|
||||
export function retainVisibleCodexSessionIds(
|
||||
selectedIds: ReadonlySet<string>,
|
||||
sessions: readonly Pick<CodexSessionSummary, 'id'>[],
|
||||
): Set<string> {
|
||||
const visibleIds = new Set(sessions.map((session) => session.id));
|
||||
return new Set(Array.from(selectedIds).filter((id) => visibleIds.has(id)));
|
||||
}
|
||||
+1066
-154
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
codexSessionPageCounts,
|
||||
retainVisibleCodexSessionIds,
|
||||
} from '../src/services/codexSessionState';
|
||||
|
||||
describe('Codex session page state', () => {
|
||||
it('counts active and archived sessions on the current page', () => {
|
||||
expect(codexSessionPageCounts([
|
||||
{ archived: false },
|
||||
{ archived: true },
|
||||
{ archived: false },
|
||||
])).toEqual({ active: 2, archived: 1 });
|
||||
});
|
||||
|
||||
it('drops selections that are no longer on the visible page', () => {
|
||||
expect(Array.from(retainVisibleCodexSessionIds(
|
||||
new Set(['thread-a', 'thread-c']),
|
||||
[{ id: 'thread-a' }, { id: 'thread-b' }],
|
||||
))).toEqual(['thread-a']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user