diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 4d543ba..b02a3e6 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -16,6 +16,7 @@ pub(crate) fn inspect_agent_config_statuses( AgentClient::OpenCode, AgentClient::OpenClaw, AgentClient::Hermes, + AgentClient::DeepSeekHarness, ] .into_iter() .map(|client| inspect_agent_config(client, &home, config.port, api_key)) diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index e527b37..a7ae96a 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -189,6 +189,31 @@ pub(crate) fn build_agent_updates_with_oauth( after, }]) } + AgentClient::DeepSeekHarness => { + if paths.len() != 2 { + return Err("DeepSeek Harness 配置路径数量无效".to_string()); + } + let settings_before = read_optional_text(&paths[0])?; + let credentials_before = read_optional_text(&paths[1])?; + Ok(vec![ + AgentFileUpdate { + path: paths[0].clone(), + after: build_deepseek_harness_settings( + settings_before.as_deref(), + &openai_base, + model, + models, + )?, + }, + AgentFileUpdate { + path: paths[1].clone(), + after: build_deepseek_harness_credentials( + credentials_before.as_deref(), + api_key, + )?, + }, + ]) + } } } @@ -201,6 +226,187 @@ pub(crate) fn read_optional_text(path: &Path) -> Result, String> .map_err(|error| format!("读取配置失败 {}: {error}", path_to_string(path))) } +pub(crate) fn read_agent_yaml_mapping_or_empty( + path: &Path, + label: &str, +) -> Result { + if !path.is_file() { + return Ok(serde_norway::Mapping::new()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; + parse_agent_yaml_mapping(Some(&content), label) +} + +pub(crate) fn parse_agent_yaml_mapping( + content: Option<&str>, + label: &str, +) -> Result { + let Some(content) = content.map(str::trim).filter(|content| !content.is_empty()) else { + return Ok(serde_norway::Mapping::new()); + }; + serde_norway::from_str::(content) + .map_err(|error| format!("{label} YAML 格式无效: {error}"))? + .as_mapping() + .cloned() + .ok_or_else(|| format!("{label} 根节点必须是 YAML 映射")) +} + +pub(crate) fn render_agent_yaml_mapping_update( + existing: Option<&str>, + label: &str, + update: F, +) -> Result +where + F: FnOnce(&mut serde_norway::Mapping) -> Result<(), String>, +{ + let original_mapping = parse_agent_yaml_mapping(existing, label)?; + let original = serde_norway::Value::Mapping(original_mapping.clone()); + let mut updated_mapping = original_mapping; + update(&mut updated_mapping)?; + let updated = serde_norway::Value::Mapping(updated_mapping); + if updated == original { + return Ok(existing.unwrap_or("{}\n").to_string()); + } + let source = existing + .map(str::trim) + .filter(|content| !content.is_empty()) + .map(|_| existing.unwrap_or_default()) + .unwrap_or("{}\n"); + let mut document = yaml_serde_edit::YamlValue::parse(source) + .map_err(|error| format!("{label} YAML 格式无效: {error}"))?; + render_updated_core_yaml(&mut document, updated) + .map_err(|error| format!("更新 {label} 失败: {error}")) +} + +pub(crate) fn build_deepseek_harness_models( + models: &[AgentModelOption], + selected_model: &str, +) -> Result { + let entries = ordered_agent_models(models, selected_model) + .into_iter() + .map(|model| { + let mut entry = serde_norway::Mapping::new(); + entry.insert(yaml_key("id"), serde_norway::Value::String(model.name)); + if let Some(name) = model.alias.filter(|name| !name.trim().is_empty()) { + entry.insert(yaml_key("name"), serde_norway::Value::String(name)); + } + if let Some(context_window) = model.context_window { + entry.insert( + yaml_key("contextWindow"), + serde_norway::to_value(context_window) + .map_err(|error| format!("序列化 Harness 模型上下文失败: {error}"))?, + ); + } + Ok(serde_norway::Value::Mapping(entry)) + }) + .collect::, String>>()?; + Ok(serde_norway::Value::Sequence(entries)) +} + +pub(crate) fn build_deepseek_harness_settings( + existing: Option<&str>, + base_url: &str, + model: &str, + models: &[AgentModelOption], +) -> Result { + let published_models = build_deepseek_harness_models(models, model)?; + render_agent_yaml_mapping_update(existing, "DeepSeek Harness settings", |root| { + let llm = root + .entry(yaml_key("llm-pi-ai")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "DeepSeek Harness llm-pi-ai 必须是映射".to_string())?; + let providers = llm + .entry(yaml_key("providers")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "DeepSeek Harness llm-pi-ai.providers 必须是映射".to_string())?; + let provider = providers + .entry(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID)) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "DeepSeek Harness EasyCLIProxyAPI provider 必须是映射".to_string())?; + for (key, value) in [ + ("displayName", "EasyCLIProxyAPI"), + ("apiKeyEnv", DEEPSEEK_HARNESS_CREDENTIAL), + ("api", "openai-completions"), + ("baseURL", base_url), + ] { + provider.insert( + yaml_key(key), + serde_norway::Value::String(value.to_string()), + ); + } + provider.insert(yaml_key("models"), published_models); + + let selection = root + .entry(yaml_key("agent-default-model")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "DeepSeek Harness agent-default-model 必须是映射".to_string())?; + selection.insert( + yaml_key("provider"), + serde_norway::Value::String(DEEPSEEK_HARNESS_PROVIDER_ID.to_string()), + ); + selection.insert( + yaml_key("model"), + serde_norway::Value::String(model.to_string()), + ); + selection.remove(yaml_key("reasoningEffort")); + Ok(()) + }) +} + +pub(crate) fn build_deepseek_harness_credentials( + existing: Option<&str>, + api_key: &str, +) -> Result { + render_agent_yaml_mapping_update(existing, "DeepSeek Harness credentials", |root| { + root.insert( + yaml_key(DEEPSEEK_HARNESS_CREDENTIAL), + serde_norway::Value::String(api_key.to_string()), + ); + Ok(()) + }) +} + +pub(crate) fn write_deepseek_harness_file( + path: &Path, + content: &[u8], + owner_only: bool, +) -> Result<(), String> { + write_bytes_directly(path, content)?; + #[cfg(unix)] + if owner_only { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| { + format!( + "设置 DeepSeek Harness 凭据权限失败 {}: {error}", + path_to_string(path) + ) + })?; + } + #[cfg(not(unix))] + let _ = owner_only; + Ok(()) +} + +pub(crate) fn write_agent_configuration_file( + client: AgentClient, + path: &Path, + content: &[u8], +) -> Result<(), String> { + if client == AgentClient::DeepSeekHarness { + let owner_only = path.file_name().and_then(|name| name.to_str()) + == Some(DEEPSEEK_HARNESS_CREDENTIALS_FILE); + write_deepseek_harness_file(path, content, owner_only) + } else { + write_bytes_directly(path, content) + } +} + pub(crate) fn build_claude_agent_config( existing: Option<&str>, base_url: &str, @@ -967,9 +1173,105 @@ pub(crate) fn remove_agent_managed_configuration( AgentClient::OpenCode => remove_opencode_managed_configuration(paths), AgentClient::OpenClaw => remove_openclaw_managed_configuration(paths), AgentClient::Hermes => remove_hermes_managed_configuration(paths), + AgentClient::DeepSeekHarness => remove_deepseek_harness_managed_configuration(paths), } } +pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::Mapping) -> bool { + let mut changed = false; + let mut remove_llm = false; + if let Some(llm) = root + .get_mut(yaml_key("llm-pi-ai")) + .and_then(serde_norway::Value::as_mapping_mut) + { + let mut remove_providers = false; + if let Some(providers) = llm + .get_mut(yaml_key("providers")) + .and_then(serde_norway::Value::as_mapping_mut) + { + changed |= providers + .remove(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID)) + .is_some(); + remove_providers = providers.is_empty(); + } + if remove_providers { + llm.remove(yaml_key("providers")); + } + remove_llm = llm.is_empty(); + } + if remove_llm { + root.remove(yaml_key("llm-pi-ai")); + } + let managed_selection = root + .get(yaml_key("agent-default-model")) + .and_then(serde_norway::Value::as_mapping) + .and_then(|selection| yaml_mapping_value(selection, "provider")) + .and_then(serde_norway::Value::as_str) + == Some(DEEPSEEK_HARNESS_PROVIDER_ID); + if managed_selection { + root.remove(yaml_key("agent-default-model")); + changed = true; + } + changed +} + +pub(crate) fn remove_deepseek_harness_managed_configuration( + paths: &[PathBuf], +) -> Result, String> { + if paths.len() != 2 { + return Err("DeepSeek Harness 配置路径数量无效".to_string()); + } + let mut changed_paths = Vec::new(); + if paths[0].is_file() { + let current = fs::read_to_string(&paths[0]) + .map_err(|error| format!("读取 DeepSeek Harness settings 失败: {error}"))?; + let mut changed = false; + let rendered = render_agent_yaml_mapping_update( + Some(¤t), + "DeepSeek Harness settings", + |root| { + changed = remove_deepseek_harness_settings_fields(root); + Ok(()) + }, + )?; + if changed { + let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness settings")?; + if root.is_empty() { + fs::remove_file(&paths[0]) + .map_err(|error| format!("删除空的 DeepSeek Harness settings 失败: {error}"))?; + } else { + write_bytes_directly(&paths[0], rendered.as_bytes())?; + } + changed_paths.push(path_to_string(&paths[0])); + } + } + if paths[1].is_file() { + let current = fs::read_to_string(&paths[1]) + .map_err(|error| format!("读取 DeepSeek Harness credentials 失败: {error}"))?; + let mut changed = false; + let rendered = render_agent_yaml_mapping_update( + Some(¤t), + "DeepSeek Harness credentials", + |root| { + changed = root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some(); + Ok(()) + }, + )?; + if changed { + let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness credentials")?; + if root.is_empty() { + fs::remove_file(&paths[1]).map_err(|error| { + format!("删除空的 DeepSeek Harness credentials 失败: {error}") + })?; + } else { + write_deepseek_harness_file(&paths[1], rendered.as_bytes(), true)?; + } + changed_paths.push(path_to_string(&paths[1])); + } + } + Ok(changed_paths) +} + pub(crate) fn restore_json_key( current: &mut serde_json::Map, original: Option<&serde_json::Map>, @@ -1442,6 +1744,93 @@ pub(crate) fn build_restored_hermes_config( render_updated_core_yaml(&mut document, updated).map(Some) } +pub(crate) fn restore_deepseek_harness_provider( + current: &mut serde_norway::Mapping, + original: Option<&serde_norway::Mapping>, +) -> Result<(), String> { + let original_llm = original + .and_then(|root| yaml_mapping_value(root, "llm-pi-ai")) + .and_then(serde_norway::Value::as_mapping); + let original_providers = original_llm + .and_then(|llm| yaml_mapping_value(llm, "providers")) + .and_then(serde_norway::Value::as_mapping); + let original_provider = original_providers + .and_then(|providers| yaml_mapping_value(providers, DEEPSEEK_HARNESS_PROVIDER_ID)) + .cloned(); + + if let Some(original_provider) = original_provider { + let llm = current + .entry(yaml_key("llm-pi-ai")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "当前 DeepSeek Harness llm-pi-ai 必须是映射".to_string())?; + let providers = llm + .entry(yaml_key("providers")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "当前 DeepSeek Harness llm-pi-ai.providers 必须是映射".to_string())?; + providers.insert(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID), original_provider); + } else { + let mut remove_llm = false; + if let Some(llm) = current + .get_mut(yaml_key("llm-pi-ai")) + .and_then(serde_norway::Value::as_mapping_mut) + { + let mut remove_providers = false; + if let Some(providers) = llm + .get_mut(yaml_key("providers")) + .and_then(serde_norway::Value::as_mapping_mut) + { + providers.remove(yaml_key(DEEPSEEK_HARNESS_PROVIDER_ID)); + remove_providers = providers.is_empty() && original_providers.is_none(); + } + if remove_providers { + llm.remove(yaml_key("providers")); + } + remove_llm = llm.is_empty() && original_llm.is_none(); + } + if remove_llm { + current.remove(yaml_key("llm-pi-ai")); + } + } + Ok(()) +} + +pub(crate) fn build_restored_deepseek_harness_settings( + current: &str, + original: Option<&str>, +) -> Result, String> { + let original_root = parse_agent_yaml_mapping(original, "原始 DeepSeek Harness settings")?; + let rendered = render_agent_yaml_mapping_update( + Some(current), + "当前 DeepSeek Harness settings", + |root| { + restore_deepseek_harness_provider(root, Some(&original_root))?; + restore_yaml_key(root, Some(&original_root), "agent-default-model"); + Ok(()) + }, + )?; + let root = parse_agent_yaml_mapping(Some(&rendered), "恢复后的 DeepSeek Harness settings")?; + Ok((!root.is_empty() || original.is_some()).then_some(rendered)) +} + +pub(crate) fn build_restored_deepseek_harness_credentials( + current: &str, + original: Option<&str>, +) -> Result, String> { + let original_root = parse_agent_yaml_mapping(original, "原始 DeepSeek Harness credentials")?; + let rendered = render_agent_yaml_mapping_update( + Some(current), + "当前 DeepSeek Harness credentials", + |root| { + restore_yaml_key(root, Some(&original_root), DEEPSEEK_HARNESS_CREDENTIAL); + Ok(()) + }, + )?; + let root = parse_agent_yaml_mapping(Some(&rendered), "恢复后的 DeepSeek Harness credentials")?; + Ok((!root.is_empty() || original.is_some()).then_some(rendered)) +} + pub(crate) fn agent_config_semantically_equal( client: AgentClient, actual: &str, @@ -1460,6 +1849,10 @@ pub(crate) fn agent_config_semantically_equal( serde_norway::from_str::(actual).ok() == serde_norway::from_str::(expected).ok() } + AgentClient::DeepSeekHarness => { + serde_norway::from_str::(actual).ok() + == serde_norway::from_str::(expected).ok() + } _ => { serde_json::from_str::(actual).ok() == serde_json::from_str::(expected).ok() @@ -1502,6 +1895,17 @@ pub(crate) fn build_agent_session_restored_bytes( AgentClient::OpenCode => build_restored_opencode_config(current, original)?, AgentClient::OpenClaw => build_restored_openclaw_config(current, original)?, AgentClient::Hermes => build_restored_hermes_config(current, original)?, + AgentClient::DeepSeekHarness => { + let index = paths + .iter() + .position(|candidate| candidate == path) + .ok_or_else(|| "DeepSeek Harness 恢复路径不匹配".to_string())?; + match index { + 0 => build_restored_deepseek_harness_settings(current, original)?, + 1 => build_restored_deepseek_harness_credentials(current, original)?, + _ => return Err("DeepSeek Harness 恢复路径索引无效".to_string()), + } + } }; if let (Some(restored), Some(original), Some(original_bytes)) = (restored.as_deref(), original, original_bytes) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index b3e3af2..2d9023f 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -25,9 +25,44 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![home.join(".config/opencode/opencode.json")], AgentClient::OpenClaw => vec![home.join(".openclaw/openclaw.json")], AgentClient::Hermes => vec![hermes_agent_config_path(home)], + AgentClient::DeepSeekHarness => { + let directory = deepseek_harness_home(home); + vec![ + directory.join(DEEPSEEK_HARNESS_SETTINGS_FILE), + directory.join(DEEPSEEK_HARNESS_CREDENTIALS_FILE), + ] + } } } +pub(crate) fn deepseek_harness_home(home: &Path) -> PathBuf { + env::var_os("DSH_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".dsh")) +} + +pub(crate) fn read_deepseek_harness_profile_version(home: &Path) -> Option { + let directory = deepseek_harness_home(home); + [ + directory.join("profiles/node_modules/@deepseek-ai/dsh/package.json"), + directory.join("node_modules/@deepseek-ai/dsh/package.json"), + ] + .into_iter() + .find_map(|path| read_package_json_version(&path)) +} + +pub(crate) fn read_package_json_version(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let package = serde_json::from_str::(&content).ok()?; + package + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|version| !version.is_empty()) + .map(str::to_string) +} + pub(crate) fn pi_agent_directory(home: &Path) -> PathBuf { env::var_os("PI_CODING_AGENT_DIR") .map(PathBuf::from) @@ -765,6 +800,7 @@ pub(crate) fn inspect_agent_config( let app_version = match client { AgentClient::ClaudeDesktop => read_claude_desktop_version(home), AgentClient::Codex => read_codex_app_version(home), + AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); @@ -902,6 +938,8 @@ pub(crate) fn inspect_agent_managed_config( .map(|(configured, model)| (configured, model, false)), AgentClient::Hermes => inspect_hermes_agent_config(&paths[0], port, api_key) .map(|(configured, model)| (configured, model, false)), + AgentClient::DeepSeekHarness => inspect_deepseek_harness_config(paths, port, api_key) + .map(|(configured, model)| (configured, model, false)), } } @@ -1020,6 +1058,7 @@ pub(crate) fn agent_has_managed_marker( == Some(MANAGED_AGENT_PROVIDER_ID); Ok(provider_exists && model_selected) } + AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), } } @@ -2277,3 +2316,91 @@ pub(crate) fn inspect_hermes_agent_config( .map(str::to_string); Ok((configured, model)) } + +pub(crate) fn inspect_deepseek_harness_config( + paths: &[PathBuf], + port: u16, + api_key: &str, +) -> Result<(bool, Option), String> { + if paths.len() != 2 { + return Err("DeepSeek Harness 配置路径数量无效".to_string()); + } + let settings = read_agent_yaml_mapping_or_empty(&paths[0], "DeepSeek Harness settings")?; + let credentials = read_agent_yaml_mapping_or_empty(&paths[1], "DeepSeek Harness credentials")?; + let provider = yaml_mapping_value(&settings, "llm-pi-ai") + .and_then(serde_norway::Value::as_mapping) + .and_then(|section| yaml_mapping_value(section, "providers")) + .and_then(serde_norway::Value::as_mapping) + .and_then(|providers| yaml_mapping_value(providers, DEEPSEEK_HARNESS_PROVIDER_ID)) + .and_then(serde_norway::Value::as_mapping); + let selection = yaml_mapping_value(&settings, "agent-default-model") + .and_then(serde_norway::Value::as_mapping); + let selected_provider = selection + .and_then(|selection| yaml_mapping_value(selection, "provider")) + .and_then(serde_norway::Value::as_str); + let model = (selected_provider == Some(DEEPSEEK_HARNESS_PROVIDER_ID)) + .then(|| { + selection + .and_then(|selection| yaml_mapping_value(selection, "model")) + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_string) + }) + .flatten(); + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let credential = yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL) + .and_then(serde_norway::Value::as_str); + let model_is_published = model.as_deref().is_some_and(|selected| { + provider + .and_then(|provider| yaml_mapping_value(provider, "models")) + .and_then(serde_norway::Value::as_sequence) + .is_some_and(|models| { + models.iter().any(|model| { + model + .as_mapping() + .and_then(|model| yaml_mapping_value(model, "id")) + .and_then(serde_norway::Value::as_str) + .is_some_and(|id| id.eq_ignore_ascii_case(selected)) + }) + }) + }); + let configured = provider + .and_then(|provider| yaml_mapping_value(provider, "apiKeyEnv")) + .and_then(serde_norway::Value::as_str) + == Some(DEEPSEEK_HARNESS_CREDENTIAL) + && provider + .and_then(|provider| yaml_mapping_value(provider, "api")) + .and_then(serde_norway::Value::as_str) + == Some("openai-completions") + && provider + .and_then(|provider| yaml_mapping_value(provider, "baseURL")) + .and_then(serde_norway::Value::as_str) + == Some(expected_base.as_str()) + && selected_provider == Some(DEEPSEEK_HARNESS_PROVIDER_ID) + && model_is_published + && credential == Some(api_key); + Ok((configured, model)) +} + +pub(crate) fn deepseek_harness_has_managed_marker(paths: &[PathBuf]) -> Result { + if paths.len() != 2 { + return Err("DeepSeek Harness 配置路径数量无效".to_string()); + } + let settings = read_agent_yaml_mapping_or_empty(&paths[0], "DeepSeek Harness settings")?; + let credentials = read_agent_yaml_mapping_or_empty(&paths[1], "DeepSeek Harness credentials")?; + let provider_exists = yaml_mapping_value(&settings, "llm-pi-ai") + .and_then(serde_norway::Value::as_mapping) + .and_then(|section| yaml_mapping_value(section, "providers")) + .and_then(serde_norway::Value::as_mapping) + .is_some_and(|providers| { + yaml_mapping_value(providers, DEEPSEEK_HARNESS_PROVIDER_ID).is_some() + }); + let default_selected = yaml_mapping_value(&settings, "agent-default-model") + .and_then(serde_norway::Value::as_mapping) + .and_then(|selection| yaml_mapping_value(selection, "provider")) + .and_then(serde_norway::Value::as_str) + == Some(DEEPSEEK_HARNESS_PROVIDER_ID); + let credential_exists = yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL).is_some(); + Ok(provider_exists || default_selected || credential_exists) +} diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index c25418b..6c613fa 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -724,6 +724,10 @@ pub(crate) fn fresh_agent_contents_with_oauth( model, models, )?]), + AgentClient::DeepSeekHarness => Ok(vec![ + build_deepseek_harness_settings(None, &openai_base, model, models)?, + build_deepseek_harness_credentials(None, api_key)?, + ]), } } @@ -742,6 +746,10 @@ pub(crate) fn agent_contents_equal(client: AgentClient, actual: &str, expected: serde_yaml::from_str::(actual).ok() == serde_yaml::from_str::(expected).ok() } + AgentClient::DeepSeekHarness => { + serde_norway::from_str::(actual).ok() + == serde_norway::from_str::(expected).ok() + } _ => { serde_json::from_str::(actual).ok() == serde_json::from_str::(expected).ok() @@ -1187,15 +1195,23 @@ pub(crate) fn dated_agent_backup_path(path: &Path) -> Result { } pub(crate) fn prepare_agent_session_backups( + client: AgentClient, updates: &[AgentFileUpdate], ) -> Result, String> { - let mut backups = Vec::with_capacity(updates.len()); + let mut backups: Vec = Vec::with_capacity(updates.len()); let mut renamed = Vec::new(); for update in updates { let existed_before = update.path.is_file(); let backup_path = dated_agent_backup_path(&update.path)?; if existed_before { if backup_path.exists() { + if client == AgentClient::DeepSeekHarness { + for backup in &backups { + if backup.existed_before { + let _ = fs::remove_file(&backup.backup_path); + } + } + } for (path, backup) in renamed.iter().rev() { let _ = fs::rename(backup, path); } @@ -1204,13 +1220,27 @@ pub(crate) fn prepare_agent_session_backups( path_to_string(&backup_path) )); } - fs::rename(&update.path, &backup_path).map_err(|error| { - format!( - "重命名原智能体配置为备份失败 {}: {error}", - path_to_string(&update.path) - ) - })?; - renamed.push((update.path.clone(), backup_path.clone())); + if client == AgentClient::DeepSeekHarness { + if let Err(error) = fs::copy(&update.path, &backup_path) { + for backup in &backups { + if backup.existed_before { + let _ = fs::remove_file(&backup.backup_path); + } + } + return Err(format!( + "备份 DeepSeek Harness 配置失败 {}: {error}", + path_to_string(&update.path) + )); + } + } else { + fs::rename(&update.path, &backup_path).map_err(|error| { + format!( + "重命名原智能体配置为备份失败 {}: {error}", + path_to_string(&update.path) + ) + })?; + renamed.push((update.path.clone(), backup_path.clone())); + } } backups.push(AgentAppliedBackupFile { path: update.path.clone(), @@ -1284,7 +1314,7 @@ pub(crate) fn restore_agent_applied_state_configuration( let restore_result = (|| -> Result<(), String> { for (file, replacement) in state.backup_files.iter().zip(&replacements).rev() { if let Some(replacement) = replacement { - write_bytes_directly(&file.path, replacement)?; + write_agent_configuration_file(client, &file.path, replacement)?; } else if file.path.exists() { fs::remove_file(&file.path).map_err(|error| { format!( @@ -1327,12 +1357,13 @@ pub(crate) fn restore_agent_applied_state_configuration( Ok(()) } -pub(crate) fn agent_clients_restored_on_exit() -> [AgentClient; 4] { +pub(crate) fn agent_clients_restored_on_exit() -> [AgentClient; 5] { [ AgentClient::Codex, AgentClient::OpenCode, AgentClient::OpenClaw, AgentClient::Hermes, + AgentClient::DeepSeekHarness, ] } @@ -1382,7 +1413,7 @@ pub(crate) fn commit_agent_configuration( if let Some(state) = existing_state.filter(|state| !state.backup_files.is_empty()) { state.backup_files } else { - prepare_agent_session_backups(updates)? + prepare_agent_session_backups(client, updates)? }; let transaction = (|| -> Result, String> { @@ -1392,7 +1423,7 @@ pub(crate) fn commit_agent_configuration( if read_agent_bytes(&update.path)?.as_deref() == Some(next) { continue; } - write_bytes_directly(&update.path, next)?; + write_agent_configuration_file(client, &update.path, next)?; changed.push(path_to_string(&update.path)); } let state = AgentAppliedState { @@ -1546,6 +1577,30 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( claude_code_model_mappings, claude_desktop_model_mappings, } = request; + if client == AgentClient::DeepSeekHarness { + let updates = build_agent_updates_with_oauth( + client, + home, + port, + api_key, + model, + AgentConfigurationOptions { + models, + codex_catalog, + oauth_configuration, + claude_code_model_mappings, + claude_desktop_model_mappings, + }, + )?; + return commit_agent_configuration( + client, + home, + model, + &updates, + "default", + claude_desktop_model_mappings, + ); + } let paths = agent_config_paths(client, home); let contents = fresh_agent_contents_with_oauth( client, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 900e587..79078e1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -130,6 +130,10 @@ const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30; const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0; const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; +const DEEPSEEK_HARNESS_PROVIDER_ID: &str = "easy-cliproxyapi"; +const DEEPSEEK_HARNESS_CREDENTIAL: &str = "EASYCLIPROXYAPI_API_KEY"; +const DEEPSEEK_HARNESS_SETTINGS_FILE: &str = "settings.yaml"; +const DEEPSEEK_HARNESS_CREDENTIALS_FILE: &str = ".credentials.yaml"; const PI_AGENT_ID: &str = "pi"; const PI_AGENT_NAME: &str = "Pi"; const PI_CLIPROXYAPI_PACKAGE: &str = "npm:@router-for-me/pi-cliproxyapi-provider"; @@ -967,6 +971,7 @@ enum AgentClient { OpenCode, OpenClaw, Hermes, + DeepSeekHarness, } #[derive(Clone, Debug)] @@ -986,6 +991,7 @@ impl AgentClient { "opencode" => Ok(Self::OpenCode), "openclaw" => Ok(Self::OpenClaw), "hermes" => Ok(Self::Hermes), + "deepseek-harness" => Ok(Self::DeepSeekHarness), _ => Err(format!("不支持的智能体客户端: {value}")), } } @@ -998,6 +1004,7 @@ impl AgentClient { Self::OpenCode => "opencode", Self::OpenClaw => "openclaw", Self::Hermes => "hermes", + Self::DeepSeekHarness => "deepseek-harness", } } @@ -1009,6 +1016,7 @@ impl AgentClient { Self::OpenCode => "OpenCode", Self::OpenClaw => "OpenClaw", Self::Hermes => "Hermes Agent", + Self::DeepSeekHarness => "DeepSeek Harness", } } @@ -1029,6 +1037,7 @@ impl AgentClient { Self::OpenCode => &["opencode"], Self::OpenClaw => &["openclaw"], Self::Hermes => &["hermes"], + Self::DeepSeekHarness => &["dsh"], } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 16ea01e..0b39fd8 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -781,6 +781,145 @@ fn exit_preserves_claude_client_configurations() { assert!(!restored.contains(&AgentClient::ClaudeDesktop)); assert!(restored.contains(&AgentClient::Codex)); assert!(restored.contains(&AgentClient::OpenCode)); + assert!(restored.contains(&AgentClient::DeepSeekHarness)); +} + +#[test] +fn deepseek_harness_settings_preserve_unrelated_sections_and_publish_models() { + let existing = r#"# keep this comment +ui-onboarding: + welcomeNoticeVersion: 3 +llm-pi-ai: + providers: + existing: + api: openai-completions + baseURL: https://example.invalid/v1 +agent-default-model: + provider: existing + model: old-model + reasoningEffort: high +"#; + let models = vec![ + AgentModelOption { + name: "gpt-selected".to_string(), + alias: Some("Selected Model".to_string()), + is_alias: false, + context_window: Some(272_000), + }, + AgentModelOption { + name: "gpt-other".to_string(), + alias: None, + is_alias: false, + context_window: None, + }, + ]; + let rendered = build_deepseek_harness_settings( + Some(existing), + "http://127.0.0.1:8317/v1", + "gpt-selected", + &models, + ) + .unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + + assert!(rendered.contains("# keep this comment")); + assert_eq!( + value["ui-onboarding"]["welcomeNoticeVersion"].as_u64(), + Some(3) + ); + assert_eq!( + value["llm-pi-ai"]["providers"]["existing"]["baseURL"].as_str(), + Some("https://example.invalid/v1") + ); + let provider = &value["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]; + assert_eq!( + provider["apiKeyEnv"].as_str(), + Some(DEEPSEEK_HARNESS_CREDENTIAL) + ); + assert_eq!(provider["api"].as_str(), Some("openai-completions")); + assert_eq!( + provider["baseURL"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); + assert_eq!(provider["models"][0]["id"].as_str(), Some("gpt-selected")); + assert_eq!( + provider["models"][0]["name"].as_str(), + Some("Selected Model") + ); + assert_eq!( + provider["models"][0]["contextWindow"].as_u64(), + Some(272_000) + ); + assert_eq!(provider["models"][1]["id"].as_str(), Some("gpt-other")); + assert_eq!( + value["agent-default-model"]["provider"].as_str(), + Some(DEEPSEEK_HARNESS_PROVIDER_ID) + ); + assert_eq!( + value["agent-default-model"]["model"].as_str(), + Some("gpt-selected") + ); + assert!(value["agent-default-model"] + .get("reasoningEffort") + .is_none()); +} + +#[test] +fn deepseek_harness_credentials_preserve_other_entries() { + let rendered = build_deepseek_harness_credentials( + Some("# existing key\nOTHER_API_KEY: other-secret\n"), + "cpa-secret", + ) + .unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + + assert!(rendered.contains("# existing key")); + assert_eq!(value["OTHER_API_KEY"].as_str(), Some("other-secret")); + assert_eq!( + value[DEEPSEEK_HARNESS_CREDENTIAL].as_str(), + Some("cpa-secret") + ); +} + +#[test] +fn deepseek_harness_inspection_requires_managed_route_selection_and_credential() { + let home = agent_test_home("deepseek-harness-inspection"); + let paths = vec![home.join("settings.yaml"), home.join(".credentials.yaml")]; + let models = test_agent_models(&["gpt-test"]); + fs::write( + &paths[0], + build_deepseek_harness_settings(None, "http://127.0.0.1:8317/v1", "gpt-test", &models) + .unwrap(), + ) + .unwrap(); + fs::write( + &paths[1], + build_deepseek_harness_credentials(None, "agent-key").unwrap(), + ) + .unwrap(); + + let (configured, model) = inspect_deepseek_harness_config(&paths, 8317, "agent-key").unwrap(); + assert!(configured); + assert_eq!(model.as_deref(), Some("gpt-test")); + assert!(deepseek_harness_has_managed_marker(&paths).unwrap()); + + let (wrong_key, _) = inspect_deepseek_harness_config(&paths, 8317, "different-key").unwrap(); + assert!(!wrong_key); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn deepseek_harness_profile_version_comes_from_installed_profile() { + let home = agent_test_home("deepseek-harness-version"); + let package = home.join(".dsh/profiles/node_modules/@deepseek-ai/dsh/package.json"); + fs::create_dir_all(package.parent().unwrap()).unwrap(); + fs::write(&package, r#"{"version":"0.1.0-rc.5"}"#).unwrap(); + + assert_eq!( + read_deepseek_harness_profile_version(&home).as_deref(), + Some("0.1.0-rc.5") + ); + fs::remove_dir_all(home).unwrap(); } #[test] diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 122824d..d11c1aa 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -204,6 +204,85 @@ fn agent_configuration_is_restored_from_the_dated_session_backup_on_exit() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn deepseek_harness_session_restore_keeps_concurrent_unmanaged_yaml_changes() { + let home = agent_test_home("deepseek-harness-session-restore"); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + let original_settings = r#"# original settings +llm-pi-ai: + providers: + existing: + api: openai-completions + baseURL: https://example.invalid/v1 +agent-default-model: + provider: existing + model: old-model +"#; + let original_credentials = "OTHER_API_KEY: old-secret\n"; + fs::write(&paths[0], original_settings).unwrap(); + fs::write(&paths[1], original_credentials).unwrap(); + let models = test_agent_models(&["gpt-test", "gpt-other"]); + + apply_agent_configuration( + AgentClient::DeepSeekHarness, + &home, + 8317, + "agent-key", + "gpt-test", + &models, + None, + ) + .unwrap(); + let (configured, model) = inspect_deepseek_harness_config(&paths, 8317, "agent-key").unwrap(); + assert!(configured); + assert_eq!(model.as_deref(), Some("gpt-test")); + + let settings = fs::read_to_string(&paths[0]).unwrap(); + let settings = + render_agent_yaml_mapping_update(Some(&settings), "test Harness settings", |root| { + root.insert( + yaml_key("added-after-apply"), + serde_norway::Value::Bool(true), + ); + Ok(()) + }) + .unwrap(); + fs::write(&paths[0], settings).unwrap(); + let credentials = fs::read_to_string(&paths[1]).unwrap(); + let credentials = + render_agent_yaml_mapping_update(Some(&credentials), "test Harness credentials", |root| { + root.insert( + yaml_key("NEW_API_KEY"), + serde_norway::Value::String("new-secret".to_string()), + ); + Ok(()) + }) + .unwrap(); + fs::write(&paths[1], credentials).unwrap(); + + restore_agent_session_configuration(AgentClient::DeepSeekHarness, &home).unwrap(); + let settings: serde_norway::Value = + serde_norway::from_str(&fs::read_to_string(&paths[0]).unwrap()).unwrap(); + let credentials: serde_norway::Value = + serde_norway::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); + assert_eq!( + settings["agent-default-model"]["provider"].as_str(), + Some("existing") + ); + assert!(settings["llm-pi-ai"]["providers"] + .get(DEEPSEEK_HARNESS_PROVIDER_ID) + .is_none()); + assert_eq!(settings["added-after-apply"].as_bool(), Some(true)); + assert_eq!(credentials["OTHER_API_KEY"].as_str(), Some("old-secret")); + assert_eq!(credentials["NEW_API_KEY"].as_str(), Some("new-secret")); + assert!(credentials.get(DEEPSEEK_HARNESS_CREDENTIAL).is_none()); + assert!(!dated_agent_backup_path(&paths[0]).unwrap().exists()); + assert!(!dated_agent_backup_path(&paths[1]).unwrap().exists()); + assert!(!agent_state_path(&paths).unwrap().exists()); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() { let home = agent_test_home("opencode-runtime-edit-merge"); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 17a03c7..e3c0f66 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -465,6 +465,7 @@ export const jaOverrides = { 'agents.description.opencode': 'CPA OpenAI Compatible プロバイダーを使用', 'agents.description.openclaw': 'CPA モデルプロバイダーと既定モデルを使用', 'agents.description.hermes': 'CPA カスタムプロバイダーを使用', + 'agents.description.deepseekHarness': 'Harness のネイティブ DeepSeek 設定と分離された OpenAI 互換ルートで CPA を使用', 'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます', 'agents.list.detecting': '検出中', 'agents.list.unsupported': '現在のプラットフォームでは非対応', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0aa5544..067af82 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -465,6 +465,7 @@ export const en: Record = { 'agents.description.opencode': 'Uses the CPA OpenAI-compatible provider', 'agents.description.openclaw': 'Uses the CPA model provider and default model', 'agents.description.hermes': 'Uses the CPA custom provider', + 'agents.description.deepseekHarness': 'Uses CPA through an isolated, OpenAI-compatible Harness route', 'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider', 'agents.list.detecting': 'Detecting', 'agents.list.unsupported': 'Unsupported on this platform', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e0e9c6c..89e90d8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -463,6 +463,7 @@ export const zhCN = { 'agents.description.opencode': '使用 CPA OpenAI Compatible provider', 'agents.description.openclaw': '使用 CPA 模型供应商和默认模型', 'agents.description.hermes': '使用 CPA custom provider', + 'agents.description.deepseekHarness': '通过通用 OpenAI 兼容路由使用 CPA,并与 Harness 原生 DeepSeek 配置隔离', 'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI provider 动态加载模型', 'agents.list.detecting': '正在检测', 'agents.list.unsupported': '当前平台不支持', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index ef7f5a9..d76b90a 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -25,6 +25,7 @@ import { } from 'lucide-react'; import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; +import deepseekIcon from '../assets/icons/deepseek.svg'; import hermesIcon from '../assets/icons/hermes.png'; import openclawIcon from '../assets/icons/openclaw.svg'; import opencodeIcon from '../assets/icons/opencode.svg'; @@ -54,6 +55,7 @@ type AgentClientId = | 'opencode' | 'openclaw' | 'hermes' + | 'deepseek-harness' | 'pi'; type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; @@ -168,7 +170,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.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.pi'; }; type AgentSubpageId = 'core' | 'sessions'; @@ -216,6 +218,12 @@ const agentDefinitions: AgentDefinition[] = [ icon: hermesIcon, descriptionKey: 'agents.description.hermes', }, + { + id: 'deepseek-harness', + name: 'DeepSeek Harness', + icon: deepseekIcon, + descriptionKey: 'agents.description.deepseekHarness', + }, { id: 'pi', name: 'Pi', diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 18086a9..116f1d3 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -5,6 +5,7 @@ export type AgentConfigurationClientId = | 'opencode' | 'openclaw' | 'hermes' + | 'deepseek-harness' | 'pi'; export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid';