feat: add Hermes UI components, presets, and config panels (Phase 8)

- Add 7 provider presets (OpenRouter, Anthropic, OpenAI, Google, DeepSeek, Together, Nous)
- Create HermesFormFields + useHermesFormState for provider form integration
- Create Model/Agent/Env config panels with save/load functionality
- Create HermesHealthBanner for config warnings
- Add hermes icon (violet winged H) to icon system
- Integrate into App.tsx: 3 new view types (hermesModel/hermesAgent/hermesEnv),
  sidebar buttons (Brain/Bot/KeyRound), health banner, session support
- Integrate into ProviderForm: presets, form state, key validation, rendering
- Integrate into AddProviderDialog: universal tab exclusion, providerKey, base_url extraction
- Add i18n keys for all Hermes UI (zh/en/ja)
This commit is contained in:
Jason
2026-04-15 21:02:46 +08:00
parent a0b585992a
commit 240969d8c7
23 changed files with 1512 additions and 54 deletions
+13 -21
View File
@@ -171,9 +171,7 @@ fn is_top_level_key_line(line: &str) -> bool {
}
if let Some(colon_pos) = line.find(':') {
let after_colon = &line[colon_pos + 1..];
after_colon.is_empty()
|| after_colon.starts_with(' ')
|| after_colon.starts_with('\t')
after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t')
} else {
false
}
@@ -221,10 +219,7 @@ fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize
/// ```
fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String, AppError> {
let mut section = serde_yaml::Mapping::new();
section.insert(
serde_yaml::Value::String(key.to_string()),
value.clone(),
);
section.insert(serde_yaml::Value::String(key.to_string()), value.clone());
let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section))
.map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?;
Ok(yaml_str)
@@ -367,7 +362,10 @@ fn write_yaml_section_to_config_locked(
let warnings = scan_hermes_health_internal(&new_raw);
log::debug!("Hermes config section '{section_key}' written to {:?}", config_path);
log::debug!(
"Hermes config section '{section_key}' written to {:?}",
config_path
);
Ok(HermesWriteOutcome {
backup_path: backup_path.map(|p| p.display().to_string()),
warnings,
@@ -438,11 +436,10 @@ pub fn set_provider(
);
}
if let Some(existing) = providers.iter_mut().find(|p| {
p.get("name")
.and_then(|n| n.as_str())
== Some(name)
}) {
if let Some(existing) = providers
.iter_mut()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name))
{
*existing = yaml_val;
} else {
providers.push(yaml_val);
@@ -467,11 +464,7 @@ pub fn remove_provider(name: &str) -> Result<HermesWriteOutcome, AppError> {
.unwrap_or_default();
let original_len = providers.len();
providers.retain(|p| {
p.get("name")
.and_then(|n| n.as_str())
!= Some(name)
});
providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name));
if providers.len() == original_len {
return Ok(HermesWriteOutcome::default());
@@ -690,9 +683,8 @@ fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
if !providers.is_sequence() {
warnings.push(HermesHealthWarning {
code: "custom_providers_not_list".to_string(),
message:
"custom_providers should be a YAML list (sequence), not a mapping"
.to_string(),
message: "custom_providers should be a YAML list (sequence), not a mapping"
.to_string(),
path: Some("custom_providers".to_string()),
});
}
+2 -5
View File
@@ -83,8 +83,7 @@ fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
result.insert("url".into(), url.clone());
}
if let Some(headers) = obj.get("headers") {
if headers.is_object()
&& !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
{
result.insert("headers".into(), headers.clone());
}
@@ -142,9 +141,7 @@ fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError>
result.insert("url".into(), url.clone());
}
if let Some(headers) = obj.get("headers") {
if headers.is_object()
&& !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
{
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("headers".into(), headers.clone());
}
}
+1 -5
View File
@@ -132,11 +132,7 @@ impl McpService {
log::debug!("OpenClaw MCP support is still in development, skipping sync");
}
AppType::Hermes => {
mcp::sync_single_server_to_hermes(
&Default::default(),
&server.id,
&server.server,
)?;
mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
}
}
Ok(())
+1 -3
View File
@@ -1393,9 +1393,7 @@ pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppErro
// Check if Hermes config directory exists
if !hermes_config::get_hermes_dir().exists() {
log::debug!(
"Hermes config directory doesn't exist, skipping removal of '{provider_id}'"
);
log::debug!("Hermes config directory doesn't exist, skipping removal of '{provider_id}'");
return Ok(());
}
+2 -3
View File
@@ -21,9 +21,8 @@ use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_hermes_providers_from_live,
import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings,
sync_current_to_live,
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
};
// Internal re-exports (pub(crate))