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
This commit is contained in:
lzt404 committed 2026-08-03 00:06:16 +08:00
1 parent 176cf87225
commit 035c77354b
6 files changed
+441 -7

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
- OpenCode
- OpenClaw
- Hermes Agent
- Pi(CLIProxyAPI provider 拡張機能)
対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の
元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。
+1
View File
@@ -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
+1
View File
@@ -92,6 +92,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
- OpenCode
- OpenClaw
- Hermes Agent
- Pi(通过 CLIProxyAPI provider 插件)
对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置、
恢复之前的配置,以及启动可用的桌面端或命令行入口。
+348 -1
View File
@@ -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<String>,
launch_targets: Vec<AgentLaunchTarget>,
version: Option<String>,
@@ -1661,6 +1667,7 @@ fn inspect_agent_config_statuses(
.into_iter()
.map(|client| inspect_agent_config(client, &home, config.port, api_key))
.collect::<Vec<_>>();
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<Vec<AgentModelOption>, 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<AgentConfigActionResult, String> {
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<PathBuf, String> {
let value = value.trim();
if value.is_empty() {
@@ -2489,6 +2524,31 @@ async fn launch_agent(
working_directory: Option<String>,
suppress_working_directory_prompt: Option<bool>,
) -> 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<PathBuf> {
}
}
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<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))
}
fn build_pi_provider_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
) -> Result<String, String> {
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(&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<AgentConfigActionResult, String> {
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<PathBuf> {
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<PathBuf> {
find_named_agent_executable(home, &["pi"])
}
fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option<PathBuf> {
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(
+16
View File
@@ -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<MessageKey, string> = {
'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<MessageKey, string> = {
'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',
+74 -6
View File
@@ -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<AgentConfigActionResult>('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() {
</div>
) : null}
{!isClaudeModelMappingClient ? (
{!isClaudeModelMappingClient && !isPiClient ? (
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
@@ -1293,6 +1328,38 @@ export function AgentsPage() {
</section>
) : null}
{isPiClient ? (
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.configured ? 'enabled' : ''}`}>
<div className="agent-section-heading">
<div>
<strong>{t('agents.pi.installTitle')}</strong>
<span>{t('agents.pi.installDescription')}</span>
</div>
</div>
<div className="agent-modification-control">
<div className="agent-modification-buttons">
<button
type="button"
className="primary-button"
onClick={() => void installPiProvider()}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
>
{busyAction === 'install-pi' ? <LoaderCircle size={16} className="spin" /> : null}
{busyAction === 'install-pi'
? t('agents.pi.installing')
: activeStatus?.configured
? t('agents.pi.update')
: t('agents.pi.install')}
</button>
</div>
{configurationError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{configurationError}
</span>
) : null}
</div>
</section>
) : (
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
<div className="agent-section-heading">
<div>
@@ -1377,6 +1444,7 @@ export function AgentsPage() {
) : null}
</div>
</section>
)}
<div className="agent-config-footer">
<div className="agent-launch-control">