Merge pull request #66 from router-for-me/dev

release v0.2.7
This commit is contained in:
lzt404 authored and GitHub committed 2026-07-27 01:33:12 +08:00
commit 9bf20f17eb
17 files changed
+5771 -433

No files matched your search

+1 -1
View File
@@ -1 +1 @@
7.2.99
7.2.101
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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]
+1
View File
@@ -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!({
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -2,6 +2,7 @@ const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
['繁体中文', '繁體中文'],
['简体中文', '簡體中文'],
['智能体', '智慧代理'],
['会话', '工作階段'],
['使用记录', '使用記錄'],
['认证文件', '認證檔案'],
['配置文件', '設定檔'],
+483 -200
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+517
View File
@@ -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>
);
}
+89
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -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']);
});
});