feat(agents): improve Pi plugin configuration and update handling

This commit is contained in:
lzt404 committed 2026-08-04 21:30:03 +08:00
1 parent 44fb5a70fd
commit 5ffde1e668
6 files changed
+538 -80

No files matched your search

+379 -40
View File
@@ -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)
}
@@ -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,
@@ -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 -4
View File
@@ -563,6 +563,7 @@ export const jaOverrides = {
'agents.clientVersion': 'クライアントバージョン',
'agents.cliVersion': 'CLI バージョン',
'agents.appVersion': 'App バージョン',
'agents.pluginVersion': 'プラグインバージョン',
'agents.notFetched': '未取得',
'agents.useModel': '使用モデル',
'agents.pending': '未適用',
@@ -614,12 +615,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 -8
View File
@@ -566,6 +566,7 @@ export const zhCN = {
'agents.clientVersion': '客户端版本',
'agents.cliVersion': 'CLI 版本',
'agents.appVersion': 'App 版本',
'agents.pluginVersion': '插件版本',
'agents.notFetched': '未获取',
'agents.useModel': '使用模型',
'agents.pending': '尚未应用',
@@ -617,12 +618,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': '重置为默认配置',
@@ -1525,6 +1528,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 +1580,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',
+112 -26
View File
@@ -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 ? (
+4 -2
View File
@@ -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 === '安装失败'
+22
View File
@@ -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));
}