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:
1 parent
176cf87225
commit
035c77354b
6 files changed
+441
-7
No files matched your search
@@ -95,6 +95,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- Hermes Agent
|
||||
- Pi(CLIProxyAPI provider 拡張機能)
|
||||
|
||||
対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の
|
||||
元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- Hermes Agent
|
||||
- Pi(通过 CLIProxyAPI provider 插件)
|
||||
|
||||
对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置、
|
||||
恢复之前的配置,以及启动可用的桌面端或命令行入口。
|
||||
|
||||
+348
-1
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user