feat(hermes): add channel display settings form

This commit is contained in:
晴天
2026-05-24 18:42:34 +08:00
parent a1307716dd
commit c0b40070c0
7 changed files with 716 additions and 1 deletions

View File

@@ -2165,6 +2165,9 @@ const HERMES_CHANNEL_PLATFORMS: [&str; 10] = [
"simplex",
];
const HERMES_DISPLAY_TOOL_PROGRESS_VALUES: [&str; 4] = ["off", "new", "all", "verbose"];
const HERMES_DISPLAY_STREAMING_VALUES: [&str; 3] = ["inherit", "true", "false"];
fn normalize_hermes_channel_platform(platform: &str) -> Option<&'static str> {
let platform = platform.trim().to_ascii_lowercase();
HERMES_CHANNEL_PLATFORMS
@@ -2173,6 +2176,78 @@ fn normalize_hermes_channel_platform(platform: &str) -> Option<&'static str> {
.find(|item| *item == platform)
}
fn normalize_hermes_display_tool_progress(
value: Option<String>,
strict: bool,
key: &str,
) -> Result<String, String> {
let progress = value.unwrap_or_default().trim().to_ascii_lowercase();
let progress = if progress.is_empty() {
"all".to_string()
} else {
progress
};
if HERMES_DISPLAY_TOOL_PROGRESS_VALUES.contains(&progress.as_str()) {
Ok(progress)
} else if strict {
Err(format!("{key} 必须是 off、new、all 或 verbose"))
} else {
Ok("all".to_string())
}
}
fn normalize_hermes_display_streaming_text(
value: Option<String>,
strict: bool,
key: &str,
) -> Result<String, String> {
let streaming = value.unwrap_or_default().trim().to_ascii_lowercase();
let streaming = if streaming.is_empty() {
"inherit".to_string()
} else {
streaming
};
if HERMES_DISPLAY_STREAMING_VALUES.contains(&streaming.as_str()) {
Ok(streaming)
} else if strict {
Err(format!("{key} 必须是 inherit、true 或 false"))
} else {
Ok("inherit".to_string())
}
}
fn normalize_hermes_display_streaming_yaml(
value: Option<&serde_yaml::Value>,
strict: bool,
key: &str,
) -> Result<String, String> {
if let Some(value) = value {
if let Some(value) = value.as_bool() {
return Ok(if value { "true" } else { "false" }.to_string());
}
if let Some(value) = value.as_str() {
return normalize_hermes_display_streaming_text(Some(value.to_string()), strict, key);
}
}
normalize_hermes_display_streaming_text(None, strict, key)
}
fn normalize_hermes_display_streaming_json(
value: Option<&Value>,
strict: bool,
key: &str,
) -> Result<String, String> {
if let Some(value) = value {
if let Some(value) = value.as_bool() {
return Ok(if value { "true" } else { "false" }.to_string());
}
if let Some(value) = value.as_str() {
return normalize_hermes_display_streaming_text(Some(value.to_string()), strict, key);
}
}
normalize_hermes_display_streaming_text(None, strict, key)
}
fn yaml_key(key: &str) -> serde_yaml::Value {
serde_yaml::Value::String(key.to_string())
}
@@ -2350,6 +2425,79 @@ fn insert_hermes_home_channel_if_present(
}
}
fn insert_hermes_channel_display_fields(
form: &mut serde_json::Map<String, Value>,
config: &serde_yaml::Value,
platform: &str,
) {
let display = config
.as_mapping()
.and_then(|map| yaml_get_mapping(map, "display"));
let platform_display = display
.and_then(|map| yaml_get_mapping(map, "platforms"))
.and_then(|map| yaml_get_mapping(map, platform));
let legacy_tool_progress = display
.and_then(|map| yaml_get_mapping(map, "tool_progress_overrides"))
.and_then(|map| yaml_string_field(map, platform));
let tool_progress = normalize_hermes_display_tool_progress(
platform_display
.and_then(|map| yaml_string_field(map, "tool_progress"))
.or(legacy_tool_progress)
.or_else(|| display.and_then(|map| yaml_string_field(map, "tool_progress"))),
false,
"display.tool_progress",
)
.unwrap_or_else(|_| "all".to_string());
let show_reasoning = platform_display
.and_then(|map| yaml_bool_field(map, "show_reasoning"))
.or_else(|| display.and_then(|map| yaml_bool_field(map, "show_reasoning")))
.unwrap_or(false);
let tool_preview_length = bounded_hermes_i64(
platform_display
.and_then(|map| yaml_i64_field(map, "tool_preview_length"))
.or_else(|| display.and_then(|map| yaml_i64_field(map, "tool_preview_length"))),
0,
0,
200000,
);
let streaming = if let Some(platform_display) = platform_display {
if let Some(value) = yaml_get(platform_display, "streaming") {
normalize_hermes_display_streaming_yaml(
Some(value),
false,
"display.platforms.streaming",
)
.unwrap_or_else(|_| "inherit".to_string())
} else {
"inherit".to_string()
}
} else {
"inherit".to_string()
};
let cleanup_progress = platform_display
.and_then(|map| yaml_bool_field(map, "cleanup_progress"))
.or_else(|| display.and_then(|map| yaml_bool_field(map, "cleanup_progress")))
.unwrap_or(false);
form.insert(
"displayToolProgress".to_string(),
Value::String(tool_progress),
);
form.insert(
"displayShowReasoning".to_string(),
Value::Bool(show_reasoning),
);
form.insert(
"displayToolPreviewLength".to_string(),
Value::Number(tool_preview_length.into()),
);
form.insert("displayStreaming".to_string(), Value::String(streaming));
form.insert(
"displayCleanupProgress".to_string(),
Value::Bool(cleanup_progress),
);
}
fn build_hermes_channel_config_values(
config: &serde_yaml::Value,
env_values: &std::collections::HashMap<String, String>,
@@ -2766,6 +2914,7 @@ fn build_hermes_channel_config_values(
insert_json_csv_if_present(&mut form, &extra, "allow_from", "allowFrom");
insert_json_csv_if_present(&mut form, &extra, "group_allow_from", "groupAllowFrom");
}
insert_hermes_channel_display_fields(&mut form, config, platform);
values.insert(platform.to_string(), Value::Object(form));
}
@@ -2950,6 +3099,95 @@ fn set_hermes_home_channel(entry: &mut serde_yaml::Mapping, form: &Value) {
entry.insert(yaml_key("home_channel"), serde_yaml::Value::Mapping(home));
}
fn merge_hermes_channel_display_config(
root: &mut serde_yaml::Mapping,
platform: &str,
form: &Value,
) -> Result<(), String> {
let has_display_fields = [
"displayToolProgress",
"displayShowReasoning",
"displayToolPreviewLength",
"displayStreaming",
"displayCleanupProgress",
]
.iter()
.any(|key| form.get(*key).is_some());
if !has_display_fields {
return Ok(());
}
let tool_progress = if form.get("displayToolProgress").is_some() {
Some(normalize_hermes_display_tool_progress(
form_string(form, "displayToolProgress"),
true,
&format!("display.platforms.{platform}.tool_progress"),
)?)
} else {
None
};
let show_reasoning = if form.get("displayShowReasoning").is_some() {
Some(form_bool(form, "displayShowReasoning").unwrap_or(false))
} else {
None
};
let tool_preview_length = if form.get("displayToolPreviewLength").is_some() {
Some(validate_hermes_i64(
form_i64(form, "displayToolPreviewLength"),
&format!("display.platforms.{platform}.tool_preview_length"),
0,
0,
200000,
)?)
} else {
None
};
let streaming = if form.get("displayStreaming").is_some() {
Some(normalize_hermes_display_streaming_json(
form.get("displayStreaming"),
true,
&format!("display.platforms.{platform}.streaming"),
)?)
} else {
None
};
let cleanup_progress = if form.get("displayCleanupProgress").is_some() {
Some(form_bool(form, "displayCleanupProgress").unwrap_or(false))
} else {
None
};
let display = yaml_child_object(root, "display")?;
let platforms = yaml_child_object(display, "platforms")?;
let platform_display = yaml_child_object(platforms, platform)?;
if let Some(value) = tool_progress {
platform_display.insert(yaml_key("tool_progress"), serde_yaml::Value::String(value));
}
if let Some(value) = show_reasoning {
platform_display.insert(yaml_key("show_reasoning"), serde_yaml::Value::Bool(value));
}
if let Some(value) = tool_preview_length {
platform_display.insert(
yaml_key("tool_preview_length"),
serde_yaml::Value::Number(value.into()),
);
}
if let Some(value) = streaming {
if value == "inherit" {
platform_display.remove(yaml_key("streaming"));
} else {
platform_display.insert(
yaml_key("streaming"),
serde_yaml::Value::Bool(value == "true"),
);
}
}
if let Some(value) = cleanup_progress {
platform_display.insert(yaml_key("cleanup_progress"), serde_yaml::Value::Bool(value));
}
Ok(())
}
fn split_csv_items(value: &str) -> Vec<String> {
value
.split([',', ';', '\n'])
@@ -3660,6 +3898,7 @@ fn merge_hermes_channel_config(
let platform = normalize_hermes_channel_platform(platform)
.ok_or_else(|| format!("不支持的 Hermes 渠道: {platform}"))?;
let root = ensure_yaml_object(config)?;
merge_hermes_channel_display_config(root, platform, form)?;
let platforms = yaml_child_object(root, "platforms")?;
let entry = yaml_child_object(platforms, platform)?;
@@ -10677,4 +10916,158 @@ platforms:
assert!(env.contains(&("LINE_ALLOWED_GROUPS".to_string(), "C1".to_string())));
assert!(env.contains(&("LINE_HOME_CHANNEL".to_string(), "U-home".to_string())));
}
#[test]
fn channel_display_values_read_platform_overrides_and_legacy_fallback() {
let config: serde_yaml::Value = serde_yaml::from_str(
r#"
display:
tool_progress: all
show_reasoning: false
cleanup_progress: false
tool_progress_overrides:
discord: off
platforms:
telegram:
tool_progress: new
show_reasoning: true
tool_preview_length: 80
streaming: false
cleanup_progress: true
custom_flag: keep-me
"#,
)
.unwrap();
let values = build_hermes_channel_config_values(&config, &HashMap::new());
assert_eq!(values["telegram"]["displayToolProgress"], "new");
assert_eq!(values["telegram"]["displayShowReasoning"], true);
assert_eq!(values["telegram"]["displayToolPreviewLength"], 80);
assert_eq!(values["telegram"]["displayStreaming"], "false");
assert_eq!(values["telegram"]["displayCleanupProgress"], true);
assert_eq!(values["discord"]["displayToolProgress"], "off");
assert_eq!(values["discord"]["displayStreaming"], "inherit");
}
#[test]
fn merge_channel_display_writes_platform_overrides_and_preserves_unknown_fields() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
display:
tool_progress: all
tool_progress_overrides:
telegram: off
platforms:
telegram:
tool_progress: new
streaming: false
custom_flag: keep-me
runtime_footer:
enabled: true
platforms:
telegram:
enabled: true
extra:
unknown_option: keep-platform
"#,
)
.unwrap();
merge_hermes_channel_config(
&mut config,
"telegram",
&json!({
"enabled": true,
"botToken": "",
"displayToolProgress": "verbose",
"displayShowReasoning": false,
"displayToolPreviewLength": "120",
"displayStreaming": "inherit",
"displayCleanupProgress": false,
}),
)
.unwrap();
assert_eq!(config["display"]["tool_progress"].as_str(), Some("all"));
assert_eq!(
config["display"]["tool_progress_overrides"]["telegram"].as_str(),
Some("off")
);
assert_eq!(
config["display"]["platforms"]["telegram"]["tool_progress"].as_str(),
Some("verbose")
);
assert_eq!(
config["display"]["platforms"]["telegram"]["show_reasoning"].as_bool(),
Some(false)
);
assert_eq!(
config["display"]["platforms"]["telegram"]["tool_preview_length"].as_i64(),
Some(120)
);
assert_eq!(
config["display"]["platforms"]["telegram"]["streaming"],
serde_yaml::Value::Null
);
assert_eq!(
config["display"]["platforms"]["telegram"]["cleanup_progress"].as_bool(),
Some(false)
);
assert_eq!(
config["display"]["platforms"]["telegram"]["custom_flag"].as_str(),
Some("keep-me")
);
assert_eq!(
config["display"]["platforms"]["telegram"]["runtime_footer"]["enabled"].as_bool(),
Some(true)
);
assert_eq!(
config["platforms"]["telegram"]["extra"]["unknown_option"].as_str(),
Some("keep-platform")
);
}
#[test]
fn merge_channel_display_rejects_invalid_values() {
let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let err = merge_hermes_channel_config(
&mut config,
"telegram",
&json!({
"enabled": true,
"displayToolProgress": "everything",
"displayToolPreviewLength": 80,
"displayStreaming": "inherit",
}),
)
.unwrap_err();
assert!(err.contains("display.platforms.telegram.tool_progress"));
let err = merge_hermes_channel_config(
&mut config,
"telegram",
&json!({
"enabled": true,
"displayToolProgress": "all",
"displayToolPreviewLength": 200001,
"displayStreaming": "inherit",
}),
)
.unwrap_err();
assert!(err.contains("display.platforms.telegram.tool_preview_length"));
let err = merge_hermes_channel_config(
&mut config,
"telegram",
&json!({
"enabled": true,
"displayToolProgress": "all",
"displayToolPreviewLength": 80,
"displayStreaming": "global",
}),
)
.unwrap_err();
assert!(err.contains("display.platforms.telegram.streaming"));
}
}