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
This commit is contained in:
lzt404 committed 2026-09-08 19:39:34 +08:00
1 parent f7bc460e3c
commit 70ed5bc7f2
13 files changed
+1537 -46

No files matched your search

+128 -5
View File
@@ -425,7 +425,8 @@ pub(crate) fn build_deepseek_harness_credentials(
api_key: &str,
) -> Result<String, String> {
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<bool, String> {
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(&current),
"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<Option<String>, 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(
+33 -2
View File
@@ -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<bool, String> {
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<b
.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();
let credential_exists = yaml_mapping_value(&credentials, "refs")
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|refs| yaml_mapping_value(refs, DEEPSEEK_HARNESS_CREDENTIAL).is_some())
|| yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL).is_some();
Ok(provider_exists || default_selected || credential_exists)
}
+581 -6
View File
@@ -1,4 +1,5 @@
use super::*;
use std::net::TcpListener;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -110,9 +111,11 @@ pub(crate) fn normalize_agent_terminal(value: &str) -> 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<String>,
working_directory: Option<String>,
deepseek_harness_options: Option<DeepSeekHarnessLaunchOptions>,
) -> 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<String, String> {
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<Vec<String>, 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<Vec<String>, 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<DeepSeekHarnessProcessStatus, String> {
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<DeepSeekHarnessProcessStatus, String> {
process_state.status()
}
#[tauri::command]
pub(crate) fn stop_deepseek_harness_process(
process_state: tauri::State<'_, DeepSeekHarnessProcessState>,
) -> Result<DeepSeekHarnessProcessStatus, String> {
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<Child, String> {
#[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<String, String> {
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::<Vec<_>>()
.join(" ");
let invocation = std::iter::once(shell_single_quote(&path_to_string(executable)))
.chain(
arguments
.iter()
.map(|argument| shell_single_quote(argument)),
)
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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::<String>();
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");
+42
View File
@@ -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<Option<isize>>,
}
#[derive(Default)]
struct DeepSeekHarnessProcessState {
process: Mutex<Option<ManagedDeepSeekHarnessProcess>>,
}
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<String>,
web_port: Option<u16>,
open_browser: Option<bool>,
#[serde(default)]
trusted_hosts: Vec<String>,
task: Option<String>,
profile: Option<String>,
#[serde(default)]
patches: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct DeepSeekHarnessProcessStatus {
running: bool,
pid: Option<u32>,
mode: Option<String>,
}
#[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::<DeepSeekHarnessProcessState>();
if let Err(error) = stop_managed_deepseek_harness(deepseek_process_state.inner()) {
eprintln!("关闭 DeepSeek Harness 失败: {error}");
}
let gui_config_state = app_handle.state::<GuiConfigState>();
let process_state = app_handle.state::<CoreProcessState>();
shutdown_managed_core(process_state.inner(), gui_config_state.inner());
+108 -3
View File
@@ -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();
}
+18 -5
View File
@@ -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());
+37
View File
@@ -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': '未インストール',
+37
View File
@@ -767,6 +767,43 @@ export const en: Record<MessageKey, string> = {
'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',
+37
View File
@@ -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': '未安装',
+278 -24
View File
@@ -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<AgentLaunchTarget | null>(null);
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
const [deepSeekHarnessLaunchDraft, setDeepSeekHarnessLaunchDraft] = useState<DeepSeekHarnessLaunchDraft>(
() => ({ ...DEFAULT_DEEPSEEK_HARNESS_LAUNCH_DRAFT }),
);
const [deepSeekHarnessProcessStatus, setDeepSeekHarnessProcessStatus] = useState<DeepSeekHarnessProcessStatus>({
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<DeepSeekHarnessProcessStatus>('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<DeepSeekHarnessLaunchDraft>) => {
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<DeepSeekHarnessProcessStatus>('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
</button>
<button
type="button"
className="secondary-button"
onClick={() => void launchAgent(defaultLaunchTarget)}
disabled={busy || !canLaunchTarget(defaultLaunchTarget)}
title={defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
className={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? 'danger-button'
: 'secondary-button'}
onClick={() => void (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? stopDeepSeekHarness()
: launchAgent(defaultLaunchTarget))}
disabled={busy || (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? false
: !canLaunchTarget(defaultLaunchTarget))}
title={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.runningDetail', {
pid: deepSeekHarnessProcessStatus.pid ?? '—',
mode: deepSeekHarnessProcessStatus.mode ?? '—',
})
: defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch' || busyAction === 'launch-cli'
{busyAction === 'launch' || busyAction === 'launch-cli' || busyAction === 'stop-deepseek'
? <LoaderCircle size={16} className="spin" />
: <Play size={16} />}
{busyAction === 'launch' || busyAction === 'launch-cli'
? t('agents.launch.starting')
: t('agents.launch.start', { target: defaultLaunchTarget?.label ?? activeDefinition.name })}
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? <Square size={16} />
: <Play size={16} />}
{busyAction === 'stop-deepseek'
? t('agents.deepseekLaunch.stopping')
: busyAction === 'launch' || busyAction === 'launch-cli'
? t('agents.launch.starting')
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.stop')
: t('agents.launch.start', { target: defaultLaunchTarget?.label ?? activeDefinition.name })}
</button>
</div>
@@ -2066,21 +2181,38 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
) : (
<button
type="button"
className="primary-button agent-launch-button"
onClick={() => void launchAgent(defaultLaunchTarget)}
disabled={busy || !canLaunchTarget(defaultLaunchTarget)}
title={defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
className={`${isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? 'danger-button'
: 'primary-button'} agent-launch-button`}
onClick={() => void (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? stopDeepSeekHarness()
: launchAgent(defaultLaunchTarget))}
disabled={busy || (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? false
: !canLaunchTarget(defaultLaunchTarget))}
title={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.runningDetail', {
pid: deepSeekHarnessProcessStatus.pid ?? '—',
mode: deepSeekHarnessProcessStatus.mode ?? '—',
})
: defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch'
{busyAction === 'launch' || busyAction === 'stop-deepseek'
? <LoaderCircle size={16} className="spin" />
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? <Square size={16} />
: defaultLaunchTarget?.id === 'cli'
? <Terminal size={16} />
: <Play size={16} />}
{busyAction === 'launch'
? t('agents.launch.starting')
: defaultLaunchTarget
? t('agents.launch.start', { target: defaultLaunchTarget.label })
: t('agents.launch.unavailable')}
{busyAction === 'stop-deepseek'
? t('agents.deepseekLaunch.stopping')
: busyAction === 'launch'
? t('agents.launch.starting')
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.stop')
: defaultLaunchTarget
? t('agents.launch.start', { target: defaultLaunchTarget.label })
: t('agents.launch.unavailable')}
</button>
)}
</div>
@@ -2133,11 +2265,131 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<div className="config-dialog-heading">
<div>
<h2 id="agent-launch-directory-title">
{t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name })}
{isDeepSeekHarnessClient
? t('agents.deepseekLaunch.dialogTitle')
: t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name })}
</h2>
</div>
</div>
<p>{t('agents.launchDirectory.dialogDescription', { client: activeDefinition.name })}</p>
<p>{isDeepSeekHarnessClient
? t('agents.deepseekLaunch.dialogDescription')
: t('agents.launchDirectory.dialogDescription', { client: activeDefinition.name })}</p>
{isDeepSeekHarnessClient ? (
<div className="deepseek-harness-launch-options">
<label className="config-dialog-field deepseek-harness-launch-field">
<span>{t('agents.deepseekLaunch.mode.label')}</span>
<select
className="config-dialog-text-input"
value={deepSeekHarnessLaunchDraft.mode}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({
mode: event.currentTarget.value as DeepSeekHarnessLaunchMode,
})}
disabled={busy}
>
<option value="web">{t('agents.deepseekLaunch.mode.web')}</option>
<option value="headless">{t('agents.deepseekLaunch.mode.headless')}</option>
<option value="acp">{t('agents.deepseekLaunch.mode.acp')}</option>
<option value="sdk">{t('agents.deepseekLaunch.mode.sdk')}</option>
<option value="sdk-minimal">{t('agents.deepseekLaunch.mode.sdkMinimal')}</option>
<option value="custom">{t('agents.deepseekLaunch.mode.custom')}</option>
</select>
</label>
<p className="deepseek-harness-launch-mode-description">
{deepSeekHarnessLaunchModeDescription}
</p>
{deepSeekHarnessLaunchDraft.mode === 'web' ? (
<>
<div className="deepseek-harness-launch-grid">
<label className="config-dialog-field deepseek-harness-launch-field">
<span>{t('agents.deepseekLaunch.web.host')}</span>
<input
className="config-dialog-text-input"
type="text"
value={deepSeekHarnessLaunchDraft.webHost}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ webHost: event.currentTarget.value })}
placeholder={t('agents.deepseekLaunch.web.hostPlaceholder')}
disabled={busy}
/>
</label>
<label className="config-dialog-field deepseek-harness-launch-field">
<span>{t('agents.deepseekLaunch.web.port')}</span>
<input
className="config-dialog-text-input"
type="text"
inputMode="numeric"
value={deepSeekHarnessLaunchDraft.webPort}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ webPort: event.currentTarget.value })}
placeholder={t('agents.deepseekLaunch.web.portPlaceholder')}
disabled={busy}
/>
</label>
</div>
<label className="deepseek-harness-launch-checkbox">
<input
type="checkbox"
checked={deepSeekHarnessLaunchDraft.openBrowser}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ openBrowser: event.currentTarget.checked })}
disabled={busy}
/>
<span>
<strong>{t('agents.deepseekLaunch.web.openBrowser')}</strong>
<small>{t('agents.deepseekLaunch.web.openBrowserDescription')}</small>
</span>
</label>
<label className="config-dialog-field deepseek-harness-launch-field multiline">
<span>{t('agents.deepseekLaunch.web.trustedHosts')}</span>
<textarea
className="config-dialog-text-input"
value={deepSeekHarnessLaunchDraft.trustedHosts}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ trustedHosts: event.currentTarget.value })}
placeholder={t('agents.deepseekLaunch.web.trustedHostsPlaceholder')}
disabled={busy}
/>
</label>
</>
) : null}
{deepSeekHarnessLaunchDraft.mode === 'headless' ? (
<label className="config-dialog-field deepseek-harness-launch-field">
<span>{t('agents.deepseekLaunch.headless.task')}</span>
<input
className="config-dialog-text-input"
type="text"
value={deepSeekHarnessLaunchDraft.task}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ task: event.currentTarget.value })}
placeholder={t('agents.deepseekLaunch.headless.taskPlaceholder')}
disabled={busy}
/>
</label>
) : null}
{deepSeekHarnessLaunchDraft.mode === 'custom' ? (
<label className="config-dialog-field deepseek-harness-launch-field">
<span>{t('agents.deepseekLaunch.custom.profile')}</span>
<input
className="config-dialog-text-input"
type="text"
value={deepSeekHarnessLaunchDraft.profile}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ profile: event.currentTarget.value })}
placeholder={t('agents.deepseekLaunch.custom.profilePlaceholder')}
disabled={busy}
/>
</label>
) : null}
<label className="config-dialog-field deepseek-harness-launch-field multiline">
<span>{t('agents.deepseekLaunch.patches')}</span>
<textarea
className="config-dialog-text-input"
value={deepSeekHarnessLaunchDraft.patches}
onChange={(event) => updateDeepSeekHarnessLaunchDraft({ patches: event.currentTarget.value })}
placeholder={t('agents.deepseekLaunch.patchesPlaceholder')}
disabled={busy}
/>
</label>
</div>
) : null}
<div className="config-dialog-field">
<span>{t('agents.launchDirectory.workingDirectory')}</span>
<button
@@ -2222,7 +2474,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{busyAction === 'launch' || busyAction === 'launch-cli'
? <LoaderCircle size={16} className="spin" />
: null}
{t('agents.launchDirectory.launch', { client: activeDefinition.name })}
{isDeepSeekHarnessClient
? t('agents.deepseekLaunch.launch', { mode: deepSeekHarnessLaunchModeLabel })
: t('agents.launchDirectory.launch', { client: activeDefinition.name })}
</button>
</div>
</section>
+98
View File
@@ -0,0 +1,98 @@
export type DeepSeekHarnessLaunchMode =
| 'web'
| 'headless'
| 'acp'
| 'sdk'
| 'sdk-minimal'
| 'custom';
export type DeepSeekHarnessLaunchDraft = {
mode: DeepSeekHarnessLaunchMode;
webHost: string;
webPort: string;
openBrowser: boolean;
trustedHosts: string;
task: string;
profile: string;
patches: string;
};
export type DeepSeekHarnessLaunchOptions = {
mode: DeepSeekHarnessLaunchMode;
webHost: string | null;
webPort: number | null;
openBrowser: boolean;
trustedHosts: string[];
task: string | null;
profile: string | null;
patches: string[];
};
export type DeepSeekHarnessLaunchValidationError =
| 'invalidPort'
| 'taskRequired'
| 'profileRequired'
| 'invalidProfile';
export type DeepSeekHarnessLaunchBuildResult =
| { options: DeepSeekHarnessLaunchOptions; error: null }
| { options: null; error: DeepSeekHarnessLaunchValidationError };
export const DEFAULT_DEEPSEEK_HARNESS_LAUNCH_DRAFT: DeepSeekHarnessLaunchDraft = {
mode: 'web',
webHost: '',
webPort: '',
openBrowser: true,
trustedHosts: '',
task: '',
profile: '',
patches: '',
};
const nonEmptyLines = (value: string): string[] => value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
export const buildDeepSeekHarnessLaunchOptions = (
draft: DeepSeekHarnessLaunchDraft,
): DeepSeekHarnessLaunchBuildResult => {
const portText = draft.webPort.trim();
if (draft.mode === 'web' && portText && !/^\d+$/u.test(portText)) {
return { options: null, error: 'invalidPort' };
}
const webPort = portText ? Number(portText) : null;
if (draft.mode === 'web' && webPort !== null && (!Number.isInteger(webPort) || webPort < 0 || webPort > 65535)) {
return { options: null, error: 'invalidPort' };
}
const task = draft.task.trim();
if (draft.mode === 'headless' && !task) {
return { options: null, error: 'taskRequired' };
}
const profile = draft.profile.trim();
if (draft.mode === 'custom' && !profile) {
return { options: null, error: 'profileRequired' };
}
if (
draft.mode === 'custom'
&& (profile === '.' || profile === '..' || !/^[A-Za-z0-9._-]+$/u.test(profile))
) {
return { options: null, error: 'invalidProfile' };
}
return {
options: {
mode: draft.mode,
webHost: draft.mode === 'web' ? draft.webHost.trim() || null : null,
webPort: draft.mode === 'web' ? webPort : null,
openBrowser: draft.openBrowser,
trustedHosts: draft.mode === 'web' ? nonEmptyLines(draft.trustedHosts) : [],
task: draft.mode === 'headless' ? task : null,
profile: draft.mode === 'custom' ? profile : null,
patches: nonEmptyLines(draft.patches),
},
error: null,
};
};
+81 -1
View File
@@ -6160,6 +6160,87 @@ textarea:focus-visible {
min-height: 0;
}
.deepseek-harness-launch-options {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--theme-e3ded5);
border-radius: 10px;
background: var(--theme-faf8f4);
}
.deepseek-harness-launch-field {
grid-template-rows: auto auto;
gap: 6px;
}
.deepseek-harness-launch-field > span {
font-size: var(--font-size-label);
}
.deepseek-harness-launch-field select.config-dialog-text-input {
cursor: pointer;
}
.deepseek-harness-launch-field textarea.config-dialog-text-input {
height: 72px;
min-height: 72px;
padding-block: 9px;
resize: vertical;
line-height: var(--line-height-relaxed);
}
.deepseek-harness-launch-mode-description {
margin: -4px 0 0;
color: var(--theme-777067);
font-size: var(--font-size-label);
font-weight: 500;
line-height: var(--line-height-relaxed);
}
.deepseek-harness-launch-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(132px, 0.42fr);
gap: 10px;
}
.deepseek-harness-launch-checkbox {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--theme-5f5a53);
cursor: pointer;
}
.deepseek-harness-launch-checkbox > input {
width: 16px;
height: 16px;
margin: 2px 0 0;
accent-color: var(--theme-8069ad);
}
.deepseek-harness-launch-checkbox > span {
display: grid;
gap: 2px;
}
.deepseek-harness-launch-checkbox strong {
color: var(--theme-2d2a26);
font-size: var(--font-size-label);
}
.deepseek-harness-launch-checkbox small {
color: var(--theme-777067);
font-size: var(--font-size-meta);
line-height: var(--line-height-relaxed);
}
@media (max-width: 680px) {
.deepseek-harness-launch-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.agent-launch-directory-picker {
display: flex;
width: 100%;
@@ -12945,7 +13026,6 @@ body.table-col-resizing * {
gap: 10px;
}
/* 聚焦指导切换按钮 */
.simple-mode-guide-toggle {
display: inline-flex;
align-items: center;
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'bun:test';
import {
buildDeepSeekHarnessLaunchOptions,
DEFAULT_DEEPSEEK_HARNESS_LAUNCH_DRAFT,
type DeepSeekHarnessLaunchDraft,
} from '../src/services/deepSeekHarnessLaunch';
const draft = (overrides: Partial<DeepSeekHarnessLaunchDraft> = {}): DeepSeekHarnessLaunchDraft => ({
...DEFAULT_DEEPSEEK_HARNESS_LAUNCH_DRAFT,
...overrides,
});
describe('DeepSeek Harness launch options', () => {
it('builds web options and normalizes repeatable values', () => {
const result = buildDeepSeekHarnessLaunchOptions(draft({
webHost: ' 127.0.0.1 ',
webPort: '8080',
openBrowser: false,
trustedHosts: 'localhost:3000\n\n example.test ',
patches: './base.yml\n ./local.yml ',
}));
expect(result).toEqual({
options: {
mode: 'web',
webHost: '127.0.0.1',
webPort: 8080,
openBrowser: false,
trustedHosts: ['localhost:3000', 'example.test'],
task: null,
profile: null,
patches: ['./base.yml', './local.yml'],
},
error: null,
});
});
it('requires valid web ports', () => {
expect(buildDeepSeekHarnessLaunchOptions(draft({ webPort: '65536' })).error).toBe('invalidPort');
expect(buildDeepSeekHarnessLaunchOptions(draft({ webPort: '12.5' })).error).toBe('invalidPort');
});
it('requires a headless task and keeps it as one value', () => {
expect(buildDeepSeekHarnessLaunchOptions(draft({ mode: 'headless' })).error).toBe('taskRequired');
expect(buildDeepSeekHarnessLaunchOptions(draft({ mode: 'headless', task: ' review repo ' }))).toMatchObject({
options: { mode: 'headless', task: 'review repo' },
error: null,
});
});
it('validates custom profile names', () => {
expect(buildDeepSeekHarnessLaunchOptions(draft({ mode: 'custom' })).error).toBe('profileRequired');
expect(buildDeepSeekHarnessLaunchOptions(draft({ mode: 'custom', profile: '../web' })).error).toBe('invalidProfile');
expect(buildDeepSeekHarnessLaunchOptions(draft({ mode: 'custom', profile: 'tui-dev' }))).toMatchObject({
options: { mode: 'custom', profile: 'tui-dev' },
error: null,
});
});
});