9 files changed
+889
-140
No files matched your search
+337
-46
@@ -8,14 +8,15 @@ use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::{GuiConfigState, MANAGED_AGENT_PROVIDER_ID};
|
||||
use crate::GuiConfigState;
|
||||
|
||||
const DEFAULT_PAGE_SIZE: usize = 50;
|
||||
const MAX_PAGE_SIZE: usize = 100;
|
||||
@@ -203,6 +204,12 @@ struct SessionIndexPlan {
|
||||
candidates: Vec<SessionIndexCleanupCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionIndexMetadata {
|
||||
title: String,
|
||||
updated_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_codex_sessions(
|
||||
app: tauri::AppHandle,
|
||||
@@ -254,8 +261,8 @@ pub(crate) async fn repair_codex_session_metadata(
|
||||
let codex_home = resolve_codex_home(&user_home);
|
||||
let progress_app = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
validate_managed_codex_provider(&codex_home)?;
|
||||
repair_codex_session_metadata_from_home(&codex_home, |progress| {
|
||||
let target_provider = resolve_current_codex_provider(&codex_home)?;
|
||||
repair_codex_session_metadata_from_home(&codex_home, &target_provider, |progress| {
|
||||
let _ = progress_app.emit(REPAIR_PROGRESS_EVENT, progress);
|
||||
})
|
||||
})
|
||||
@@ -339,11 +346,11 @@ pub(crate) fn repair_before_codex_launch(
|
||||
fn repair_before_codex_launch_from_home(
|
||||
codex_home: &Path,
|
||||
) -> Result<CodexSessionRepairResult, String> {
|
||||
validate_managed_codex_provider(codex_home)?;
|
||||
match repair_codex_session_metadata_from_home(codex_home, |_| {}) {
|
||||
let target_provider = resolve_current_codex_provider(codex_home)?;
|
||||
match repair_codex_session_metadata_from_home(codex_home, &target_provider, |_| {}) {
|
||||
Ok(result) => Ok(result),
|
||||
Err(error) if is_locked_error_message(&error) => Ok(CodexSessionRepairResult {
|
||||
target_provider: MANAGED_AGENT_PROVIDER_ID.to_string(),
|
||||
target_provider,
|
||||
changed_rollout_files: 0,
|
||||
sqlite_rows_updated: 0,
|
||||
skipped_locked_files: Vec::new(),
|
||||
@@ -526,6 +533,10 @@ fn list_codex_sessions_from_home(
|
||||
Err(error) => warnings.push(error),
|
||||
}
|
||||
}
|
||||
let (mut rollout_sessions, rollout_warnings) = list_sessions_from_rollouts(codex_home);
|
||||
sessions.append(&mut rollout_sessions);
|
||||
warnings.extend(rollout_warnings);
|
||||
let mut sessions = merge_session_summaries(sessions);
|
||||
sessions.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at_ms
|
||||
@@ -533,8 +544,6 @@ fn list_codex_sessions_from_home(
|
||||
.then_with(|| right.id.cmp(&left.id))
|
||||
.then_with(|| left.database_path.cmp(&right.database_path))
|
||||
});
|
||||
let mut seen = HashSet::new();
|
||||
sessions.retain(|session| seen.insert(session.id.clone()));
|
||||
let has_more = sessions.len() > offset.saturating_add(limit);
|
||||
let sessions = sessions.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(CodexSessionPage {
|
||||
@@ -552,6 +561,231 @@ fn list_codex_sessions_from_home(
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_session_summaries(sessions: Vec<CodexSessionSummary>) -> Vec<CodexSessionSummary> {
|
||||
let mut merged = Vec::<CodexSessionSummary>::new();
|
||||
let mut positions = HashMap::<String, usize>::new();
|
||||
for session in sessions {
|
||||
let key = normalize_thread_id(&session.id);
|
||||
let key = if key.is_empty() {
|
||||
session.id.clone()
|
||||
} else {
|
||||
key
|
||||
};
|
||||
let Some(index) = positions.get(&key).copied() else {
|
||||
positions.insert(key, merged.len());
|
||||
merged.push(session);
|
||||
continue;
|
||||
};
|
||||
let existing = &mut merged[index];
|
||||
let candidate_is_newer = session.updated_at_ms > existing.updated_at_ms;
|
||||
if candidate_is_newer {
|
||||
let mut next = session;
|
||||
fill_missing_session_fields(&mut next, existing);
|
||||
*existing = next;
|
||||
} else {
|
||||
fill_missing_session_fields(existing, &session);
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn fill_missing_session_fields(target: &mut CodexSessionSummary, fallback: &CodexSessionSummary) {
|
||||
if target.title.trim().is_empty() {
|
||||
target.title = fallback.title.clone();
|
||||
}
|
||||
if target.cwd.trim().is_empty() {
|
||||
target.cwd = fallback.cwd.clone();
|
||||
}
|
||||
if target.model_provider.trim().is_empty() {
|
||||
target.model_provider = fallback.model_provider.clone();
|
||||
}
|
||||
if target.database_path.trim().is_empty() {
|
||||
target.database_path = fallback.database_path.clone();
|
||||
}
|
||||
if target.updated_at_ms.is_none() {
|
||||
target.updated_at_ms = fallback.updated_at_ms;
|
||||
}
|
||||
}
|
||||
|
||||
fn list_sessions_from_rollouts(codex_home: &Path) -> (Vec<CodexSessionSummary>, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let metadata = match read_session_index_metadata(codex_home) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
warnings.push(error);
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
let paths = match collect_rollout_files(codex_home) {
|
||||
Ok(paths) => paths,
|
||||
Err(error) => {
|
||||
warnings.push(error);
|
||||
return (Vec::new(), warnings);
|
||||
}
|
||||
};
|
||||
let mut sessions = Vec::new();
|
||||
for path in paths {
|
||||
match summarize_rollout_session(codex_home, &path, &metadata) {
|
||||
Ok(Some(session)) => sessions.push(session),
|
||||
Ok(None) => {}
|
||||
Err(error) if is_locked_error_message(&error) => {
|
||||
warnings.push(format!(
|
||||
"会话文件正在使用,已跳过 {}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Err(error) => warnings.push(error),
|
||||
}
|
||||
}
|
||||
(sessions, warnings)
|
||||
}
|
||||
|
||||
fn read_session_index_metadata(
|
||||
codex_home: &Path,
|
||||
) -> Result<HashMap<String, SessionIndexMetadata>, String> {
|
||||
let path = codex_home.join("session_index.jsonl");
|
||||
if !path.is_file() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读取 session_index.jsonl 失败: {error}"))?;
|
||||
let mut metadata = HashMap::new();
|
||||
for line in content.lines() {
|
||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
let Some(object) = record.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let item = SessionIndexMetadata {
|
||||
title: object
|
||||
.get("thread_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
updated_at_ms: object
|
||||
.get("updated_at")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_session_timestamp_millis),
|
||||
};
|
||||
metadata.insert(id.to_string(), item.clone());
|
||||
let normalized = normalize_thread_id(id);
|
||||
if !normalized.is_empty() {
|
||||
metadata.entry(normalized).or_insert(item);
|
||||
}
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn summarize_rollout_session(
|
||||
codex_home: &Path,
|
||||
path: &Path,
|
||||
metadata: &HashMap<String, SessionIndexMetadata>,
|
||||
) -> Result<Option<CodexSessionSummary>, String> {
|
||||
let file = fs::File::open(path)
|
||||
.map_err(|error| format!("读取 rollout 失败 {}: {error}", path.display()))?;
|
||||
let mut id = None;
|
||||
let mut title = String::new();
|
||||
let mut cwd = String::new();
|
||||
let mut model_provider = String::new();
|
||||
let mut updated_at_ms = None;
|
||||
for line in BufReader::new(file).lines() {
|
||||
let line =
|
||||
line.map_err(|error| format!("读取 rollout 失败 {}: {error}", path.display()))?;
|
||||
let Ok(record) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if record.get("type").and_then(Value::as_str) != Some("session_meta") {
|
||||
continue;
|
||||
}
|
||||
let Some(payload) = record.get("payload").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
id = payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
title = payload
|
||||
.get("title")
|
||||
.or_else(|| payload.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
cwd = payload
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_workspace_path)
|
||||
.unwrap_or_default();
|
||||
model_provider = payload
|
||||
.get("model_provider")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
updated_at_ms = record
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_session_timestamp_millis);
|
||||
break;
|
||||
}
|
||||
let id = id.or_else(|| rollout_thread_id_from_file_name(path));
|
||||
let Some(id) = id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let normalized_id = normalize_thread_id(&id);
|
||||
let index_metadata = metadata.get(&id).or_else(|| metadata.get(&normalized_id));
|
||||
if title.trim().is_empty() {
|
||||
title = index_metadata
|
||||
.map(|item| item.title.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
updated_at_ms = index_metadata
|
||||
.and_then(|item| item.updated_at_ms)
|
||||
.or(updated_at_ms)
|
||||
.or_else(|| {
|
||||
fs::metadata(path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.ok()
|
||||
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|duration| i64::try_from(duration.as_millis()).ok())
|
||||
});
|
||||
Ok(Some(CodexSessionSummary {
|
||||
id,
|
||||
title,
|
||||
cwd,
|
||||
model_provider,
|
||||
archived: path.starts_with(codex_home.join("archived_sessions")),
|
||||
updated_at_ms,
|
||||
database_path: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_session_timestamp_millis(value: &str) -> Option<i64> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(value) = value.parse::<i64>() {
|
||||
return Some(if value.abs() < 100_000_000_000 {
|
||||
value.saturating_mul(1_000)
|
||||
} else {
|
||||
value
|
||||
});
|
||||
}
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|timestamp| timestamp.timestamp_millis())
|
||||
}
|
||||
|
||||
fn list_sessions_from_database(
|
||||
path: &Path,
|
||||
limit: usize,
|
||||
@@ -1225,28 +1459,37 @@ fn write_json_atomically(path: &Path, value: &Value) -> Result<(), String> {
|
||||
crate::write_bytes_atomically(path, &bytes)
|
||||
}
|
||||
|
||||
fn validate_managed_codex_provider(codex_home: &Path) -> Result<(), String> {
|
||||
fn resolve_current_codex_provider(codex_home: &Path) -> Result<String, String> {
|
||||
let path = codex_home.join("config.toml");
|
||||
if !path.is_file() {
|
||||
return Ok("openai".to_string());
|
||||
}
|
||||
let text = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读取 Codex 配置失败 {}: {error}", path.display()))?;
|
||||
let document = text
|
||||
.parse::<toml::Value>()
|
||||
.map_err(|error| format!("Codex 配置无法解析: {error}"))?;
|
||||
if document.get("model_provider").and_then(toml::Value::as_str)
|
||||
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
{
|
||||
return Err("请先为 Codex 应用 EasyCLIProxyAPI 托管配置,再恢复历史会话".to_string());
|
||||
}
|
||||
Ok(())
|
||||
Ok(document
|
||||
.get("model_provider")
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|provider| !provider.is_empty())
|
||||
.unwrap_or("openai")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn repair_codex_session_metadata_from_home<F>(
|
||||
codex_home: &Path,
|
||||
target_provider: &str,
|
||||
mut progress: F,
|
||||
) -> Result<CodexSessionRepairResult, String>
|
||||
where
|
||||
F: FnMut(CodexSessionRepairProgress),
|
||||
{
|
||||
let target_provider = target_provider.trim();
|
||||
if target_provider.is_empty() {
|
||||
return Err("当前 Codex provider 不能为空".to_string());
|
||||
}
|
||||
let _guard = CODEX_SESSION_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "Codex 会话写入锁已损坏".to_string())?;
|
||||
@@ -1262,7 +1505,7 @@ where
|
||||
let mut skipped_locked_files = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for (index, path) in rollout_paths.iter().enumerate() {
|
||||
match build_rollout_repair(path, MANAGED_AGENT_PROVIDER_ID) {
|
||||
match build_rollout_repair(path, target_provider) {
|
||||
Ok(Some(repair)) => repairs.push(repair),
|
||||
Ok(None) => {}
|
||||
Err(error) if is_locked_error_message(&error) => {
|
||||
@@ -1283,7 +1526,7 @@ where
|
||||
let (database_paths, database_warnings) = discover_database_paths(codex_home, false);
|
||||
warnings.extend(database_warnings);
|
||||
let (sqlite_updates_needed, database_count_warnings) =
|
||||
count_sqlite_repairs(&database_paths, &repairs, &projectless)?;
|
||||
count_sqlite_repairs(&database_paths, &repairs, &projectless, target_provider)?;
|
||||
let sqlite_scan_incomplete = !database_count_warnings.is_empty();
|
||||
warnings.extend(database_count_warnings);
|
||||
let changed_repairs = repairs
|
||||
@@ -1306,6 +1549,7 @@ where
|
||||
&database_paths,
|
||||
&changed_repairs,
|
||||
"provider-repair",
|
||||
target_provider,
|
||||
)?)
|
||||
};
|
||||
progress(CodexSessionRepairProgress {
|
||||
@@ -1348,25 +1592,26 @@ where
|
||||
processed: 0,
|
||||
total: database_paths.len(),
|
||||
});
|
||||
let sqlite_rows_updated = match apply_sqlite_repairs(&database_paths, &repairs, &projectless) {
|
||||
Ok((updated, database_lock_warnings)) => {
|
||||
warnings.extend(database_lock_warnings);
|
||||
updated
|
||||
}
|
||||
Err(error) => {
|
||||
for previous in written.iter().rev() {
|
||||
let _ = crate::write_bytes_atomically(&previous.path, &previous.original);
|
||||
restore_modified_time(&previous.path, previous.original_mtime);
|
||||
let sqlite_rows_updated =
|
||||
match apply_sqlite_repairs(&database_paths, &repairs, &projectless, target_provider) {
|
||||
Ok((updated, database_lock_warnings)) => {
|
||||
warnings.extend(database_lock_warnings);
|
||||
updated
|
||||
}
|
||||
return Err(format!(
|
||||
"更新会话数据库失败;rollout 已尝试回滚,备份仍保留:{error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
Err(error) => {
|
||||
for previous in written.iter().rev() {
|
||||
let _ = crate::write_bytes_atomically(&previous.path, &previous.original);
|
||||
restore_modified_time(&previous.path, previous.original_mtime);
|
||||
}
|
||||
return Err(format!(
|
||||
"更新会话数据库失败;rollout 已尝试回滚,备份仍保留:{error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if backup_path.is_some() {
|
||||
prune_repair_backups(codex_home)?;
|
||||
}
|
||||
let encrypted_content_warning = encrypted_content_warning(&repairs);
|
||||
let encrypted_content_warning = encrypted_content_warning(&repairs, target_provider);
|
||||
progress(CodexSessionRepairProgress {
|
||||
phase: "complete".to_string(),
|
||||
percent: 100,
|
||||
@@ -1374,7 +1619,7 @@ where
|
||||
total,
|
||||
});
|
||||
Ok(CodexSessionRepairResult {
|
||||
target_provider: MANAGED_AGENT_PROVIDER_ID.to_string(),
|
||||
target_provider: target_provider.to_string(),
|
||||
changed_rollout_files: written.len(),
|
||||
sqlite_rows_updated,
|
||||
skipped_locked_files,
|
||||
@@ -1562,6 +1807,7 @@ fn count_sqlite_repairs(
|
||||
paths: &[PathBuf],
|
||||
repairs: &[RolloutRepair],
|
||||
projectless: &HashSet<String>,
|
||||
target_provider: &str,
|
||||
) -> Result<(usize, Vec<String>), String> {
|
||||
let (user_event_ids, cwd_by_id) = repair_maps(repairs, projectless);
|
||||
let mut total = 0usize;
|
||||
@@ -1575,7 +1821,7 @@ fn count_sqlite_repairs(
|
||||
count += connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM threads WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[MANAGED_AGENT_PROVIDER_ID],
|
||||
[target_provider],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map_err(|error| format!("统计 Provider 迁移行失败: {error}"))?
|
||||
@@ -1623,6 +1869,7 @@ fn apply_sqlite_repairs(
|
||||
paths: &[PathBuf],
|
||||
repairs: &[RolloutRepair],
|
||||
projectless: &HashSet<String>,
|
||||
target_provider: &str,
|
||||
) -> Result<(usize, Vec<String>), String> {
|
||||
let (user_event_ids, cwd_by_id) = repair_maps(repairs, projectless);
|
||||
let mut total = 0usize;
|
||||
@@ -1642,7 +1889,7 @@ fn apply_sqlite_repairs(
|
||||
updated += transaction
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[MANAGED_AGENT_PROVIDER_ID],
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| format!("更新会话 provider 失败: {error}"))?;
|
||||
}
|
||||
@@ -1688,6 +1935,7 @@ fn create_repair_backup(
|
||||
database_paths: &[PathBuf],
|
||||
repairs: &[RolloutRepair],
|
||||
label: &str,
|
||||
target_provider: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let directory = create_operation_directory(codex_home, "session-repair", label)?;
|
||||
for name in [
|
||||
@@ -1750,7 +1998,7 @@ fn create_repair_backup(
|
||||
"schemaVersion": 1,
|
||||
"kind": label,
|
||||
"createdAt": Utc::now().to_rfc3339(),
|
||||
"targetProvider": MANAGED_AGENT_PROVIDER_ID,
|
||||
"targetProvider": target_provider,
|
||||
"databaseFiles": database_files,
|
||||
"rolloutFiles": rollout_files,
|
||||
"managedBy": "EasyCLIProxyAPI"
|
||||
@@ -1776,12 +2024,12 @@ fn restore_modified_time(path: &Path, modified: Option<SystemTime>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypted_content_warning(repairs: &[RolloutRepair]) -> Option<String> {
|
||||
fn encrypted_content_warning(repairs: &[RolloutRepair], target_provider: &str) -> Option<String> {
|
||||
let mut providers = repairs
|
||||
.iter()
|
||||
.filter(|repair| repair.encrypted_content)
|
||||
.flat_map(|repair| repair.providers.iter().cloned())
|
||||
.filter(|provider| provider != MANAGED_AGENT_PROVIDER_ID)
|
||||
.filter(|provider| provider != target_provider)
|
||||
.collect::<Vec<_>>();
|
||||
providers.sort();
|
||||
providers.dedup();
|
||||
@@ -1791,7 +2039,7 @@ fn encrypted_content_warning(repairs: &[RolloutRepair]) -> Option<String> {
|
||||
Some(format!(
|
||||
"部分历史会话包含来自 {} 的 encrypted_content;元数据已同步到 {},但继续或压缩这些会话可能失败。",
|
||||
providers.join("、"),
|
||||
MANAGED_AGENT_PROVIDER_ID
|
||||
target_provider
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2379,6 +2627,45 @@ mod tests {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_includes_rollout_sessions_from_every_provider() {
|
||||
let root = test_root("list-all-providers");
|
||||
let api_rollout = root.join("sessions/api.jsonl");
|
||||
let oauth_rollout = root.join("archived_sessions/oauth.jsonl");
|
||||
fs::create_dir_all(api_rollout.parent().unwrap()).unwrap();
|
||||
fs::create_dir_all(oauth_rollout.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&api_rollout,
|
||||
"{\"timestamp\":\"2026-08-04T12:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"api-thread\",\"cwd\":\"C:/api\",\"model_provider\":\"cpa-gui\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&oauth_rollout,
|
||||
"{\"timestamp\":\"2026-08-03T12:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"oauth-thread\",\"cwd\":\"C:/oauth\",\"model_provider\":\"openai\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("session_index.jsonl"),
|
||||
concat!(
|
||||
"{\"id\":\"api-thread\",\"thread_name\":\"API session\",\"updated_at\":\"2026-08-04T12:00:00Z\"}\n",
|
||||
"{\"id\":\"oauth-thread\",\"thread_name\":\"OAuth session\",\"updated_at\":\"2026-08-03T12:00:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let page = list_codex_sessions_from_home(&root, 0, 50, false).unwrap();
|
||||
assert_eq!(page.sessions.len(), 2);
|
||||
assert_eq!(page.sessions[0].id, "api-thread");
|
||||
assert_eq!(page.sessions[0].title, "API session");
|
||||
assert_eq!(page.sessions[0].model_provider, "cpa-gui");
|
||||
assert!(!page.sessions[0].archived);
|
||||
assert_eq!(page.sessions[1].id, "oauth-thread");
|
||||
assert_eq!(page.sessions[1].title, "OAuth session");
|
||||
assert_eq!(page.sessions[1].model_provider, "openai");
|
||||
assert!(page.sessions[1].archived);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_accepts_optional_thread_columns_and_warns_about_a_broken_database() {
|
||||
let root = test_root("list-optional");
|
||||
@@ -2668,7 +2955,7 @@ mod tests {
|
||||
.unwrap();
|
||||
create_threads_db(&root.join("state_5.sqlite"), &rollout);
|
||||
|
||||
let result = repair_codex_session_metadata_from_home(&root, |_| {}).unwrap();
|
||||
let result = repair_codex_session_metadata_from_home(&root, "cpa-gui", |_| {}).unwrap();
|
||||
assert_eq!(result.changed_rollout_files, 1);
|
||||
assert_eq!(result.sqlite_rows_updated, 2);
|
||||
let backup = PathBuf::from(result.backup_path.as_ref().unwrap());
|
||||
@@ -2705,7 +2992,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = repair_codex_session_metadata_from_home(&root, |_| {}).unwrap();
|
||||
let result = repair_codex_session_metadata_from_home(&root, "cpa-gui", |_| {}).unwrap();
|
||||
assert_eq!(result.changed_rollout_files, 0);
|
||||
assert_eq!(result.sqlite_rows_updated, 0);
|
||||
assert_eq!(result.backup_path, None);
|
||||
@@ -2733,7 +3020,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let before_mtime = fs::metadata(&rollout).unwrap().modified().unwrap();
|
||||
|
||||
let result = repair_codex_session_metadata_from_home(&root, |_| {}).unwrap();
|
||||
let result = repair_codex_session_metadata_from_home(&root, "cpa-gui", |_| {}).unwrap();
|
||||
assert_eq!(result.changed_rollout_files, 1);
|
||||
assert!(result.encrypted_content_warning.is_some());
|
||||
let next = fs::read(&rollout).unwrap();
|
||||
@@ -2765,6 +3052,7 @@ mod tests {
|
||||
std::slice::from_ref(&database),
|
||||
&[],
|
||||
"provider-repair",
|
||||
"cpa-gui",
|
||||
)
|
||||
.unwrap();
|
||||
for name in ["state_5.sqlite", "state_5.sqlite-wal", "state_5.sqlite-shm"] {
|
||||
@@ -2805,20 +3093,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_hook_requires_managed_config_and_repairs_before_returning() {
|
||||
fn launch_hook_repairs_sessions_for_the_current_provider() {
|
||||
let root = test_root("repair-launch-hook");
|
||||
fs::create_dir_all(root.join("sessions")).unwrap();
|
||||
let rollout = root.join("sessions/rollout.jsonl");
|
||||
fs::write(
|
||||
&rollout,
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"openai\"}}\n",
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"legacy\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("config.toml"), "model_provider = \"openai\"\n").unwrap();
|
||||
assert!(repair_before_codex_launch_from_home(&root).is_err());
|
||||
let result = repair_before_codex_launch_from_home(&root).unwrap();
|
||||
assert_eq!(result.target_provider, "openai");
|
||||
assert!(fs::read_to_string(&rollout).unwrap().contains("openai"));
|
||||
|
||||
fs::write(root.join("config.toml"), "model_provider = \"cpa-gui\"\n").unwrap();
|
||||
let result = repair_before_codex_launch_from_home(&root).unwrap();
|
||||
assert_eq!(result.target_provider, "cpa-gui");
|
||||
assert_eq!(result.changed_rollout_files, 1);
|
||||
assert!(fs::read_to_string(&rollout).unwrap().contains("cpa-gui"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
|
||||
+382
-43
@@ -86,6 +86,9 @@ const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
|
||||
const PI_AGENT_ID: &str = "pi";
|
||||
const PI_AGENT_NAME: &str = "Pi";
|
||||
const PI_CLIPROXYAPI_PACKAGE: &str = "npm:@router-for-me/pi-cliproxyapi-provider";
|
||||
const PI_CLIPROXYAPI_NPM_LATEST_URL: &str =
|
||||
"https://registry.npmjs.org/@router-for-me%2Fpi-cliproxyapi-provider/latest";
|
||||
const PI_CLIPROXYAPI_PROVIDER_ID: &str = "cliproxyapi";
|
||||
const PI_AGENT_CONFIG_FILE: &str = "cliproxyapi.json";
|
||||
const PI_AGENT_SETTINGS_FILE: &str = "settings.json";
|
||||
const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json";
|
||||
@@ -655,6 +658,7 @@ struct AgentConfigStatus {
|
||||
version: Option<String>,
|
||||
cli_version: Option<String>,
|
||||
app_version: Option<String>,
|
||||
plugin_version: Option<String>,
|
||||
config_paths: Vec<String>,
|
||||
config_exists: bool,
|
||||
config_valid: bool,
|
||||
@@ -691,6 +695,14 @@ struct AgentConfigActionResult {
|
||||
conflict_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PiProviderUpdateStatus {
|
||||
installed_version: Option<String>,
|
||||
latest_version: Option<String>,
|
||||
update_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexModelCatalogUpdateResult {
|
||||
@@ -1799,11 +1811,40 @@ async fn get_agent_models(
|
||||
Ok(fetch_prepared_agent_models(client, &config).await?.models)
|
||||
}
|
||||
|
||||
async fn resolve_pi_default_model(config: &GuiConfigFile, model: &str) -> Result<String, String> {
|
||||
let models = fetch_agent_models(config.port, effective_agent_api_key(config)).await?;
|
||||
resolve_available_agent_model(&models, &validate_agent_model(model)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_pi_provider_update(app: tauri::AppHandle) -> Result<PiProviderUpdateStatus, String> {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let installed_version = read_pi_provider_version(&home)?;
|
||||
let Some(installed_version_value) = installed_version.as_deref() else {
|
||||
return Ok(PiProviderUpdateStatus {
|
||||
installed_version: None,
|
||||
latest_version: None,
|
||||
update_available: false,
|
||||
});
|
||||
};
|
||||
let latest_version = fetch_pi_provider_latest_version().await?;
|
||||
let update_available = pi_provider_update_available(installed_version_value, &latest_version)?;
|
||||
Ok(PiProviderUpdateStatus {
|
||||
installed_version,
|
||||
latest_version: Some(latest_version),
|
||||
update_available,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_pi_provider(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
model: String,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let home = app
|
||||
.path()
|
||||
@@ -1812,10 +1853,11 @@ async fn install_pi_provider(
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let executable = find_pi_executable(&home)
|
||||
.ok_or_else(|| "未检测到 Pi CLI,请先安装 Pi 并确保 pi 命令在 PATH 中".to_string())?;
|
||||
let model = resolve_pi_default_model(&config, &model).await?;
|
||||
let port = config.port;
|
||||
let api_key = effective_agent_api_key(&config).to_string();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
install_pi_provider_inner(&home, &executable, port, &api_key)
|
||||
install_pi_provider_inner(&home, &executable, port, &api_key, &model)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("安装 Pi CLIProxyAPI provider 任务失败: {error}"))??;
|
||||
@@ -1824,22 +1866,51 @@ async fn install_pi_provider(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn sync_pi_provider(
|
||||
async fn update_pi_provider(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
model: String,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("鏃犳硶鑾峰彇鐢ㄦ埛鐩綍: {error}"))?;
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let executable = find_pi_executable(&home)
|
||||
.ok_or_else(|| "未检测到 Pi CLI,请先安装 Pi 并确保 pi 命令在 PATH 中".to_string())?;
|
||||
let model = resolve_pi_default_model(&config, &model).await?;
|
||||
let port = config.port;
|
||||
let api_key = effective_agent_api_key(&config).to_string();
|
||||
let result =
|
||||
tauri::async_runtime::spawn_blocking(move || sync_pi_provider_inner(&home, port, &api_key))
|
||||
.await
|
||||
.map_err(|error| format!("鍚屾 Pi 閰嶇疆浠诲姟澶辫触: {error}"))??;
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
update_pi_provider_inner(&home, &executable, port, &api_key, &model)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("更新 Pi 插件任务失败: {error}"))??;
|
||||
cache.clear()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_pi_provider(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
model: String,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let model = resolve_pi_default_model(&config, &model).await?;
|
||||
let port = config.port;
|
||||
let api_key = effective_agent_api_key(&config).to_string();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
repair_pi_provider_inner(&home, port, &api_key, &model)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("修复 Pi 配置任务失败: {error}"))??;
|
||||
cache.clear()?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -2678,7 +2749,7 @@ async fn launch_agent(
|
||||
if client == AgentClient::Codex && status.oauth_configuration {
|
||||
validate_codex_oauth_login(&home)?;
|
||||
}
|
||||
if client == AgentClient::Codex && config.codex_session_repair_on_launch && status.configured {
|
||||
if client == AgentClient::Codex && config.codex_session_repair_on_launch {
|
||||
let repair_home = home.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
codex_sessions::repair_before_codex_launch(&repair_home)
|
||||
@@ -3208,11 +3279,107 @@ fn pi_provider_settings_path(home: &Path) -> PathBuf {
|
||||
pi_agent_directory(home).join(PI_AGENT_SETTINGS_FILE)
|
||||
}
|
||||
|
||||
fn pi_provider_package_json_path(home: &Path) -> PathBuf {
|
||||
pi_agent_directory(home)
|
||||
.join("npm/node_modules/@router-for-me/pi-cliproxyapi-provider/package.json")
|
||||
}
|
||||
|
||||
fn read_pi_provider_version(home: &Path) -> Result<Option<String>, String> {
|
||||
let path = pi_provider_package_json_path(home);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|error| {
|
||||
format!(
|
||||
"读取 Pi provider package.json 失败 {}: {error}",
|
||||
path_to_string(&path)
|
||||
)
|
||||
})?;
|
||||
let package = serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
|
||||
format!(
|
||||
"解析 Pi provider package.json 失败 {}: {error}",
|
||||
path_to_string(&path)
|
||||
)
|
||||
})?;
|
||||
Ok(package
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
fn parse_pi_provider_latest_version(payload: &serde_json::Value) -> Result<String, String> {
|
||||
payload
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "npm registry 返回的 Pi provider 版本无效".to_string())
|
||||
}
|
||||
|
||||
async fn fetch_pi_provider_latest_version() -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build()
|
||||
.map_err(|error| format!("创建 Pi provider 更新检测客户端失败: {error}"))?;
|
||||
let response = client
|
||||
.get(PI_CLIPROXYAPI_NPM_LATEST_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("查询 Pi provider 最新版本失败: {error}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"查询 Pi provider 最新版本失败: HTTP {}",
|
||||
status.as_u16()
|
||||
));
|
||||
}
|
||||
let payload = response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|error| format!("解析 Pi provider 最新版本失败: {error}"))?;
|
||||
parse_pi_provider_latest_version(&payload)
|
||||
}
|
||||
|
||||
fn pi_provider_update_available(installed: &str, latest: &str) -> Result<bool, String> {
|
||||
let parse = |value: &str| {
|
||||
semver::Version::parse(value.trim().trim_start_matches('v'))
|
||||
.map_err(|error| format!("无法解析 Pi provider 版本号 {value}: {error}"))
|
||||
};
|
||||
Ok(parse(latest)? > parse(installed)?)
|
||||
}
|
||||
|
||||
fn pi_package_source_matches(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
value == PI_CLIPROXYAPI_PACKAGE || value.starts_with(&format!("{PI_CLIPROXYAPI_PACKAGE}@"))
|
||||
}
|
||||
|
||||
fn read_pi_settings(home: &Path) -> Result<Option<serde_json::Value>, String> {
|
||||
let path = pi_provider_settings_path(home);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|error| {
|
||||
format!(
|
||||
"读取 Pi settings.json 失败 {}: {error}",
|
||||
path_to_string(&path)
|
||||
)
|
||||
})?;
|
||||
serde_json::from_str::<serde_json::Value>(&content)
|
||||
.map(Some)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"解析 Pi settings.json 失败 {}: {error}",
|
||||
path_to_string(&path)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn pi_settings_contains_provider(settings: &serde_json::Value) -> bool {
|
||||
settings
|
||||
.get("packages")
|
||||
@@ -3229,23 +3396,9 @@ fn pi_settings_contains_provider(settings: &serde_json::Value) -> bool {
|
||||
}
|
||||
|
||||
fn pi_provider_package_installed(home: &Path) -> Result<bool, String> {
|
||||
let path = pi_provider_settings_path(home);
|
||||
if !path.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|error| {
|
||||
format!(
|
||||
"读取 Pi settings.json 失败 {}: {error}",
|
||||
path_to_string(&path)
|
||||
)
|
||||
})?;
|
||||
let settings = serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
|
||||
format!(
|
||||
"解析 Pi settings.json 失败 {}: {error}",
|
||||
path_to_string(&path)
|
||||
)
|
||||
})?;
|
||||
Ok(pi_settings_contains_provider(&settings))
|
||||
Ok(read_pi_settings(home)?
|
||||
.as_ref()
|
||||
.is_some_and(pi_settings_contains_provider))
|
||||
}
|
||||
|
||||
fn build_pi_provider_config(
|
||||
@@ -3269,6 +3422,27 @@ fn build_pi_provider_config(
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn build_pi_provider_settings(existing: &str, default_model: &str) -> Result<String, String> {
|
||||
let default_model = default_model.trim();
|
||||
if default_model.is_empty() {
|
||||
return Err("Pi 默认模型不能为空".to_string());
|
||||
}
|
||||
let mut root = serde_json::from_str::<serde_json::Value>(existing)
|
||||
.map_err(|error| format!("解析 Pi settings.json 失败: {error}"))?;
|
||||
let object = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "Pi settings.json 根节点必须是 JSON 对象".to_string())?;
|
||||
object.insert(
|
||||
"defaultProvider".to_string(),
|
||||
serde_json::json!(PI_CLIPROXYAPI_PROVIDER_ID),
|
||||
);
|
||||
object.insert("defaultModel".to_string(), serde_json::json!(default_model));
|
||||
let mut rendered = serde_json::to_string_pretty(&root)
|
||||
.map_err(|error| format!("生成 Pi settings.json 失败: {error}"))?;
|
||||
rendered.push('\n');
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentConfigStatus {
|
||||
let config_path = pi_provider_config_path(home);
|
||||
let settings_path = pi_provider_settings_path(home);
|
||||
@@ -3283,8 +3457,28 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let plugin_installed = pi_provider_package_installed(home).unwrap_or_default();
|
||||
let mut errors = Vec::new();
|
||||
let (plugin_installed, default_provider_matches, current_model) = match read_pi_settings(home) {
|
||||
Ok(Some(settings)) => {
|
||||
let plugin_installed = pi_settings_contains_provider(&settings);
|
||||
let default_provider_matches = settings
|
||||
.get("defaultProvider")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| value.trim() == PI_CLIPROXYAPI_PROVIDER_ID);
|
||||
let current_model = settings
|
||||
.get("defaultModel")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
(plugin_installed, default_provider_matches, current_model)
|
||||
}
|
||||
Ok(None) => (false, false, None),
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
(false, false, None)
|
||||
}
|
||||
};
|
||||
let mut credentials_match = false;
|
||||
if config_path.is_file() {
|
||||
match fs::read_to_string(&config_path)
|
||||
@@ -3307,20 +3501,24 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon
|
||||
Err(error) => errors.push(error),
|
||||
}
|
||||
}
|
||||
if settings_path.is_file() {
|
||||
if let Err(error) = pi_provider_package_installed(home) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
let config_exists = config_path.is_file() || settings_path.is_file();
|
||||
let config_valid = errors.is_empty();
|
||||
let configured = plugin_installed && credentials_match && config_valid;
|
||||
let configured = plugin_installed
|
||||
&& credentials_match
|
||||
&& default_provider_matches
|
||||
&& current_model.is_some()
|
||||
&& config_valid;
|
||||
let mut warnings = Vec::new();
|
||||
if executable.is_some() && !plugin_installed && config_valid {
|
||||
warnings.push("Pi CLIProxyAPI provider 插件尚未安装".to_string());
|
||||
} else if plugin_installed && !credentials_match && config_valid && config_exists {
|
||||
warnings
|
||||
.push("Pi 插件已安装,但尚未同步当前 EasyCLIProxyAPI 的 baseUrl/API key".to_string());
|
||||
} else if plugin_installed
|
||||
&& (!credentials_match || !default_provider_matches || current_model.is_none())
|
||||
&& config_valid
|
||||
&& config_exists
|
||||
{
|
||||
warnings.push(
|
||||
"Pi 插件配置不完整,请使用“应用配置”重新写入凭据、默认 provider 和默认模型".to_string(),
|
||||
);
|
||||
}
|
||||
if executable.is_none() && plugin_installed {
|
||||
warnings.push("已找到 Pi 插件配置,但未检测到 Pi CLI 命令".to_string());
|
||||
@@ -3332,6 +3530,7 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon
|
||||
};
|
||||
let error = errors.into_iter().next();
|
||||
let cli_version = executable.as_deref().and_then(read_agent_version);
|
||||
let plugin_version = read_pi_provider_version(home).ok().flatten();
|
||||
AgentConfigStatus {
|
||||
id: PI_AGENT_ID.to_string(),
|
||||
name: PI_AGENT_NAME.to_string(),
|
||||
@@ -3343,16 +3542,17 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon
|
||||
version: cli_version.clone(),
|
||||
cli_version,
|
||||
app_version: None,
|
||||
plugin_version,
|
||||
config_paths: vec![path_to_string(&config_path), path_to_string(&settings_path)],
|
||||
config_exists,
|
||||
config_valid,
|
||||
configured,
|
||||
current_model: None,
|
||||
current_model: current_model.clone(),
|
||||
oauth_configuration: false,
|
||||
modification_enabled: configured,
|
||||
modification_state: modification_state.to_string(),
|
||||
backup_available: false,
|
||||
applied_model: None,
|
||||
applied_model: current_model,
|
||||
claude_code_model_mappings: None,
|
||||
claude_desktop_model_mappings: None,
|
||||
claude_code_working_directory: None,
|
||||
@@ -3367,6 +3567,7 @@ fn install_pi_provider_inner(
|
||||
executable: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
default_model: &str,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
if port == 0 {
|
||||
return Err("内核端口无效".to_string());
|
||||
@@ -3381,16 +3582,17 @@ fn install_pi_provider_inner(
|
||||
changed_files.push(path_to_string(&settings_path));
|
||||
}
|
||||
|
||||
let mut result = sync_pi_provider_inner(home, port, api_key)?;
|
||||
let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?;
|
||||
changed_files.extend(result.changed_files);
|
||||
result.changed_files = changed_files;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn sync_pi_provider_inner(
|
||||
fn repair_pi_provider_inner(
|
||||
home: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
default_model: &str,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
if port == 0 {
|
||||
return Err("Core port is invalid".to_string());
|
||||
@@ -3399,6 +3601,7 @@ fn sync_pi_provider_inner(
|
||||
return Err("EasyCLIProxyAPI has no usable API key".to_string());
|
||||
}
|
||||
let config_path = pi_provider_config_path(home);
|
||||
let settings_path = pi_provider_settings_path(home);
|
||||
let mut changed_files = Vec::new();
|
||||
if !pi_provider_package_installed(home)? {
|
||||
return Err("Pi CLIProxyAPI provider is not installed".to_string());
|
||||
@@ -3420,15 +3623,43 @@ fn sync_pi_provider_inner(
|
||||
changed_files.push(path_to_string(&config_path));
|
||||
}
|
||||
|
||||
let settings = fs::read_to_string(&settings_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 Pi settings.json 失败 {}: {error}",
|
||||
path_to_string(&settings_path)
|
||||
)
|
||||
})?;
|
||||
let rendered_settings = build_pi_provider_settings(&settings, default_model)?;
|
||||
if settings != rendered_settings {
|
||||
write_bytes_atomically(&settings_path, rendered_settings.as_bytes())?;
|
||||
changed_files.push(path_to_string(&settings_path));
|
||||
}
|
||||
|
||||
Ok(action_result(
|
||||
"applied",
|
||||
true,
|
||||
None,
|
||||
Some(default_model.trim().to_string()),
|
||||
changed_files,
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
fn update_pi_provider_inner(
|
||||
home: &Path,
|
||||
executable: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
default_model: &str,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
if !pi_provider_package_installed(home)? {
|
||||
return Err("Pi CLIProxyAPI provider 插件尚未安装".to_string());
|
||||
}
|
||||
update_pi_package(executable, home)?;
|
||||
let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?;
|
||||
result.outcome = "updated".to_string();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn uninstall_pi_provider_inner(
|
||||
home: &Path,
|
||||
executable: &Path,
|
||||
@@ -3483,6 +3714,38 @@ fn install_pi_package(executable: &Path, home: &Path) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn update_pi_package(executable: &Path, home: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = windows_command_for_executable(executable, false);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let mut command = Command::new(executable);
|
||||
|
||||
command
|
||||
.arg("update")
|
||||
.arg("--extension")
|
||||
.arg(PI_CLIPROXYAPI_PACKAGE)
|
||||
.current_dir(home)
|
||||
.stdin(Stdio::null());
|
||||
configure_background_command(&mut command);
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|error| format!("执行 Pi 插件更新失败: {error}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let detail = if !stderr.is_empty() { stderr } else { stdout };
|
||||
Err(format!(
|
||||
"Pi CLIProxyAPI provider 插件更新失败{}",
|
||||
if detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {detail}")
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fn remove_pi_package(executable: &Path, home: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = windows_command_for_executable(executable, false);
|
||||
@@ -3751,6 +4014,7 @@ fn inspect_agent_config(
|
||||
version,
|
||||
cli_version,
|
||||
app_version,
|
||||
plugin_version: None,
|
||||
config_paths: paths.iter().map(|path| path_to_string(path)).collect(),
|
||||
config_exists,
|
||||
config_valid,
|
||||
@@ -5248,9 +5512,9 @@ fn claude_code_model_settings(
|
||||
}
|
||||
|
||||
fn format_context_window(context_window: u64) -> String {
|
||||
if context_window % 1_000_000 == 0 {
|
||||
if context_window.is_multiple_of(1_000_000) {
|
||||
format!("{}M", context_window / 1_000_000)
|
||||
} else if context_window % 1_000 == 0 {
|
||||
} else if context_window.is_multiple_of(1_000) {
|
||||
format!("{}K", context_window / 1_000)
|
||||
} else {
|
||||
context_window.to_string()
|
||||
@@ -18489,8 +18753,10 @@ fn main() {
|
||||
get_agent_config_statuses,
|
||||
refresh_agent_config_statuses,
|
||||
get_agent_models,
|
||||
check_pi_provider_update,
|
||||
install_pi_provider,
|
||||
sync_pi_provider,
|
||||
update_pi_provider,
|
||||
repair_pi_provider,
|
||||
uninstall_pi_provider,
|
||||
check_codex_oauth_login,
|
||||
update_codex_model_catalog,
|
||||
@@ -18617,6 +18883,79 @@ mod tests {
|
||||
assert_eq!(value["nested"]["keep"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_provider_settings_preserve_user_fields_and_set_cpa_defaults() {
|
||||
let rendered = build_pi_provider_settings(
|
||||
r#"{"theme":"dark","packages":["npm:@router-for-me/pi-cliproxyapi-provider"],"defaultProvider":"other","defaultModel":"old-model"}"#,
|
||||
"new-model",
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
|
||||
assert_eq!(value["theme"], "dark");
|
||||
assert_eq!(value["packages"][0], PI_CLIPROXYAPI_PACKAGE);
|
||||
assert_eq!(value["defaultProvider"], PI_CLIPROXYAPI_PROVIDER_ID);
|
||||
assert_eq!(value["defaultModel"], "new-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairing_pi_provider_updates_credentials_defaults_and_model() {
|
||||
let home = agent_test_home("repair-pi-provider");
|
||||
let settings_path = pi_provider_settings_path(&home);
|
||||
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&settings_path,
|
||||
format!(
|
||||
r#"{{"packages":["{PI_CLIPROXYAPI_PACKAGE}"],"defaultProvider":"other","defaultModel":"old-model"}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = repair_pi_provider_inner(&home, 9527, "agent-key", "new-model").unwrap();
|
||||
let provider: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(pi_provider_config_path(&home)).unwrap())
|
||||
.unwrap();
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(result.model.as_deref(), Some("new-model"));
|
||||
assert_eq!(provider["baseUrl"], "http://127.0.0.1:9527");
|
||||
assert_eq!(provider["apiKey"], "agent-key");
|
||||
assert_eq!(settings["defaultProvider"], PI_CLIPROXYAPI_PROVIDER_ID);
|
||||
assert_eq!(settings["defaultModel"], "new-model");
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_provider_version_reads_installed_package_metadata() {
|
||||
let home = agent_test_home("pi-provider-version");
|
||||
let package_path = pi_provider_package_json_path(&home);
|
||||
fs::create_dir_all(package_path.parent().unwrap()).unwrap();
|
||||
fs::write(&package_path, r#"{"version":"1.4.10"}"#).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read_pi_provider_version(&home).unwrap().as_deref(),
|
||||
Some("1.4.10")
|
||||
);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_provider_latest_version_parses_npm_metadata() {
|
||||
assert_eq!(
|
||||
parse_pi_provider_latest_version(&serde_json::json!({ "version": "1.5.0" })).unwrap(),
|
||||
"1.5.0"
|
||||
);
|
||||
assert!(parse_pi_provider_latest_version(&serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_provider_update_detection_requires_a_newer_semver() {
|
||||
assert!(pi_provider_update_available("1.4.12", "1.5.0").unwrap());
|
||||
assert!(!pi_provider_update_available("1.4.12", "1.4.12").unwrap());
|
||||
assert!(!pi_provider_update_available("1.4.12", "1.4.11").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_package_source_match_accepts_unpinned_and_pinned_sources() {
|
||||
assert!(pi_package_source_matches(PI_CLIPROXYAPI_PACKAGE));
|
||||
|
||||
+7
-5
@@ -504,7 +504,6 @@ export const jaOverrides = {
|
||||
'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}',
|
||||
@@ -563,6 +562,7 @@ export const jaOverrides = {
|
||||
'agents.clientVersion': 'クライアントバージョン',
|
||||
'agents.cliVersion': 'CLI バージョン',
|
||||
'agents.appVersion': 'App バージョン',
|
||||
'agents.pluginVersion': 'プラグインバージョン',
|
||||
'agents.notFetched': '未取得',
|
||||
'agents.useModel': '使用モデル',
|
||||
'agents.pending': '未適用',
|
||||
@@ -614,12 +614,14 @@ export const jaOverrides = {
|
||||
'agents.launch.closeChatgpt': 'ChatGPT App を終了',
|
||||
'agents.launch.closingChatgpt': '終了中',
|
||||
'agents.pi.installTitle': 'Pi CLIProxyAPI プロバイダー',
|
||||
'agents.pi.installDescription': 'プラグインのインストール後に自動設定します',
|
||||
'agents.pi.installDescription': 'プラグインをインストールまたは更新し、CPA 認証情報、既定のプロバイダー、既定のモデルを修復します',
|
||||
'agents.pi.install': 'Pi プラグインをインストール',
|
||||
'agents.pi.update': 'Pi の設定を同期',
|
||||
'agents.pi.update': 'Pi プラグインを更新',
|
||||
'agents.pi.updateAvailable': '新しいバージョン {version} が利用できます',
|
||||
'agents.pi.updating': 'プラグインを更新中',
|
||||
'agents.pi.installing': 'プラグインをインストール中',
|
||||
'agents.pi.sync': 'Pi の設定を同期',
|
||||
'agents.pi.syncing': '設定を同期中',
|
||||
'agents.pi.repair': '設定を適用',
|
||||
'agents.pi.repairing': '設定を適用中',
|
||||
'agents.pi.uninstall': 'Pi プラグインをアンインストール',
|
||||
'agents.pi.uninstalling': 'プラグインをアンインストール中',
|
||||
'agents.default.title': 'デフォルト設定にリセット',
|
||||
|
||||
+14
-10
@@ -507,7 +507,6 @@ export const zhCN = {
|
||||
'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}',
|
||||
@@ -566,6 +565,7 @@ export const zhCN = {
|
||||
'agents.clientVersion': '客户端版本',
|
||||
'agents.cliVersion': 'CLI 版本',
|
||||
'agents.appVersion': 'App 版本',
|
||||
'agents.pluginVersion': '插件版本',
|
||||
'agents.notFetched': '未获取',
|
||||
'agents.useModel': '使用模型',
|
||||
'agents.pending': '尚未应用',
|
||||
@@ -617,12 +617,14 @@ export const zhCN = {
|
||||
'agents.launch.closeChatgpt': '关闭 ChatGPT App',
|
||||
'agents.launch.closingChatgpt': '关闭中',
|
||||
'agents.pi.installTitle': 'Pi CLIProxyAPI provider',
|
||||
'agents.pi.installDescription': '安装插件后自动配置',
|
||||
'agents.pi.installDescription': '安装或更新插件,并修复 CPA 凭据、默认 provider 与默认模型',
|
||||
'agents.pi.install': '安装 Pi 插件',
|
||||
'agents.pi.update': '同步 Pi 配置',
|
||||
'agents.pi.update': '更新 Pi 插件',
|
||||
'agents.pi.updateAvailable': '发现新版本 {version}',
|
||||
'agents.pi.updating': '正在更新插件',
|
||||
'agents.pi.installing': '正在安装插件',
|
||||
'agents.pi.sync': '同步 Pi 配置',
|
||||
'agents.pi.syncing': '正在同步配置',
|
||||
'agents.pi.repair': '应用配置',
|
||||
'agents.pi.repairing': '正在应用配置',
|
||||
'agents.pi.uninstall': '卸载 Pi 插件',
|
||||
'agents.pi.uninstalling': '正在卸载插件',
|
||||
'agents.default.title': '重置为默认配置',
|
||||
@@ -1466,7 +1468,6 @@ export const en: Record<MessageKey, string> = {
|
||||
'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}',
|
||||
@@ -1525,6 +1526,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.clientVersion': 'Client Version',
|
||||
'agents.cliVersion': 'CLI Version',
|
||||
'agents.appVersion': 'App Version',
|
||||
'agents.pluginVersion': 'Plugin Version',
|
||||
'agents.notFetched': 'Not available',
|
||||
'agents.useModel': 'Model',
|
||||
'agents.pending': 'Not Applied',
|
||||
@@ -1576,12 +1578,14 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.launch.closeChatgpt': 'Close ChatGPT App',
|
||||
'agents.launch.closingChatgpt': 'Closing',
|
||||
'agents.pi.installTitle': 'Pi CLIProxyAPI provider',
|
||||
'agents.pi.installDescription': 'Automatically configure after installing the plugin.',
|
||||
'agents.pi.installDescription': 'Install or update the plugin and repair CPA credentials, the default provider, and the default model.',
|
||||
'agents.pi.install': 'Install Pi Plugin',
|
||||
'agents.pi.update': 'Sync Pi Configuration',
|
||||
'agents.pi.update': 'Update Pi Plugin',
|
||||
'agents.pi.updateAvailable': 'Version {version} is available',
|
||||
'agents.pi.updating': 'Updating plugin',
|
||||
'agents.pi.installing': 'Installing plugin',
|
||||
'agents.pi.sync': 'Sync Pi Configuration',
|
||||
'agents.pi.syncing': 'Syncing configuration',
|
||||
'agents.pi.repair': 'Apply Configuration',
|
||||
'agents.pi.repairing': 'Applying configuration',
|
||||
'agents.pi.uninstall': 'Uninstall Pi Plugin',
|
||||
'agents.pi.uninstalling': 'Uninstalling plugin',
|
||||
'agents.default.title': 'Reset to Default Configuration',
|
||||
|
||||
+113
-27
@@ -25,6 +25,7 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
@@ -73,6 +74,7 @@ type AgentConfigStatus = {
|
||||
version: string | null;
|
||||
cliVersion: string | null;
|
||||
appVersion: string | null;
|
||||
pluginVersion: string | null;
|
||||
configValid: boolean;
|
||||
configured: boolean;
|
||||
currentModel: string | null;
|
||||
@@ -103,6 +105,12 @@ type AgentConfigActionResult = {
|
||||
conflictFiles: string[];
|
||||
};
|
||||
|
||||
type PiProviderUpdateStatus = {
|
||||
installedVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
};
|
||||
|
||||
type ChatGptCloseResult = {
|
||||
wasRunning: boolean;
|
||||
closedProcesses: number;
|
||||
@@ -554,7 +562,7 @@ export function AgentsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'sync-pi' | 'uninstall-pi' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | 'directory' | null
|
||||
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | 'directory' | null
|
||||
>(null);
|
||||
const busy = busyAction !== null;
|
||||
const [detectionError, setDetectionError] = useState('');
|
||||
@@ -572,7 +580,9 @@ export function AgentsPage() {
|
||||
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
|
||||
const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState<OAuthLoginRequiredAction | null>(null);
|
||||
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
|
||||
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(null);
|
||||
const modelRequestRef = useRef(0);
|
||||
const piUpdateRequestRef = useRef(0);
|
||||
const claudeModelMappingsDirtyRef = useRef(false);
|
||||
|
||||
const loadStatuses = useCallback(async (forceRefresh = false) => {
|
||||
@@ -583,7 +593,7 @@ export function AgentsPage() {
|
||||
setStatuses(nextStatuses);
|
||||
}, []);
|
||||
|
||||
const loadModels = useCallback(async (client: AgentClientId) => {
|
||||
const loadModels = useCallback(async (client: AgentClientId, preferredModel = '') => {
|
||||
const requestId = modelRequestRef.current + 1;
|
||||
modelRequestRef.current = requestId;
|
||||
setModelLoading(true);
|
||||
@@ -597,7 +607,7 @@ export function AgentsPage() {
|
||||
setModelByClient((current) => {
|
||||
const next = {
|
||||
...current,
|
||||
[client]: resolveAgentModelSelection(nextModels, current[client] ?? ''),
|
||||
[client]: resolveAgentModelSelection(nextModels, current[client] ?? preferredModel),
|
||||
};
|
||||
writeAgentModelSelections(next);
|
||||
return next;
|
||||
@@ -630,14 +640,10 @@ export function AgentsPage() {
|
||||
}, [loadStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected === 'pi') {
|
||||
setModels([]);
|
||||
setModelLoading(false);
|
||||
setModelError('');
|
||||
return;
|
||||
}
|
||||
void loadModels(selected);
|
||||
}, [loadModels, selected]);
|
||||
if (loading) return;
|
||||
const preferredModel = statuses.find((status) => status.id === selected)?.currentModel ?? '';
|
||||
void loadModels(selected, preferredModel);
|
||||
}, [loadModels, loading, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -698,6 +704,35 @@ export function AgentsPage() {
|
||||
[claudeCustomMapping, models],
|
||||
);
|
||||
|
||||
const loadPiProviderUpdateStatus = useCallback(async () => {
|
||||
const requestId = piUpdateRequestRef.current + 1;
|
||||
piUpdateRequestRef.current = requestId;
|
||||
try {
|
||||
const nextStatus = await invoke<PiProviderUpdateStatus>('check_pi_provider_update');
|
||||
if (piUpdateRequestRef.current === requestId) setPiProviderUpdateStatus(nextStatus);
|
||||
} catch {
|
||||
if (piUpdateRequestRef.current === requestId) setPiProviderUpdateStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPiClient || !activeStatus?.pluginInstalled || !activeStatus.pluginVersion) {
|
||||
piUpdateRequestRef.current += 1;
|
||||
setPiProviderUpdateStatus(null);
|
||||
return;
|
||||
}
|
||||
void loadPiProviderUpdateStatus();
|
||||
}, [activeStatus?.pluginInstalled, activeStatus?.pluginVersion, isPiClient, loadPiProviderUpdateStatus]);
|
||||
|
||||
const piPluginUpdateAvailable = Boolean(
|
||||
piProviderUpdateStatus?.updateAvailable
|
||||
&& piProviderUpdateStatus.installedVersion === activeStatus?.pluginVersion
|
||||
&& piProviderUpdateStatus.latestVersion,
|
||||
);
|
||||
const piPluginUpdateTitle = piPluginUpdateAvailable
|
||||
? t('agents.pi.updateAvailable', { version: piProviderUpdateStatus?.latestVersion ?? '' })
|
||||
: activeStatus?.pluginVersion ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClaudeModelMappingClient || !selectedModel) return;
|
||||
const appliedMappings = selected === 'claude-code'
|
||||
@@ -772,10 +807,9 @@ export function AgentsPage() {
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& !modelLoading
|
||||
&& (isPiClient
|
||||
|| (isClaudeModelMappingClient
|
||||
&& (isClaudeModelMappingClient
|
||||
? claudeMappingsReady
|
||||
: selectedModelOption)),
|
||||
: selectedModelOption),
|
||||
);
|
||||
const launchEnabled = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
@@ -937,10 +971,12 @@ export function AgentsPage() {
|
||||
};
|
||||
|
||||
const installPiProvider = async () => {
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('install-pi');
|
||||
setConfigurationError('');
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('install_pi_provider');
|
||||
await invoke<AgentConfigActionResult>('install_pi_provider', { model });
|
||||
await reloadStatusesAfterAction();
|
||||
} catch (requestError) {
|
||||
setConfigurationError(String(requestError));
|
||||
@@ -949,11 +985,30 @@ export function AgentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const syncPiProvider = async () => {
|
||||
setBusyAction('sync-pi');
|
||||
const updatePiProvider = async () => {
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('update-pi');
|
||||
setConfigurationError('');
|
||||
setPiProviderUpdateStatus(null);
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('update_pi_provider', { model });
|
||||
await reloadStatusesAfterAction();
|
||||
await loadPiProviderUpdateStatus();
|
||||
} catch (requestError) {
|
||||
setConfigurationError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const repairPiProvider = async () => {
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('repair-pi');
|
||||
setConfigurationError('');
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('sync_pi_provider');
|
||||
await invoke<AgentConfigActionResult>('repair_pi_provider', { model });
|
||||
await reloadStatusesAfterAction();
|
||||
} catch (requestError) {
|
||||
setConfigurationError(String(requestError));
|
||||
@@ -965,6 +1020,7 @@ export function AgentsPage() {
|
||||
const uninstallPiProvider = async () => {
|
||||
setBusyAction('uninstall-pi');
|
||||
setConfigurationError('');
|
||||
setPiProviderUpdateStatus(null);
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('uninstall_pi_provider');
|
||||
await reloadStatusesAfterAction();
|
||||
@@ -1272,7 +1328,7 @@ export function AgentsPage() {
|
||||
role="tabpanel"
|
||||
aria-labelledby="agent-subpage-tab-core"
|
||||
>
|
||||
<div className={`agent-status-grid ${selected === 'codex' ? 'codex-status-grid' : ''}`}>
|
||||
<div className={`agent-status-grid ${selected === 'codex' ? 'codex-status-grid' : isPiClient ? 'pi-status-grid' : ''}`}>
|
||||
<div>
|
||||
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
|
||||
<strong>{activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}</strong>
|
||||
@@ -1288,6 +1344,27 @@ export function AgentsPage() {
|
||||
<strong title={activeStatus?.appVersion ?? undefined}>{activeStatus?.appVersion ?? t('agents.notFetched')}</strong>
|
||||
</div>
|
||||
</>
|
||||
) : isPiClient ? (
|
||||
<>
|
||||
<div>
|
||||
<span>{t('agents.clientVersion')}</span>
|
||||
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? t('agents.notFetched')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
{t('agents.pluginVersion')}
|
||||
{piPluginUpdateAvailable ? (
|
||||
<span
|
||||
className="agent-version-update-dot"
|
||||
role="status"
|
||||
aria-label={piPluginUpdateTitle}
|
||||
title={piPluginUpdateTitle}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<strong title={piPluginUpdateTitle}>{activeStatus?.pluginVersion ?? t('agents.notFetched')}</strong>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<span>{t('agents.clientVersion')}</span>
|
||||
@@ -1296,7 +1373,7 @@ export function AgentsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeStatus?.error || (!isPiClient && activeStatus?.warnings.length) ? (
|
||||
{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>
|
||||
@@ -1306,7 +1383,7 @@ export function AgentsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isClaudeModelMappingClient && !isPiClient ? (
|
||||
{!isClaudeModelMappingClient ? (
|
||||
<section className="agent-core-setting-section agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>{t('agents.useModel')}</strong></div>
|
||||
@@ -1394,7 +1471,7 @@ export function AgentsPage() {
|
||||
type="button"
|
||||
className={activeStatus?.pluginInstalled ? 'danger-button' : 'primary-button'}
|
||||
onClick={() => void (activeStatus?.pluginInstalled ? uninstallPiProvider() : installPiProvider())}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || (!activeStatus.pluginInstalled && !selectedModelOption)}
|
||||
>
|
||||
{busyAction === 'install-pi' || busyAction === 'uninstall-pi'
|
||||
? <LoaderCircle size={16} className="spin" />
|
||||
@@ -1406,11 +1483,20 @@ export function AgentsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => void syncPiProvider()}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled}
|
||||
onClick={() => void repairPiProvider()}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption}
|
||||
>
|
||||
{busyAction === 'sync-pi' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
|
||||
{busyAction === 'sync-pi' ? t('agents.pi.syncing') : t('agents.pi.sync')}
|
||||
{busyAction === 'repair-pi' ? <LoaderCircle size={16} className="spin" /> : <Wrench size={16} />}
|
||||
{busyAction === 'repair-pi' ? t('agents.pi.repairing') : t('agents.pi.repair')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => void updatePiProvider()}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption}
|
||||
>
|
||||
{busyAction === 'update-pi' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
|
||||
{busyAction === 'update-pi' ? t('agents.pi.updating') : t('agents.pi.update')}
|
||||
</button>
|
||||
</div>
|
||||
{configurationError ? (
|
||||
@@ -1591,7 +1677,7 @@ export function AgentsPage() {
|
||||
role="tabpanel"
|
||||
aria-labelledby="agent-subpage-tab-sessions"
|
||||
>
|
||||
<CodexSessionsPanel managedProviderActive={activeStatus?.modificationState === 'applied'} />
|
||||
<CodexSessionsPanel />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -588,9 +588,12 @@ export function AuthFileManagementPage() {
|
||||
placeholder={t('authFiles.priority.placeholder')}
|
||||
disabled={busy}
|
||||
aria-invalid={Boolean(priorityEditor.error)}
|
||||
onChange={(event) => setPriorityEditor((current) => current
|
||||
? { ...current, value: event.currentTarget.value, error: '' }
|
||||
: current)}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setPriorityEditor((current) => current
|
||||
? { ...current, value, error: '' }
|
||||
: current);
|
||||
}}
|
||||
/>
|
||||
<small>{t('authFiles.priority.hint')}</small>
|
||||
</label>
|
||||
|
||||
@@ -45,7 +45,7 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function CodexSessionsPanel({ managedProviderActive }: { managedProviderActive: boolean }) {
|
||||
export function CodexSessionsPanel() {
|
||||
const { formatDate, t } = useI18n();
|
||||
const [page, setPage] = useState<CodexSessionPage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -320,8 +320,7 @@ export function CodexSessionsPanel({ managedProviderActive }: { managedProviderA
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={busy || !managedProviderActive}
|
||||
title={!managedProviderActive ? t('agents.sessions.applyManagedFirst') : undefined}
|
||||
disabled={busy}
|
||||
onClick={() => void repairSessions()}
|
||||
>
|
||||
{operation === 'repair' ? <LoaderCircle size={15} className="spin" /> : <ShieldCheck size={15} />}
|
||||
@@ -490,9 +489,10 @@ export function CodexSessionsPanel({ managedProviderActive }: { managedProviderA
|
||||
type="checkbox"
|
||||
checked={cleanupSelectedIds.has(candidate.id)}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setCleanupSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.currentTarget.checked) next.add(candidate.id);
|
||||
if (checked) next.add(candidate.id);
|
||||
else next.delete(candidate.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -631,7 +631,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
: offlineInstallRequired
|
||||
? t('kernel.install.githubFailed')
|
||||
: message || updateStateLabel;
|
||||
const versionStatusTone: MessageType = installTaskRunning
|
||||
const versionStatusTone: MessageType | 'update' = installTaskRunning
|
||||
? 'info'
|
||||
: offlineInstallRequired
|
||||
? 'error'
|
||||
@@ -641,7 +641,9 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
? 'error'
|
||||
: currentVersion && currentVersion === latestVersion
|
||||
? 'success'
|
||||
: 'info';
|
||||
: latestVersion && currentVersion !== latestVersion
|
||||
? 'update'
|
||||
: 'info';
|
||||
const installDialogTone: MessageType = progress?.result
|
||||
? 'success'
|
||||
: progress?.phase === '安装失败'
|
||||
|
||||
@@ -1299,6 +1299,10 @@ textarea:focus-visible {
|
||||
color: var(--theme-2f6b3f);
|
||||
}
|
||||
|
||||
.panel-detail-row dd.update {
|
||||
color: var(--theme-c77b30);
|
||||
}
|
||||
|
||||
.panel-detail-row dd.error {
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
@@ -5389,6 +5393,10 @@ textarea:focus-visible {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.agent-status-grid.pi-status-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.agent-status-grid > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@@ -5405,6 +5413,16 @@ textarea:focus-visible {
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.agent-status-grid .agent-version-update-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
background: var(--theme-c77b30);
|
||||
box-shadow: 0 0 0 3px rgba(199, 123, 48, 0.14);
|
||||
}
|
||||
|
||||
.agent-status-grid strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -7359,6 +7377,10 @@ textarea:focus-visible {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-status-grid.pi-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.codex-session-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user