From 5ffde1e6684464274c9e65fc1713efd08de3d3fb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 4 Aug 2026 21:30:03 +0800 Subject: [PATCH] feat(agents): improve Pi plugin configuration and update handling --- src-tauri/src/main.rs | 419 +++++++++++++++++++++++++++++++++++---- src/i18n/ja.ts | 11 +- src/i18n/resources.ts | 22 +- src/pages/AgentsPage.tsx | 138 ++++++++++--- src/pages/Kernel.tsx | 6 +- src/styles.css | 22 ++ 6 files changed, 538 insertions(+), 80 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0d3aa06..0c4aa6e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, cli_version: Option, app_version: Option, + plugin_version: Option, config_paths: Vec, config_exists: bool, config_valid: bool, @@ -691,6 +695,14 @@ struct AgentConfigActionResult { conflict_files: Vec, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PiProviderUpdateStatus { + installed_version: Option, + latest_version: Option, + 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 { + 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 { + 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 { 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 { 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 { + 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, 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::(&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 { + 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 { + 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::() + .await + .map_err(|error| format!("解析 Pi provider 最新版本失败: {error}"))?; + parse_pi_provider_latest_version(&payload) +} + +fn pi_provider_update_available(installed: &str, latest: &str) -> Result { + 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, 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::(&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 { - 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::(&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 { + let default_model = default_model.trim(); + if default_model.is_empty() { + return Err("Pi 默认模型不能为空".to_string()); + } + let mut root = serde_json::from_str::(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 { 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 { 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 { + 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)); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index e765763..f370745 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': 'デフォルト設定にリセット', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 17ac135..af3a96d 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -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 = { '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 = { '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', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index d4b67ab..d09ad4f 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -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(null); const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState(null); + const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState(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('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('install_pi_provider'); + await invoke('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('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('sync_pi_provider'); + await invoke('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('uninstall_pi_provider'); await reloadStatusesAfterAction(); @@ -1272,7 +1328,7 @@ export function AgentsPage() { role="tabpanel" aria-labelledby="agent-subpage-tab-core" > -
+
{t('agents.installStatus')} {activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')} @@ -1288,6 +1344,27 @@ export function AgentsPage() { {activeStatus?.appVersion ?? t('agents.notFetched')}
+ ) : isPiClient ? ( + <> +
+ {t('agents.clientVersion')} + {activeStatus?.version ?? t('agents.notFetched')} +
+
+ + {t('agents.pluginVersion')} + {piPluginUpdateAvailable ? ( + + ) : null} + + {activeStatus?.pluginVersion ?? t('agents.notFetched')} +
+ ) : (
{t('agents.clientVersion')} @@ -1296,7 +1373,7 @@ export function AgentsPage() { )}
- {activeStatus?.error || (!isPiClient && activeStatus?.warnings.length) ? ( + {activeStatus?.error || activeStatus?.warnings.length ? (
{activeStatus.error ? ( {activeStatus.error} @@ -1306,7 +1383,7 @@ export function AgentsPage() {
) : null} - {!isClaudeModelMappingClient && !isPiClient ? ( + {!isClaudeModelMappingClient ? (
{t('agents.useModel')}
@@ -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' ? @@ -1406,11 +1483,20 @@ export function AgentsPage() { +
{configurationError ? ( diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index dc38d63..c9ab2bf 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -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 === '安装失败' diff --git a/src/styles.css b/src/styles.css index fa42ecf..a378c6e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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)); }