diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 0ec6482..2054508 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -5,6 +5,7 @@ use super::*; mod commands; mod configuration; mod discovery; +mod deepseek_harness; mod transactions; mod backups; mod templates; @@ -13,6 +14,7 @@ mod state; pub(crate) use commands::*; pub(crate) use configuration::*; pub(crate) use discovery::*; +pub(crate) use deepseek_harness::*; pub(crate) use transactions::*; pub(crate) use backups::*; pub(crate) use templates::*; diff --git a/src-tauri/src/agents/backups.rs b/src-tauri/src/agents/backups.rs index d9e3a90..99f7b16 100644 --- a/src-tauri/src/agents/backups.rs +++ b/src-tauri/src/agents/backups.rs @@ -410,6 +410,13 @@ pub(crate) fn desktop_mapping_path(paths: &[PathBuf]) -> Result .join("current-mapping.json")) } +pub(crate) fn deepseek_harness_catalog_state_path(paths: &[PathBuf]) -> Result { + Ok(agent_data_directory(paths)? + .join("agents/deepseek-harness") + .join(path_identity(paths)) + .join("catalog-state.json")) +} + fn profile_models_hash(images: &Images) -> Option { let (path, bytes) = images.get(2)?; let profile = parse(path, text(bytes.as_deref()).ok()?).ok()?; @@ -452,6 +459,9 @@ pub(crate) fn current_desktop_mappings(home: &Path) -> Option Result { + if client == "deepseek-harness" { + return Ok(image_hash(read_agent_bytes(&deepseek_harness_catalog_state_path(paths)?)?.as_deref())); + } if client != "claude-desktop" { return Ok(String::new()); } diff --git a/src-tauri/src/agents/backups/restore.rs b/src-tauri/src/agents/backups/restore.rs index 80db2a1..0039959 100644 --- a/src-tauri/src/agents/backups/restore.rs +++ b/src-tauri/src/agents/backups/restore.rs @@ -63,6 +63,8 @@ fn desktop_restore_models(plan: &RestorePlan) -> Result Vec { ["gpt-one", "gpt-two"] .into_iter() .map(|name| AgentModelOption { + input_modalities: None, + harness_metadata: None, name: name.into(), alias: None, is_alias: false, diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index e289111..e75f992 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1173,8 +1173,15 @@ pub(crate) async fn update_agent_config( claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, ) -> Result { - apply_agent_config(app, gui_config_state, client, model, oauth_configuration, - claude_code_model_mappings, claude_desktop_model_mappings).await + apply_agent_config( + app, + gui_config_state, + client, + model, + oauth_configuration, + claude_code_model_mappings, + claude_desktop_model_mappings, + ).await } pub(crate) fn validate_agent_can_enable( @@ -1224,11 +1231,18 @@ pub(crate) fn resolve_available_agent_model( pub(crate) fn parse_agent_model_options( payload: &serde_json::Value, ) -> Result, String> { - let source = payload - .as_array() + let object_models = payload.get("models").and_then(serde_json::Value::as_object).map(|models| { + models.iter().map(|(id, value)| { + let mut entry = value.as_object().cloned().unwrap_or_default(); + entry.entry("id").or_insert(serde_json::json!(id)); + serde_json::Value::Object(entry) + }).collect::>() + }); + let source = payload.as_array() .or_else(|| payload.get("data").and_then(serde_json::Value::as_array)) .or_else(|| payload.get("models").and_then(serde_json::Value::as_array)) - .ok_or_else(|| "本机模型列表响应缺少 data 或 models 数组".to_string())?; + .or(object_models.as_ref()) + .ok_or_else(|| "本机模型列表响应缺少 data 数组或 models 目录".to_string())?; let mut models = Vec::new(); for item in source { let name = if let Some(name) = item.as_str() { @@ -1241,7 +1255,7 @@ pub(crate) fn parse_agent_model_options( .trim() .to_string() }; - let display_name = ["display_name", "displayName"] + let display_name = ["display_name", "displayName", "name"] .into_iter() .find_map(|key| item.get(key).and_then(serde_json::Value::as_str)) .map(str::trim) @@ -1270,14 +1284,18 @@ pub(crate) fn parse_agent_model_options( ] .into_iter() .find_map(|key| item.get(key).and_then(json_positive_u64)); + let input_modalities = codex_catalog::parse_modalities(item).or_else(|| { + codex_catalog::parse_modalities(&serde_json::json!({"input_modalities": item.get("input")})) + }); + let harness_metadata = harness_api_metadata(item); if let Some(model_alias) = model_alias { if keep_original { - append_agent_model_option(&mut models, &name, display_name, false, context_window); + append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities.clone(), harness_metadata.clone()); } - append_agent_model_option(&mut models, &model_alias, Some(name), true, context_window); + append_agent_model_option(&mut models, &model_alias, Some(name), true, context_window, input_modalities, harness_metadata); } else { - append_agent_model_option(&mut models, &name, display_name, false, context_window); + append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities, harness_metadata); } } Ok(models) @@ -1289,6 +1307,8 @@ pub(crate) fn append_agent_model_option( alias: Option, is_alias: bool, context_window: Option, + input_modalities: Option>, + harness_metadata: Option, ) { let name = name.trim(); if name.is_empty() @@ -1304,6 +1324,8 @@ pub(crate) fn append_agent_model_option( .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(name)) .map(str::to_string); models.push(AgentModelOption { + input_modalities, + harness_metadata, name: name.to_string(), alias, is_alias, diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 2a43fb2..12ce41c 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -322,6 +322,13 @@ pub(crate) fn build_deepseek_harness_models( .map_err(|error| format!("序列化 Harness 模型上下文失败: {error}"))?, ); } + if let Some(input) = model.input_modalities { + entry.insert( + yaml_key("input"), + serde_norway::to_value(input) + .map_err(|_| "序列化 Harness 模型输入类型失败")?, + ); + } Ok(serde_norway::Value::Mapping(entry)) }) .collect::, String>>()?; @@ -334,7 +341,21 @@ pub(crate) fn build_deepseek_harness_settings( model: &str, models: &[AgentModelOption], ) -> Result { - let published_models = build_deepseek_harness_models(models, model)?; + build_deepseek_harness_catalog_settings( + existing, + base_url, + model, + models, + &mut DeepSeekHarnessCatalogState::default(), + ) +} + +pub(crate) fn render_deepseek_harness_settings( + existing: Option<&str>, + base_url: &str, + model: &str, + published_models: serde_norway::Value, +) -> Result { render_agent_yaml_mapping_update(existing, "DeepSeek Harness settings", |root| { let llm = root .entry(yaml_key("llm-pi-ai")) @@ -369,6 +390,8 @@ pub(crate) fn build_deepseek_harness_settings( .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "DeepSeek Harness agent-default-model 必须是映射".to_string())?; + let same_model = selection.get(yaml_key("provider")).and_then(serde_norway::Value::as_str) == Some(DEEPSEEK_HARNESS_PROVIDER_ID) + && selection.get(yaml_key("model")).and_then(serde_norway::Value::as_str) == Some(model); selection.insert( yaml_key("provider"), serde_norway::Value::String(DEEPSEEK_HARNESS_PROVIDER_ID.to_string()), @@ -377,7 +400,7 @@ pub(crate) fn build_deepseek_harness_settings( yaml_key("model"), serde_norway::Value::String(model.to_string()), ); - selection.remove(yaml_key("reasoningEffort")); + if !same_model { selection.remove(yaml_key("reasoningEffort")); } Ok(()) }) } @@ -693,6 +716,8 @@ pub(crate) fn ordered_agent_models( ordered.push(selected.clone()); } else { ordered.push(AgentModelOption { + input_modalities: None, + harness_metadata: None, name: selected_model.to_string(), alias: None, is_alias: false, diff --git a/src-tauri/src/agents/deepseek_harness.rs b/src-tauri/src/agents/deepseek_harness.rs new file mode 100644 index 0000000..3c6ab66 --- /dev/null +++ b/src-tauri/src/agents/deepseek_harness.rs @@ -0,0 +1,681 @@ +use super::*; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; + +type Profile = Map; + +static HARNESS_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!( + "../../../src/services/deepSeekHarnessSchema.json" + )) + .unwrap() +}); + +pub(crate) fn harness_fields(group: &str) -> impl Iterator { + HARNESS_SCHEMA[group] + .as_array() + .unwrap() + .iter() + .map(|field| field["name"].as_str().unwrap()) +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DeepSeekHarnessCatalogState { + models: BTreeMap, + provider: Profile, + generated_models: BTreeMap, + generated_provider: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct HarnessEditorModel { + pub id: String, + pub defaults: Profile, + pub configuration: Profile, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HarnessEditorSnapshot { + pub revision: String, + pub models: Vec, + pub provider: Profile, + base_url: String, + default_model: Option, + configured: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct HarnessEditorRequest { + pub revision: String, + pub models: Vec, + pub provider: Profile, +} + +fn read_harness_state(bytes: Option<&[u8]>) -> Result { + bytes + .map(|bytes| { + serde_json::from_slice(bytes) + .map_err(|_| "读取 DSH 模型配置状态失败,请检查 catalog-state.json".into()) + }) + .unwrap_or_else(|| Ok(Default::default())) +} + +fn harness_provider(root: &Value) -> Option<&Value> { + root.get("llm-pi-ai")? + .get("providers")? + .get(DEEPSEEK_HARNESS_PROVIDER_ID) +} + +fn harness_root(existing: Option<&str>) -> Result { + serde_json::to_value(parse_agent_yaml_mapping( + existing, + "DeepSeek Harness settings", + )?) + .map_err(|_| "DSH settings 必须是有效的配置映射".into()) +} + +fn selected_fields(value: &Value, group: &str) -> Profile { + harness_fields(group) + .filter_map(|key| value.get(key).map(|value| (key.into(), value.clone()))) + .collect() +} + +fn import_changes( + current: &Profile, + generated: Option<&Profile>, + overrides: &mut Profile, + group: &str, +) { + for key in harness_fields(group) { + let current = current.get(key); + let previous = generated.and_then(|fields| fields.get(key)); + if current != previous { + if let Some(value) = current { + overrides.insert(key.into(), value.clone()); + } else { + overrides.remove(key); + } + } + } +} + +fn import_harness_configuration(root: &Value, state: &mut DeepSeekHarnessCatalogState) { + let Some(provider) = harness_provider(root) else { + return; + }; + import_changes( + &selected_fields(provider, "provider"), + state.generated_provider.as_ref(), + &mut state.provider, + "provider", + ); + if let Some(models) = provider.get("models").and_then(Value::as_array) { + for model in models { + if let Some(id) = model.get("id").and_then(Value::as_str) { + import_changes( + &selected_fields(model, "model"), + state.generated_models.get(id), + state.models.entry(id.into()).or_default(), + "model", + ); + } + } + } + state.models.retain(|_, profile| !profile.is_empty()); +} + +pub(crate) fn harness_api_metadata(item: &Value) -> Option { + let mut fields = Profile::new(); + for (field, keys) in [ + ( + "maxTokens", + &[ + "maxTokens", + "max_tokens", + "max_output_tokens", + "maxOutputTokens", + ][..], + ), + ( + "reasoningEfforts", + &["reasoningEfforts", "reasoning_efforts"][..], + ), + ("compat", &["compat"][..]), + ] { + if let Some(value) = keys.iter().find_map(|key| item.get(key)) { + let definition = HARNESS_SCHEMA["model"] + .as_array() + .unwrap() + .iter() + .find(|item| item["name"] == field) + .unwrap(); + if validate_harness_field(value, definition, "", field).is_ok() { + fields.insert(field.into(), value.clone()); + } + } + } + (!fields.is_empty()).then_some(Value::Object(fields)) +} + +fn harness_model_defaults(model: &AgentModelOption, api: &str) -> Profile { + let mut fields = model + .harness_metadata + .as_ref() + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let Some(compat) = fields.get_mut("compat").and_then(Value::as_object_mut) { + compat.retain(|key, _| { + HARNESS_SCHEMA["compat"] + .as_array() + .unwrap() + .iter() + .find(|field| field["name"] == key.as_str()) + .is_some_and(|field| field["apis"].as_array().unwrap().contains(&json!(api))) + }); + if compat.is_empty() { + fields.remove("compat"); + } + } + if let Some(name) = &model.alias { + fields.insert("name".into(), json!(name)); + } + if let Some(context) = model.context_window { + fields.insert("contextWindow".into(), json!(context)); + } + if let Some(input) = &model.input_modalities { + fields.insert("input".into(), json!(input)); + } + fields +} + +fn merge_profile(mut defaults: Profile, configuration: &Profile) -> Profile { + for (key, value) in configuration { + if key == "compat" { + if let (Some(old), Some(new)) = ( + defaults.get_mut(key).and_then(Value::as_object_mut), + value.as_object(), + ) { + old.extend(new.clone()); + continue; + } + } + defaults.insert(key.clone(), value.clone()); + } + defaults +} + +pub(crate) fn validate_harness_profile( + profile: &Profile, + group: &str, + api: &str, + path: &str, +) -> Result<(), String> { + let definitions = HARNESS_SCHEMA[group].as_array().unwrap(); + for (key, value) in profile { + let field = definitions + .iter() + .find(|field| field["name"] == key.as_str()) + .ok_or_else(|| format!("{path}.{key}: 不支持的配置字段"))?; + validate_harness_field(value, field, api, &format!("{path}.{key}"))?; + } + for field in definitions { + if field["required"] == true && !profile.contains_key(field["name"].as_str().unwrap()) { + return Err(format!( + "{path}.{}: 必须填写", + field["name"].as_str().unwrap() + )); + } + } + if group == "backoff" + && profile + .get("initialDelayMs") + .and_then(Value::as_f64) + .unwrap_or(500.0) + > profile + .get("maxDelayMs") + .and_then(Value::as_f64) + .unwrap_or(10000.0) + { + return Err(format!("{path}: initialDelayMs 不能大于 maxDelayMs")); + } + Ok(()) +} + +fn validate_harness_field( + value: &Value, + field: &Value, + api: &str, + path: &str, +) -> Result<(), String> { + let invalid = || format!("{path}: 配置值不符合 DSH 文档要求"); + if !api.is_empty() + && field + .get("apis") + .and_then(Value::as_array) + .is_some_and(|apis| !apis.contains(&json!(api))) + { + return Err(format!("{path}: 不适用于 {api},请恢复自动或选择对应协议")); + } + let valid = match field["kind"].as_str().unwrap() { + "group" => { + return validate_harness_profile( + value.as_object().ok_or_else(invalid)?, + field["group"].as_str().unwrap(), + api, + path, + ) + } + "string" => value.as_str().is_some_and(|value| !value.trim().is_empty()), + "boolean" => value.is_boolean(), + "integer" | "number" => value.as_f64().is_some_and(|n| { + n.is_finite() + && n.abs() <= 9007199254740991.0 + && (field["kind"] != "integer" || n.fract() == 0.0) + && field["min"].as_f64().is_none_or(|min| n >= min) + && field["exclusiveMin"].as_f64().is_none_or(|min| n > min) + && field["max"].as_f64().is_none_or(|max| n <= max) + }), + "enum" => field["values"].as_array().unwrap().contains(value), + "modalities" | "strings" => value.as_array().is_some_and(|values| { + (!values.is_empty() || field["name"] == "input") + && values.iter().enumerate().all(|(index, value)| { + value.as_str().is_some_and(|s| { + !s.trim().is_empty() + && (field["kind"] != "modalities" || matches!(s, "text" | "image")) + }) && !values[..index].contains(value) + }) + }), + "reasoning" => { + value == &json!(false) + || value.as_object().is_some_and(|values| { + !values.is_empty() + && values.iter().all(|(key, value)| { + ["off", "minimal", "low", "medium", "high", "xhigh", "max"] + .contains(&key.as_str()) + && ((key == "off" && value.is_null()) + || value.as_str().is_some_and(|s| !s.trim().is_empty())) + }) + }) + } + "headers" => value.as_object().is_some_and(|values| { + values.iter().all(|(key, value)| { + reqwest::header::HeaderName::from_bytes(key.as_bytes()).is_ok() + && value.as_str().is_some_and(|s| { + s.chars().all(|c| (c as u32) <= 255) + && reqwest::header::HeaderValue::from_str(s).is_ok() + }) + }) + }), + "kwargs" => value.as_object().is_some_and(|values| { + values.values().all(|value| { + !value.is_array() + && (!value.is_object() + || value.as_object().is_some_and(|object| { + object + .keys() + .all(|key| matches!(key.as_str(), "$var" | "omitWhenOff")) + && value["$var"].as_str().is_some_and(|s| { + ["thinking.enabled", "thinking.effort", "thinking.budget"] + .contains(&s) + }) + && value.get("omitWhenOff").is_none_or(Value::is_boolean) + })) + }) + }), + _ => false, + }; + if valid { + Ok(()) + } else { + Err(invalid()) + } +} + +fn render_harness_profiles( + existing: Option<&str>, + base_url: &str, + models: &[AgentModelOption], + state: &mut DeepSeekHarnessCatalogState, +) -> Result { + let root = harness_root(existing)?; + let old = harness_provider(&root); + let api = state + .provider + .get("api") + .and_then(Value::as_str) + .unwrap_or("openai-completions"); + validate_harness_profile(&state.provider, "provider", api, "provider")?; + let mut published = Vec::new(); + let mut generated = BTreeMap::new(); + for model in models { + let profile = merge_profile( + harness_model_defaults(model, api), + state.models.get(&model.name).unwrap_or(&Profile::new()), + ); + validate_harness_profile(&profile, "model", api, &model.name)?; + let mut entry = old + .and_then(|p| p.get("models")) + .and_then(Value::as_array) + .and_then(|entries| entries.iter().find(|entry| entry["id"] == model.name)) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for key in harness_fields("model") { + entry.remove(key); + } + entry.extend(profile.clone()); + entry.insert("id".into(), json!(model.name)); + generated.insert(model.name.clone(), profile); + published.push(entry); + } + let mut provider = state.provider.clone(); + provider + .entry("displayName") + .or_insert(json!("EasyCLIProxyAPI")); + provider.entry("api").or_insert(json!("openai-completions")); + let rendered = + render_agent_yaml_mapping_update(existing, "DeepSeek Harness settings", |root| { + let managed = root + .get_mut(yaml_key("llm-pi-ai")) + .and_then(|v| v.get_mut("providers")) + .and_then(|v| v.get_mut(DEEPSEEK_HARNESS_PROVIDER_ID)) + .and_then(serde_norway::Value::as_mapping_mut) + .ok_or("DSH provider 配置缺失")?; + managed.insert( + yaml_key("baseURL"), + serde_norway::Value::String( + if api == "anthropic-messages" { + base_url.trim_end_matches("/v1") + } else { + base_url + } + .into(), + ), + ); + for key in harness_fields("provider") { + managed.remove(yaml_key(key)); + } + for (key, value) in &provider { + managed.insert( + yaml_key(key), + serde_norway::to_value(value).map_err(|_| "序列化 DSH provider 失败")?, + ); + } + managed.insert( + yaml_key("models"), + serde_norway::to_value(&published).map_err(|_| "序列化 DSH 模型失败")?, + ); + Ok(()) + })?; + state.generated_models = generated; + state.generated_provider = Some(provider); + Ok(rendered) +} + +pub(crate) fn deepseek_harness_template_catalog_state(images: &Images) -> Result, String> { + let root = harness_root(text( + images.first().ok_or("缺少 DSH settings")?.1.as_deref(), + )?)?; + let mut state = DeepSeekHarnessCatalogState::default(); + if let Some(provider) = harness_provider(&root) { + state.generated_provider = Some(selected_fields(provider, "provider")); + if let Some(models) = provider.get("models").and_then(Value::as_array) { + for model in models { + if let Some(id) = model.get("id").and_then(Value::as_str) { + state + .generated_models + .insert(id.into(), selected_fields(model, "model")); + } + } + } + } + serde_json::to_vec(&state).map_err(|_| "生成 DSH 模型配置状态失败".into()) +} + +pub(crate) fn build_deepseek_harness_catalog_settings( + existing: Option<&str>, + base_url: &str, + selected: &str, + models: &[AgentModelOption], + state: &mut DeepSeekHarnessCatalogState, +) -> Result { + import_harness_configuration(&harness_root(existing)?, state); + let ordered = ordered_agent_models(models, selected); + let settings = render_deepseek_harness_settings( + existing, + base_url, + selected, + build_deepseek_harness_models(models, selected)?, + )?; + render_harness_profiles(Some(&settings), base_url, &ordered, state) +} + +pub(crate) fn prepare_deepseek_harness_configuration( + home: &Path, + port: u16, + api_key: &str, + selected: &str, + models: &[AgentModelOption], +) -> Result<(Vec, Images, Images), String> { + let mut paths = config_paths("deepseek-harness", home)?; + paths.push(deepseek_harness_catalog_state_path(&paths)?); + let before = config_images(&paths)?; + let mut state = read_harness_state(before[2].1.as_deref())?; + let settings = build_deepseek_harness_catalog_settings( + text(before[0].1.as_deref())?, + &format!("{}/v1", managed_core_loopback_origin(port)), + selected, + models, + &mut state, + )?; + let updates = vec![ + AgentFileUpdate { + path: paths[0].clone(), + after: settings, + }, + AgentFileUpdate { + path: paths[1].clone(), + after: build_deepseek_harness_credentials(text(before[1].1.as_deref())?, api_key)?, + }, + ]; + let mut after = prepare_config_updates( + "deepseek-harness", + &paths[..2], + &before[..2].to_vec(), + &updates, + false, + )?; + after.push(( + paths[2].clone(), + Some(serde_json::to_vec(&state).map_err(|_| "生成 DSH 模型配置状态失败")?), + )); + Ok((paths, before, after)) +} + +pub(crate) fn apply_deepseek_harness_configuration( + home: &Path, + port: u16, + api_key: &str, + selected: &str, + models: &[AgentModelOption], +) -> Result { + let (paths, before, after) = + prepare_deepseek_harness_configuration(home, port, api_key, selected, models)?; + commit_config( + "deepseek-harness", + &paths, + &before, + &after, + "update", + Some(selected.into()), + ) +} + +fn harness_editor_from_images( + before: &Images, + models: &[AgentModelOption], + port: u16, +) -> Result<(HarnessEditorSnapshot, DeepSeekHarnessCatalogState), String> { + let root = harness_root(text(before[0].1.as_deref())?)?; + let mut state = read_harness_state(before[2].1.as_deref())?; + import_harness_configuration(&root, &mut state); + let api = state + .provider + .get("api") + .and_then(Value::as_str) + .unwrap_or("openai-completions"); + let models = models + .iter() + .map(|model| HarnessEditorModel { + id: model.name.clone(), + defaults: harness_model_defaults(model, api), + configuration: state.models.get(&model.name).cloned().unwrap_or_default(), + }) + .collect::>(); + let base_url = format!("{}/v1", managed_core_loopback_origin(port)); + let revision = sha256_bytes( + &serde_json::to_vec(&(image_revision(before), &models, &base_url)) + .map_err(|_| "生成 DSH 配置版本失败")?, + ); + Ok(( + HarnessEditorSnapshot { + revision, + models, + provider: state.provider.clone(), + base_url, + default_model: root + .get("agent-default-model") + .filter(|s| s["provider"] == DEEPSEEK_HARNESS_PROVIDER_ID) + .and_then(|s| s.get("model")) + .and_then(Value::as_str) + .map(str::to_string), + configured: harness_provider(&root).is_some(), + }, + state, + )) +} + +pub(crate) fn harness_editor_snapshot( + home: &Path, + models: &[AgentModelOption], + port: u16, +) -> Result { + let mut paths = config_paths("deepseek-harness", home)?; + paths.push(deepseek_harness_catalog_state_path(&paths)?); + harness_editor_from_images(&config_images(&paths)?, models, port).map(|(snapshot, _)| snapshot) +} + +pub(crate) fn save_harness_editor( + home: &Path, + models: &[AgentModelOption], + port: u16, + request: HarnessEditorRequest, +) -> Result { + let mut paths = config_paths("deepseek-harness", home)?; + paths.push(deepseek_harness_catalog_state_path(&paths)?); + let before = config_images(&paths)?; + let (snapshot, mut state) = harness_editor_from_images(&before, models, port)?; + if request.revision != snapshot.revision { + return Err("DSH_MODEL_CATALOG_CHANGED: 模型列表或配置已变化,请重新加载".into()); + } + if request.models.len() != models.len() { + return Err("DSH 模型列表不完整,请重新加载".into()); + } + let mut seen = std::collections::HashSet::new(); + for model in request.models { + if !seen.insert(model.id.clone()) || !models.iter().any(|m| m.name == model.id) { + return Err("DSH 模型列表包含重复或不可用的 ID".into()); + } + state.models.insert(model.id, model.configuration); + } + state.provider = request.provider; + let api = state + .provider + .get("api") + .and_then(Value::as_str) + .unwrap_or("openai-completions"); + validate_harness_profile(&state.provider, "provider", api, "provider")?; + for model in models { + validate_harness_profile( + &merge_profile( + harness_model_defaults(model, api), + &state.models[&model.name], + ), + "model", + api, + &model.name, + )?; + } + let mut after = before.clone(); + if snapshot.configured { + if snapshot + .default_model + .as_ref() + .is_some_and(|id| !models.iter().any(|m| &m.name == id)) + { + return Err("当前默认模型已不可用,请先更新默认模型".into()); + } + let settings = render_harness_profiles( + text(before[0].1.as_deref())?, + &format!("{}/v1", managed_core_loopback_origin(port)), + models, + &mut state, + )?; + let updates = vec![AgentFileUpdate { + path: paths[0].clone(), + after: settings, + }]; + let prepared = prepare_config_updates( + "deepseek-harness", + &paths[..2], + &before[..2].to_vec(), + &updates, + false, + )?; + after[0] = prepared[0].clone(); + } + state.models.retain(|_, profile| !profile.is_empty()); + after[2].1 = Some(serde_json::to_vec(&state).map_err(|_| "生成 DSH 模型配置状态失败")?); + commit_config( + "deepseek-harness", + &paths, + &before, + &after, + "catalog", + snapshot.default_model.clone(), + )?; + harness_editor_snapshot(home, models, port) +} + +#[tauri::command] +pub(crate) async fn get_deepseek_harness_model_catalog_editor( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + let config = gui_config_state.snapshot()?; + let models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏")?; + harness_editor_snapshot(&home, &models, config.port) +} + +#[tauri::command] +pub(crate) async fn save_deepseek_harness_model_catalog_editor( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, + request: HarnessEditorRequest, +) -> Result { + let config = gui_config_state.snapshot()?; + let models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏")?; + save_harness_editor(&home, &models, config.port, request) +} diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index cbe0a7c..74d2f90 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -3855,7 +3855,11 @@ pub(crate) fn inspect_deepseek_harness_config( .map(str::to_string) }) .flatten(); - let expected_base = format!("{}/v1", managed_core_loopback_origin(port)); + let expected_base = if provider.and_then(|p| yaml_mapping_value(p, "api")).and_then(serde_norway::Value::as_str) == Some("anthropic-messages") { + managed_core_loopback_origin(port) + } else { + format!("{}/v1", managed_core_loopback_origin(port)) + }; let credentials_layout_supported = deepseek_harness_credentials_layout_supported(&credentials); let credential = yaml_mapping_value(&credentials, "refs") .and_then(serde_norway::Value::as_mapping) @@ -3882,7 +3886,7 @@ pub(crate) fn inspect_deepseek_harness_config( && provider .and_then(|provider| yaml_mapping_value(provider, "api")) .and_then(serde_norway::Value::as_str) - == Some("openai-completions") + .is_some_and(|api| matches!(api, "openai-completions" | "openai-responses" | "anthropic-messages")) && provider .and_then(|provider| yaml_mapping_value(provider, "baseURL")) .and_then(serde_norway::Value::as_str) diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 0e2cc2a..90f1841 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -635,6 +635,8 @@ pub(crate) fn fresh_agent_contents( model: &str, ) -> Result, String> { let models = [AgentModelOption { + input_modalities: None, + harness_metadata: None, name: model.to_string(), alias: None, is_alias: false, @@ -1445,6 +1447,9 @@ pub(crate) fn apply_agent_configuration_with_oauth( model: &str, options: AgentConfigurationOptions<'_>, ) -> Result { + if client == AgentClient::DeepSeekHarness { + return apply_deepseek_harness_configuration(home, port, api_key, model, options.models); + } let before = config_images(&config_paths(client.id(), home)?)?; validate_config_images(&before)?; let mappings = options.claude_desktop_model_mappings; @@ -1469,6 +1474,8 @@ pub(crate) fn reset_agent_configuration_to_default( api_key, model, models: &[AgentModelOption { + input_modalities: None, + harness_metadata: None, name: model.to_string(), alias: None, is_alias: false, diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index e07be8b..61b8d9f 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -181,6 +181,17 @@ fn commit_config_transaction( } let mut previous = before.clone(); let mut target = after.clone(); + if client == "deepseek-harness" && matches!(source, "template" | "restore") { + let state_path = deepseek_harness_catalog_state_path(paths)?; + let state_before = read_agent_bytes(&state_path)?; + let state_after = if source == "template" { + Some(deepseek_harness_template_catalog_state(after)?) + } else { + None + }; + previous.push((state_path.clone(), state_before)); + target.push((state_path, state_after)); + } if client == "claude-desktop" { let state_path = desktop_mapping_path(paths)?; let state_before = read_agent_bytes(&state_path)?; @@ -382,7 +393,7 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: & "claude-desktop" => &["name", "contextWindow", "supports1m", "prefer1m"], "opencode" | "zcode" => &["name"], "openclaw" => &["id", "name", "alias"], - "deepseek-harness" => &["id", "name", "contextWindow"], + "deepseek-harness" => &["id", "name", "contextWindow", "input", "maxTokens", "reasoningEfforts", "compat"], "kimi-code" => &[ "provider", "model", @@ -553,7 +564,7 @@ fn validate_unmanaged_preserved( } if client == "deepseek-harness" { if path == &paths[0] { - for key in ["displayName", "apiKeyEnv", "api", "baseURL", "models"] { + for key in ["apiKeyEnv", "baseURL", "models"].into_iter().chain(harness_fields("provider")) { set( &mut value, &[ diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index de7e832..9ab2379 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -519,6 +519,8 @@ fn prepare_catalog_with_customizations( let models = entries .iter() .map(|entry| AgentModelOption { + input_modalities: None, + harness_metadata: None, name: string_value(&entry.value, "slug"), alias: optional_map_string(&entry.value, "display_name").filter(|display| { !display.eq_ignore_ascii_case(&string_value(&entry.value, "slug")) @@ -681,7 +683,7 @@ fn enable_fast_mode(model: &mut Map) { ); } -fn parse_modalities(value: &Value) -> Option> { +pub(crate) fn parse_modalities(value: &Value) -> Option> { let raw = value .get("input_modalities") .or_else(|| value.get("inputModalities")) @@ -1070,18 +1072,24 @@ mod tests { fn runtime_context_windows_override_generic_model_metadata() { let mut models = vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "GPT-Test".to_string(), alias: None, is_alias: false, context_window: Some(200_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "unmatched".to_string(), alias: None, is_alias: false, context_window: Some(128_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "max-only".to_string(), alias: None, is_alias: false, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5f856ab..5deaa41 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1121,8 +1121,13 @@ struct AgentModelOption { is_alias: bool, #[serde(default)] context_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + input_modalities: Option>, + #[serde(skip)] + harness_metadata: Option, } + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct ClaudeDesktopModelMappings { @@ -2521,6 +2526,8 @@ fn main() { uninstall_pi_provider, check_codex_oauth_login, update_codex_model_catalog, + get_deepseek_harness_model_catalog_editor, + save_deepseek_harness_model_catalog_editor, get_codex_model_catalog_editor, save_codex_model_catalog_editor, get_thinking_aliases, diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 0921fb9..018d2cb 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -1,6 +1,7 @@ use super::*; mod agent_configuration; +mod deepseek_harness_catalog; mod agent_paths; mod agent_state; mod agent_transactions; diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 5bacbc1..91a935a 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -128,24 +128,32 @@ fn claude_code_role_mappings_drive_settings() { }; let models = vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-opus-base".to_string(), alias: None, is_alias: false, context_window: Some(1_000_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: mappings.opus.clone(), alias: Some("gpt-opus-base".to_string()), is_alias: true, context_window: Some(128_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: mappings.sonnet.clone(), alias: None, is_alias: false, context_window: Some(272_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: mappings.haiku.clone(), alias: None, is_alias: false, @@ -188,12 +196,16 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { }; let models = vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "custom-pro".to_string(), alias: Some("Custom Pro".to_string()), is_alias: false, context_window: Some(200_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "custom-flash".to_string(), alias: Some("Custom Flash".to_string()), is_alias: false, @@ -251,6 +263,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { #[test] fn claude_desktop_keeps_original_context_when_1m_is_off() { let models = vec![AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "runtime-model".to_string(), alias: None, is_alias: false, @@ -574,18 +588,24 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { }; let models = vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: mappings.opus.clone(), alias: None, is_alias: false, context_window: Some(1_000_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: mappings.sonnet.clone(), alias: None, is_alias: false, context_window: Some(272_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: mappings.haiku.clone(), alias: None, is_alias: false, @@ -1358,30 +1378,40 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { models, vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-5".to_string(), alias: Some("GPT 5".to_string()), is_alias: false, context_window: Some(272_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "claude-sonnet".to_string(), alias: None, is_alias: false, context_window: Some(1_000_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "claude-sonnet-xhigh".to_string(), alias: Some("claude-sonnet".to_string()), is_alias: true, context_window: Some(1_000_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "visible-alias".to_string(), alias: Some("hidden-original".to_string()), is_alias: true, context_window: Some(128_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "deepseek-chat".to_string(), alias: None, is_alias: false, @@ -1483,12 +1513,16 @@ agent-default-model: "#; let models = vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-selected".to_string(), alias: Some("Selected Model".to_string()), is_alias: false, context_window: Some(272_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-other".to_string(), alias: None, is_alias: false, @@ -2067,12 +2101,16 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() { }; let models = vec![ AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-original".to_string(), alias: None, is_alias: false, context_window: Some(1_000_000), }, AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-high".to_string(), alias: Some("gpt-original".to_string()), is_alias: true, @@ -2306,6 +2344,8 @@ fn codex_model_list_is_empty_when_cpa_has_no_writable_models() { #[test] fn agent_model_validation_only_accepts_models_in_current_list() { let models = vec![AgentModelOption { + input_modalities: None, + harness_metadata: None, name: "gpt-5.4".to_string(), alias: Some("GPT 5.4".to_string()), is_alias: false, diff --git a/src-tauri/src/tests/deepseek_harness_catalog.rs b/src-tauri/src/tests/deepseek_harness_catalog.rs new file mode 100644 index 0000000..19c27f6 --- /dev/null +++ b/src-tauri/src/tests/deepseek_harness_catalog.rs @@ -0,0 +1,378 @@ +use super::support::*; +use super::*; +use serde_json::{json, Value}; + +fn models() -> Vec { + parse_agent_model_options(&json!({"data":[ + {"id":"vision","input_modalities":["text","image"],"context_window":128000}, + {"id":"text-only","inputModalities":["text"]}, + {"id":"unknown"} + ]})) + .unwrap() +} + +fn root(home: &Path) -> Value { + let paths = agent_config_paths(AgentClient::DeepSeekHarness, home); + serde_json::to_value( + serde_norway::from_str::(&fs::read_to_string(&paths[0]).unwrap()) + .unwrap(), + ) + .unwrap() +} + +fn provider(home: &Path) -> Value { + root(home)["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID].clone() +} + +fn apply(home: &Path, models: &[AgentModelOption]) -> AgentConfigActionResult { + apply_agent_configuration( + AgentClient::DeepSeekHarness, + home, + 8317, + "test-key", + "vision", + models, + None, + ) + .unwrap() +} + +fn request(snapshot: HarnessEditorSnapshot) -> HarnessEditorRequest { + HarnessEditorRequest { + revision: snapshot.revision, + models: snapshot.models, + provider: snapshot.provider, + } +} + +#[test] +fn harness_api_defaults_support_aliases_and_model_maps_without_guessing() { + let models = parse_agent_model_options(&json!({"data":[ + {"id":"original","alias":"alias","fork":true,"supported_input_modalities":[" TEXT ","IMAGE","image"],"max_output_tokens":8192,"reasoning_efforts":{"off":null,"high":"high"}}, + {"id":"empty","input_modalities":[]}, {"id":"gpt-known-name"} + ]})).unwrap(); + assert_eq!( + models[0].input_modalities, + Some(vec!["text".into(), "image".into()]) + ); + assert_eq!(models[0].harness_metadata, models[1].harness_metadata); + assert_eq!(models[1].name, "alias"); + assert_eq!( + models[1].harness_metadata.as_ref().unwrap()["maxTokens"], + 8192 + ); + assert!(models[2..] + .iter() + .all(|model| model.input_modalities.is_none())); + let mapped = + parse_agent_model_options(&json!({"models":{"vision":{"input":["image"]},"unknown":{}}})) + .unwrap(); + assert_eq!( + mapped + .iter() + .find(|m| m.name == "vision") + .unwrap() + .input_modalities, + Some(vec!["image".into()]) + ); +} + +#[tokio::test] +async fn harness_discovery_uses_authenticated_models_endpoint() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 2048]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let read = socket.read(&mut buffer).unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + } + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /v1/models http/1.1\r\n")); + assert!(request.contains("authorization: bearer test-harness-key\r\n")); + assert!(!request.contains("client_version")); + let body = + r#"{"data":[{"id":"vision","input_modalities":["text","image"]},{"id":"unknown"}]}"#; + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + }); + let models = fetch_agent_models(port, "test-harness-key").await.unwrap(); + server.join().unwrap(); + assert_eq!(models.len(), 2); + assert!(models[1].input_modalities.is_none()); +} + +#[test] +fn harness_refreshes_api_defaults_and_preserves_catalog_overrides() { + let home = agent_test_home("harness-catalog-refresh"); + let mut models = models(); + apply(&home, &models); + assert_eq!( + provider(&home)["models"][0]["input"], + json!(["text", "image"]) + ); + assert!(provider(&home)["models"][2].get("input").is_none()); + assert_eq!(apply(&home, &models).outcome, "unchanged"); + let initial = harness_editor_snapshot(&home, &models, 8317).unwrap(); + assert!(initial.models.iter().all(|m| m.configuration.is_empty())); + let mut edit = request(initial); + edit.models[1].configuration = json!({"name":"Text model","maxTokens":4096,"reasoningEfforts":false,"compat":{"supportsDeveloperRole":false}}).as_object().unwrap().clone(); + edit.models[2].configuration = json!({"input":["text","image"]}) + .as_object() + .unwrap() + .clone(); + edit.provider = json!({"defaultInput":["text","image"],"timeoutMs":60000,"retryPolicy":{"mode":"normal","maxRetries":3}}).as_object().unwrap().clone(); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + models[0].input_modalities = Some(vec!["text".into()]); + models[0].context_window = Some(256000); + apply(&home, &models); + let provider = provider(&home); + assert_eq!(provider["models"][0]["input"], json!(["text"])); + assert_eq!(provider["models"][0]["contextWindow"], 256000); + assert_eq!(provider["models"][1]["name"], "Text model"); + assert_eq!(provider["models"][1]["maxTokens"], 4096); + assert_eq!(provider["models"][2]["input"], json!(["text", "image"])); + assert_eq!(provider["defaultInput"], json!(["text", "image"])); + assert_eq!(root(&home)["agent-default-model"]["model"], "vision"); + let mut reset = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + reset + .models + .iter_mut() + .for_each(|m| m.configuration.clear()); + reset.provider.clear(); + save_harness_editor(&home, &models, 8317, reset).unwrap(); + apply(&home, &models); + let updated = self::provider(&home); + assert!(updated["models"][1].get("maxTokens").is_none()); + assert!(updated["models"][1].get("compat").is_none()); + assert!(updated["models"][2].get("input").is_none()); + assert!(updated.get("defaultInput").is_none()); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn harness_saves_before_connection_and_applies_documented_protocol() { + let home = agent_test_home("harness-catalog-draft"); + let mut models = models(); + models[2].harness_metadata = harness_api_metadata(&json!({"compat":{ + "supportsStore":false,"supportsMaxOutputTokens":false,"supportsTemperature":false + }})); + let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + edit.provider = json!({"api":"openai-responses","compat":{"supportsMaxOutputTokens":false},"transport":"sse"}).as_object().unwrap().clone(); + edit.models[2] + .configuration + .insert("input".into(), json!(["text", "image"])); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + assert!(!paths[0].exists()); + assert!(!paths[1].exists()); + apply(&home, &models); + assert_eq!(provider(&home)["api"], "openai-responses"); + assert_eq!( + provider(&home)["models"][2]["compat"], + json!({"supportsMaxOutputTokens":false}) + ); + assert_eq!( + provider(&home)["models"][2]["input"], + json!(["text", "image"]) + ); + assert!( + inspect_deepseek_harness_config(&paths, 8317, "test-key") + .unwrap() + .0 + ); + let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + edit.provider = json!({"api":"anthropic-messages","compat":{"supportsTemperature":false}}) + .as_object() + .unwrap() + .clone(); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + assert_eq!(provider(&home)["baseURL"], "http://127.0.0.1:8317"); + assert_eq!( + provider(&home)["models"][2]["compat"], + json!({"supportsTemperature":false}) + ); + assert!( + inspect_deepseek_harness_config(&paths, 8317, "test-key") + .unwrap() + .0 + ); + apply(&home, &models); + assert_eq!(provider(&home)["baseURL"], "http://127.0.0.1:8317"); + + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn harness_rejects_stale_catalog_and_invalid_fields_without_writing() { + let home = agent_test_home("harness-catalog-validation"); + let models = models(); + apply(&home, &models); + let mut changed_models = models.clone(); + changed_models[0].context_window = Some(64); + let stale = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + assert!(save_harness_editor(&home, &changed_models, 8317, stale) + .err() + .unwrap() + .contains("DSH_MODEL_CATALOG_CHANGED")); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + let before = config_images(&paths).unwrap(); + for configuration in [ + json!({"contextWindow":0}), + json!({"maxTokens":1.5}), + json!({"input":["audio"]}), + json!({"reasoningEfforts":{"high":null}}), + json!({"compat":{"supportsTemperature":true}}), + json!({"compat":{"supportsStore":null}}), + json!({"compat":{"vllmPriority":0.5}}), + json!({"unknownField":true}), + ] { + let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + edit.models[0].configuration = configuration.as_object().unwrap().clone(); + assert!(save_harness_editor(&home, &models, 8317, edit).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + } + for configuration in [ + json!({"defaultInput":[]}), + json!({"streamIdleTimeoutMs":2147483648_u64}), + json!({"headers":{"x-test":"bad\r\nheader"}}), + json!({"retryPolicy":{"mode":"normal","backoff":{"initialDelayMs":20000}}}), + json!({"apiKeyEnv":"UNMANAGED"}), + ] { + let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + edit.provider = configuration.as_object().unwrap().clone(); + assert!(save_harness_editor(&home, &models, 8317, edit).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + } + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn harness_imports_external_edits_and_keeps_default_selection_and_other_settings() { + let home = agent_test_home("harness-catalog-external"); + let models = models(); + apply(&home, &models); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + let stale = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + let mut document = root(&home); + document["agent-default-model"]["reasoningEffort"] = json!("high"); + document["unrelated"] = json!({"enabled":true}); + document["llm-pi-ai"]["providers"]["another"] = json!({"apiKeyEnv":"OTHER"}); + document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]["name"] = + json!("Manual name"); + document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]["input"] = + json!(["text"]); + document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0] + ["futureExtension"] = json!({"keep":true}); + fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap(); + assert!(save_harness_editor(&home, &models, 8317, stale).is_err()); + let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + assert_eq!(edit.models[0].configuration["name"], "Manual name"); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + apply(&home, &models); + assert_eq!( + root(&home)["agent-default-model"]["reasoningEffort"], + "high" + ); + assert_eq!(root(&home)["unrelated"], json!({"enabled":true})); + assert_eq!( + provider(&home)["models"][0]["futureExtension"], + json!({"keep":true}) + ); + assert_eq!(provider(&home)["models"][0]["input"], json!(["text"])); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn harness_state_failure_rolls_back_settings_and_credentials() { + let home = agent_test_home("harness-catalog-rollback"); + apply(&home, &models()); + let mut changed = models(); + changed[0].input_modalities = Some(vec!["text".into()]); + let (paths, before, after) = + prepare_deepseek_harness_configuration(&home, 8317, "new-key", "vision", &changed).unwrap(); + let mut failed = false; + let result = commit_config_with_writer( + "deepseek-harness", + &paths, + &before, + &after, + "update", + Some("vision".into()), + None, + &mut |client, images| { + for image in images { + if image.0 == paths[2] && !failed { + failed = true; + return Err("simulated failure".into()); + } + write_config_images(client, &vec![image.clone()])?; + } + Ok(()) + }, + ); + assert!(result.is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn harness_template_and_backup_restore_reconcile_catalog_state() { + let home = agent_test_home("harness-catalog-template"); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + let before = config_images(&paths).unwrap(); + let settings = + build_deepseek_harness_settings(None, "http://127.0.0.1:8317/v1", "vision", &models()) + .unwrap(); + let after = vec![ + (paths[0].clone(), Some(settings.into_bytes())), + ( + paths[1].clone(), + Some( + build_deepseek_harness_credentials(None, "test-key") + .unwrap() + .into_bytes(), + ), + ), + ]; + commit_config( + "deepseek-harness", + &paths, + &before, + &after, + "template", + Some("vision".into()), + ) + .unwrap(); + assert!(harness_editor_snapshot(&home, &models(), 8317) + .unwrap() + .models + .iter() + .all(|m| m.configuration.is_empty())); + let state_path = deepseek_harness_catalog_state_path(&paths).unwrap(); + assert!(state_path.is_file()); + commit_config( + "deepseek-harness", + &paths, + &after, + &after, + "restore", + Some("vision".into()), + ) + .unwrap(); + assert!(!state_path.exists()); + assert_eq!( + harness_editor_snapshot(&home, &models(), 8317) + .unwrap() + .models[0] + .configuration["input"], + json!(["text", "image"]) + ); + fs::remove_dir_all(home).unwrap(); +} diff --git a/src-tauri/src/tests/support.rs b/src-tauri/src/tests/support.rs index a306e71..2d5e812 100644 --- a/src-tauri/src/tests/support.rs +++ b/src-tauri/src/tests/support.rs @@ -17,6 +17,8 @@ pub(super) fn test_agent_models(names: &[&str]) -> Vec { names .iter() .map(|name| AgentModelOption { + input_modalities: None, + harness_metadata: None, name: (*name).to_string(), alias: None, is_alias: false, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7938891..3ea014f 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -63,6 +63,104 @@ export const jaOverrides = { 'config.software.defaultTerminal': '既定のターミナル', 'config.software.defaultTerminalDescription': 'CLI エージェントの起動に使用するターミナルアプリ', 'config.software.terminal.auto': '自動選択', + "agents.harness.button": "モデル設定", + "agents.harness.providerInput": "プロバイダーの既定入力を使用", + "agents.harness.defaultModel": "デフォルトモデル", + "agents.harness.defaultHint": "新しいセッションの初期モデルです。DSH 内で自由に切り替えられます。", + "agents.harness.title": "DeepSeek Harness モデル設定", + "agents.harness.subtitle": "API のモデル一覧を取得し、モデル能力とプロバイダー設定を調整します。", + "agents.harness.provider": "プロバイダー設定", + "agents.harness.auto": "自動", + "agents.harness.apiSource": "API に従う", + "agents.harness.inheritHint": "空欄は API または DSH の既定値に従います。", + "agents.harness.apiInput": "API の入力形式:{value}", + "agents.harness.unknown": "未提供(非対応とは限りません)", + "agents.harness.managedConnection": "接続先:{url}。URL と認証は EasyCLIProxyAPI が管理します。", + "agents.harness.reset": "自動に戻す", + "agents.harness.resetGroup": "このグループをリセット", + "agents.harness.yes": "有効", + "agents.harness.no": "無効", + "agents.harness.textOnly": "テキストのみ", + "agents.harness.textImage": "テキストと画像", + "agents.harness.reasoningDisabled": "推論なし", + "agents.harness.reasoningCustom": "推論レベルを指定", + "agents.harness.noWireValue": "パラメーターを省略", + "agents.harness.invalid": "設定値を確認してください:{field}", + "agents.harness.changed": "モデル一覧または設定が変更されました。再読み込みしてください。", + "agents.harness.saved": "保存しました。次のリクエストから適用されます。デフォルトモデルは変更されません。", + "agents.harness.savedDraft": "保存しました。DSH 接続時に適用されます。", + "agents.harness.saveHint": "デフォルトモデルを変更せずに一覧の設定を保存します。", + "agents.harness.hint.input": "API の情報を使用し、未提供の場合はプロバイダーの既定値に従います。", + "agents.harness.hint.defaultInput": "API に入力情報がないモデルの既定値です。画像を有効にして、例外をモデルごとに変更できます。", + "agents.harness.hint.maxTokens": "設定すると、リクエストごとの既定の出力上限にもなります。", + "agents.harness.hint.defaultMaxTokens": "モデル能力の既定値です。リクエストの出力制限にはなりません。", + "agents.harness.hint.api": "全モデルに同じプロトコルを使用します。変更後は互換性設定を確認してください。", + "agents.harness.hint.headers": "ヘッダー名と値の JSON オブジェクト。空欄は既定値を使用します。", + "agents.harness.hint.reasoningEfforts": "利用可能なレベルと送信する reasoning_effort 値を指定します。off のみ空欄にできます。", + "agents.harness.hint.compat": "現在のプロトコルの互換性設定です。モデル設定が優先され、自動値は DSH が決定します。", + "agents.harness.hint.thinkingBudgets": "推論予算に対応するプロバイダーの各レベルのトークン数。", + "agents.harness.hint.retryPolicy": "normal は既定で最大 5 回再試行します。always は成功またはキャンセルまで続けます。", + "agents.harness.hint.backoff": "再試行間隔とジッターを設定します。初期間隔は最大間隔以下にしてください。", + "agents.harness.field.name": "表示名", + "agents.harness.field.contextWindow": "コンテキスト(tokens)", + "agents.harness.field.maxTokens": "出力上限(tokens)", + "agents.harness.field.input": "入力形式", + "agents.harness.field.reasoningEfforts": "推論レベル", + "agents.harness.field.compat": "プロトコル互換性", + "agents.harness.field.displayName": "プロバイダー名", + "agents.harness.field.api": "API プロトコル", + "agents.harness.field.defaultContextWindow": "既定コンテキスト(tokens)", + "agents.harness.field.defaultMaxTokens": "既定出力能力(tokens)", + "agents.harness.field.defaultInput": "既定入力形式", + "agents.harness.field.reasoning": "既定推論レベル", + "agents.harness.field.thinkingBudgets": "推論予算(tokens)", + "agents.harness.field.cacheRetention": "キャッシュ保持", + "agents.harness.field.transport": "ストリーミング方式", + "agents.harness.field.timeoutMs": "リクエストタイムアウト(ms)", + "agents.harness.field.websocketConnectTimeoutMs": "WebSocket 接続タイムアウト(ms)", + "agents.harness.field.streamIdleTimeoutMs": "ストリーム待機タイムアウト(ms)", + "agents.harness.field.maxRequestImageBytes": "リクエスト画像合計(bytes)", + "agents.harness.field.requestImagePixelBudget": "画像ピクセル予算", + "agents.harness.field.requestImageMaxBytes": "画像サイズ(bytes)", + "agents.harness.field.headers": "追加ヘッダー(JSON)", + "agents.harness.field.retryPolicy": "再試行ポリシー", + "agents.harness.field.minimal": "minimal", + "agents.harness.field.low": "low", + "agents.harness.field.medium": "medium", + "agents.harness.field.high": "high", + "agents.harness.field.mode": "再試行モード", + "agents.harness.field.maxRetries": "最大再試行回数", + "agents.harness.field.retryableCodes": "再試行するエラー(JSON)", + "agents.harness.field.backoff": "再試行間隔", + "agents.harness.field.initialDelayMs": "初期間隔(ms)", + "agents.harness.field.maxDelayMs": "最大間隔(ms)", + "agents.harness.field.jitterRatio": "ジッター比率(0–1)", + "agents.harness.field.supportsStore": "supportsStore", + "agents.harness.field.supportsDeveloperRole": "supportsDeveloperRole", + "agents.harness.field.supportsReasoningEffort": "supportsReasoningEffort", + "agents.harness.field.supportsUsageInStreaming": "supportsUsageInStreaming", + "agents.harness.field.supportsFinishReason": "supportsFinishReason", + "agents.harness.field.maxTokensField": "maxTokensField", + "agents.harness.field.requiresToolResultName": "requiresToolResultName", + "agents.harness.field.requiresAssistantAfterToolResult": "requiresAssistantAfterToolResult", + "agents.harness.field.requiresThinkingAsText": "requiresThinkingAsText", + "agents.harness.field.requiresReasoningContentOnAssistantMessages": "requiresReasoningContentOnAssistantMessages", + "agents.harness.field.thinkingFormat": "thinkingFormat", + "agents.harness.field.chatTemplateKwargs": "chatTemplateKwargs", + "agents.harness.field.chatTemplateArgs": "chatTemplateArgs", + "agents.harness.field.supportsThinkingTokenBudget": "supportsThinkingTokenBudget", + "agents.harness.field.thinkingTokenBudgetField": "thinkingTokenBudgetField", + "agents.harness.field.vllmPriority": "vllmPriority", + "agents.harness.field.supportsMaxOutputTokens": "supportsMaxOutputTokens", + "agents.harness.field.supportsStrictMode": "supportsStrictMode", + "agents.harness.field.cacheControlFormat": "cacheControlFormat", + "agents.harness.field.supportsLongCacheRetention": "supportsLongCacheRetention", + "agents.harness.field.supportsEagerToolInputStreaming": "supportsEagerToolInputStreaming", + "agents.harness.field.supportsCacheControlOnTools": "supportsCacheControlOnTools", + "agents.harness.field.supportsTemperature": "supportsTemperature", + "agents.harness.field.forceAdaptiveThinking": "forceAdaptiveThinking", + "agents.harness.field.allowEmptySignature": "allowEmptySignature", + "agents.harness.field.supportsStrictTools": "supportsStrictTools", 'agents.catalog.button': 'モデル一覧の設定', 'agents.catalog.title': 'Codex モデル一覧', 'agents.catalog.subtitle': 'コンテキストの既定値は CPA コア API から取得します。モデルのパラメーターを編集し、Codex モデル一覧に保存できます。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d5a022c..d8f6470 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -13,6 +13,104 @@ export const en: Record = { 'apiAccess.cloak.never': 'Never enabled', 'usage.unit.requests': 'requests', 'usage.unit.tokens': 'Token', + "agents.harness.button": "Configure models", + "agents.harness.providerInput": "Use provider default input", + "agents.harness.defaultModel": "Default model", + "agents.harness.defaultHint": "Used for new sessions. You can switch to any catalog model inside DSH.", + "agents.harness.title": "DeepSeek Harness model configuration", + "agents.harness.subtitle": "Load the model catalog from the API and customize models and provider settings.", + "agents.harness.provider": "Provider settings", + "agents.harness.auto": "Automatic", + "agents.harness.apiSource": "Follow API", + "agents.harness.inheritHint": "Leave fields empty to follow the API or DSH defaults.", + "agents.harness.apiInput": "API input modalities: {value}", + "agents.harness.unknown": "Not provided (capability unknown)", + "agents.harness.managedConnection": "Endpoint: {url}. EasyCLIProxyAPI manages the endpoint and credentials.", + "agents.harness.reset": "Restore automatic", + "agents.harness.resetGroup": "Reset this group", + "agents.harness.yes": "Enabled", + "agents.harness.no": "Disabled", + "agents.harness.textOnly": "Text only", + "agents.harness.textImage": "Text and image", + "agents.harness.reasoningDisabled": "No reasoning", + "agents.harness.reasoningCustom": "Custom reasoning levels", + "agents.harness.noWireValue": "Omit parameter", + "agents.harness.invalid": "Check the configuration value: {field}", + "agents.harness.changed": "The model list or configuration changed. Reload before saving.", + "agents.harness.saved": "Saved. Applies on the next model request; the default model is unchanged.", + "agents.harness.savedDraft": "Saved. These settings will be used when connecting DSH.", + "agents.harness.saveHint": "Save catalog settings without changing the selected default model.", + "agents.harness.hint.input": "Use API metadata automatically; otherwise inherit the provider default input.", + "agents.harness.hint.defaultInput": "Fallback for models without API input metadata. Set text and image here, then override exceptions per model.", + "agents.harness.hint.maxTokens": "An explicit value also becomes the model’s default output limit per request.", + "agents.harness.hint.defaultMaxTokens": "Fallback output capacity; does not set a per-request output limit.", + "agents.harness.hint.api": "One protocol applies to the whole provider. Review compatibility settings after changing it.", + "agents.harness.hint.headers": "JSON object of header names and values; empty uses default headers.", + "agents.harness.hint.reasoningEfforts": "Choose offered levels and their wire reasoning_effort values. Only off may have an empty wire value.", + "agents.harness.hint.compat": "Compatibility switches for the current protocol. Model settings override provider settings; DSH resolves automatic values.", + "agents.harness.hint.thinkingBudgets": "Token budgets for providers that support reasoning budgets.", + "agents.harness.hint.retryPolicy": "Normal retries up to five times by default. Always retries until success or cancellation.", + "agents.harness.hint.backoff": "Configure retry delays and jitter. Initial delay must not exceed maximum delay.", + "agents.harness.field.name": "Display name", + "agents.harness.field.contextWindow": "Context window (tokens)", + "agents.harness.field.maxTokens": "Maximum output (tokens)", + "agents.harness.field.input": "Input modalities", + "agents.harness.field.reasoningEfforts": "Reasoning levels", + "agents.harness.field.compat": "Protocol compatibility", + "agents.harness.field.displayName": "Provider name", + "agents.harness.field.api": "API protocol", + "agents.harness.field.defaultContextWindow": "Default context (tokens)", + "agents.harness.field.defaultMaxTokens": "Default output capacity (tokens)", + "agents.harness.field.defaultInput": "Default input modalities", + "agents.harness.field.reasoning": "Default reasoning level", + "agents.harness.field.thinkingBudgets": "Thinking budgets (tokens)", + "agents.harness.field.cacheRetention": "Cache retention", + "agents.harness.field.transport": "Streaming transport", + "agents.harness.field.timeoutMs": "Request timeout (ms)", + "agents.harness.field.websocketConnectTimeoutMs": "WebSocket connection timeout (ms)", + "agents.harness.field.streamIdleTimeoutMs": "Stream idle timeout (ms)", + "agents.harness.field.maxRequestImageBytes": "Total request image budget (bytes)", + "agents.harness.field.requestImagePixelBudget": "Pixels per image", + "agents.harness.field.requestImageMaxBytes": "Encoded bytes per image", + "agents.harness.field.headers": "Additional headers (JSON)", + "agents.harness.field.retryPolicy": "Retry policy", + "agents.harness.field.minimal": "minimal", + "agents.harness.field.low": "low", + "agents.harness.field.medium": "medium", + "agents.harness.field.high": "high", + "agents.harness.field.mode": "Retry mode", + "agents.harness.field.maxRetries": "Maximum retries", + "agents.harness.field.retryableCodes": "Retryable error codes (JSON)", + "agents.harness.field.backoff": "Retry backoff", + "agents.harness.field.initialDelayMs": "Initial delay (ms)", + "agents.harness.field.maxDelayMs": "Maximum delay (ms)", + "agents.harness.field.jitterRatio": "Jitter ratio (0–1)", + "agents.harness.field.supportsStore": "supportsStore", + "agents.harness.field.supportsDeveloperRole": "supportsDeveloperRole", + "agents.harness.field.supportsReasoningEffort": "supportsReasoningEffort", + "agents.harness.field.supportsUsageInStreaming": "supportsUsageInStreaming", + "agents.harness.field.supportsFinishReason": "supportsFinishReason", + "agents.harness.field.maxTokensField": "maxTokensField", + "agents.harness.field.requiresToolResultName": "requiresToolResultName", + "agents.harness.field.requiresAssistantAfterToolResult": "requiresAssistantAfterToolResult", + "agents.harness.field.requiresThinkingAsText": "requiresThinkingAsText", + "agents.harness.field.requiresReasoningContentOnAssistantMessages": "requiresReasoningContentOnAssistantMessages", + "agents.harness.field.thinkingFormat": "thinkingFormat", + "agents.harness.field.chatTemplateKwargs": "chatTemplateKwargs", + "agents.harness.field.chatTemplateArgs": "chatTemplateArgs", + "agents.harness.field.supportsThinkingTokenBudget": "supportsThinkingTokenBudget", + "agents.harness.field.thinkingTokenBudgetField": "thinkingTokenBudgetField", + "agents.harness.field.vllmPriority": "vllmPriority", + "agents.harness.field.supportsMaxOutputTokens": "supportsMaxOutputTokens", + "agents.harness.field.supportsStrictMode": "supportsStrictMode", + "agents.harness.field.cacheControlFormat": "cacheControlFormat", + "agents.harness.field.supportsLongCacheRetention": "supportsLongCacheRetention", + "agents.harness.field.supportsEagerToolInputStreaming": "supportsEagerToolInputStreaming", + "agents.harness.field.supportsCacheControlOnTools": "supportsCacheControlOnTools", + "agents.harness.field.supportsTemperature": "supportsTemperature", + "agents.harness.field.forceAdaptiveThinking": "forceAdaptiveThinking", + "agents.harness.field.allowEmptySignature": "allowEmptySignature", + "agents.harness.field.supportsStrictTools": "supportsStrictTools", 'agents.catalog.button': 'Model list settings', 'agents.catalog.title': 'Codex model catalog', 'agents.catalog.subtitle': 'Default context settings come from the CPA core API. Customize model parameters and save them to the Codex catalog.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9e3e095..50c7e9e 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -12,6 +12,104 @@ export const zhCN = { 'apiAccess.cloak.never': '从不启用', 'usage.unit.requests': '次请求', 'usage.unit.tokens': 'Token', + "agents.harness.button": "配置模型", + "agents.harness.providerInput": "使用供应商默认输入", + "agents.harness.defaultModel": "默认模型", + "agents.harness.defaultHint": "仅作为新会话的默认模型;启动 DSH 后仍可自由切换整个目录中的模型。", + "agents.harness.title": "DeepSeek Harness 模型配置", + "agents.harness.subtitle": "从 API 获取整个模型目录,按需覆盖模型能力和供应商参数。", + "agents.harness.provider": "供应商设置", + "agents.harness.auto": "自动", + "agents.harness.apiSource": "跟随 API", + "agents.harness.inheritHint": "留空即跟随 API 或 DSH 默认值。", + "agents.harness.apiInput": "API 声明的输入类型:{value}", + "agents.harness.unknown": "未提供(不代表不支持)", + "agents.harness.managedConnection": "连接地址:{url}。地址和鉴权由 EasyCLIProxyAPI 管理。", + "agents.harness.reset": "恢复自动", + "agents.harness.resetGroup": "此组恢复自动", + "agents.harness.yes": "开启", + "agents.harness.no": "关闭", + "agents.harness.textOnly": "仅文本", + "agents.harness.textImage": "文本和图像", + "agents.harness.reasoningDisabled": "不支持推理", + "agents.harness.reasoningCustom": "自定义推理等级", + "agents.harness.noWireValue": "不发送参数", + "agents.harness.invalid": "请检查配置值:{field}", + "agents.harness.changed": "模型列表或配置已变化,请重新加载后再保存。", + "agents.harness.saved": "已保存,下次模型请求时生效;默认模型保持不变。", + "agents.harness.savedDraft": "已保存,接入 DSH 时会使用这些配置。", + "agents.harness.saveHint": "保存整个目录的配置,不改变页面选择的默认模型。", + "agents.harness.hint.input": "自动使用 API 声明;未声明时继承供应商默认输入类型。", + "agents.harness.hint.defaultInput": "仅用于 API 未声明输入类型的模型。可统一设为文本和图像,再单独修正例外模型。", + "agents.harness.hint.maxTokens": "手动设置后也会成为该模型每次请求的默认输出上限。", + "agents.harness.hint.defaultMaxTokens": "模型的输出能力兜底值,不会自动成为每次请求的输出限制。", + "agents.harness.hint.api": "整个供应商使用同一种协议;切换协议后请检查兼容选项。", + "agents.harness.hint.headers": "JSON 键值对象;留空使用默认请求头。", + "agents.harness.hint.reasoningEfforts": "勾选 DSH 中可选的等级,并填写实际发送的 reasoning_effort 值。只有 off 可留空。", + "agents.harness.hint.compat": "仅显示当前协议的兼容开关。单个模型的设置优先于供应商设置,自动值由 DSH 决定。", + "agents.harness.hint.thinkingBudgets": "为支持推理预算的供应商指定各等级的 token 预算。", + "agents.harness.hint.retryPolicy": "normal 默认最多重试 5 次;always 会持续重试直到成功或取消。", + "agents.harness.hint.backoff": "配置重试间隔与抖动;初始间隔不能大于最大间隔。", + "agents.harness.field.name": "显示名称", + "agents.harness.field.contextWindow": "上下文窗口(tokens)", + "agents.harness.field.maxTokens": "输出上限(tokens)", + "agents.harness.field.input": "输入类型", + "agents.harness.field.reasoningEfforts": "推理等级", + "agents.harness.field.compat": "协议兼容选项", + "agents.harness.field.displayName": "供应商名称", + "agents.harness.field.api": "API 协议", + "agents.harness.field.defaultContextWindow": "默认上下文(tokens)", + "agents.harness.field.defaultMaxTokens": "默认输出能力(tokens)", + "agents.harness.field.defaultInput": "默认输入类型", + "agents.harness.field.reasoning": "默认推理等级", + "agents.harness.field.thinkingBudgets": "推理预算(tokens)", + "agents.harness.field.cacheRetention": "缓存保留", + "agents.harness.field.transport": "流式传输方式", + "agents.harness.field.timeoutMs": "请求超时(ms)", + "agents.harness.field.websocketConnectTimeoutMs": "WebSocket 连接超时(ms)", + "agents.harness.field.streamIdleTimeoutMs": "流式空闲超时(ms)", + "agents.harness.field.maxRequestImageBytes": "请求图像总预算(bytes)", + "agents.harness.field.requestImagePixelBudget": "单图像素预算", + "agents.harness.field.requestImageMaxBytes": "单图编码预算(bytes)", + "agents.harness.field.headers": "额外请求头(JSON)", + "agents.harness.field.retryPolicy": "重试策略", + "agents.harness.field.minimal": "minimal", + "agents.harness.field.low": "low", + "agents.harness.field.medium": "medium", + "agents.harness.field.high": "high", + "agents.harness.field.mode": "重试模式", + "agents.harness.field.maxRetries": "最大重试次数", + "agents.harness.field.retryableCodes": "可重试错误码(JSON)", + "agents.harness.field.backoff": "重试间隔", + "agents.harness.field.initialDelayMs": "初始间隔(ms)", + "agents.harness.field.maxDelayMs": "最大间隔(ms)", + "agents.harness.field.jitterRatio": "抖动比例(0–1)", + "agents.harness.field.supportsStore": "supportsStore", + "agents.harness.field.supportsDeveloperRole": "supportsDeveloperRole", + "agents.harness.field.supportsReasoningEffort": "supportsReasoningEffort", + "agents.harness.field.supportsUsageInStreaming": "supportsUsageInStreaming", + "agents.harness.field.supportsFinishReason": "supportsFinishReason", + "agents.harness.field.maxTokensField": "maxTokensField", + "agents.harness.field.requiresToolResultName": "requiresToolResultName", + "agents.harness.field.requiresAssistantAfterToolResult": "requiresAssistantAfterToolResult", + "agents.harness.field.requiresThinkingAsText": "requiresThinkingAsText", + "agents.harness.field.requiresReasoningContentOnAssistantMessages": "requiresReasoningContentOnAssistantMessages", + "agents.harness.field.thinkingFormat": "thinkingFormat", + "agents.harness.field.chatTemplateKwargs": "chatTemplateKwargs", + "agents.harness.field.chatTemplateArgs": "chatTemplateArgs", + "agents.harness.field.supportsThinkingTokenBudget": "supportsThinkingTokenBudget", + "agents.harness.field.thinkingTokenBudgetField": "thinkingTokenBudgetField", + "agents.harness.field.vllmPriority": "vllmPriority", + "agents.harness.field.supportsMaxOutputTokens": "supportsMaxOutputTokens", + "agents.harness.field.supportsStrictMode": "supportsStrictMode", + "agents.harness.field.cacheControlFormat": "cacheControlFormat", + "agents.harness.field.supportsLongCacheRetention": "supportsLongCacheRetention", + "agents.harness.field.supportsEagerToolInputStreaming": "supportsEagerToolInputStreaming", + "agents.harness.field.supportsCacheControlOnTools": "supportsCacheControlOnTools", + "agents.harness.field.supportsTemperature": "supportsTemperature", + "agents.harness.field.forceAdaptiveThinking": "forceAdaptiveThinking", + "agents.harness.field.allowEmptySignature": "allowEmptySignature", + "agents.harness.field.supportsStrictTools": "supportsStrictTools", 'agents.catalog.button': '模型列表设置', 'agents.catalog.title': 'Codex 模型列表', 'agents.catalog.subtitle': '默认上下文参数读取自 CPA 内核 API;可自定义当前模型参数并保存到 Codex 模型列表。', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 83dbe35..57465be 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -63,6 +63,7 @@ import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; +import { DeepSeekHarnessCatalogDialog } from './DeepSeekHarnessCatalogDialog'; import { AgentConfigBackupDialog } from './AgentConfigBackupDialog'; import { AgentConfigManagementPanel, AgentConfigurationFeedback, AgentRunControls } from './AgentControls'; @@ -753,6 +754,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false); const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false); + const [harnessCatalogDialogOpen, setHarnessCatalogDialogOpen] = useState(false); const [launchDirectory, setLaunchDirectory] = useState(''); const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); const [launchDirectoryError, setLaunchDirectoryError] = useState(''); @@ -1326,6 +1328,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP claudeModelMappingsDirtyRef.current[selected] = false; } await reloadStatusesAfterAction(); + if (isDeepSeekHarnessClient) await loadModels('deepseek-harness'); setOauthConfigurationDraft(null); setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.backup.unchanged' : 'agents.backup.updated')); onConfigurationApplied?.(); @@ -1686,6 +1689,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : null; + const harnessCatalogButton = isDeepSeekHarnessClient ? ( + + ) : null; + return (
@@ -1796,7 +1806,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP {selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ?

{t('agents.backup.mappingRequired')}

: null}
- + - {codexCatalogButton} + {codexCatalogButton}{harnessCatalogButton}
+ {isDeepSeekHarnessClient ?

{t('agents.harness.defaultHint')}

: null} +
: null} + ; + } + if (field.kind === 'reasoning') { + let mapping: HarnessProfile = {}; + try { const parsed: unknown = JSON.parse(value || '{}'); if (isHarnessRecord(parsed)) mapping = parsed; } catch {} + return
+ + {value && value !== 'false' ?
{harnessReasoningLevels.map(level =>
+ + {Object.prototype.hasOwnProperty.call(mapping, level) ? set(JSON.stringify({ ...mapping, [level]: level === 'off' && !e.currentTarget.value ? null : e.currentTarget.value }))} /> : null} +
)}
: null} + {t('agents.harness.hint.reasoningEfforts')} +
; + } + const jsonField = ['headers', 'kwargs', 'strings'].includes(field.kind); + return