From 035c77354bb044df84b2f7b8eb567086c999b05a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 3 Aug 2026 00:06:16 +0800 Subject: [PATCH] feat(agents): add Pi CLIProxyAPI provider integration - Add Pi detection and launch support - Install the CLIProxyAPI provider extension through Pi - Sync EasyCLIProxyAPI base URL and API key to Pi configuration - Add Pi agent UI, translations, documentation, and tests --- README.ja.md | 1 + README.md | 1 + README.zh-CN.md | 1 + src-tauri/src/main.rs | 349 ++++++++++++++++++++++++++++++++++++++- src/i18n/resources.ts | 16 ++ src/pages/AgentsPage.tsx | 80 ++++++++- 6 files changed, 441 insertions(+), 7 deletions(-) diff --git a/README.ja.md b/README.ja.md index 4181372..a5127c6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -95,6 +95,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - OpenCode - OpenClaw - Hermes Agent +- Pi(CLIProxyAPI provider 拡張機能) 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 diff --git a/README.md b/README.md index 1e079db..b9f17df 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ proxy. Supported clients include: - OpenCode - OpenClaw - Hermes Agent +- Pi (with the CLIProxyAPI provider extension) For supported clients, the application can synchronize the available model catalog, select a default model, back up the original configuration before applying managed settings, restore the diff --git a/README.zh-CN.md b/README.zh-CN.md index 9ae0607..1d9509e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,6 +92,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - OpenCode - OpenClaw - Hermes Agent +- Pi(通过 CLIProxyAPI provider 插件) 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置、 恢复之前的配置,以及启动可用的桌面端或命令行入口。 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 85e2a81..0132a39 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -82,6 +82,11 @@ const DEFAULT_API_KEY: &str = "123456"; const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥"; const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; 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_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"; const CODEX_OAUTH_LOGIN_REQUIRED_ERROR: &str = "CODEX_OAUTH_LOGIN_REQUIRED"; const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700"; @@ -618,6 +623,7 @@ struct AgentConfigStatus { name: String, supported_platform: bool, installed: bool, + plugin_installed: bool, executable_path: Option, launch_targets: Vec, version: Option, @@ -1661,6 +1667,7 @@ fn inspect_agent_config_statuses( .into_iter() .map(|client| inspect_agent_config(client, &home, config.port, api_key)) .collect::>(); + statuses.push(inspect_pi_provider_status(&home, config.port, api_key)); if let Some(status) = statuses .iter_mut() .find(|status| status.id == AgentClient::ClaudeCode.id()) @@ -1725,11 +1732,39 @@ async fn get_agent_models( gui_config_state: tauri::State<'_, GuiConfigState>, client: String, ) -> Result, String> { + if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) { + let config = gui_config_state.snapshot()?; + return fetch_agent_models(config.port, effective_agent_api_key(&config)).await; + } let client = AgentClient::parse(&client)?; let config = gui_config_state.snapshot()?; Ok(fetch_prepared_agent_models(client, &config).await?.models) } +#[tauri::command] +async fn install_pi_provider( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, + cache: tauri::State<'_, AgentConfigStatusCache>, +) -> Result { + let home = app + .path() + .home_dir() + .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 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) + }) + .await + .map_err(|error| format!("安装 Pi CLIProxyAPI provider 任务失败: {error}"))??; + cache.clear()?; + Ok(result) +} + fn validate_claude_code_working_directory(value: &str) -> Result { let value = value.trim(); if value.is_empty() { @@ -2489,6 +2524,31 @@ async fn launch_agent( working_directory: Option, suppress_working_directory_prompt: Option, ) -> Result<(), String> { + if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) { + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let config = gui_config_state.snapshot()?; + let status = + inspect_pi_provider_status(&home, config.port, effective_agent_api_key(&config)); + if !status.installed { + return Err("未检测到 Pi CLI,请先安装 Pi 并确保 pi 命令在 PATH 中".to_string()); + } + if !status.configured { + return Err("请先安装并配置 Pi CLIProxyAPI provider 插件".to_string()); + } + let requested_target = target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if requested_target.is_some_and(|value| value != "cli") { + return Err("Pi 只支持 CLI 启动方式".to_string()); + } + let executable = + find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?; + return launch_cli_agent(&executable, PI_AGENT_NAME, &home, &[], &[]); + } let client = AgentClient::parse(&client)?; if !client.supported_platform() { return Err(format!("当前平台不支持启动 {}", client.name())); @@ -3009,6 +3069,252 @@ fn agent_config_paths(client: AgentClient, home: &Path) -> Vec { } } +fn pi_agent_directory(home: &Path) -> PathBuf { + env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".pi/agent")) +} + +fn pi_provider_config_path(home: &Path) -> PathBuf { + pi_agent_directory(home).join(PI_AGENT_CONFIG_FILE) +} + +fn pi_provider_settings_path(home: &Path) -> PathBuf { + pi_agent_directory(home).join(PI_AGENT_SETTINGS_FILE) +} + +fn pi_package_source_matches(value: &str) -> bool { + let value = value.trim(); + value == PI_CLIPROXYAPI_PACKAGE || value.starts_with(&format!("{PI_CLIPROXYAPI_PACKAGE}@")) +} + +fn pi_settings_contains_provider(settings: &serde_json::Value) -> bool { + settings + .get("packages") + .and_then(serde_json::Value::as_array) + .is_some_and(|packages| { + packages.iter().any(|package| { + package.as_str().is_some_and(pi_package_source_matches) + || package + .get("source") + .and_then(serde_json::Value::as_str) + .is_some_and(pi_package_source_matches) + }) + }) +} + +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)) +} + +fn build_pi_provider_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, +) -> Result { + let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_json::from_str::(value) + .map_err(|error| format!("解析 Pi CLIProxyAPI 配置失败: {error}"))?, + None => serde_json::json!({}), + }; + let object = root + .as_object_mut() + .ok_or_else(|| "Pi CLIProxyAPI 配置根节点必须是 JSON 对象".to_string())?; + object.insert("baseUrl".to_string(), serde_json::json!(base_url)); + object.insert("apiKey".to_string(), serde_json::json!(api_key)); + let mut rendered = serde_json::to_string_pretty(&root) + .map_err(|error| format!("生成 Pi CLIProxyAPI 配置失败: {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); + let executable = find_pi_executable(home); + let launch_targets = executable + .as_ref() + .map(|path| { + vec![AgentLaunchTarget { + id: "cli".to_string(), + label: PI_AGENT_NAME.to_string(), + detail: path_to_string(path), + }] + }) + .unwrap_or_default(); + let plugin_installed = match pi_provider_package_installed(home) { + Ok(installed) => installed, + Err(_) => false, + }; + let mut errors = Vec::new(); + let mut credentials_match = false; + if config_path.is_file() { + match fs::read_to_string(&config_path) + .map_err(|error| format!("读取 Pi CLIProxyAPI 配置失败: {error}")) + .and_then(|content| { + let root = serde_json::from_str::(&content) + .map_err(|error| format!("解析 Pi CLIProxyAPI 配置失败: {error}"))?; + let object = root + .as_object() + .ok_or_else(|| "Pi CLIProxyAPI 配置根节点必须是 JSON 对象".to_string())?; + let expected_base_url = format!("http://127.0.0.1:{port}"); + credentials_match = object + .get("baseUrl") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value.trim() == expected_base_url) + && object.get("apiKey").and_then(serde_json::Value::as_str) == Some(api_key); + Ok(()) + }) { + Ok(()) => {} + 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 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()); + } + if executable.is_none() && plugin_installed { + warnings.push("已找到 Pi 插件配置,但未检测到 Pi CLI 命令".to_string()); + } + let modification_state = if configured { + "applied" + } else { + "unconfigured" + }; + let error = errors.into_iter().next(); + AgentConfigStatus { + id: PI_AGENT_ID.to_string(), + name: PI_AGENT_NAME.to_string(), + supported_platform: true, + installed: executable.is_some(), + plugin_installed, + executable_path: launch_targets.first().map(|target| target.detail.clone()), + launch_targets, + version: executable.as_deref().and_then(read_agent_version), + config_paths: vec![path_to_string(&config_path), path_to_string(&settings_path)], + config_exists, + config_valid, + configured, + current_model: None, + oauth_configuration: false, + modification_enabled: configured, + modification_state: modification_state.to_string(), + backup_available: false, + applied_model: None, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + claude_code_working_directory: None, + claude_code_working_directory_prompt_disabled: false, + warnings, + error, + } +} + +fn install_pi_provider_inner( + home: &Path, + executable: &Path, + port: u16, + api_key: &str, +) -> Result { + if port == 0 { + return Err("内核端口无效".to_string()); + } + if api_key.trim().is_empty() { + return Err("EasyCLIProxyAPI 没有可用的 API key".to_string()); + } + let settings_path = pi_provider_settings_path(home); + let mut changed_files = Vec::new(); + if !pi_provider_package_installed(home)? { + install_pi_package(executable, home)?; + changed_files.push(path_to_string(&settings_path)); + } + + let config_path = pi_provider_config_path(home); + let existing = if config_path.is_file() { + Some(fs::read_to_string(&config_path).map_err(|error| { + format!( + "读取 Pi CLIProxyAPI 配置失败 {}: {error}", + path_to_string(&config_path) + ) + })?) + } else { + None + }; + let base_url = format!("http://127.0.0.1:{port}"); + let rendered = build_pi_provider_config(existing.as_deref(), &base_url, api_key)?; + if existing.as_deref() != Some(rendered.as_str()) { + write_bytes_atomically(&config_path, rendered.as_bytes())?; + changed_files.push(path_to_string(&config_path)); + } + + Ok(action_result( + "applied", + true, + None, + changed_files, + Vec::new(), + )) +} + +fn install_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("install") + .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 codex_configuration_directory(home: &Path) -> PathBuf { env::var_os("CODEX_HOME") .map(PathBuf::from) @@ -3238,6 +3544,7 @@ fn inspect_agent_config( name: client.name().to_string(), supported_platform: client.supported_platform(), installed, + plugin_installed: true, executable_path: launch_targets.first().map(|target| target.detail.clone()), launch_targets, version, @@ -4018,8 +4325,16 @@ fn find_agent_executable(client: AgentClient, home: &Path) -> Option { if client == AgentClient::ClaudeDesktop { return find_claude_desktop_executable(home); } + find_named_agent_executable(home, client.executable_names()) +} + +fn find_pi_executable(home: &Path) -> Option { + find_named_agent_executable(home, &["pi"]) +} + +fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option { for directory in agent_executable_directories(home) { - for name in client.executable_names() { + for name in names { #[cfg(target_os = "windows")] let candidates = [ directory.join(format!("{name}.exe")), @@ -17613,6 +17928,7 @@ fn main() { get_agent_config_statuses, refresh_agent_config_statuses, get_agent_models, + install_pi_provider, check_codex_oauth_login, update_codex_model_catalog, get_thinking_aliases, @@ -17721,6 +18037,37 @@ fn main() { mod tests { use super::*; + #[test] + fn pi_provider_config_preserves_existing_fields_and_syncs_cpa_credentials() { + let rendered = build_pi_provider_config( + Some(r#"{"fast":true,"providerId":"custom","nested":{"keep":1}}"#), + "http://127.0.0.1:9527", + "agent-key", + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["baseUrl"], "http://127.0.0.1:9527"); + assert_eq!(value["apiKey"], "agent-key"); + assert_eq!(value["fast"], true); + assert_eq!(value["providerId"], "custom"); + assert_eq!(value["nested"]["keep"], 1); + } + + #[test] + fn pi_package_source_match_accepts_unpinned_and_pinned_sources() { + assert!(pi_package_source_matches(PI_CLIPROXYAPI_PACKAGE)); + assert!(pi_package_source_matches( + "npm:@router-for-me/pi-cliproxyapi-provider@1.4.10" + )); + assert!(!pi_package_source_matches( + "npm:@router-for-me/other-provider" + )); + assert!(pi_settings_contains_provider(&serde_json::json!({ + "packages": [{"source": PI_CLIPROXYAPI_PACKAGE}] + }))); + } + #[test] fn provider_health_stream_detects_only_real_model_text() { assert!(provider_health_content_type_is_streaming( diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 033f219..302d979 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -433,12 +433,15 @@ export const zhCN = { 'agents.description.opencode': '使用 CPA OpenAI Compatible provider', 'agents.description.openclaw': '使用 CPA 模型供应商和默认模型', 'agents.description.hermes': '使用 CPA custom provider', + 'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI provider 动态加载模型', 'agents.list.detecting': '正在检测', 'agents.list.unsupported': '当前平台不支持', 'agents.list.notInstalled': '未检测到安装', 'agents.list.modified': '已修改 · {model}', 'agents.list.installedVersion': '已安装 · {version}', 'agents.list.installed': '已安装 · 保持原配置', + 'agents.list.pluginNotInstalled': 'Pi provider 插件未安装', + 'agents.list.configured': '已配置 · EasyCLIProxyAPI', 'agents.model.loading': '正在读取模型', 'agents.model.loadFailed': '获取模型失败', 'agents.model.select': '选择模型', @@ -594,6 +597,11 @@ export const zhCN = { 'agents.launch.unavailable': '无法启动', 'agents.launch.closeChatgpt': '关闭 ChatGPT App', 'agents.launch.closingChatgpt': '关闭中', + 'agents.pi.installTitle': 'Pi CLIProxyAPI provider', + 'agents.pi.installDescription': '安装官方插件,并将当前 EasyCLIProxyAPI 的 baseUrl 和 API key 写入 Pi 配置。', + 'agents.pi.install': '安装并配置 Pi 插件', + 'agents.pi.update': '重新同步 Pi 配置', + 'agents.pi.installing': '正在安装并配置', 'agents.default.title': '重置为默认配置', 'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。该操作没有备份且无法恢复。', 'agents.default.confirm': '确认写入默认配置', @@ -1361,12 +1369,15 @@ export const en: Record = { 'agents.description.opencode': 'Uses the CPA OpenAI-compatible provider', 'agents.description.openclaw': 'Uses the CPA model provider and default model', 'agents.description.hermes': 'Uses the CPA custom provider', + 'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider', 'agents.list.detecting': 'Detecting', 'agents.list.unsupported': 'Unsupported on this platform', 'agents.list.notInstalled': 'Installation not detected', 'agents.list.modified': 'Modified · {model}', 'agents.list.installedVersion': 'Installed · {version}', 'agents.list.installed': 'Installed · original configuration', + 'agents.list.pluginNotInstalled': 'Pi provider not installed', + 'agents.list.configured': 'Configured · EasyCLIProxyAPI', 'agents.model.loading': 'Loading models', 'agents.model.loadFailed': 'Failed to load models', 'agents.model.select': 'Select a model', @@ -1522,6 +1533,11 @@ export const en: Record = { 'agents.launch.unavailable': 'Cannot Start', 'agents.launch.closeChatgpt': 'Close ChatGPT App', 'agents.launch.closingChatgpt': 'Closing', + 'agents.pi.installTitle': 'Pi CLIProxyAPI provider', + 'agents.pi.installDescription': 'Install the official plugin and write the current EasyCLIProxyAPI base URL and API key into Pi configuration.', + 'agents.pi.install': 'Install and Configure Pi', + 'agents.pi.update': 'Resync Pi Configuration', + 'agents.pi.installing': 'Installing and configuring', 'agents.default.title': 'Reset to Default Configuration', 'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. No backup is created and the operation cannot be undone.', 'agents.default.confirm': 'Write Default Configuration', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index db4a84d..db567b7 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -51,7 +51,8 @@ type AgentClientId = | 'codex' | 'opencode' | 'openclaw' - | 'hermes'; + | 'hermes' + | 'pi'; type AgentModificationState = 'unconfigured' | 'applied' | 'invalid'; @@ -60,6 +61,7 @@ type AgentConfigStatus = { name: string; supportedPlatform: boolean; installed: boolean; + pluginInstalled: boolean; executablePath: string | null; launchTargets: AgentLaunchTarget[]; version: string | null; @@ -139,7 +141,7 @@ type AgentDefinition = { name: string; icon?: string; Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>; - descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes'; + descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.pi'; }; type AgentSubpageId = 'core' | 'sessions'; @@ -187,6 +189,12 @@ const agentDefinitions: AgentDefinition[] = [ icon: hermesIcon, descriptionKey: 'agents.description.hermes', }, + { + id: 'pi', + name: 'Pi', + Icon: Bot, + descriptionKey: 'agents.description.pi', + }, ]; const agentSubpages: AgentSubpageDefinition[] = [ @@ -268,6 +276,12 @@ const listStatusText = (status: AgentConfigStatus | undefined) => { if (!status) return translate(locale, 'agents.list.detecting'); if (!status.supportedPlatform) return translate(locale, 'agents.list.unsupported'); if (!status.installed) return translate(locale, 'agents.list.notInstalled'); + if (status.id === 'pi' && !status.pluginInstalled) { + return translate(locale, 'agents.list.pluginNotInstalled'); + } + if (status.id === 'pi' && status.configured) { + return translate(locale, 'agents.list.configured'); + } if (status.modificationState === 'invalid') return translate(locale, 'agents.status.invalid'); if (status.modificationState === 'applied') return translate(locale, 'agents.list.modified', { model: status.appliedModel ?? '—' }); return status.version @@ -538,7 +552,7 @@ export function AgentsPage() { const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'close-config' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | 'directory' | null + 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | 'directory' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -614,6 +628,12 @@ export function AgentsPage() { }, [loadStatuses]); useEffect(() => { + if (selected === 'pi') { + setModels([]); + setModelLoading(false); + setModelError(''); + return; + } void loadModels(selected); }, [loadModels, selected]); @@ -668,6 +688,7 @@ export function AgentsPage() { const savedSelectedModel = modelByClient[selected] ?? ''; const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; + const isPiClient = selected === 'pi'; const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop'; const claudeMappingModels = useMemo( () => filterAgentModelsByAlias(models, claudeCustomMapping), @@ -738,9 +759,10 @@ export function AgentsPage() { activeStatus?.supportedPlatform && activeStatus.installed && !modelLoading - && (isClaudeModelMappingClient + && (isPiClient + || (isClaudeModelMappingClient ? claudeMappingsReady - : selectedModelOption), + : selectedModelOption)), ); const launchEnabled = Boolean( activeStatus?.supportedPlatform @@ -901,6 +923,19 @@ export function AgentsPage() { } }; + const installPiProvider = async () => { + setBusyAction('install-pi'); + setConfigurationError(''); + try { + await invoke('install_pi_provider'); + await reloadStatusesAfterAction(); + } catch (requestError) { + setConfigurationError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + const closeConfigurationChanges = async () => { setConfigurationError(''); setBusyAction('close-config'); @@ -1217,7 +1252,7 @@ export function AgentsPage() { ) : null} - {!isClaudeModelMappingClient ? ( + {!isClaudeModelMappingClient && !isPiClient ? (
{t('agents.useModel')}
@@ -1293,6 +1328,38 @@ export function AgentsPage() {
) : null} + {isPiClient ? ( +
+
+
+ {t('agents.pi.installTitle')} + {t('agents.pi.installDescription')} +
+
+
+
+ +
+ {configurationError ? ( + + {configurationError} + + ) : null} +
+
+ ) : (
@@ -1377,6 +1444,7 @@ export function AgentsPage() { ) : null}
+ )}