feat(agents): add WorkBuddy configuration support
- Support installation detection, configuration, backup, restore, launch, and restart - Handle both models.json formats while preserving custom settings - Simplify model names and retain existing CPA models when switching - Fix model visibility restoration when disabling integration - Add regression tests, translations, and documentation
This commit is contained in:
1 parent
7b72d97270
commit
3545941414
24 files changed
+1121
-23
No files matched your search
@@ -99,6 +99,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
|
||||
- Hermes Agent
|
||||
- Pi(CLIProxyAPI provider 拡張機能)
|
||||
- ZCode
|
||||
- WorkBuddy / WorkBuddy AI
|
||||
- Kimi Code
|
||||
- Grok Build
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ proxy. Supported clients include:
|
||||
- Hermes Agent
|
||||
- Pi (with the CLIProxyAPI provider extension)
|
||||
- ZCode
|
||||
- WorkBuddy / WorkBuddy AI
|
||||
- Kimi Code
|
||||
- Grok Build
|
||||
|
||||
|
||||
@@ -108,12 +108,17 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
|
||||
- Hermes Agent
|
||||
- Pi(通过 CLIProxyAPI provider 插件)
|
||||
- ZCode
|
||||
- WorkBuddy / WorkBuddy AI
|
||||
- Kimi Code
|
||||
- Grok Build
|
||||
|
||||
对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置,
|
||||
以及恢复之前的配置。
|
||||
|
||||
WorkBuddy 接入沿用模型选择、更新配置、关闭配置修改、手动备份/恢复和桌面启动/重启流程。
|
||||
支持 WorkBuddy 与 WorkBuddy AI 的 `models.json` 对象及数组格式,并保留已有自定义模型。
|
||||
应用后需在 WorkBuddy 内选择 `CPA:模型名`;已有会话不会自动切换模型,未显示时可重启 App。
|
||||
|
||||
## 其他功能
|
||||
|
||||
- 管理内核配置、API Key、远程管理凭证和路由策略。
|
||||
|
||||
@@ -10,6 +10,7 @@ mod native_oauth;
|
||||
mod state;
|
||||
mod templates;
|
||||
mod transactions;
|
||||
mod workbuddy;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_probe;
|
||||
pub(crate) use backups::*;
|
||||
@@ -22,5 +23,6 @@ pub(crate) use native_oauth::*;
|
||||
pub(crate) use state::*;
|
||||
pub(crate) use templates::*;
|
||||
pub(crate) use transactions::*;
|
||||
pub(crate) use workbuddy::*;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) use windows_probe::*;
|
||||
@@ -84,6 +84,7 @@ fn clients() -> Vec<AgentClient> {
|
||||
"hermes",
|
||||
"deepseek-harness",
|
||||
"zcode",
|
||||
"workbuddy",
|
||||
"kimi-code",
|
||||
"grok-build",
|
||||
]
|
||||
@@ -271,6 +272,21 @@ fn corrupt_raw_and_non_utf8_backups_are_saved_but_not_restorable() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_native_array_backup_restores_exact_bytes_after_template() {
|
||||
let home = Home::new();
|
||||
let client = AgentClient::WorkBuddy;
|
||||
let paths = config_paths(client.id(), &home.0).unwrap();
|
||||
let original = b"[\n {\"id\":\"user-model\",\"apiKey\":\"private-key\"}\n]\n";
|
||||
save(&paths[0], original);
|
||||
let backup = create_backup(client.id(), &home.0).unwrap();
|
||||
assert!(backup.restorable);
|
||||
apply(&home.0, client, "gpt-one").unwrap();
|
||||
template(&home.0, client).unwrap();
|
||||
test_restore_backup(client, &home.0, &backup.id);
|
||||
assert_eq!(fs::read(&paths[0]).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_validation_uses_the_clients_actual_json_format() {
|
||||
for (client, restorable) in [
|
||||
@@ -398,6 +414,7 @@ fn clear_integration_needs_no_backup_and_removes_state_for_each_client() {
|
||||
AgentClient::Hermes,
|
||||
AgentClient::DeepSeekHarness,
|
||||
AgentClient::ZCode,
|
||||
AgentClient::WorkBuddy,
|
||||
AgentClient::KimiCode,
|
||||
AgentClient::GrokBuild,
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) fn inspect_agent_config_statuses(
|
||||
AgentStatusDetectionTarget::Client(AgentClient::Hermes),
|
||||
AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness),
|
||||
AgentStatusDetectionTarget::Client(AgentClient::ZCode),
|
||||
AgentStatusDetectionTarget::Client(AgentClient::WorkBuddy),
|
||||
AgentStatusDetectionTarget::Client(AgentClient::KimiCode),
|
||||
AgentStatusDetectionTarget::Client(AgentClient::GrokBuild),
|
||||
AgentStatusDetectionTarget::PiProvider,
|
||||
@@ -956,7 +957,7 @@ pub(crate) fn start_codex_model_catalog_sync(app: tauri::AppHandle) {
|
||||
pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool {
|
||||
matches!(
|
||||
client,
|
||||
AgentClient::ZCode | AgentClient::KimiCode | AgentClient::GrokBuild
|
||||
AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::KimiCode | AgentClient::GrokBuild
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,13 @@ pub(crate) fn build_agent_updates_with_oauth(
|
||||
},
|
||||
])
|
||||
}
|
||||
AgentClient::WorkBuddy => {
|
||||
let before = read_optional_text(&paths[0])?;
|
||||
Ok(vec![AgentFileUpdate {
|
||||
path: paths[0].clone(),
|
||||
after: build_workbuddy_agent_config(before.as_deref(), &openai_base, api_key, model, models)?,
|
||||
}])
|
||||
}
|
||||
AgentClient::ZCode => {
|
||||
let app_before = read_optional_text(&paths[0])?;
|
||||
let cli_before = read_optional_text(&paths[1])?;
|
||||
@@ -1621,6 +1628,7 @@ pub(crate) fn prepare_agent_managed_removal(
|
||||
AgentClient::Hermes => prepare_hermes_managed_removal(paths),
|
||||
AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths),
|
||||
AgentClient::ZCode => prepare_zcode_managed_removal(paths),
|
||||
AgentClient::WorkBuddy => prepare_workbuddy_managed_removal(paths),
|
||||
AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths),
|
||||
AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths),
|
||||
}
|
||||
@@ -2658,6 +2666,7 @@ pub(crate) fn build_agent_session_restored_bytes_with_preference(
|
||||
}
|
||||
}
|
||||
AgentClient::ZCode => build_restored_zcode_config(current, original)?,
|
||||
AgentClient::WorkBuddy => build_restored_workbuddy_config(current, original)?,
|
||||
AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?,
|
||||
AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?,
|
||||
};
|
||||
|
||||
@@ -4,14 +4,14 @@ const AGENT_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const AGENT_VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn agent_configuration_environment(name: &str) -> Option<PathBuf> {
|
||||
pub(crate) fn agent_configuration_environment(name: &str) -> Option<PathBuf> {
|
||||
env::var_os(name)
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn agent_configuration_environment(_: &str) -> Option<PathBuf> {
|
||||
pub(crate) fn agent_configuration_environment(_: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBu
|
||||
directory.join(DEEPSEEK_HARNESS_CREDENTIALS_FILE),
|
||||
]
|
||||
}
|
||||
AgentClient::WorkBuddy => vec![workbuddy_home(home).join("models.json")],
|
||||
AgentClient::ZCode => vec![
|
||||
home.join(".zcode/v2").join(ZCODE_CONFIG_FILE),
|
||||
home.join(".zcode/cli").join(ZCODE_CONFIG_FILE),
|
||||
@@ -980,6 +981,7 @@ pub(crate) fn inspect_agent_config(
|
||||
.and_then(read_opencode_desktop_version),
|
||||
AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home),
|
||||
AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version),
|
||||
AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version),
|
||||
_ => None,
|
||||
};
|
||||
let version = cli_version.clone().or_else(|| app_version.clone());
|
||||
@@ -1065,13 +1067,13 @@ pub(crate) fn agent_installation_detected(
|
||||
version.is_some()
|
||||
|| (matches!(
|
||||
client,
|
||||
AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode
|
||||
AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy
|
||||
) && executable_found)
|
||||
|| app_installed
|
||||
}
|
||||
|
||||
pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool {
|
||||
!matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode)
|
||||
!matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_launch_targets(
|
||||
@@ -1125,11 +1127,11 @@ pub(crate) fn agent_launch_targets(
|
||||
});
|
||||
}
|
||||
}
|
||||
AgentClient::ZCode => {
|
||||
AgentClient::ZCode | AgentClient::WorkBuddy => {
|
||||
if let Some(executable) = executable {
|
||||
targets.push(AgentLaunchTarget {
|
||||
id: "app".to_string(),
|
||||
label: "ZCode".to_string(),
|
||||
label: client.name().to_string(),
|
||||
detail: path_to_string(executable),
|
||||
});
|
||||
}
|
||||
@@ -1262,6 +1264,8 @@ pub(crate) fn inspect_agent_managed_config(
|
||||
false,
|
||||
))
|
||||
}
|
||||
AgentClient::WorkBuddy => inspect_workbuddy_agent_config(&paths[0], port, api_key)
|
||||
.map(|(configured, model)| (configured, model, false)),
|
||||
AgentClient::KimiCode => inspect_kimi_code_agent_config(&paths[0], port, api_key)
|
||||
.map(|(configured, model)| (configured, model, false)),
|
||||
AgentClient::GrokBuild => inspect_grok_build_agent_config(&paths[0], port, api_key)
|
||||
@@ -1468,6 +1472,7 @@ pub(crate) fn agent_has_managed_marker(
|
||||
Ok(provider_exists && model_selected)
|
||||
}
|
||||
AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths),
|
||||
AgentClient::WorkBuddy => workbuddy_has_managed_marker(&paths[0]),
|
||||
AgentClient::ZCode => {
|
||||
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
||||
for path in paths {
|
||||
@@ -2390,6 +2395,9 @@ pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option<
|
||||
if client == AgentClient::ZCode {
|
||||
return find_zcode_desktop_executable(home);
|
||||
}
|
||||
if client == AgentClient::WorkBuddy {
|
||||
return find_workbuddy_desktop_executable(home);
|
||||
}
|
||||
if client == AgentClient::KimiCode {
|
||||
return find_kimi_code_executable(home);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,11 @@ pub(crate) fn launch_agent(
|
||||
let requested_target = requested_target.unwrap_or(default_target);
|
||||
match (client, requested_target) {
|
||||
(AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home),
|
||||
(AgentClient::WorkBuddy, "app") => {
|
||||
let executable = find_workbuddy_desktop_executable(&home)
|
||||
.ok_or_else(|| "未找到 WorkBuddy 应用程序".to_string())?;
|
||||
launch_desktop_agent(&executable, client.name())
|
||||
}
|
||||
(AgentClient::ZCode, "app") => {
|
||||
let executable = find_zcode_desktop_executable(&home)
|
||||
.ok_or_else(|| "未找到 ZCode 应用程序".to_string())?;
|
||||
@@ -188,7 +193,7 @@ pub(crate) fn launch_agent(
|
||||
}
|
||||
(AgentClient::Codex, "app") => launch_codex_desktop(&home),
|
||||
(AgentClient::OpenCode, "app") => launch_opencode_desktop(&home),
|
||||
(AgentClient::ClaudeDesktop | AgentClient::ZCode, "cli") => {
|
||||
(AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy, "cli") => {
|
||||
Err(format!("{} 不支持 CLI 启动方式", client.name()))
|
||||
}
|
||||
(_, "cli") => {
|
||||
@@ -695,6 +700,7 @@ pub(crate) async fn restart_agent_app(app: tauri::AppHandle, client: String) ->
|
||||
| AgentClient::OpenCode
|
||||
| AgentClient::ClaudeDesktop
|
||||
| AgentClient::ZCode
|
||||
| AgentClient::WorkBuddy
|
||||
) {
|
||||
return Err(format!("{} 不支持桌面应用重启", client.name()));
|
||||
}
|
||||
@@ -738,6 +744,9 @@ fn find_desktop_restart_target(
|
||||
AgentClient::ZCode => {
|
||||
find_zcode_desktop_executable(home).map(DesktopAppTarget::Application)
|
||||
}
|
||||
AgentClient::WorkBuddy => {
|
||||
find_workbuddy_desktop_executable(home).map(DesktopAppTarget::Application)
|
||||
}
|
||||
AgentClient::ClaudeDesktop => {
|
||||
let executable =
|
||||
find_claude_desktop_executable(home).map(DesktopAppTarget::Application);
|
||||
|
||||
@@ -727,6 +727,9 @@ pub(crate) fn fresh_agent_contents_with_oauth(
|
||||
build_deepseek_harness_settings(None, &openai_base, model, models)?,
|
||||
build_deepseek_harness_credentials(None, api_key)?,
|
||||
]),
|
||||
AgentClient::WorkBuddy => Ok(vec![build_workbuddy_agent_config(
|
||||
None, &openai_base, api_key, model, models,
|
||||
)?]),
|
||||
AgentClient::ZCode => Ok(vec![
|
||||
build_zcode_agent_config(None, &root_base, api_key, model, models)?,
|
||||
build_zcode_cli_agent_config(None, &root_base, api_key, model, models)?,
|
||||
@@ -1458,7 +1461,13 @@ pub(crate) fn apply_agent_configuration_with_oauth(
|
||||
validate_config_images(&before)?;
|
||||
let mappings = options.claude_desktop_model_mappings;
|
||||
let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options)
|
||||
.map_err(|_| "配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string())?;
|
||||
.map_err(|error| {
|
||||
if client == AgentClient::WorkBuddy {
|
||||
error
|
||||
} else {
|
||||
"配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string()
|
||||
}
|
||||
})?;
|
||||
config_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ fn merge_user_value(
|
||||
}
|
||||
|
||||
fn merge_user_image(
|
||||
client: &str,
|
||||
path: &Path,
|
||||
baseline: Option<&[u8]>,
|
||||
applied: Option<&[u8]>,
|
||||
@@ -139,17 +140,23 @@ fn merge_user_image(
|
||||
.map(|bytes| std::str::from_utf8(bytes).map_err(|_| "原始状态不是 UTF-8 文本"))
|
||||
.transpose()?,
|
||||
)?;
|
||||
let merged = merge_user_value(
|
||||
let mut merged = merge_user_value(
|
||||
baseline.map(|_| &baseline_value),
|
||||
applied.map(|_| &applied_value),
|
||||
Some(¤t_value),
|
||||
);
|
||||
if client == "workbuddy" {
|
||||
if let Some(merged) = &mut merged {
|
||||
merge_workbuddy_visibility(&baseline_value, &applied_value, ¤t_value, merged);
|
||||
}
|
||||
}
|
||||
merged
|
||||
.map(|value| render(path, &value).map(|value| value.into_bytes()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn promote_user_changes(
|
||||
client: &str,
|
||||
paths: &[PathBuf],
|
||||
baseline: &Images,
|
||||
applied: &Images,
|
||||
@@ -164,6 +171,7 @@ fn promote_user_changes(
|
||||
Ok((
|
||||
path.clone(),
|
||||
merge_user_image(
|
||||
client,
|
||||
path,
|
||||
baseline.as_deref(),
|
||||
applied.as_deref(),
|
||||
@@ -181,6 +189,7 @@ fn prepare_integration_restore(
|
||||
state: &AgentIntegrationState,
|
||||
) -> Result<Images, String> {
|
||||
let effective_original = promote_user_changes(
|
||||
client.id(),
|
||||
paths,
|
||||
&state.original,
|
||||
&state.applied,
|
||||
@@ -410,6 +419,7 @@ fn commit_config_transaction(
|
||||
let parsed_client = AgentClient::parse(client)?;
|
||||
let next = if let Some(state) = current_state {
|
||||
let original = promote_user_changes(
|
||||
client,
|
||||
managed_paths,
|
||||
&state.original,
|
||||
&state.applied,
|
||||
@@ -563,6 +573,17 @@ pub(crate) fn validate_config_images(images: &Images) -> Result<(), String> {
|
||||
|
||||
pub(crate) fn validate_client_config_images(client: &str, images: &Images) -> Result<(), String> {
|
||||
validate_config_images(images)?;
|
||||
if client == "workbuddy" {
|
||||
for (_, bytes) in images {
|
||||
parse_workbuddy_config(text(bytes.as_deref())?)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
for (path, bytes) in images {
|
||||
if !parse(path, text(bytes.as_deref())?)?.is_object() {
|
||||
return Err("配置根节点必须是对象".into());
|
||||
}
|
||||
}
|
||||
if matches!(client, "opencode" | "openclaw") {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -811,6 +832,9 @@ fn validate_unmanaged_preserved(
|
||||
before: &Value,
|
||||
after: &Value,
|
||||
) -> Result<(), String> {
|
||||
if client == "workbuddy" {
|
||||
return validate_workbuddy_unmanaged_preserved(before, after);
|
||||
}
|
||||
let project = |value: &Value| -> Result<Value, String> {
|
||||
let mut value = value.clone();
|
||||
if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) {
|
||||
@@ -901,7 +925,8 @@ pub(crate) fn parse(path: &Path, content: Option<&str>) -> Result<Value, String>
|
||||
path_to_string(path)
|
||||
)
|
||||
})?;
|
||||
if !value.is_object() {
|
||||
// WorkBuddy also saves models.json as a top-level array.
|
||||
if !value.is_object() && !(path.file_name().is_some_and(|n| n == "models.json") && value.is_array()) {
|
||||
return Err(format!("{} 配置根节点必须是对象", path_to_string(path)));
|
||||
}
|
||||
Ok(value)
|
||||
|
||||
@@ -235,11 +235,15 @@ pub(crate) fn windows_app_path_executable(file_name: &str) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
pub(crate) fn collect_windows_zcode_registrations() -> Vec<WindowsZcodeRegistration> {
|
||||
collect_windows_desktop_registrations("ZCode.exe", windows_display_name_matches_zcode)
|
||||
}
|
||||
|
||||
fn collect_windows_desktop_registrations(executable: &str, matches_name: fn(&str) -> bool) -> Vec<WindowsZcodeRegistration> {
|
||||
let mut registrations = Vec::new();
|
||||
for root in current_version_roots() {
|
||||
if let Some(path) = RegistryKey::open(
|
||||
root.hive,
|
||||
&join_registry_path(root.prefix, r"App Paths\ZCode.exe"),
|
||||
&join_registry_path(root.prefix, &format!(r"App Paths\{executable}")),
|
||||
)
|
||||
.and_then(|key| key.string(""))
|
||||
{
|
||||
@@ -263,7 +267,7 @@ pub(crate) fn collect_windows_zcode_registrations() -> Vec<WindowsZcodeRegistrat
|
||||
let Some(display_name) = entry.string("DisplayName") else {
|
||||
continue;
|
||||
};
|
||||
if !windows_display_name_matches_zcode(&display_name) {
|
||||
if !matches_name(&display_name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = entry.string("InstallLocation") {
|
||||
@@ -297,6 +301,10 @@ pub(crate) fn windows_display_name_matches_zcode(name: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Option<PathBuf> {
|
||||
parse_windows_desktop_registration(kind, value, "ZCode.exe")
|
||||
}
|
||||
|
||||
pub(crate) fn parse_windows_desktop_registration(kind: &str, value: &str, executable_name: &str) -> Option<PathBuf> {
|
||||
let value = value.trim();
|
||||
let value =
|
||||
if let Some(quoted) = value.strip_prefix('"') {
|
||||
@@ -325,14 +333,14 @@ pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Optio
|
||||
}
|
||||
let executable = match kind {
|
||||
"executable" => path,
|
||||
"directory" => path.join("ZCode.exe"),
|
||||
"icon" | "uninstaller" => path.parent()?.join("ZCode.exe"),
|
||||
"directory" => path.join(executable_name),
|
||||
"icon" | "uninstaller" => path.parent()?.join(executable_name),
|
||||
_ => return None,
|
||||
};
|
||||
(executable
|
||||
.file_name()?
|
||||
.to_str()?
|
||||
.eq_ignore_ascii_case("ZCode.exe")
|
||||
.eq_ignore_ascii_case(executable_name)
|
||||
&& executable.is_file())
|
||||
.then_some(executable)
|
||||
}
|
||||
@@ -345,6 +353,20 @@ pub(crate) fn find_windows_registered_zcode_executable() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn find_windows_registered_workbuddy_executable() -> Option<PathBuf> {
|
||||
for executable in ["WorkBuddyAI.exe", "WorkBuddy.exe"] {
|
||||
if let Some(path) = collect_windows_desktop_registrations(executable, |name| {
|
||||
["WorkBuddyAI", "WorkBuddy AI", "WorkBuddy"].iter().any(|prefix| {
|
||||
name.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '(']))
|
||||
})
|
||||
}).into_iter().find_map(|r| parse_windows_desktop_registration(r.kind, &r.value, executable)) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn read_windows_executable_version(path: &Path) -> Option<String> {
|
||||
let wide = to_wide(&path_to_string(path));
|
||||
let mut handle = 0;
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
use super::*;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// WorkBuddy AI 5.5 reads models.json as either LanguageModel[] or { models, availableModels }.
|
||||
// Its chat selection is account/session-local; publishing a model must not rewrite that state.
|
||||
pub(crate) fn workbuddy_home(home: &Path) -> PathBuf {
|
||||
workbuddy_home_from_environment(
|
||||
home,
|
||||
agent_configuration_environment("WORKBUDDY_CONFIG_DIR").as_deref(),
|
||||
agent_configuration_environment("CODEBUDDY_CONFIG_DIR").as_deref(),
|
||||
find_workbuddy_desktop_executable(home).as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn workbuddy_home_from_environment(
|
||||
home: &Path,
|
||||
workbuddy_dir: Option<&Path>,
|
||||
codebuddy_dir: Option<&Path>,
|
||||
executable: Option<&Path>,
|
||||
) -> PathBuf {
|
||||
if let Some(directory) = workbuddy_dir.or(codebuddy_dir) {
|
||||
return directory.to_path_buf();
|
||||
}
|
||||
if let Some(executable) = executable {
|
||||
let resources = workbuddy_resources(executable);
|
||||
if let Some(folder) = resources
|
||||
.and_then(|p| fs::read_to_string(p.join("app.asar.unpacked/cli/product.json")).ok())
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.and_then(|v| {
|
||||
v.get("dataFolderName")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
return home.join(folder.trim());
|
||||
}
|
||||
}
|
||||
let ai = home.join(".workbuddy-ai");
|
||||
let legacy = home.join(".workbuddy");
|
||||
if !ai.exists() && legacy.join("settings.json").is_file() {
|
||||
legacy
|
||||
} else {
|
||||
ai
|
||||
}
|
||||
}
|
||||
|
||||
fn workbuddy_resources(executable: &Path) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Some(executable.parent()?.parent()?.join("Resources"))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Some(executable.parent()?.join("resources"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_workbuddy_desktop_executable(home: &Path) -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local = agent_configuration_environment("LOCALAPPDATA")
|
||||
.unwrap_or_else(|| home.join("AppData/Local"));
|
||||
let mut roots = vec![local.join("Programs"), local];
|
||||
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(root) = agent_configuration_environment(variable) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
if let Some(root) = agent_configuration_environment("WORKBUDDY_INSTALL_DIR") {
|
||||
candidates.extend([root.join("WorkBuddyAI.exe"), root.join("WorkBuddy.exe")]);
|
||||
}
|
||||
for root in roots {
|
||||
for (directory, executable) in [
|
||||
("WorkBuddyAI", "WorkBuddyAI.exe"),
|
||||
("WorkBuddy", "WorkBuddy.exe"),
|
||||
("WorkBuddy AI", "WorkBuddyAI.exe"),
|
||||
] {
|
||||
candidates.push(root.join(directory).join(executable));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for (app, executable) in [("WorkBuddy AI", "WorkBuddy AI"), ("WorkBuddy", "WorkBuddy")] {
|
||||
candidates.push(root.join(format!("{app}.app/Contents/MacOS/{executable}")));
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
candidates.extend([
|
||||
home.join(".local/bin/workbuddy"),
|
||||
PathBuf::from("/opt/WorkBuddy/workbuddy"),
|
||||
PathBuf::from("/opt/WorkBuddyAI/workbuddy"),
|
||||
]);
|
||||
}
|
||||
let found = candidates.into_iter().find(|path| path.is_file());
|
||||
#[cfg(all(target_os = "windows", not(test)))]
|
||||
let found = found.or_else(find_windows_registered_workbuddy_executable);
|
||||
found
|
||||
}
|
||||
|
||||
pub(crate) fn read_workbuddy_app_version(executable: &Path) -> Option<String> {
|
||||
// Use file metadata, never run an Electron application with --version during detection.
|
||||
read_zcode_app_version(executable)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_workbuddy_config(content: Option<&str>) -> Result<Value, String> {
|
||||
let value = match content {
|
||||
Some(content) => serde_json::from_str(content.strip_prefix('\u{feff}').unwrap_or(content))
|
||||
.map_err(|_| "WorkBuddy models.json 格式无效,请使用手动备份恢复或基础配置模板修复")?,
|
||||
None => json!({}),
|
||||
};
|
||||
validate_workbuddy_config(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workbuddy_config(value: &Value) -> Result<(), String> {
|
||||
let entries = if let Some(array) = value.as_array() {
|
||||
Some(array)
|
||||
} else if let Some(root) = value.as_object() {
|
||||
if root.get("availableModels").is_some_and(|v| {
|
||||
v.as_array()
|
||||
.is_none_or(|a| a.iter().any(|v| !v.is_string()))
|
||||
}) {
|
||||
return Err("WorkBuddy availableModels 必须是字符串数组".into());
|
||||
}
|
||||
match root.get("models") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_array().ok_or("WorkBuddy models 必须是数组")?),
|
||||
}
|
||||
} else {
|
||||
return Err("WorkBuddy models.json 根节点必须是对象或数组".into());
|
||||
};
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for entry in entries.into_iter().flatten() {
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or("WorkBuddy 模型缺少有效 id")?;
|
||||
if !ids.insert(workbuddy_request_model(id)) {
|
||||
return Err("WorkBuddy 存在重复模型 id,请先修复配置".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workbuddy_request_model(id: &str) -> &str {
|
||||
id.strip_prefix("custom-local:").unwrap_or(id)
|
||||
}
|
||||
|
||||
fn workbuddy_models(value: &Value) -> &[Value] {
|
||||
value
|
||||
.as_array()
|
||||
.or_else(|| value.get("models").and_then(Value::as_array))
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn workbuddy_managed_model(value: &Value) -> bool {
|
||||
value.get("vendor").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
}
|
||||
|
||||
fn set_workbuddy_models(value: &mut Value, models: Vec<Value>) {
|
||||
if value.is_array() {
|
||||
*value = Value::Array(models);
|
||||
} else if models.is_empty() {
|
||||
value.as_object_mut().unwrap().remove("models");
|
||||
} else {
|
||||
value["models"] = Value::Array(models);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_workbuddy_agent_config(
|
||||
existing: Option<&str>,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
available_models: &[AgentModelOption],
|
||||
) -> Result<String, String> {
|
||||
let mut root = parse_workbuddy_config(existing)?;
|
||||
let mut entries = workbuddy_models(&root)
|
||||
.iter()
|
||||
.filter(|m| !workbuddy_managed_model(m))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if entries.iter().any(|m| {
|
||||
m.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| workbuddy_request_model(id) == model)
|
||||
}) {
|
||||
return Err(
|
||||
"WorkBuddy 已有同名自定义模型,请先在 WorkBuddy 中修改该模型 ID,或选择其他 CPA 模型"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut entry = workbuddy_models(&root)
|
||||
.iter()
|
||||
.find(|m| {
|
||||
workbuddy_managed_model(m)
|
||||
&& m.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(workbuddy_request_model)
|
||||
== Some(model)
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
entry["id"] = json!(model);
|
||||
update_workbuddy_model(&mut entry, base_url, api_key, model, available_models);
|
||||
entries.push(entry);
|
||||
for previous in workbuddy_models(&root)
|
||||
.iter()
|
||||
.filter(|m| workbuddy_managed_model(m))
|
||||
{
|
||||
let id = previous["id"].as_str().ok_or("WorkBuddy 模型缺少有效 id")?;
|
||||
let previous_model = workbuddy_request_model(id);
|
||||
if previous_model == model {
|
||||
continue;
|
||||
}
|
||||
let mut retained = previous.clone();
|
||||
update_workbuddy_model(
|
||||
&mut retained,
|
||||
base_url,
|
||||
api_key,
|
||||
previous_model,
|
||||
available_models,
|
||||
);
|
||||
entries.push(retained);
|
||||
}
|
||||
set_workbuddy_models(&mut root, entries);
|
||||
if let Some(visible) = root
|
||||
.get_mut("availableModels")
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
if !visible.is_empty()
|
||||
&& !visible
|
||||
.iter()
|
||||
.any(|v| v.as_str().map(workbuddy_request_model) == Some(model))
|
||||
{
|
||||
visible.push(json!(model));
|
||||
}
|
||||
}
|
||||
serde_json::to_string_pretty(&root).map_err(|_| "生成 WorkBuddy 配置失败".into())
|
||||
}
|
||||
|
||||
fn update_workbuddy_model(
|
||||
entry: &mut Value,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
available_models: &[AgentModelOption],
|
||||
) {
|
||||
entry["name"] = json!("CPA");
|
||||
entry["vendor"] = json!(MANAGED_AGENT_PROVIDER_ID);
|
||||
entry["url"] = json!(format!(
|
||||
"{}/chat/completions",
|
||||
base_url.trim_end_matches('/')
|
||||
));
|
||||
entry["apiKey"] = json!(api_key);
|
||||
entry["supportsToolCall"] = json!(true);
|
||||
entry["disabled"] = json!(false);
|
||||
// WorkBuddy prefixes local model IDs itself and removes that prefix before sending requests.
|
||||
entry["useCustomProtocol"] = json!(false);
|
||||
if let Some(metadata) = available_models.iter().find(|m| m.name == model) {
|
||||
if let Some(context) = metadata.context_window {
|
||||
entry["maxInputTokens"] = json!(context);
|
||||
}
|
||||
if let Some(input) = &metadata.input_modalities {
|
||||
entry["supportsImages"] = json!(input.iter().any(|s| s == "image"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn workbuddy_has_managed_marker(path: &Path) -> Result<bool, String> {
|
||||
let root = parse_workbuddy_config(read_optional_text(path)?.as_deref())?;
|
||||
Ok(workbuddy_models(&root).iter().any(workbuddy_managed_model))
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_workbuddy_agent_config(
|
||||
path: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
) -> Result<(bool, Option<String>), String> {
|
||||
let root = parse_workbuddy_config(read_optional_text(path)?.as_deref())?;
|
||||
let managed = workbuddy_models(&root)
|
||||
.iter()
|
||||
.filter(|m| workbuddy_managed_model(m))
|
||||
.collect::<Vec<_>>();
|
||||
let Some(entry) = managed.first() else {
|
||||
return Ok((false, None));
|
||||
};
|
||||
let model = entry
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(workbuddy_request_model);
|
||||
let visible = root
|
||||
.get("availableModels")
|
||||
.and_then(Value::as_array)
|
||||
.is_none_or(|a| {
|
||||
a.is_empty()
|
||||
|| a.iter()
|
||||
.any(|v| v.as_str().map(workbuddy_request_model) == model)
|
||||
});
|
||||
let expected_url = format!("{}/v1/chat/completions", managed_core_loopback_origin(port));
|
||||
let configured = visible
|
||||
&& managed.iter().all(|entry| {
|
||||
entry.get("url").and_then(Value::as_str) == Some(&expected_url)
|
||||
&& entry.get("apiKey").and_then(Value::as_str) == Some(api_key)
|
||||
&& entry.get("disabled").and_then(Value::as_bool) != Some(true)
|
||||
&& entry.get("supportsToolCall").and_then(Value::as_bool) == Some(true)
|
||||
});
|
||||
Ok((configured, model.map(str::to_string)))
|
||||
}
|
||||
|
||||
pub(crate) fn strip_workbuddy_managed(root: &mut Value) {
|
||||
let ids = workbuddy_models(root)
|
||||
.iter()
|
||||
.filter(|m| workbuddy_managed_model(m))
|
||||
.filter_map(|m| m.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
let entries = workbuddy_models(root)
|
||||
.iter()
|
||||
.filter(|m| !workbuddy_managed_model(m))
|
||||
.cloned()
|
||||
.collect();
|
||||
set_workbuddy_models(root, entries);
|
||||
if let Some(visible) = root
|
||||
.get_mut("availableModels")
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
visible.retain(|v| {
|
||||
!v.as_str().is_some_and(|id| {
|
||||
ids.iter()
|
||||
.any(|old| workbuddy_request_model(id) == workbuddy_request_model(old))
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_workbuddy_visibility(
|
||||
baseline: &Value,
|
||||
applied: &Value,
|
||||
current: &Value,
|
||||
merged: &mut Value,
|
||||
) {
|
||||
if !merged.is_object() || current.get("availableModels") == applied.get("availableModels") {
|
||||
return;
|
||||
}
|
||||
let Some(now) = current.get("availableModels").and_then(Value::as_array) else {
|
||||
merged.as_object_mut().unwrap().remove("availableModels");
|
||||
return;
|
||||
};
|
||||
let original_ids = baseline
|
||||
.get("availableModels")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(workbuddy_request_model)
|
||||
.collect::<Vec<_>>();
|
||||
let removed_ids = [applied, current]
|
||||
.into_iter()
|
||||
.flat_map(workbuddy_models)
|
||||
.filter(|m| workbuddy_managed_model(m))
|
||||
.filter_map(|m| m.get("id").and_then(Value::as_str))
|
||||
.map(workbuddy_request_model)
|
||||
.collect::<Vec<_>>();
|
||||
let surviving_ids = workbuddy_models(current)
|
||||
.iter()
|
||||
.filter(|m| !workbuddy_managed_model(m))
|
||||
.filter_map(|m| m.get("id").and_then(Value::as_str))
|
||||
.map(workbuddy_request_model)
|
||||
.collect::<Vec<_>>();
|
||||
let visible = now
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
let Some(id) = value.as_str().map(workbuddy_request_model) else {
|
||||
return false;
|
||||
};
|
||||
!removed_ids.contains(&id) || original_ids.contains(&id) || surviving_ids.contains(&id)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
merged["availableModels"] = json!(visible);
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workbuddy_unmanaged_preserved(
|
||||
before: &Value,
|
||||
after: &Value,
|
||||
) -> Result<(), String> {
|
||||
let ids = [before, after]
|
||||
.into_iter()
|
||||
.flat_map(workbuddy_models)
|
||||
.filter(|m| workbuddy_managed_model(m))
|
||||
.filter_map(|m| m.get("id").and_then(Value::as_str))
|
||||
.map(workbuddy_request_model)
|
||||
.collect::<Vec<_>>();
|
||||
let project = |value: &Value| {
|
||||
let mut value = value.clone();
|
||||
strip_workbuddy_managed(&mut value);
|
||||
if let Some(visible) = value
|
||||
.get_mut("availableModels")
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
visible.retain(|v| {
|
||||
!v.as_str()
|
||||
.is_some_and(|id| ids.contains(&workbuddy_request_model(id)))
|
||||
});
|
||||
}
|
||||
value
|
||||
};
|
||||
if project(before) != project(after) {
|
||||
return Err("更新意外改变了 WorkBuddy 自定义配置,已拒绝写入".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn build_restored_workbuddy_config(
|
||||
current: &str,
|
||||
original: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut root = parse_workbuddy_config(Some(current))?;
|
||||
strip_workbuddy_managed(&mut root);
|
||||
if let Some(original) = original {
|
||||
let before = parse_workbuddy_config(Some(original))?;
|
||||
// Keep an explicitly empty models list and the original visibility filter when unchanged.
|
||||
if root.is_object() && before.get("models").is_some() && root.get("models").is_none() {
|
||||
root["models"] = json!([]);
|
||||
}
|
||||
if root.is_object() {
|
||||
if let Some(visible) = before.get("availableModels") {
|
||||
root["availableModels"] = visible.clone();
|
||||
} else {
|
||||
root.as_object_mut().unwrap().remove("availableModels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if original.is_none()
|
||||
&& (root.as_object().is_some_and(|r| r.is_empty())
|
||||
|| root.as_array().is_some_and(|r| r.is_empty()))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::to_string_pretty(&root)
|
||||
.map(Some)
|
||||
.map_err(|_| "恢复 WorkBuddy 配置失败".into())
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_workbuddy_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
|
||||
let path = paths.first().ok_or("WorkBuddy 配置路径不可用")?;
|
||||
let Some(current) = read_optional_text(path)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut value = parse_workbuddy_config(Some(¤t))?;
|
||||
if !workbuddy_models(&value).iter().any(workbuddy_managed_model) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
strip_workbuddy_managed(&mut value);
|
||||
let bytes = if value.as_object().is_some_and(|v| v.is_empty())
|
||||
|| value.as_array().is_some_and(|v| v.is_empty())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_vec_pretty(&value).map_err(|_| "生成 WorkBuddy 配置失败")?)
|
||||
};
|
||||
Ok(vec![(path.clone(), bytes)])
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
use super::*;
|
||||
|
||||
struct Home(PathBuf);
|
||||
impl Home {
|
||||
fn new() -> Self {
|
||||
let path = env::temp_dir().join(format!(
|
||||
"cpa-workbuddy-test-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self) -> PathBuf {
|
||||
self.0.join(".workbuddy-ai/models.json")
|
||||
}
|
||||
fn save(&self, value: &Value) {
|
||||
fs::create_dir_all(self.path().parent().unwrap()).unwrap();
|
||||
fs::write(self.path(), serde_json::to_string_pretty(value).unwrap()).unwrap();
|
||||
}
|
||||
fn read(&self) -> Value {
|
||||
parse_workbuddy_config(read_optional_text(&self.path()).unwrap().as_deref()).unwrap()
|
||||
}
|
||||
fn apply(&self, model: &str) -> Result<AgentConfigActionResult, String> {
|
||||
apply_agent_configuration_with_oauth(
|
||||
AgentClient::WorkBuddy,
|
||||
&self.0,
|
||||
8317,
|
||||
"test-key",
|
||||
model,
|
||||
AgentConfigurationOptions {
|
||||
models: &[],
|
||||
codex_catalog: None,
|
||||
oauth_configuration: false,
|
||||
claude_code_model_mappings: None,
|
||||
claude_desktop_model_mappings: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Home {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_formats_round_trip_through_update_backup_and_disable() {
|
||||
for original in [
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!({"models": []}),
|
||||
json!([{"id":"my-model","url":"https://example.test/v1/chat/completions","apiKey":"private"}]),
|
||||
json!({"models":[{"id":"my-model","extensions":{"keep":true}}],"availableModels":["my-model"],"custom":true}),
|
||||
json!({"availableModels":["gpt-one","my-model"]}),
|
||||
] {
|
||||
let home = Home::new();
|
||||
home.save(&original);
|
||||
home.apply("gpt-one").unwrap();
|
||||
assert_eq!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(),
|
||||
(true, Some("gpt-one".into()))
|
||||
);
|
||||
let bytes = fs::read(home.path()).unwrap();
|
||||
assert_eq!(home.apply("gpt-one").unwrap().outcome, "unchanged");
|
||||
assert_eq!(fs::read(home.path()).unwrap(), bytes);
|
||||
assert_eq!(
|
||||
serde_json::to_value(create_backup("workbuddy", &home.0).unwrap()).unwrap()
|
||||
["restorable"],
|
||||
true
|
||||
);
|
||||
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
|
||||
assert_eq!(home.read(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_switching_and_disable_keep_external_models_and_settings() {
|
||||
for array in [false, true] {
|
||||
let home = Home::new();
|
||||
home.save(&if array {
|
||||
json!([])
|
||||
} else {
|
||||
json!({"availableModels":["built-in"]})
|
||||
});
|
||||
home.apply("gpt-one").unwrap();
|
||||
let mut edited = home.read();
|
||||
let mut entries = workbuddy_models(&edited).to_vec();
|
||||
entries[0]["temperature"] = json!(0.2);
|
||||
let first_model = entries[0].clone();
|
||||
entries.push(json!({"id":"user-added","apiKey":"user-key","extra":{"a":[1,2]}}));
|
||||
set_workbuddy_models(&mut edited, entries);
|
||||
home.save(&edited);
|
||||
home.apply("gpt-two").unwrap();
|
||||
let updated = home.read();
|
||||
assert_eq!(workbuddy_models(&updated).len(), 3);
|
||||
assert!(workbuddy_models(&updated).contains(&first_model));
|
||||
assert_eq!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(),
|
||||
(true, Some("gpt-two".into()))
|
||||
);
|
||||
if !array {
|
||||
assert_eq!(
|
||||
updated["availableModels"],
|
||||
json!(["built-in", "gpt-one", "gpt-two"])
|
||||
);
|
||||
}
|
||||
let second_model = workbuddy_models(&updated)
|
||||
.iter()
|
||||
.find(|m| m["id"] == "gpt-two")
|
||||
.unwrap()
|
||||
.clone();
|
||||
home.apply("gpt-one").unwrap();
|
||||
let switched_back = home.read();
|
||||
assert_eq!(workbuddy_models(&switched_back).len(), 3);
|
||||
assert!(workbuddy_models(&switched_back).contains(&first_model));
|
||||
assert!(workbuddy_models(&switched_back).contains(&second_model));
|
||||
assert_eq!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(),
|
||||
(true, Some("gpt-one".into()))
|
||||
);
|
||||
let bytes = fs::read(home.path()).unwrap();
|
||||
assert_eq!(home.apply("gpt-one").unwrap().outcome, "unchanged");
|
||||
assert_eq!(fs::read(home.path()).unwrap(), bytes);
|
||||
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
|
||||
assert_eq!(
|
||||
workbuddy_models(&home.read()),
|
||||
&[json!({"id":"user-added","apiKey":"user-key","extra":{"a":[1,2]}})]
|
||||
);
|
||||
if !array {
|
||||
assert_eq!(home.read()["availableModels"], json!(["built-in"]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_status_checks_endpoint_key_visibility_and_tool_support() {
|
||||
let home = Home::new();
|
||||
home.apply("gpt-one").unwrap();
|
||||
assert!(
|
||||
!inspect_workbuddy_agent_config(&home.path(), 8318, "test-key")
|
||||
.unwrap()
|
||||
.0
|
||||
);
|
||||
assert!(
|
||||
!inspect_workbuddy_agent_config(&home.path(), 8317, "wrong-key")
|
||||
.unwrap()
|
||||
.0
|
||||
);
|
||||
let original = home.read();
|
||||
for (pointer, value) in [
|
||||
("/availableModels", json!(["hidden"])),
|
||||
("/models/0/disabled", json!(true)),
|
||||
("/models/0/supportsToolCall", json!(false)),
|
||||
("/models/0/url", json!("http://127.0.0.1:8317/v1")),
|
||||
] {
|
||||
let mut changed = original.clone();
|
||||
if pointer == "/availableModels" {
|
||||
changed["availableModels"] = value;
|
||||
} else {
|
||||
*changed.pointer_mut(pointer).unwrap() = value;
|
||||
}
|
||||
home.save(&changed);
|
||||
assert!(
|
||||
!inspect_workbuddy_agent_config(&home.path(), 8317, "test-key")
|
||||
.unwrap()
|
||||
.0
|
||||
);
|
||||
assert!(workbuddy_has_managed_marker(&home.path()).unwrap());
|
||||
}
|
||||
let mut prefixed = original;
|
||||
prefixed["availableModels"] = json!(["custom-local:gpt-one"]);
|
||||
home.save(&prefixed);
|
||||
assert!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key")
|
||||
.unwrap()
|
||||
.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_visibility_restores_original_references_and_keeps_user_edits() {
|
||||
for external_edit in [false, true] {
|
||||
let home = Home::new();
|
||||
home.save(&json!({"availableModels":["gpt-one","built-in"]}));
|
||||
home.apply("gpt-one").unwrap();
|
||||
home.apply("gpt-two").unwrap();
|
||||
if external_edit {
|
||||
let mut current = home.read();
|
||||
current["availableModels"] = json!(["gpt-two", "user-added"]);
|
||||
home.save(¤t);
|
||||
}
|
||||
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
|
||||
assert_eq!(
|
||||
home.read()["availableModels"],
|
||||
if external_edit {
|
||||
json!(["user-added"])
|
||||
} else {
|
||||
json!(["gpt-one", "built-in"])
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_new_visibility_filters_remove_managed_references_on_disable() {
|
||||
for original in [json!({}), json!({"availableModels": []}), json!([])] {
|
||||
for reapply in [false, true] {
|
||||
for (visible, expected) in [
|
||||
(json!(["gpt-one"]), json!([])),
|
||||
(
|
||||
json!(["custom-local:gpt-one", "custom-local:gpt-two", "built-in"]),
|
||||
json!(["built-in"]),
|
||||
),
|
||||
] {
|
||||
let home = Home::new();
|
||||
home.save(&original);
|
||||
home.apply("gpt-one").unwrap();
|
||||
home.apply("gpt-two").unwrap();
|
||||
let current = home.read();
|
||||
let mut current = if current.is_array() {
|
||||
json!({"models": current})
|
||||
} else {
|
||||
current
|
||||
};
|
||||
current["availableModels"] = visible;
|
||||
home.save(¤t);
|
||||
if reapply {
|
||||
home.apply("gpt-two").unwrap();
|
||||
}
|
||||
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
|
||||
let restored = home.read();
|
||||
assert!(workbuddy_models(&restored).is_empty());
|
||||
assert_eq!(restored["availableModels"], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_cleared_visibility_does_not_restore_old_references() {
|
||||
for reapply in [false, true] {
|
||||
for remove_field in [false, true] {
|
||||
let home = Home::new();
|
||||
home.save(&json!({"availableModels": ["gpt-one", "built-in"]}));
|
||||
home.apply("gpt-one").unwrap();
|
||||
home.apply("gpt-two").unwrap();
|
||||
let mut current = home.read();
|
||||
if remove_field {
|
||||
current.as_object_mut().unwrap().remove("availableModels");
|
||||
} else {
|
||||
current["availableModels"] = json!([]);
|
||||
}
|
||||
home.save(¤t);
|
||||
if reapply {
|
||||
home.apply("gpt-two").unwrap();
|
||||
}
|
||||
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
|
||||
let restored = home.read();
|
||||
assert!(workbuddy_models(&restored).is_empty());
|
||||
if remove_field {
|
||||
assert!(restored.get("availableModels").is_none());
|
||||
} else {
|
||||
assert_eq!(restored["availableModels"], json!([]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_visibility_keeps_models_detached_from_cpa() {
|
||||
for reapply in [false, true] {
|
||||
let home = Home::new();
|
||||
home.apply("gpt-one").unwrap();
|
||||
let mut current = home.read();
|
||||
current["models"][0]["vendor"] = json!("user-provider");
|
||||
current["availableModels"] = json!(["custom-local:gpt-one", "built-in"]);
|
||||
home.save(¤t);
|
||||
if reapply {
|
||||
home.apply("gpt-two").unwrap();
|
||||
}
|
||||
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
|
||||
assert_eq!(home.read(), current);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_write_guard_rejects_removing_unrelated_visible_models() {
|
||||
let before = json!({"availableModels":["user-model"]});
|
||||
let rendered = build_workbuddy_agent_config(
|
||||
Some(&before.to_string()),
|
||||
"http://127.0.0.1:8317/v1",
|
||||
"key",
|
||||
"gpt-one",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut after = parse_workbuddy_config(Some(&rendered)).unwrap();
|
||||
validate_workbuddy_unmanaged_preserved(&before, &after).unwrap();
|
||||
after["availableModels"] = json!(["gpt-one"]);
|
||||
assert!(validate_workbuddy_unmanaged_preserved(&before, &after).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_rejects_collisions_and_malformed_configs_without_writing() {
|
||||
for value in [
|
||||
json!([{"id":"gpt-one","apiKey":"user-key"}]),
|
||||
json!({"models":[{"id":"custom-local:gpt-one","apiKey":"user-key"}]}),
|
||||
json!({"models":{}}),
|
||||
json!({"availableModels":false}),
|
||||
json!({"models":[{"id":"same"},{"id":"custom-local:same"}]}),
|
||||
] {
|
||||
let home = Home::new();
|
||||
home.save(&value);
|
||||
let before = fs::read(home.path()).unwrap();
|
||||
assert!(home.apply("gpt-one").is_err());
|
||||
assert_eq!(fs::read(home.path()).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_switch_refreshes_retained_credentials_and_metadata() {
|
||||
let home = Home::new();
|
||||
home.apply("vision").unwrap();
|
||||
let mut current = home.read();
|
||||
current["models"][0]["id"] = json!("custom-local:vision");
|
||||
current["models"][0]["temperature"] = json!(0.2);
|
||||
current["availableModels"] = json!(["custom-local:vision"]);
|
||||
let next = build_workbuddy_agent_config(
|
||||
Some(¤t.to_string()),
|
||||
"http://127.0.0.1:8318/v1",
|
||||
"new-key",
|
||||
"gpt-two",
|
||||
&[AgentModelOption {
|
||||
name: "vision".into(),
|
||||
alias: None,
|
||||
is_alias: false,
|
||||
context_window: Some(128_000),
|
||||
input_modalities: Some(vec!["text".into(), "image".into()]),
|
||||
harness_metadata: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let updated = parse_workbuddy_config(Some(&next)).unwrap();
|
||||
assert_eq!(
|
||||
updated["availableModels"],
|
||||
json!(["custom-local:vision", "gpt-two"])
|
||||
);
|
||||
assert_eq!(workbuddy_models(&updated).len(), 2);
|
||||
let retained = &updated["models"][1];
|
||||
assert_eq!(retained["id"], "custom-local:vision");
|
||||
assert_eq!(retained["maxInputTokens"], 128_000);
|
||||
assert_eq!(retained["supportsImages"], true);
|
||||
assert_eq!(retained["temperature"], 0.2);
|
||||
for entry in workbuddy_models(&updated) {
|
||||
assert_eq!(entry["url"], "http://127.0.0.1:8318/v1/chat/completions");
|
||||
assert_eq!(entry["apiKey"], "new-key");
|
||||
}
|
||||
home.save(&updated);
|
||||
assert_eq!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(),
|
||||
(true, Some("gpt-two".into()))
|
||||
);
|
||||
for (field, value) in [
|
||||
("apiKey", json!("old-key")),
|
||||
("url", json!("http://127.0.0.1:8317/v1/chat/completions")),
|
||||
("disabled", json!(true)),
|
||||
("supportsToolCall", json!(false)),
|
||||
] {
|
||||
let mut stale = updated.clone();
|
||||
stale["models"][1][field] = value;
|
||||
home.save(&stale);
|
||||
assert_eq!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(),
|
||||
(false, Some("gpt-two".into()))
|
||||
);
|
||||
}
|
||||
let switched_back = build_workbuddy_agent_config(
|
||||
Some(&next),
|
||||
"http://127.0.0.1:8318/v1",
|
||||
"new-key",
|
||||
"vision",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let switched_back = parse_workbuddy_config(Some(&switched_back)).unwrap();
|
||||
assert_eq!(workbuddy_models(&switched_back).len(), 2);
|
||||
assert_eq!(switched_back["models"][0]["id"], "vision");
|
||||
assert_eq!(switched_back["models"][0]["temperature"], 0.2);
|
||||
assert_eq!(switched_back["availableModels"], updated["availableModels"]);
|
||||
home.save(&switched_back);
|
||||
assert_eq!(
|
||||
inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(),
|
||||
(true, Some("vision".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_metadata_and_model_extensions_survive_reapply() {
|
||||
let config = build_workbuddy_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317/v1",
|
||||
"key",
|
||||
"vision",
|
||||
&[AgentModelOption {
|
||||
name: "vision".into(),
|
||||
alias: None,
|
||||
is_alias: false,
|
||||
context_window: Some(128_000),
|
||||
input_modalities: Some(vec!["text".into(), "image".into()]),
|
||||
harness_metadata: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let mut value = parse_workbuddy_config(Some(&config)).unwrap();
|
||||
assert_eq!(value["models"][0]["maxInputTokens"], 128_000);
|
||||
assert_eq!(value["models"][0]["supportsImages"], true);
|
||||
assert_eq!(
|
||||
value["models"][0]["url"],
|
||||
"http://127.0.0.1:8317/v1/chat/completions"
|
||||
);
|
||||
value["models"][0]["temperature"] = json!(0.2);
|
||||
let next = build_workbuddy_agent_config(
|
||||
Some(&value.to_string()),
|
||||
"http://127.0.0.1:8318/v1",
|
||||
"new",
|
||||
"vision",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parse_workbuddy_config(Some(&next)).unwrap()["models"][0]["temperature"],
|
||||
0.2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbuddy_paths_use_environment_then_installed_product_data_folder() {
|
||||
let home = Home::new();
|
||||
let executable = home.0.join("install/WorkBuddyAI.exe");
|
||||
let product = workbuddy_resources(&executable)
|
||||
.unwrap()
|
||||
.join("app.asar.unpacked/cli/product.json");
|
||||
fs::create_dir_all(product.parent().unwrap()).unwrap();
|
||||
fs::write(product, r#"{"dataFolderName":".workbuddy-enterprise"}"#).unwrap();
|
||||
let resolve = |wb, cb| workbuddy_home_from_environment(&home.0, wb, cb, Some(&executable));
|
||||
assert_eq!(resolve(None, None), home.0.join(".workbuddy-enterprise"));
|
||||
assert_eq!(resolve(Some(&home.0), None), home.0);
|
||||
assert_eq!(resolve(None, Some(&home.0)), home.0);
|
||||
assert_eq!(resolve(Some(&home.0), Some(Path::new("unused"))), home.0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn workbuddy_detects_local_install_without_launching_it() {
|
||||
let home = Home::new();
|
||||
let executable = home
|
||||
.0
|
||||
.join("AppData/Local/Programs/WorkBuddyAI/WorkBuddyAI.exe");
|
||||
fs::create_dir_all(executable.parent().unwrap()).unwrap();
|
||||
fs::write(&executable, "not an executable").unwrap();
|
||||
assert_eq!(
|
||||
find_workbuddy_desktop_executable(&home.0),
|
||||
Some(executable.clone())
|
||||
);
|
||||
assert!(agent_installation_detected(
|
||||
AgentClient::WorkBuddy,
|
||||
None,
|
||||
true,
|
||||
false
|
||||
));
|
||||
assert!(!should_probe_primary_agent_executable_version(
|
||||
AgentClient::WorkBuddy
|
||||
));
|
||||
assert_eq!(read_workbuddy_app_version(&executable), None);
|
||||
let targets = agent_launch_targets(AgentClient::WorkBuddy, Some(&executable), None, false);
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].id, "app");
|
||||
assert_eq!(
|
||||
parse_windows_desktop_registration(
|
||||
"icon",
|
||||
&format!("\"{}\",0", executable.display()),
|
||||
"WorkBuddyAI.exe"
|
||||
),
|
||||
Some(executable)
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
|
||||
AgentClient::Hermes,
|
||||
AgentClient::DeepSeekHarness,
|
||||
AgentClient::ZCode,
|
||||
AgentClient::WorkBuddy,
|
||||
AgentClient::KimiCode,
|
||||
AgentClient::GrokBuild,
|
||||
] {
|
||||
|
||||
@@ -1362,6 +1362,7 @@ enum AgentClient {
|
||||
Hermes,
|
||||
DeepSeekHarness,
|
||||
ZCode,
|
||||
WorkBuddy,
|
||||
KimiCode,
|
||||
GrokBuild,
|
||||
}
|
||||
@@ -1384,6 +1385,7 @@ impl AgentClient {
|
||||
"hermes" => Ok(Self::Hermes),
|
||||
"deepseek-harness" => Ok(Self::DeepSeekHarness),
|
||||
"zcode" => Ok(Self::ZCode),
|
||||
"workbuddy" => Ok(Self::WorkBuddy),
|
||||
"kimi-code" => Ok(Self::KimiCode),
|
||||
"grok-build" => Ok(Self::GrokBuild),
|
||||
_ => Err(format!("不支持的智能体客户端: {value}")),
|
||||
@@ -1400,6 +1402,7 @@ impl AgentClient {
|
||||
Self::Hermes => "hermes",
|
||||
Self::DeepSeekHarness => "deepseek-harness",
|
||||
Self::ZCode => "zcode",
|
||||
Self::WorkBuddy => "workbuddy",
|
||||
Self::KimiCode => "kimi-code",
|
||||
Self::GrokBuild => "grok-build",
|
||||
}
|
||||
@@ -1415,6 +1418,7 @@ impl AgentClient {
|
||||
Self::Hermes => "Hermes Agent",
|
||||
Self::DeepSeekHarness => "DeepSeek Harness",
|
||||
Self::ZCode => "ZCode",
|
||||
Self::WorkBuddy => "WorkBuddy",
|
||||
Self::KimiCode => "Kimi Code",
|
||||
Self::GrokBuild => "Grok Build",
|
||||
}
|
||||
@@ -1439,6 +1443,7 @@ impl AgentClient {
|
||||
Self::Hermes => &["hermes"],
|
||||
Self::DeepSeekHarness => &["dsh"],
|
||||
Self::ZCode => &["zcode"],
|
||||
Self::WorkBuddy => &[],
|
||||
Self::KimiCode => &["kimi"],
|
||||
Self::GrokBuild => &["grok"],
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ fn configuration_paths_ignore_inherited_environment() {
|
||||
"hermes",
|
||||
"deepseek-harness",
|
||||
"zcode",
|
||||
"workbuddy",
|
||||
"kimi-code",
|
||||
"grok-build",
|
||||
"pi",
|
||||
@@ -42,6 +43,9 @@ fn configuration_paths_ignore_inherited_environment() {
|
||||
"KIMI_CODE_HOME",
|
||||
"GROK_HOME",
|
||||
"DSH_HOME",
|
||||
"WORKBUDDY_CONFIG_DIR",
|
||||
"CODEBUDDY_CONFIG_DIR",
|
||||
"WORKBUDDY_INSTALL_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
] {
|
||||
command.env(variable, &outside);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -937,6 +937,8 @@ export const jaOverrides = {
|
||||
'agents.description.openclaw': 'CPA モデルプロバイダーと既定モデルを使用',
|
||||
'agents.description.hermes': 'CPA カスタムプロバイダーを使用',
|
||||
'agents.description.deepseekHarness': 'Harness のネイティブ DeepSeek 設定と分離された OpenAI 互換ルートで CPA を使用',
|
||||
'agents.description.workbuddy': 'CPA の OpenAI Chat Completions API で WorkBuddy のカスタムモデルを設定',
|
||||
'agents.modify.workbuddyHint': '適用後、WorkBuddy で「CPA:モデル名」を選択してください。表示されない場合は App を再起動してください。既存の会話のモデルは自動で切り替わりません。',
|
||||
'agents.description.zcode': 'CPA の Anthropic Messages API と動的モデルカタログを使用',
|
||||
'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用',
|
||||
'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用',
|
||||
|
||||
@@ -937,6 +937,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.description.openclaw': 'Uses the CPA model provider and default model',
|
||||
'agents.description.hermes': 'Uses the CPA custom provider',
|
||||
'agents.description.deepseekHarness': 'Uses CPA through an isolated, OpenAI-compatible Harness route',
|
||||
'agents.description.workbuddy': 'Configures a WorkBuddy custom model using the CPA OpenAI Chat Completions API',
|
||||
'agents.modify.workbuddyHint': 'After applying, select “CPA:model name” in WorkBuddy. Restart the app if it is missing; existing conversations keep their selected model.',
|
||||
'agents.description.zcode': 'Uses the CPA Anthropic Messages API and dynamic model catalog',
|
||||
'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog',
|
||||
'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog',
|
||||
|
||||
@@ -936,6 +936,8 @@ export const zhCN = {
|
||||
'agents.description.openclaw': '使用 CPA 模型供应商和默认模型',
|
||||
'agents.description.hermes': '使用 CPA 自定义提供方',
|
||||
'agents.description.deepseekHarness': '通过通用 OpenAI 兼容路由使用 CPA,并与 Harness 原生 DeepSeek 配置隔离',
|
||||
'agents.description.workbuddy': '使用 CPA 的 OpenAI Chat Completions 接口配置 WorkBuddy 自定义模型',
|
||||
'agents.modify.workbuddyHint': '应用后,在 WorkBuddy 的模型选择器中选择「CPA:模型名」。若未显示,请重启 App;已有会话的模型不会自动切换。',
|
||||
'agents.description.zcode': '使用 CPA 的 Anthropic Messages 接口和动态模型目录',
|
||||
'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录',
|
||||
'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录',
|
||||
|
||||
@@ -36,6 +36,7 @@ import openclawIcon from '../assets/icons/openclaw.svg';
|
||||
import opencodeIcon from '../assets/icons/opencode.svg';
|
||||
import piIcon from '../assets/icons/pi-logo-on-light.svg';
|
||||
import zcodeIcon from '../assets/icons/zcode.png';
|
||||
import workbuddyIcon from '../assets/icons/workbuddy.png';
|
||||
import {
|
||||
agentModelAlias,
|
||||
filterAgentModels,
|
||||
@@ -79,6 +80,7 @@ type AgentClientId =
|
||||
| 'hermes'
|
||||
| 'deepseek-harness'
|
||||
| 'zcode'
|
||||
| 'workbuddy'
|
||||
| 'kimi-code'
|
||||
| 'grok-build'
|
||||
| 'pi';
|
||||
@@ -228,7 +230,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' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi';
|
||||
descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi';
|
||||
};
|
||||
|
||||
type AgentSubpageId = 'core' | 'management' | 'sessions';
|
||||
@@ -282,6 +284,12 @@ const agentDefinitions: AgentDefinition[] = [
|
||||
icon: grokIcon,
|
||||
descriptionKey: 'agents.description.grokBuild',
|
||||
},
|
||||
{
|
||||
id: 'workbuddy',
|
||||
name: 'WorkBuddy',
|
||||
icon: workbuddyIcon,
|
||||
descriptionKey: 'agents.description.workbuddy',
|
||||
},
|
||||
{
|
||||
id: 'zcode',
|
||||
name: 'ZCode',
|
||||
@@ -1210,6 +1218,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
const modificationDescription = activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.modify.invalid')
|
||||
: nativeOauth ? t('agents.nativeOAuth.activeHint')
|
||||
: selected === 'workbuddy'
|
||||
? t('agents.modify.workbuddyHint')
|
||||
: selected === 'zcode'
|
||||
? t('agents.modify.zcodeRestart')
|
||||
: '';
|
||||
@@ -1731,7 +1741,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
};
|
||||
|
||||
const restartDesktopApp = async () => {
|
||||
if (!['codex', 'opencode', 'claude-desktop', 'zcode'].includes(selected)) return;
|
||||
if (!['codex', 'opencode', 'claude-desktop', 'zcode', 'workbuddy'].includes(selected)) return;
|
||||
setBusyAction('restart-app');
|
||||
setLaunchError('');
|
||||
try {
|
||||
@@ -2426,7 +2436,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
<MessageNotice tone="success" message={!configurationErrorMessage ? configurationNotice || clearNotice : null}
|
||||
onDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }} />
|
||||
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
|
||||
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode'}
|
||||
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
|
||||
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
|
||||
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
|
||||
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
|
||||
|
||||
@@ -66,7 +66,7 @@ const path = require('node:path');
|
||||
assert.equal(await tab('基础配置').getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await tab('会话管理').count(), 0);
|
||||
|
||||
for (const client of ['claude-desktop', 'zcode', 'opencode']) {
|
||||
for (const client of ['claude-desktop', 'zcode', 'workbuddy', 'opencode']) {
|
||||
await open(mode + 'client=' + client + '&app-only');
|
||||
assert.ok(await button('重启 App').isEnabled());
|
||||
await button('重启 App').click();
|
||||
@@ -80,7 +80,7 @@ const path = require('node:path');
|
||||
await open(mode + 'client=kimi-code');
|
||||
assert.equal(await button('重启 App').count(), 0);
|
||||
|
||||
for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'kimi-code', 'grok-build']) {
|
||||
for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'kimi-code', 'grok-build']) {
|
||||
await open(mode + 'client=' + client);
|
||||
assert.ok(await button('关闭配置修改').isEnabled());
|
||||
await tab('配置管理').click();
|
||||
|
||||
Vendored
+2
-2
@@ -9,7 +9,7 @@ if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-s
|
||||
localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN');
|
||||
document.documentElement.dataset.theme = params.get('theme') || 'light';
|
||||
localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex');
|
||||
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi'];
|
||||
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','workbuddy','zcode','kimi-code','grok-build','pi'];
|
||||
let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one';
|
||||
let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0;
|
||||
let currentOauth=false; const currentMappings:Record<string,any>={};
|
||||
@@ -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'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].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',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'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['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',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('模拟关闭失败,已回滚');
|
||||
|
||||
Reference in new issue
Block a user