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:
1 parent
f7bc460e3c
commit
70ed5bc7f2
13 files changed
+1537
-46
No files matched your search
@@ -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(¤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<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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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': '未インストール',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user