From 70ed5bc7f20d92098f20af6d974ed91f61754be8 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 8 Sep 2026 19:37:24 +0800 Subject: [PATCH] feat(agents): update DeepSeek Harness integration for current DSH - write credentials using the versioned refs schema - repair misplaced EasyCLIProxyAPI credential keys - add Web, Headless, ACP, SDK and custom profile launch options - track and safely stop managed DSH process trees - detect occupied Web ports before launch - add localized UI and regression tests Fixes #207 --- src-tauri/src/agents/configuration.rs | 133 ++++- src-tauri/src/agents/discovery.rs | 35 +- src-tauri/src/agents/launch.rs | 587 ++++++++++++++++++++- src-tauri/src/main.rs | 42 ++ src-tauri/src/tests/agent_configuration.rs | 111 +++- src-tauri/src/tests/agent_state.rs | 23 +- src/i18n/ja.ts | 37 ++ src/i18n/locales/en.ts | 37 ++ src/i18n/locales/zh-CN.ts | 37 ++ src/pages/AgentsPage.tsx | 302 ++++++++++- src/services/deepSeekHarnessLaunch.ts | 98 ++++ src/styles.css | 82 ++- tests/deepSeekHarnessLaunch.test.ts | 59 +++ 13 files changed, 1537 insertions(+), 46 deletions(-) create mode 100644 src/services/deepSeekHarnessLaunch.ts create mode 100644 tests/deepSeekHarnessLaunch.test.ts diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 4ee1d60..db51b6d 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -425,7 +425,8 @@ pub(crate) fn build_deepseek_harness_credentials( api_key: &str, ) -> Result { render_agent_yaml_mapping_update(existing, "DeepSeek Harness credentials", |root| { - root.insert( + let refs = deepseek_harness_credentials_refs_mut(root, "DeepSeek Harness credentials")?; + refs.insert( yaml_key(DEEPSEEK_HARNESS_CREDENTIAL), serde_norway::Value::String(api_key.to_string()), ); @@ -433,6 +434,105 @@ pub(crate) fn build_deepseek_harness_credentials( }) } +fn deepseek_harness_credentials_version_is_supported(value: &serde_norway::Value) -> bool { + value.as_u64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION) + || value.as_i64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION as i64) +} + +fn validate_deepseek_harness_versioned_credentials( + root: &serde_norway::Mapping, + label: &str, +) -> Result<(), String> { + let version = + yaml_mapping_value(root, "version").ok_or_else(|| format!("{label} 缺少 version 字段"))?; + if !deepseek_harness_credentials_version_is_supported(version) { + return Err(format!( + "{label} version 必须为 {DEEPSEEK_HARNESS_CREDENTIALS_VERSION}" + )); + } + for key in root.keys() { + let Some(key) = key.as_str() else { + return Err(format!("{label} 顶层字段名必须是字符串")); + }; + if !matches!( + key, + "version" | "refs" | "records" | DEEPSEEK_HARNESS_CREDENTIAL + ) { + return Err(format!("{label} 包含未知顶层字段 {key}")); + } + } + for section in ["refs", "records"] { + if yaml_mapping_value(root, section).is_some_and(|value| !value.is_mapping()) { + return Err(format!("{label} {section} 必须是映射")); + } + } + Ok(()) +} + +fn deepseek_harness_credentials_refs_mut<'a>( + root: &'a mut serde_norway::Mapping, + label: &str, +) -> Result<&'a mut serde_norway::Mapping, String> { + if root.is_empty() { + root.insert( + yaml_key("version"), + serde_norway::Value::Number(DEEPSEEK_HARNESS_CREDENTIALS_VERSION.into()), + ); + } else { + validate_deepseek_harness_versioned_credentials(root, label)?; + // Repair files written by older EasyCLIProxyAPI builds, which placed + // this managed reference beside version/refs/records. + root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)); + } + + root.entry(yaml_key("refs")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| format!("{label} refs 必须是映射")) +} + +fn remove_deepseek_harness_managed_credential( + root: &mut serde_norway::Mapping, + label: &str, +) -> Result { + if root.is_empty() { + return Ok(false); + } + + validate_deepseek_harness_versioned_credentials(root, label)?; + let mut changed = root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some(); + let mut remove_refs = false; + if let Some(refs) = root + .get_mut(yaml_key("refs")) + .and_then(serde_norway::Value::as_mapping_mut) + { + changed |= refs.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some(); + remove_refs = refs.is_empty(); + } + if remove_refs { + root.remove(yaml_key("refs")); + } + Ok(changed) +} + +fn deepseek_harness_credentials_document_is_empty(root: &serde_norway::Mapping) -> bool { + root.iter().all(|(key, value)| match key.as_str() { + Some("version") => deepseek_harness_credentials_version_is_supported(value), + Some("refs" | "records") => value + .as_mapping() + .is_some_and(serde_norway::Mapping::is_empty), + _ => false, + }) +} + +fn deepseek_harness_original_credential( + root: &serde_norway::Mapping, +) -> Option<&serde_norway::Value> { + yaml_mapping_value(root, "refs") + .and_then(serde_norway::Value::as_mapping) + .and_then(|refs| yaml_mapping_value(refs, DEEPSEEK_HARNESS_CREDENTIAL)) +} + pub(crate) fn write_deepseek_harness_file( path: &Path, content: &[u8], @@ -1526,13 +1626,16 @@ pub(crate) fn remove_deepseek_harness_managed_configuration( Some(¤t), "DeepSeek Harness credentials", |root| { - changed = root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)).is_some(); + changed = remove_deepseek_harness_managed_credential( + root, + "DeepSeek Harness credentials", + )?; Ok(()) }, )?; if changed { let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness credentials")?; - if root.is_empty() { + if deepseek_harness_credentials_document_is_empty(&root) { fs::remove_file(&paths[1]).map_err(|error| { format!("删除空的 DeepSeek Harness credentials 失败: {error}") })?; @@ -2352,16 +2455,36 @@ pub(crate) fn build_restored_deepseek_harness_credentials( original: Option<&str>, ) -> Result, String> { let original_root = parse_agent_yaml_mapping(original, "原始 DeepSeek Harness credentials")?; + let original_credential = deepseek_harness_original_credential(&original_root).cloned(); + let original_had_refs = yaml_mapping_value(&original_root, "refs").is_some(); let rendered = render_agent_yaml_mapping_update( Some(current), "当前 DeepSeek Harness credentials", |root| { - restore_yaml_key(root, Some(&original_root), DEEPSEEK_HARNESS_CREDENTIAL); + let refs = + deepseek_harness_credentials_refs_mut(root, "当前 DeepSeek Harness credentials")?; + if let Some(value) = original_credential { + refs.insert(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL), value); + } else { + refs.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL)); + } + let remove_refs = refs.is_empty() && !original_had_refs; + if remove_refs { + root.remove(yaml_key("refs")); + } Ok(()) }, )?; let root = parse_agent_yaml_mapping(Some(&rendered), "恢复后的 DeepSeek Harness credentials")?; - Ok((!root.is_empty() || original.is_some()).then_some(rendered)) + if deepseek_harness_credentials_document_is_empty(&root) { + if let Some(original) = original.filter(|_| original_root.is_empty()) { + return Ok(Some(original.to_string())); + } + if original.is_none() { + return Ok(None); + } + } + Ok(Some(rendered)) } pub(crate) fn agent_config_semantically_equal( diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 5dcb7db..69cf1aa 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1110,6 +1110,15 @@ pub(crate) fn agent_launch_targets( }); } } + AgentClient::DeepSeekHarness => { + if let Some(executable) = executable { + targets.push(AgentLaunchTarget { + id: "cli".to_string(), + label: "DeepSeek Harness Web".to_string(), + detail: format!("{} web", path_to_string(executable)), + }); + } + } _ => { if let Some(executable) = executable { targets.push(AgentLaunchTarget { @@ -3751,7 +3760,10 @@ pub(crate) fn inspect_deepseek_harness_config( }) .flatten(); let expected_base = format!("{}/v1", managed_core_loopback_origin(port)); - let credential = yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL) + let credentials_layout_supported = deepseek_harness_credentials_layout_supported(&credentials); + let credential = yaml_mapping_value(&credentials, "refs") + .and_then(serde_norway::Value::as_mapping) + .and_then(|refs| yaml_mapping_value(refs, DEEPSEEK_HARNESS_CREDENTIAL)) .and_then(serde_norway::Value::as_str); let model_is_published = model.as_deref().is_some_and(|selected| { provider @@ -3781,10 +3793,26 @@ pub(crate) fn inspect_deepseek_harness_config( == Some(expected_base.as_str()) && selected_provider == Some(DEEPSEEK_HARNESS_PROVIDER_ID) && model_is_published + && credentials_layout_supported && credential == Some(api_key); Ok((configured, model)) } +fn deepseek_harness_credentials_layout_supported(credentials: &serde_norway::Mapping) -> bool { + let Some(version) = yaml_mapping_value(credentials, "version") else { + return false; + }; + let version_supported = version.as_u64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION) + || version.as_i64() == Some(DEEPSEEK_HARNESS_CREDENTIALS_VERSION as i64); + version_supported + && credentials + .keys() + .all(|key| matches!(key.as_str(), Some("version" | "refs" | "records"))) + && ["refs", "records"].into_iter().all(|section| { + yaml_mapping_value(credentials, section).is_none_or(serde_norway::Value::is_mapping) + }) +} + pub(crate) fn deepseek_harness_has_managed_marker(paths: &[PathBuf]) -> Result { if paths.len() != 2 { return Err("DeepSeek Harness 配置路径数量无效".to_string()); @@ -3803,6 +3831,9 @@ pub(crate) fn deepseek_harness_has_managed_marker(paths: &[PathBuf]) -> Result String { pub(crate) fn launch_agent( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, + deepseek_process_state: tauri::State<'_, DeepSeekHarnessProcessState>, client: String, target: Option, working_directory: Option, + deepseek_harness_options: Option, ) -> Result<(), String> { let home = app .path() @@ -124,6 +127,10 @@ pub(crate) fn launch_agent( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); + if deepseek_harness_options.is_some() && !client.trim().eq_ignore_ascii_case("deepseek-harness") + { + return Err("DeepSeek Harness 启动选项不能用于其他客户端".to_string()); + } if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) { if requested_target.is_some_and(|value| value != "cli") { @@ -142,6 +149,7 @@ pub(crate) fn launch_agent( PI_AGENT_NAME, &launch_directory, &[], + &[], &terminal, ); } @@ -176,15 +184,30 @@ pub(crate) fn launch_agent( let executable = find_agent_executable(client, &home) .ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?; let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?; + if client == AgentClient::DeepSeekHarness { + let mode = deepseek_harness_launch_mode(deepseek_harness_options.as_ref())?; + let arguments = + agent_cli_launch_arguments(client, deepseek_harness_options.as_ref())?; + return launch_managed_deepseek_harness( + deepseek_process_state.inner(), + &executable, + &launch_directory, + &arguments, + &mode, + deepseek_harness_options.as_ref(), + ); + } let environment_to_remove = if client == AgentClient::ClaudeCode { &["ANTHROPIC_API_KEY"][..] } else { &[] }; + let arguments = agent_cli_launch_arguments(client, deepseek_harness_options.as_ref())?; launch_cli_agent( &executable, client.name(), &launch_directory, + &arguments, environment_to_remove, &terminal, ) @@ -194,6 +217,372 @@ pub(crate) fn launch_agent( } } +fn deepseek_harness_launch_mode( + options: Option<&DeepSeekHarnessLaunchOptions>, +) -> Result { + let mode = options + .map(|options| options.mode.trim().to_ascii_lowercase()) + .filter(|mode| !mode.is_empty()) + .unwrap_or_else(|| "web".to_string()); + if matches!( + mode.as_str(), + "web" | "headless" | "acp" | "sdk" | "sdk-minimal" | "custom" + ) { + Ok(mode) + } else { + Err(format!("不支持的 DeepSeek Harness 启动模式: {mode}")) + } +} + +fn agent_cli_launch_arguments( + client: AgentClient, + deepseek_harness_options: Option<&DeepSeekHarnessLaunchOptions>, +) -> Result, String> { + if client != AgentClient::DeepSeekHarness { + if deepseek_harness_options.is_some() { + return Err("DeepSeek Harness 启动选项不能用于其他客户端".to_string()); + } + return Ok(Vec::new()); + } + + build_deepseek_harness_launch_arguments(deepseek_harness_options) +} + +fn build_deepseek_harness_launch_arguments( + options: Option<&DeepSeekHarnessLaunchOptions>, +) -> Result, String> { + let mode = deepseek_harness_launch_mode(options)?; + let profile = match mode.as_str() { + "web" | "headless" | "acp" | "sdk" | "sdk-minimal" => mode.as_str(), + "custom" => validate_deepseek_harness_profile( + options + .and_then(|options| options.profile.as_deref()) + .unwrap_or_default(), + )?, + _ => return Err(format!("不支持的 DeepSeek Harness 启动模式: {mode}")), + }; + let mut arguments = if profile == "web" { + vec!["web".to_string()] + } else { + vec!["--profile".to_string(), profile.to_string()] + }; + + if let Some(options) = options { + for patch in &options.patches { + arguments.push("--patch".to_string()); + arguments.push(validate_deepseek_harness_argument(patch, "patch 路径")?); + } + } + + match mode.as_str() { + "web" => { + if let Some(host) = options + .and_then(|options| options.web_host.as_deref()) + .map(str::trim) + .filter(|host| !host.is_empty()) + { + arguments.push("--host".to_string()); + arguments.push(validate_deepseek_harness_argument(host, "Web host")?); + } + if let Some(port) = options.and_then(|options| options.web_port) { + arguments.push("--port".to_string()); + arguments.push(port.to_string()); + } + if options.and_then(|options| options.open_browser) == Some(false) { + arguments.push("--no-open".to_string()); + } + if let Some(options) = options { + for authority in &options.trusted_hosts { + arguments.push("--trusted-host".to_string()); + arguments.push(validate_deepseek_harness_argument( + authority, + "trusted host", + )?); + } + } + } + "headless" => { + let task = options + .and_then(|options| options.task.as_deref()) + .unwrap_or_default(); + arguments.push(validate_deepseek_harness_argument(task, "Headless 任务")?); + } + "acp" | "sdk" | "sdk-minimal" | "custom" => {} + _ => unreachable!(), + } + Ok(arguments) +} + +impl DeepSeekHarnessProcessState { + fn status(&self) -> Result { + let mut process = self + .process + .lock() + .map_err(|_| "DeepSeek Harness 进程状态锁已损坏".to_string())?; + let Some(managed) = process.as_mut() else { + return Ok(DeepSeekHarnessProcessStatus::default()); + }; + match managed.child.try_wait() { + Ok(None) => Ok(DeepSeekHarnessProcessStatus { + running: true, + pid: Some(managed.child.id()), + mode: Some(managed.mode.clone()), + }), + Ok(Some(_)) => { + *process = None; + Ok(DeepSeekHarnessProcessStatus::default()) + } + Err(error) => Err(format!("检查 DeepSeek Harness 进程状态失败: {error}")), + } + } +} + +#[tauri::command] +pub(crate) fn get_deepseek_harness_process_status( + process_state: tauri::State<'_, DeepSeekHarnessProcessState>, +) -> Result { + process_state.status() +} + +#[tauri::command] +pub(crate) fn stop_deepseek_harness_process( + process_state: tauri::State<'_, DeepSeekHarnessProcessState>, +) -> Result { + stop_managed_deepseek_harness(process_state.inner())?; + process_state.status() +} + +fn launch_managed_deepseek_harness( + process_state: &DeepSeekHarnessProcessState, + executable: &Path, + working_directory: &Path, + arguments: &[String], + mode: &str, + options: Option<&DeepSeekHarnessLaunchOptions>, +) -> Result<(), String> { + let mut process = process_state + .process + .lock() + .map_err(|_| "DeepSeek Harness 进程状态锁已损坏".to_string())?; + if let Some(managed) = process.as_mut() { + match managed.child.try_wait() { + Ok(None) => { + return Err(format!( + "DeepSeek Harness 已在运行(PID {}),请先关闭当前进程", + managed.child.id() + )); + } + Ok(Some(_)) => *process = None, + Err(error) => return Err(format!("检查 DeepSeek Harness 进程状态失败: {error}")), + } + } + + if mode == "web" { + ensure_deepseek_harness_web_endpoint_available(options)?; + } + + let child = spawn_managed_deepseek_harness(executable, working_directory, arguments)?; + *process = Some(ManagedDeepSeekHarnessProcess { + child, + mode: mode.to_string(), + }); + Ok(()) +} + +fn ensure_deepseek_harness_web_endpoint_available( + options: Option<&DeepSeekHarnessLaunchOptions>, +) -> Result<(), String> { + let port = options + .and_then(|options| options.web_port) + .unwrap_or(DEEPSEEK_HARNESS_DEFAULT_WEB_PORT); + if port == 0 { + return Ok(()); + } + let host = options + .and_then(|options| options.web_host.as_deref()) + .map(str::trim) + .filter(|host| !host.is_empty()) + .unwrap_or("127.0.0.1"); + let listener = TcpListener::bind((host, port)).map_err(|error| { + if error.kind() == io::ErrorKind::AddrInUse { + format!( + "DeepSeek Harness Web 地址 {host}:{port} 已被占用,请关闭已有服务或选择其他端口" + ) + } else { + format!("无法使用 DeepSeek Harness Web 地址 {host}:{port}: {error}") + } + })?; + drop(listener); + Ok(()) +} + +fn spawn_managed_deepseek_harness( + executable: &Path, + working_directory: &Path, + arguments: &[String], +) -> Result { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + + const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + let mut command = windows_command_for_executable(executable, false); + command + .args(arguments) + .current_dir(working_directory) + .creation_flags(CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP); + return command + .spawn() + .map_err(|error| format!("启动 DeepSeek Harness 失败: {error}")); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + let mut command = Command::new(executable); + command + .args(arguments) + .current_dir(working_directory) + .process_group(0); + return command + .spawn() + .map_err(|error| format!("启动 DeepSeek Harness 失败: {error}")); + } + + #[cfg(not(any(windows, unix)))] + { + let _ = (executable, working_directory, arguments); + Err("当前平台不支持受控启动 DeepSeek Harness".to_string()) + } +} + +pub(crate) fn stop_managed_deepseek_harness( + process_state: &DeepSeekHarnessProcessState, +) -> Result<(), String> { + let mut process = process_state + .process + .lock() + .map_err(|_| "DeepSeek Harness 进程状态锁已损坏".to_string())?; + let Some(managed) = process.as_mut() else { + return Ok(()); + }; + if managed + .child + .try_wait() + .map_err(|error| format!("检查 DeepSeek Harness 进程状态失败: {error}"))? + .is_some() + { + *process = None; + return Ok(()); + } + terminate_deepseek_harness_process_tree(&mut managed.child)?; + *process = None; + Ok(()) +} + +fn terminate_deepseek_harness_process_tree(child: &mut Child) -> Result<(), String> { + #[cfg(windows)] + { + let process_id = child.id().to_string(); + let mut command = Command::new("taskkill"); + command + .args(["/PID", &process_id, "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + let status = command + .status() + .map_err(|error| format!("关闭 DeepSeek Harness 进程树失败: {error}"))?; + if !status.success() { + return Err(format!( + "关闭 DeepSeek Harness 进程树失败: PID {process_id}" + )); + } + let _ = child.wait(); + return Ok(()); + } + + #[cfg(unix)] + { + let process_group = format!("-{}", child.id()); + let term_status = Command::new("kill") + .args(["-TERM", &process_group]) + .status() + .map_err(|error| format!("关闭 DeepSeek Harness 进程组失败: {error}"))?; + if !term_status.success() { + return Err(format!( + "关闭 DeepSeek Harness 进程组失败: PID {}", + child.id() + )); + } + for _ in 0..20 { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => thread::sleep(Duration::from_millis(100)), + Err(error) => { + return Err(format!("检查 DeepSeek Harness 进程状态失败: {error}")); + } + } + } + let kill_status = Command::new("kill") + .args(["-KILL", &process_group]) + .status() + .map_err(|error| format!("强制关闭 DeepSeek Harness 进程组失败: {error}"))?; + if !kill_status.success() { + return Err(format!( + "强制关闭 DeepSeek Harness 进程组失败: PID {}", + child.id() + )); + } + child + .wait() + .map_err(|error| format!("等待 DeepSeek Harness 进程退出失败: {error}"))?; + return Ok(()); + } + + #[cfg(not(any(windows, unix)))] + { + child + .kill() + .map_err(|error| format!("关闭 DeepSeek Harness 进程失败: {error}"))?; + child + .wait() + .map_err(|error| format!("等待 DeepSeek Harness 进程退出失败: {error}"))?; + Ok(()) + } +} + +fn validate_deepseek_harness_profile(profile: &str) -> Result<&str, String> { + let profile = profile.trim(); + if profile.is_empty() { + return Err("请输入 DeepSeek Harness profile 名称".to_string()); + } + if matches!(profile, "." | "..") + || !profile.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + return Err( + "DeepSeek Harness profile 名称只能包含字母、数字、点、连字符和下划线".to_string(), + ); + } + Ok(profile) +} + +fn validate_deepseek_harness_argument(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{label}不能为空")); + } + if value.chars().any(char::is_control) { + return Err(format!("{label}不能包含控制字符")); + } + Ok(value.to_string()) +} + #[tauri::command] pub(crate) async fn restart_codex_app(app: tauri::AppHandle) -> Result<(), String> { let home = app @@ -715,6 +1104,7 @@ fn launch_cli_agent( executable: &Path, label: &str, working_directory: &Path, + arguments: &[String], environment_to_remove: &[&str], terminal: &str, ) -> Result<(), String> { @@ -723,11 +1113,19 @@ fn launch_cli_agent( .map(|key| format!("-u {}", shell_single_quote(key))) .collect::>() .join(" "); + let invocation = std::iter::once(shell_single_quote(&path_to_string(executable))) + .chain( + arguments + .iter() + .map(|argument| shell_single_quote(argument)), + ) + .collect::>() + .join(" "); let command_line = format!( "cd {} && exec env {} {}", shell_single_quote(&path_to_string(working_directory)), removals, - shell_single_quote(&path_to_string(executable)), + invocation, ); let script = if terminal == "iterm2" { format!( @@ -760,6 +1158,7 @@ fn launch_cli_agent( executable: &Path, label: &str, working_directory: &Path, + command_arguments: &[String], environment_to_remove: &[&str], terminal: &str, ) -> Result<(), String> { @@ -777,6 +1176,7 @@ fn launch_cli_agent( command .args(arguments) .arg(executable) + .args(command_arguments) .current_dir(working_directory) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -803,6 +1203,7 @@ fn launch_cli_agent( executable: &Path, label: &str, working_directory: &Path, + arguments: &[String], environment_to_remove: &[&str], terminal: &str, ) -> Result<(), String> { @@ -820,6 +1221,7 @@ fn launch_cli_agent( "auto" => { let mut command = windows_command_for_executable(executable, true); command + .args(arguments) .current_dir(working_directory) .creation_flags(CREATE_NEW_CONSOLE); command @@ -834,18 +1236,25 @@ fn launch_cli_agent( command .arg(windows_command_processor()) .args(["/D", "/K", "call"]) - .arg(windows_batch_executable_argument(executable)); + .arg(windows_batch_executable_argument(executable)) + .args(arguments); } else { - command.arg(executable); + command.arg(executable).args(arguments); } command } "powershell" => { let mut command = Command::new(windows_powershell_executable()); + let arguments = arguments + .iter() + .map(|argument| windows_powershell_single_quoted_literal(argument)) + .collect::>() + .join(" "); let script = format!( - "Set-Location -LiteralPath {}; & {}", + "Set-Location -LiteralPath {}; & {} {}", windows_powershell_single_quoted_literal(&path_to_string(working_directory)), windows_powershell_single_quoted_literal(&path_to_string(executable)), + arguments, ); command.args(["-NoLogo", "-NoProfile", "-NoExit", "-Command", &script]); command.creation_flags(CREATE_NEW_CONSOLE); @@ -853,10 +1262,15 @@ fn launch_cli_agent( } "cmd" => { let mut command = Command::new(windows_command_processor()); + let arguments = arguments + .iter() + .map(|argument| format!(" \"{}\"", argument.replace('"', "\"\""))) + .collect::(); let command_line = format!( - "cd /d \"{}\" && call \"{}\"", + "cd /d \"{}\" && call \"{}\"{}", path_to_string(working_directory).replace('"', "\"\""), - path_to_string(executable).replace('"', "\"\"") + path_to_string(executable).replace('"', "\"\""), + arguments, ); command.args(["/D", "/K", &command_line]); command.creation_flags(CREATE_NEW_CONSOLE); @@ -878,6 +1292,7 @@ fn launch_cli_agent( _executable: &Path, label: &str, _working_directory: &Path, + _arguments: &[String], _environment_to_remove: &[&str], _terminal: &str, ) -> Result<(), String> { @@ -888,6 +1303,19 @@ fn launch_cli_agent( mod tests { use super::*; + fn deepseek_harness_options(mode: &str) -> DeepSeekHarnessLaunchOptions { + DeepSeekHarnessLaunchOptions { + mode: mode.to_string(), + web_host: None, + web_port: None, + open_browser: None, + trusted_hosts: Vec::new(), + task: None, + profile: None, + patches: Vec::new(), + } + } + #[test] fn unknown_terminal_defaults_to_automatic() { assert_eq!( @@ -914,6 +1342,153 @@ mod tests { assert_eq!(resolve_launch_directory(None, fallback).unwrap(), fallback); } + #[test] + fn deepseek_harness_launch_selects_the_web_profile() { + assert_eq!( + agent_cli_launch_arguments(AgentClient::DeepSeekHarness, None).unwrap(), + ["web"] + ); + assert!(agent_cli_launch_arguments(AgentClient::Codex, None) + .unwrap() + .is_empty()); + + let targets = agent_launch_targets( + AgentClient::DeepSeekHarness, + Some(Path::new("dsh")), + Some("0.1.2-rc.1"), + false, + ); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, "cli"); + assert_eq!(targets[0].label, "DeepSeek Harness Web"); + assert_eq!(targets[0].detail, "dsh web"); + } + + #[test] + fn deepseek_harness_web_launch_arguments_include_supported_options() { + let mut options = deepseek_harness_options("web"); + options.web_host = Some("127.0.0.1".to_string()); + options.web_port = Some(0); + options.open_browser = Some(false); + options.trusted_hosts = vec!["localhost:3000".to_string(), "example.test".to_string()]; + options.patches = vec!["./extra.yml".to_string()]; + + assert_eq!( + build_deepseek_harness_launch_arguments(Some(&options)).unwrap(), + [ + "web", + "--patch", + "./extra.yml", + "--host", + "127.0.0.1", + "--port", + "0", + "--no-open", + "--trusted-host", + "localhost:3000", + "--trusted-host", + "example.test", + ] + ); + } + + #[test] + fn deepseek_harness_non_web_profiles_build_expected_arguments() { + for mode in ["acp", "sdk", "sdk-minimal"] { + let options = deepseek_harness_options(mode); + assert_eq!( + build_deepseek_harness_launch_arguments(Some(&options)).unwrap(), + ["--profile", mode] + ); + } + + let mut headless = deepseek_harness_options("headless"); + headless.task = Some("review this repository".to_string()); + assert_eq!( + build_deepseek_harness_launch_arguments(Some(&headless)).unwrap(), + ["--profile", "headless", "review this repository"] + ); + + let mut custom = deepseek_harness_options("custom"); + custom.profile = Some("tui-dev".to_string()); + assert_eq!( + build_deepseek_harness_launch_arguments(Some(&custom)).unwrap(), + ["--profile", "tui-dev"] + ); + } + + #[test] + fn deepseek_harness_launch_options_reject_missing_required_values() { + let headless = deepseek_harness_options("headless"); + assert!(build_deepseek_harness_launch_arguments(Some(&headless)).is_err()); + + let custom = deepseek_harness_options("custom"); + assert!(build_deepseek_harness_launch_arguments(Some(&custom)).is_err()); + + let options = deepseek_harness_options("web"); + assert!(agent_cli_launch_arguments(AgentClient::Codex, Some(&options)).is_err()); + } + + #[test] + fn managed_deepseek_harness_state_stops_only_its_tracked_process() { + #[cfg(windows)] + let child = { + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 30", + ]); + configure_background_command(&mut command); + command.spawn().unwrap() + }; + #[cfg(unix)] + let child = { + use std::os::unix::process::CommandExt; + + let mut command = Command::new("sh"); + command.args(["-c", "sleep 30"]).process_group(0); + command.spawn().unwrap() + }; + + let state = DeepSeekHarnessProcessState::default(); + let tracked_pid = child.id(); + *state.process.lock().unwrap() = Some(ManagedDeepSeekHarnessProcess { + child, + mode: "test".to_string(), + }); + + let running = state.status().unwrap(); + assert!(running.running); + assert_eq!(running.pid, Some(tracked_pid)); + assert_eq!(running.mode.as_deref(), Some("test")); + + stop_managed_deepseek_harness(&state).unwrap(); + assert!(!state.status().unwrap().running); + } + + #[test] + fn deepseek_harness_web_launch_rejects_an_occupied_port() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut options = deepseek_harness_options("web"); + options.web_port = Some(port); + + let error = ensure_deepseek_harness_web_endpoint_available(Some(&options)).unwrap_err(); + + assert!(error.contains("已被占用"), "{error}"); + } + + #[test] + fn deepseek_harness_web_launch_allows_random_ports() { + let mut options = deepseek_harness_options("web"); + options.web_port = Some(0); + + ensure_deepseek_harness_web_endpoint_available(Some(&options)).unwrap(); + } + #[test] fn codex_exposes_independent_app_and_cli_targets() { let executable = Path::new("codex"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b8e5c85..cb0e353 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -149,6 +149,8 @@ const KIMI_CODE_CONFIG_FILE: &str = "config.toml"; const GROK_BUILD_CONFIG_FILE: &str = "config.toml"; const DEEPSEEK_HARNESS_PROVIDER_ID: &str = "easy-cliproxyapi"; const DEEPSEEK_HARNESS_CREDENTIAL: &str = "EASYCLIPROXYAPI_API_KEY"; +const DEEPSEEK_HARNESS_CREDENTIALS_VERSION: u64 = 1; +const DEEPSEEK_HARNESS_DEFAULT_WEB_PORT: u16 = 3080; const DEEPSEEK_HARNESS_SETTINGS_FILE: &str = "settings.yaml"; const DEEPSEEK_HARNESS_CREDENTIALS_FILE: &str = ".credentials.yaml"; const PI_AGENT_ID: &str = "pi"; @@ -308,6 +310,16 @@ struct CoreProcessState { job: Mutex>, } +#[derive(Default)] +struct DeepSeekHarnessProcessState { + process: Mutex>, +} + +struct ManagedDeepSeekHarnessProcess { + child: Child, + mode: String, +} + #[derive(Clone)] struct AdoptedCoreProcess { process_id: u32, @@ -1034,6 +1046,29 @@ struct AgentLaunchTarget { detail: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeepSeekHarnessLaunchOptions { + mode: String, + web_host: Option, + web_port: Option, + open_browser: Option, + #[serde(default)] + trusted_hosts: Vec, + task: Option, + profile: Option, + #[serde(default)] + patches: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct DeepSeekHarnessProcessStatus { + running: bool, + pid: Option, + mode: Option, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AgentConfigActionResult { @@ -2288,6 +2323,7 @@ fn main() { .manage(CoreDownloadState::default()) .manage(AppUpdateState::default()) .manage(CoreProcessState::new(gui_config.start_core_on_launch)) + .manage(DeepSeekHarnessProcessState::default()) .manage(usage::UsageCollectorState::default()) .manage(GuiConfigState::new(gui_config)) .manage(MainWindowSizeState::new(initial_window_size)) @@ -2509,6 +2545,8 @@ fn main() { set_agent_config_enabled, update_agent_config, launch_agent, + get_deepseek_harness_process_status, + stop_deepseek_harness_process, restart_codex_app, restart_opencode_app, get_lan_ipv4, @@ -2592,6 +2630,10 @@ fn main() { } tauri::RunEvent::Exit => { usage::stop_usage_collector(app_handle); + let deepseek_process_state = app_handle.state::(); + if let Err(error) = stop_managed_deepseek_harness(deepseek_process_state.inner()) { + eprintln!("关闭 DeepSeek Harness 失败: {error}"); + } let gui_config_state = app_handle.state::(); let process_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 928a6db..5bacbc1 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1549,18 +1549,105 @@ agent-default-model: #[test] fn deepseek_harness_credentials_preserve_other_entries() { let rendered = build_deepseek_harness_credentials( - Some("# existing key\nOTHER_API_KEY: other-secret\n"), + Some( + "# existing key\nversion: 1\nrefs:\n OTHER_API_KEY: other-secret\nrecords:\n plugin/account:\n kind: api-key\n key: record-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["version"].as_u64(), Some(1)); assert_eq!( - value[DEEPSEEK_HARNESS_CREDENTIAL].as_str(), + value["refs"]["OTHER_API_KEY"].as_str(), + Some("other-secret") + ); + assert_eq!( + value["refs"][DEEPSEEK_HARNESS_CREDENTIAL].as_str(), Some("cpa-secret") ); + assert_eq!( + value["records"]["plugin/account"]["key"].as_str(), + Some("record-secret") + ); + assert!(value.get(DEEPSEEK_HARNESS_CREDENTIAL).is_none()); +} + +#[test] +fn deepseek_harness_credentials_reject_legacy_flat_layout() { + let error = build_deepseek_harness_credentials( + Some("# legacy flat layout\nOTHER_API_KEY: other-secret\n"), + "cpa-secret", + ) + .unwrap_err(); + + assert!(error.contains("缺少 version 字段"), "{error}"); +} + +#[test] +fn deepseek_harness_credentials_repair_misplaced_managed_key() { + let rendered = build_deepseek_harness_credentials( + Some( + "version: 1\nrefs:\n OTHER_API_KEY: other-secret\nrecords: {}\nEASYCLIPROXYAPI_API_KEY: misplaced-secret\n", + ), + "cpa-secret", + ) + .unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + + assert_eq!( + value["refs"][DEEPSEEK_HARNESS_CREDENTIAL].as_str(), + Some("cpa-secret") + ); + assert!(value.get(DEEPSEEK_HARNESS_CREDENTIAL).is_none()); +} + +#[test] +fn deepseek_harness_removal_preserves_other_versioned_credentials() { + let home = agent_test_home("deepseek-harness-remove-versioned-credentials"); + let paths = vec![home.join("settings.yaml"), home.join(".credentials.yaml")]; + fs::create_dir_all(&home).unwrap(); + fs::write( + &paths[1], + "version: 1\nrefs:\n OTHER_API_KEY: other-secret\n EASYCLIPROXYAPI_API_KEY: cpa-secret\nrecords:\n plugin/account:\n kind: api-key\n key: record-secret\n", + ) + .unwrap(); + + let changed = remove_deepseek_harness_managed_configuration(&paths).unwrap(); + let value: serde_norway::Value = + serde_norway::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); + + assert_eq!(changed, vec![path_to_string(&paths[1])]); + assert_eq!(value["version"].as_u64(), Some(1)); + assert_eq!( + value["refs"]["OTHER_API_KEY"].as_str(), + Some("other-secret") + ); + assert!(value["refs"].get(DEEPSEEK_HARNESS_CREDENTIAL).is_none()); + assert_eq!( + value["records"]["plugin/account"]["key"].as_str(), + Some("record-secret") + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn deepseek_harness_removal_deletes_semantically_empty_credentials_file() { + let home = agent_test_home("deepseek-harness-remove-empty-credentials"); + let paths = vec![home.join("settings.yaml"), home.join(".credentials.yaml")]; + fs::create_dir_all(&home).unwrap(); + fs::write( + &paths[1], + build_deepseek_harness_credentials(None, "cpa-secret").unwrap(), + ) + .unwrap(); + + let changed = remove_deepseek_harness_managed_configuration(&paths).unwrap(); + + assert_eq!(changed, vec![path_to_string(&paths[1])]); + assert!(!paths[1].exists()); + fs::remove_dir_all(home).unwrap(); } #[test] @@ -1587,6 +1674,24 @@ fn deepseek_harness_inspection_requires_managed_route_selection_and_credential() let (wrong_key, _) = inspect_deepseek_harness_config(&paths, 8317, "different-key").unwrap(); assert!(!wrong_key); + + let credentials = fs::read_to_string(&paths[1]).unwrap(); + let credentials = render_agent_yaml_mapping_update( + Some(&credentials), + "test invalid Harness credentials", + |root| { + root.insert( + yaml_key(DEEPSEEK_HARNESS_CREDENTIAL), + serde_norway::Value::String("misplaced-key".to_string()), + ); + Ok(()) + }, + ) + .unwrap(); + fs::write(&paths[1], credentials).unwrap(); + let (invalid_layout, _) = inspect_deepseek_harness_config(&paths, 8317, "agent-key").unwrap(); + assert!(!invalid_layout); + assert!(deepseek_harness_has_managed_marker(&paths).unwrap()); fs::remove_dir_all(home).unwrap(); } diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index da2cd69..36827b4 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -219,7 +219,7 @@ agent-default-model: provider: existing model: old-model "#; - let original_credentials = "OTHER_API_KEY: old-secret\n"; + let original_credentials = "version: 1\nrefs:\n 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"]); @@ -252,7 +252,11 @@ agent-default-model: let credentials = fs::read_to_string(&paths[1]).unwrap(); let credentials = render_agent_yaml_mapping_update(Some(&credentials), "test Harness credentials", |root| { - root.insert( + let refs = root + .get_mut(yaml_key("refs")) + .and_then(serde_norway::Value::as_mapping_mut) + .unwrap(); + refs.insert( yaml_key("NEW_API_KEY"), serde_norway::Value::String("new-secret".to_string()), ); @@ -274,9 +278,18 @@ agent-default-model: .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_eq!(credentials["version"].as_u64(), Some(1)); + assert_eq!( + credentials["refs"]["OTHER_API_KEY"].as_str(), + Some("old-secret") + ); + assert_eq!( + credentials["refs"]["NEW_API_KEY"].as_str(), + Some("new-secret") + ); + assert!(credentials["refs"] + .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()); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index a557b83..763124f 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -767,6 +767,43 @@ export const jaOverrides = { 'agents.launchDirectory.recentItem': '履歴ディレクトリ', 'agents.launchDirectory.historyUse': '選択', 'agents.launchDirectory.historySelected': '選択済み', + 'agents.deepseekLaunch.dialogTitle': 'DeepSeek Harness を起動', + 'agents.deepseekLaunch.dialogDescription': '実行モード、起動オプション、既定の workspace として使う作業ディレクトリを選択します。', + 'agents.deepseekLaunch.mode.label': '実行モード', + 'agents.deepseekLaunch.mode.web': 'Web UI', + 'agents.deepseekLaunch.mode.webDescription': 'ブラウザー UI を起動します。dsh web と同じです。', + 'agents.deepseekLaunch.mode.headless': 'Headless 単発タスク', + 'agents.deepseekLaunch.mode.headlessDescription': '新しいタスクを実行し、最終結果を出力して終了します。', + 'agents.deepseekLaunch.mode.acp': 'ACP サーバー', + 'agents.deepseekLaunch.mode.acpDescription': '標準入出力を介して ACP クライアントにサービスを提供します。', + 'agents.deepseekLaunch.mode.sdk': 'SDK サーバー', + 'agents.deepseekLaunch.mode.sdkDescription': '標準入出力で SDK JSON-RPC サーバーを起動します。', + 'agents.deepseekLaunch.mode.sdkMinimal': 'SDK Minimal サーバー', + 'agents.deepseekLaunch.mode.sdkMinimalDescription': '独立した最小エージェントツリーで SDK JSON-RPC サーバーを起動します。', + 'agents.deepseekLaunch.mode.custom': 'カスタム Profile', + 'agents.deepseekLaunch.mode.customDescription': 'DSH_HOME にインストール済みのカスタム profile を起動します。', + 'agents.deepseekLaunch.web.host': 'バインドホスト(任意)', + 'agents.deepseekLaunch.web.hostPlaceholder': 'DSH の既定値を使用', + 'agents.deepseekLaunch.web.port': 'ポート(任意)', + 'agents.deepseekLaunch.web.portPlaceholder': '既定値;0 は空きポート', + 'agents.deepseekLaunch.web.openBrowser': 'ブラウザーを自動的に開く', + 'agents.deepseekLaunch.web.openBrowserDescription': '無効にすると --no-open を渡し、Web サーバーのみを起動します。', + 'agents.deepseekLaunch.web.trustedHosts': '追加の信頼済み Host(任意、1 行に 1 件)', + 'agents.deepseekLaunch.web.trustedHostsPlaceholder': 'localhost:3000\nexample.test', + 'agents.deepseekLaunch.headless.task': 'タスク内容', + 'agents.deepseekLaunch.headless.taskPlaceholder': '例:このプロジェクトを確認してテストを実行する', + 'agents.deepseekLaunch.custom.profile': 'Profile 名', + 'agents.deepseekLaunch.custom.profilePlaceholder': '例:tui', + 'agents.deepseekLaunch.patches': '追加 Patch ファイル(任意、1 行に 1 件)', + 'agents.deepseekLaunch.patchesPlaceholder': './extra.yml\n./local.patch.yml', + 'agents.deepseekLaunch.launch': '{mode} を起動', + 'agents.deepseekLaunch.stop': 'DeepSeek Harness を停止', + 'agents.deepseekLaunch.stopping': '停止中', + 'agents.deepseekLaunch.runningDetail': '実行中:モード {mode}、PID {pid}', + 'agents.deepseekLaunch.error.invalidPort': 'ポートは 0~65535 の整数にしてください', + 'agents.deepseekLaunch.error.taskRequired': 'Headless のタスク内容を入力してください', + 'agents.deepseekLaunch.error.profileRequired': 'カスタム profile 名を入力してください', + 'agents.deepseekLaunch.error.invalidProfile': 'Profile 名に使用できるのは英数字、ドット、ハイフン、アンダースコアのみです', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', 'agents.status.notInstalled': '未インストール', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b8a32a6..9a942a8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -767,6 +767,43 @@ export const en: Record = { 'agents.launchDirectory.recentItem': 'Recent directory', 'agents.launchDirectory.historyUse': 'Select', 'agents.launchDirectory.historySelected': 'Selected', + 'agents.deepseekLaunch.dialogTitle': 'Start DeepSeek Harness', + 'agents.deepseekLaunch.dialogDescription': 'Choose a runtime mode, launch options, and the working directory used as the default workspace.', + 'agents.deepseekLaunch.mode.label': 'Runtime mode', + 'agents.deepseekLaunch.mode.web': 'Web UI', + 'agents.deepseekLaunch.mode.webDescription': 'Start the browser interface; equivalent to dsh web.', + 'agents.deepseekLaunch.mode.headless': 'Headless one-shot task', + 'agents.deepseekLaunch.mode.headlessDescription': 'Run a fresh task, print its final result, and exit.', + 'agents.deepseekLaunch.mode.acp': 'ACP server', + 'agents.deepseekLaunch.mode.acpDescription': 'Serve ACP clients over standard input and output.', + 'agents.deepseekLaunch.mode.sdk': 'SDK server', + 'agents.deepseekLaunch.mode.sdkDescription': 'Start the SDK JSON-RPC server over standard input and output.', + 'agents.deepseekLaunch.mode.sdkMinimal': 'SDK Minimal server', + 'agents.deepseekLaunch.mode.sdkMinimalDescription': 'Start the SDK JSON-RPC server with the standalone minimal agent tree.', + 'agents.deepseekLaunch.mode.custom': 'Custom profile', + 'agents.deepseekLaunch.mode.customDescription': 'Start a custom profile already installed under DSH_HOME.', + 'agents.deepseekLaunch.web.host': 'Bind host (optional)', + 'agents.deepseekLaunch.web.hostPlaceholder': 'Use the DSH default', + 'agents.deepseekLaunch.web.port': 'Port (optional)', + 'agents.deepseekLaunch.web.portPlaceholder': 'Default; 0 selects a free port', + 'agents.deepseekLaunch.web.openBrowser': 'Open the browser automatically', + 'agents.deepseekLaunch.web.openBrowserDescription': 'When disabled, --no-open starts only the Web server.', + 'agents.deepseekLaunch.web.trustedHosts': 'Additional trusted hosts (optional, one per line)', + 'agents.deepseekLaunch.web.trustedHostsPlaceholder': 'localhost:3000\nexample.test', + 'agents.deepseekLaunch.headless.task': 'Task', + 'agents.deepseekLaunch.headless.taskPlaceholder': 'For example: inspect this project and run the tests', + 'agents.deepseekLaunch.custom.profile': 'Profile name', + 'agents.deepseekLaunch.custom.profilePlaceholder': 'For example: tui', + 'agents.deepseekLaunch.patches': 'Additional patch files (optional, one per line)', + 'agents.deepseekLaunch.patchesPlaceholder': './extra.yml\n./local.patch.yml', + 'agents.deepseekLaunch.launch': 'Start {mode}', + 'agents.deepseekLaunch.stop': 'Stop DeepSeek Harness', + 'agents.deepseekLaunch.stopping': 'Stopping', + 'agents.deepseekLaunch.runningDetail': 'Running: mode {mode}, PID {pid}', + 'agents.deepseekLaunch.error.invalidPort': 'Port must be an integer from 0 to 65535', + 'agents.deepseekLaunch.error.taskRequired': 'Enter a Headless task', + 'agents.deepseekLaunch.error.profileRequired': 'Enter a custom profile name', + 'agents.deepseekLaunch.error.invalidProfile': 'Profile names may contain only letters, numbers, dots, hyphens, and underscores', 'agents.status.detecting': 'Detecting', 'agents.status.unsupported': 'Unsupported Platform', 'agents.status.notInstalled': 'Not Installed', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 53ed78d..363dcd9 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -766,6 +766,43 @@ export const zhCN = { 'agents.launchDirectory.recentItem': '历史目录', 'agents.launchDirectory.historyUse': '选择', 'agents.launchDirectory.historySelected': '已选择', + 'agents.deepseekLaunch.dialogTitle': '启动 DeepSeek Harness', + 'agents.deepseekLaunch.dialogDescription': '选择运行模式、启动选项和作为默认 workspace 的工作目录。', + 'agents.deepseekLaunch.mode.label': '运行模式', + 'agents.deepseekLaunch.mode.web': 'Web UI', + 'agents.deepseekLaunch.mode.webDescription': '启动浏览器界面;等价于 dsh web。', + 'agents.deepseekLaunch.mode.headless': 'Headless 单次任务', + 'agents.deepseekLaunch.mode.headlessDescription': '执行一个新任务,输出最终结果后退出。', + 'agents.deepseekLaunch.mode.acp': 'ACP 服务', + 'agents.deepseekLaunch.mode.acpDescription': '通过标准输入输出为 ACP 客户端提供服务。', + 'agents.deepseekLaunch.mode.sdk': 'SDK 服务', + 'agents.deepseekLaunch.mode.sdkDescription': '通过标准输入输出启动 SDK JSON-RPC 服务。', + 'agents.deepseekLaunch.mode.sdkMinimal': 'SDK Minimal 服务', + 'agents.deepseekLaunch.mode.sdkMinimalDescription': '启动使用独立精简智能体树的 SDK JSON-RPC 服务。', + 'agents.deepseekLaunch.mode.custom': '自定义 Profile', + 'agents.deepseekLaunch.mode.customDescription': '启动已经安装在 DSH_HOME 下的自定义 profile。', + 'agents.deepseekLaunch.web.host': '监听地址(可选)', + 'agents.deepseekLaunch.web.hostPlaceholder': '使用 DSH 默认值', + 'agents.deepseekLaunch.web.port': '端口(可选)', + 'agents.deepseekLaunch.web.portPlaceholder': '默认;0 为随机端口', + 'agents.deepseekLaunch.web.openBrowser': '自动打开浏览器', + 'agents.deepseekLaunch.web.openBrowserDescription': '关闭后会传入 --no-open,仅启动 Web 服务。', + 'agents.deepseekLaunch.web.trustedHosts': '额外可信 Host(可选,每行一个)', + 'agents.deepseekLaunch.web.trustedHostsPlaceholder': 'localhost:3000\nexample.test', + 'agents.deepseekLaunch.headless.task': '任务内容', + 'agents.deepseekLaunch.headless.taskPlaceholder': '例如:检查这个项目并运行测试', + 'agents.deepseekLaunch.custom.profile': 'Profile 名称', + 'agents.deepseekLaunch.custom.profilePlaceholder': '例如:tui', + 'agents.deepseekLaunch.patches': '额外 Patch 文件(可选,每行一个)', + 'agents.deepseekLaunch.patchesPlaceholder': './extra.yml\n./local.patch.yml', + 'agents.deepseekLaunch.launch': '启动 {mode}', + 'agents.deepseekLaunch.stop': '关闭 DeepSeek Harness', + 'agents.deepseekLaunch.stopping': '关闭中', + 'agents.deepseekLaunch.runningDetail': '正在运行:模式 {mode},PID {pid}', + 'agents.deepseekLaunch.error.invalidPort': '端口必须是 0–65535 之间的整数', + 'agents.deepseekLaunch.error.taskRequired': '请输入 Headless 任务内容', + 'agents.deepseekLaunch.error.profileRequired': '请输入自定义 profile 名称', + 'agents.deepseekLaunch.error.invalidProfile': 'Profile 名称只能包含字母、数字、点、连字符和下划线', 'agents.status.detecting': '检测中', 'agents.status.unsupported': '平台不支持', 'agents.status.notInstalled': '未安装', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 1bf5df6..d122005 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -23,6 +23,7 @@ import { RefreshCw, Search, SlidersHorizontal, + Square, Trash2, Terminal, Wrench, @@ -57,6 +58,13 @@ import { rememberAgentLaunchDirectory, type AgentLaunchDirectoryHistory, } from '../services/agentLaunchDirectoryHistory'; +import { + buildDeepSeekHarnessLaunchOptions, + DEFAULT_DEEPSEEK_HARNESS_LAUNCH_DRAFT, + type DeepSeekHarnessLaunchDraft, + type DeepSeekHarnessLaunchMode, + type DeepSeekHarnessLaunchOptions, +} from '../services/deepSeekHarnessLaunch'; import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; @@ -111,6 +119,12 @@ type AgentLaunchTarget = { detail: string; }; +type DeepSeekHarnessProcessStatus = { + running: boolean; + pid: number | null; + mode: string | null; +}; + type AgentConfigActionResult = { outcome: 'applied' | 'default'; enabled: boolean; @@ -658,7 +672,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | null + 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -676,6 +690,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [launchDirectory, setLaunchDirectory] = useState(''); const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); const [launchDirectoryError, setLaunchDirectoryError] = useState(''); + const [deepSeekHarnessLaunchDraft, setDeepSeekHarnessLaunchDraft] = useState( + () => ({ ...DEFAULT_DEEPSEEK_HARNESS_LAUNCH_DRAFT }), + ); + const [deepSeekHarnessProcessStatus, setDeepSeekHarnessProcessStatus] = useState({ + running: false, + pid: null, + mode: null, + }); const [launchDirectoryHistory, setLaunchDirectoryHistory] = useState( readAgentLaunchDirectoryHistory, ); @@ -725,6 +747,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setStatuses(nextStatuses); }, []); + const loadDeepSeekHarnessProcessStatus = useCallback(async () => { + const status = await invoke('get_deepseek_harness_process_status'); + setDeepSeekHarnessProcessStatus(status); + }, []); + const loadModels = useCallback(async (client: AgentClientId, preferredModel = '') => { const requestId = modelRequestRef.current + 1; modelRequestRef.current = requestId; @@ -797,6 +824,22 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; }, [loadModels, loadStatuses, selected]); + useEffect(() => { + if (selected !== 'deepseek-harness') return undefined; + let disposed = false; + const refreshProcessStatus = () => { + void loadDeepSeekHarnessProcessStatus().catch((requestError) => { + if (!disposed) setLaunchError(String(requestError)); + }); + }; + refreshProcessStatus(); + const timer = window.setInterval(refreshProcessStatus, 2000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [loadDeepSeekHarnessProcessStatus, selected]); + useEffect(() => { writeSelectedAgentClient(selected); }, [selected]); @@ -829,6 +872,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; const isPiClient = selected === 'pi'; + const isDeepSeekHarnessClient = selected === 'deepseek-harness'; const hasIndependentCliAndApp = selected === 'codex' || selected === 'opencode'; const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop'; const claudeModelMappingsDraft = isClaudeModelMappingClient @@ -980,6 +1024,22 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ); const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target); const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? []; + const deepSeekHarnessLaunchModeLabel = { + web: t('agents.deepseekLaunch.mode.web'), + headless: t('agents.deepseekLaunch.mode.headless'), + acp: t('agents.deepseekLaunch.mode.acp'), + sdk: t('agents.deepseekLaunch.mode.sdk'), + 'sdk-minimal': t('agents.deepseekLaunch.mode.sdkMinimal'), + custom: t('agents.deepseekLaunch.mode.custom'), + }[deepSeekHarnessLaunchDraft.mode]; + const deepSeekHarnessLaunchModeDescription = { + web: t('agents.deepseekLaunch.mode.webDescription'), + headless: t('agents.deepseekLaunch.mode.headlessDescription'), + acp: t('agents.deepseekLaunch.mode.acpDescription'), + sdk: t('agents.deepseekLaunch.mode.sdkDescription'), + 'sdk-minimal': t('agents.deepseekLaunch.mode.sdkMinimalDescription'), + custom: t('agents.deepseekLaunch.mode.customDescription'), + }[deepSeekHarnessLaunchDraft.mode]; const modelHint = modelSelectionError || modelError || (modelLoading @@ -1365,6 +1425,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setLaunchDirectoryDialogOpen(true); }; + const updateDeepSeekHarnessLaunchDraft = (update: Partial) => { + setDeepSeekHarnessLaunchDraft((current) => ({ ...current, ...update })); + setLaunchDirectoryError(''); + }; + const chooseLaunchDirectory = async () => { setBusyAction('directory'); setLaunchDirectoryError(''); @@ -1394,6 +1459,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const invokeAgentLaunch = async ( target: AgentLaunchTarget, workingDirectory: string | null = null, + deepSeekHarnessOptions: DeepSeekHarnessLaunchOptions | null = null, ) => { const action = hasIndependentCliAndApp ? target.id === 'cli' ? 'launch-cli' : 'launch-app' @@ -1405,7 +1471,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP client: selected, target: target.id, workingDirectory, + deepseekHarnessOptions: deepSeekHarnessOptions, }); + if (selected === 'deepseek-harness') { + await loadDeepSeekHarnessProcessStatus(); + } if (workingDirectory) { rememberLaunchDirectory(selected, workingDirectory); setLaunchDirectoryDialogOpen(false); @@ -1431,6 +1501,19 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP await invokeAgentLaunch(target); }; + const stopDeepSeekHarness = async () => { + setBusyAction('stop-deepseek'); + setLaunchError(''); + try { + const status = await invoke('stop_deepseek_harness_process'); + setDeepSeekHarnessProcessStatus(status); + } catch (requestError) { + setLaunchError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + const restartDesktopApp = async () => { if (!hasIndependentCliAndApp) return; setBusyAction('restart-app'); @@ -1453,8 +1536,23 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setLaunchDirectoryError(t('agents.launchDirectory.directoryRequired')); return; } + let deepSeekHarnessOptions: DeepSeekHarnessLaunchOptions | null = null; + if (isDeepSeekHarnessClient) { + const result = buildDeepSeekHarnessLaunchOptions(deepSeekHarnessLaunchDraft); + if (result.error) { + const errorKeys = { + invalidPort: 'agents.deepseekLaunch.error.invalidPort', + taskRequired: 'agents.deepseekLaunch.error.taskRequired', + profileRequired: 'agents.deepseekLaunch.error.profileRequired', + invalidProfile: 'agents.deepseekLaunch.error.invalidProfile', + } as const; + setLaunchDirectoryError(t(errorKeys[result.error])); + return; + } + deepSeekHarnessOptions = result.options; + } setLaunchDirectoryError(''); - await invokeAgentLaunch(launchDirectoryTarget, workingDirectory); + await invokeAgentLaunch(launchDirectoryTarget, workingDirectory, deepSeekHarnessOptions); }; const openDefaultConfirmation = () => { @@ -1605,17 +1703,34 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP @@ -2066,21 +2181,38 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : ( )} @@ -2133,11 +2265,131 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP

- {t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name })} + {isDeepSeekHarnessClient + ? t('agents.deepseekLaunch.dialogTitle') + : t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name })}

-

{t('agents.launchDirectory.dialogDescription', { client: activeDefinition.name })}

+

{isDeepSeekHarnessClient + ? t('agents.deepseekLaunch.dialogDescription') + : t('agents.launchDirectory.dialogDescription', { client: activeDefinition.name })}

+ {isDeepSeekHarnessClient ? ( +
+ +

+ {deepSeekHarnessLaunchModeDescription} +

+ + {deepSeekHarnessLaunchDraft.mode === 'web' ? ( + <> +
+ + +
+ +