From 286f4bbe1bfda978af8bcb13a0a907cde3fea8f9 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 23 Sep 2026 02:16:39 +0800 Subject: [PATCH] feat(agents): allow CPA configuration for config-only clients --- src-tauri/src/agents/backups/tests.rs | 18 ++++++++ src-tauri/src/agents/commands.rs | 18 +++++--- src-tauri/src/agents/discovery.rs | 59 +++++++++++++++++++++++---- src/pages/AgentsPage.tsx | 2 +- tests/agent-management-ui.cjs | 22 ++++++---- tests/fixtures/agent-backups.tsx | 2 +- 6 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index e16a530..dcd826b 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -896,6 +896,24 @@ fn pi_template_only_writes_configuration_and_package_references() { assert_eq!(list_backups("pi", &home.0).unwrap().versions.len(), 1); } +#[test] +fn pi_config_file_allows_cpa_connection_without_detected_cli() { + let home = Home::new(); + let paths = config_paths("pi", &home.0).unwrap(); + save(&paths[0], "{}\n"); + + let before = inspect_pi_provider_status(&home.0, 8317, "test-key"); + assert!(before.config_exists); + assert!(!before.plugin_installed); + + configure_pi_provider_without_cli_inner(&home.0, 8317, "test-key", "gpt-one").unwrap(); + + let connected = inspect_pi_provider_status(&home.0, 8317, "test-key"); + assert!(connected.configured); + assert!(connected.plugin_installed); + assert_eq!(connected.current_model.as_deref(), Some("gpt-one")); +} + #[test] fn desktop_setup_repairs_legacy_names_without_changing_other_profiles() { if !AgentClient::ClaudeDesktop.supported_platform() { diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index d01bb5b..b9a2ee9 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -202,14 +202,23 @@ pub(crate) async fn install_pi_provider( .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 executable = find_pi_executable(&home); + if executable.is_none() + && !inspect_pi_provider_status(&home, config.port, effective_agent_api_key(&config)) + .config_exists + { + return Err("未检测到 Pi CLI 或 Pi 配置文件".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 proxy_url = config.proxy_url.clone(); let result = tauri::async_runtime::spawn_blocking(move || { - install_pi_provider_inner(&home, &executable, port, &api_key, &model, &proxy_url) + if let Some(executable) = executable { + install_pi_provider_inner(&home, &executable, port, &api_key, &model, &proxy_url) + } else { + configure_pi_provider_without_cli_inner(&home, port, &api_key, &model) + } }) .await .map_err(|error| format!("安装 Pi CLIProxyAPI provider 任务失败: {error}"))??; @@ -1257,8 +1266,7 @@ pub(crate) fn validate_agent_can_enable( )); } let detection = inspect_agent_config(client, home, port, api_key); - let workbuddy_config_available = client == AgentClient::WorkBuddy && detection.config_exists; - if !detection.installed && !workbuddy_config_available { + if !detection.installed && !detection.config_exists { return Err(format!("{} is not installed", client.name())); } Ok(()) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 855d075..a60ef14 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -307,6 +307,20 @@ pub(crate) fn build_pi_provider_settings( let object = root .as_object_mut() .ok_or_else(|| "Pi settings.json 根节点必须是 JSON 对象".to_string())?; + let packages = object + .entry("packages") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or_else(|| "Pi settings.json packages 必须是数组".to_string())?; + if !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) + }) { + packages.push(serde_json::json!(PI_CLIPROXYAPI_PACKAGE)); + } object.insert( "defaultProvider".to_string(), serde_json::json!(PI_CLIPROXYAPI_PROVIDER_ID), @@ -389,8 +403,10 @@ pub(crate) fn inspect_pi_provider_status( "Pi 插件配置不完整,请使用“应用配置”重新写入凭据、默认 provider 和默认模型".to_string(), ); } - if executable.is_none() && plugin_installed { - warnings.push("已找到 Pi 插件配置,但未检测到 Pi CLI 命令".to_string()); + if executable.is_none() && config_exists { + warnings.push( + "只检测到 Pi 配置文件,仍可编辑配置并接入 CPA;启动功能不可用".to_string(), + ); } let modification_state = if configured { "applied" @@ -476,6 +492,36 @@ pub(crate) fn install_pi_provider_inner( Ok(result) } +pub(crate) fn configure_pi_provider_without_cli_inner( + home: &Path, + port: u16, + api_key: &str, + default_model: &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); + config_package_operation(home, "plugin-config", || { + let settings = if settings_path.is_file() { + fs::read_to_string(&settings_path).map_err(|error| { + format!( + "读取 Pi settings.json 失败 {}: {error}", + path_to_string(&settings_path) + ) + })? + } else { + "{}".to_string() + }; + let rendered = build_pi_provider_settings(&settings, default_model)?; + write_bytes_directly(&settings_path, rendered.as_bytes()) + })?; + repair_pi_provider_inner(home, port, api_key, default_model) +} + pub(crate) fn repair_pi_provider_inner( home: &Path, port: u16, @@ -1003,12 +1049,9 @@ pub(crate) fn inspect_agent_config( if !client.supported_platform() { warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string()); } else if !installed && config_exists { - warnings.push(if client == AgentClient::WorkBuddy { - "只检测到 WorkBuddy 配置文件,仍可编辑配置并接入 CPA;启动功能不可用" - .to_string() - } else { - "只检测到配置文件,未检测到客户端".to_string() - }); + warnings.push( + "只检测到配置文件,仍可编辑配置并接入 CPA;启动功能不可用".to_string(), + ); } if let Some(message) = error.as_ref() { warnings.push(message.clone()); diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 8619ab1..3257f10 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1199,7 +1199,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ); const canConfigureActiveClient = Boolean( activeStatus?.supportedPlatform - && (activeStatus.installed || (selected === 'workbuddy' && activeStatus.configExists)), + && (activeStatus.installed || activeStatus.configExists), ); const canEnable = Boolean( canConfigureActiveClient diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs index 524aef3..9750f79 100644 --- a/tests/agent-management-ui.cjs +++ b/tests/agent-management-ui.cjs @@ -141,14 +141,15 @@ const path = require('node:path'); await open(mode + 'not-installed'); assert.ok(await button('启动 App').isDisabled()); assert.ok(await button('重启 App').isDisabled()); - await open(mode + 'client=workbuddy&config-only&fresh'); - assert.ok(await page.locator('.agent-model-trigger').isEnabled()); - assert.ok(await button('一键接入').isEnabled()); - assert.ok(await button('启动 App').isDisabled()); - assert.ok(await button('重启 App').isDisabled()); - await button('一键接入').click(); - await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'update_agent_config')); - assert.deepEqual((await calls('update_agent_config'))[0].args.client, 'workbuddy'); + for (const client of ['workbuddy', 'openclaw']) { + await open(mode + `client=${client}&config-only=${client}&fresh`); + assert.ok(await page.locator('.agent-model-trigger').isEnabled()); + assert.ok(await button('一键接入').isEnabled()); + assert.ok(await page.locator('.agent-launch-actions button').first().isDisabled()); + await button('一键接入').click(); + await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'update_agent_config')); + assert.deepEqual((await calls('update_agent_config'))[0].args.client, client); + } await open(mode + 'client=kimi-code'); assert.equal(await button('重启 App').count(), 0); @@ -199,6 +200,11 @@ const path = require('node:path'); assert.equal(await button('手动备份').count(), 0); await open(mode + 'client=pi&no-plugin'); assert.ok(await button('安装 Pi 插件').isEnabled()); + await open(mode + 'client=pi&config-only=pi&no-plugin&fresh'); + assert.ok(await button('安装 Pi 插件').isEnabled()); + assert.ok(await button('启动 CLI').isDisabled()); + await button('安装 Pi 插件').click(); + await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'install_pi_provider')); await open(mode + 'client=deepseek-harness&running'); await button('重启 Web').click(); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index a2dc5bd..1dfb466 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -35,7 +35,7 @@ mockIPC(async (cmd,args:any) => { calls.push({cmd,args}); if(cmd==='plugin:event|listen') return 1; if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null; - if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed')&&!(params.has('config-only')&&id==='workbuddy'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')||(params.has('config-only')&&id==='workbuddy')?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configExists:!params.has('not-installed')||(params.has('config-only')&&id==='workbuddy'),configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); + if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed')&¶ms.get('config-only')!==id,pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')||params.get('config-only')===id?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configExists:!params.has('not-installed')||params.get('config-only')===id,configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); if(cmd==='close_codex_config_modification') { closeCount++; if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');