diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b840d12..cd42a11 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3342,6 +3342,19 @@ const HERMES_DISPLAY_BACKGROUND_PROCESS_NOTIFICATIONS = new Set(['off', 'result' const HERMES_DISPLAY_FINAL_RESPONSE_MARKDOWN_VALUES = new Set(['render', 'strip', 'raw']) const HERMES_DISPLAY_LANGUAGE_VALUES = new Set(['en', 'zh', 'zh-hant', 'ja', 'de', 'es', 'fr', 'tr', 'uk', 'af', 'ko', 'it', 'ga', 'pt', 'ru', 'hu']) const HERMES_RUNTIME_FOOTER_FIELDS = new Set(['model', 'context_pct', 'cwd', 'duration', 'tokens', 'cost']) +const HERMES_DEFAULT_PLATFORM_TOOLSETS = { + cli: ['hermes-cli'], + telegram: ['hermes-telegram'], + discord: ['hermes-discord'], + whatsapp: ['hermes-whatsapp'], + slack: ['hermes-slack'], + signal: ['hermes-signal'], + homeassistant: ['hermes-homeassistant'], + qqbot: ['hermes-qqbot'], + yuanbao: ['hermes-yuanbao'], + teams: ['hermes-teams'], + google_chat: ['hermes-google_chat'], +} function parseHermesInteger(value, key, fallback, min, max, strict = false) { const raw = String(value ?? '').trim() @@ -3820,6 +3833,40 @@ function normalizeHermesToolsetList(value, fieldName = 'agent.disabled_toolsets' return normalized } +function validateHermesPlatformToolsets(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('platform_toolsets 必须是 JSON 对象') + } + const normalized = {} + for (const [rawPlatform, rawToolsets] of Object.entries(value)) { + const platform = String(rawPlatform || '').trim() + if (!platform || !/^[a-zA-Z0-9_.-]+$/.test(platform)) { + throw new Error(`platform_toolsets.${platform || ''} 平台名只能包含字母、数字、下划线、点和短横线`) + } + if (!Array.isArray(rawToolsets)) { + throw new Error(`platform_toolsets.${platform} 必须是工具集数组`) + } + const toolsets = normalizeHermesToolsetList(rawToolsets, `platform_toolsets.${platform}`) + if (!toolsets.length) { + throw new Error(`platform_toolsets.${platform} 至少需要一个工具集`) + } + normalized[platform] = toolsets + } + return normalized +} + +function parseHermesPlatformToolsetsJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return {} + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`platform_toolsets JSON 格式错误: ${err.message}`) + } + return validateHermesPlatformToolsets(value) +} + export function buildHermesSkillsConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const skills = root.skills && typeof root.skills === 'object' && !Array.isArray(root.skills) @@ -3872,6 +3919,24 @@ export function mergeHermesAgentToolsetsConfig(config = {}, form = {}) { return next } +export function buildHermesPlatformToolsetsConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const platformToolsets = root.platform_toolsets && typeof root.platform_toolsets === 'object' && !Array.isArray(root.platform_toolsets) + ? validateHermesPlatformToolsets(root.platform_toolsets) + : HERMES_DEFAULT_PLATFORM_TOOLSETS + return { + platformToolsetsJson: JSON.stringify(platformToolsets, null, 2), + } +} + +export function mergeHermesPlatformToolsetsConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesPlatformToolsetsConfigValues(next) + const platformToolsets = parseHermesPlatformToolsetsJson(Object.hasOwn(form, 'platformToolsetsJson') ? form.platformToolsetsJson : currentValues.platformToolsetsJson) + next.platform_toolsets = platformToolsets + return next +} + export function buildHermesAgentRuntimeConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const agent = root.agent && typeof root.agent === 'object' && !Array.isArray(root.agent) @@ -10870,6 +10935,27 @@ const handlers = { } }, + hermes_platform_toolsets_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesPlatformToolsetsConfigValues(config), + } + }, + + hermes_platform_toolsets_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesPlatformToolsetsConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesPlatformToolsetsConfigValues(next), + } + }, + hermes_agent_runtime_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 843ab2d..4d312b9 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3922,6 +3922,101 @@ fn normalize_hermes_toolset_list(raw: Option) -> Result, Str Ok(normalized) } +fn default_hermes_platform_toolsets() -> serde_json::Map { + let defaults = [ + ("cli", "hermes-cli"), + ("telegram", "hermes-telegram"), + ("discord", "hermes-discord"), + ("whatsapp", "hermes-whatsapp"), + ("slack", "hermes-slack"), + ("signal", "hermes-signal"), + ("homeassistant", "hermes-homeassistant"), + ("qqbot", "hermes-qqbot"), + ("yuanbao", "hermes-yuanbao"), + ("teams", "hermes-teams"), + ("google_chat", "hermes-google_chat"), + ]; + defaults + .into_iter() + .map(|(platform, toolset)| { + ( + platform.to_string(), + Value::Array(vec![Value::String(toolset.to_string())]), + ) + }) + .collect() +} + +fn normalize_hermes_toolset_values(value: &Value, field_name: &str) -> Result, String> { + let Some(items) = value.as_array() else { + return Err(format!("{field_name} 必须是工具集数组")); + }; + let mut normalized = Vec::new(); + for item in items { + let Some(text) = item.as_str() else { + return Err(format!("{field_name} 只能包含字符串工具集")); + }; + let text = text.trim(); + if text.is_empty() { + continue; + } + if !text + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-')) + { + return Err(format!( + "{field_name} 只能包含字母、数字、下划线、点和短横线" + )); + } + if !normalized.iter().any(|existing| existing == text) { + normalized.push(text.to_string()); + } + } + if normalized.is_empty() { + return Err(format!("{field_name} 至少需要一个工具集")); + } + Ok(normalized) +} + +fn validate_hermes_platform_toolsets( + value: &Value, +) -> Result, String> { + let Some(map) = value.as_object() else { + return Err("platform_toolsets 必须是 JSON 对象".to_string()); + }; + let mut normalized = serde_json::Map::new(); + for (platform, toolsets) in map { + if platform.is_empty() + || !platform + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-')) + { + return Err(format!( + "platform_toolsets.{platform} 平台名只能包含字母、数字、下划线、点和短横线" + )); + } + let values = + normalize_hermes_toolset_values(toolsets, &format!("platform_toolsets.{platform}"))?; + normalized.insert( + platform.clone(), + Value::Array(values.into_iter().map(Value::String).collect()), + ); + } + Ok(normalized) +} + +fn parse_hermes_platform_toolsets_json( + raw: Option, +) -> Result, String> { + let text = raw.unwrap_or_default().trim().to_string(); + if text.is_empty() { + return Ok(serde_json::Map::new()); + } + let value: Value = serde_json::from_str(&text) + .map_err(|err| format!("platform_toolsets JSON 格式错误: {err}"))?; + validate_hermes_platform_toolsets(&value) +} + fn build_hermes_agent_toolsets_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let disabled_toolsets = root @@ -3934,6 +4029,39 @@ fn build_hermes_agent_toolsets_config_values(config: &serde_yaml::Value) -> Valu }) } +fn build_hermes_platform_toolsets_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let platform_toolsets = root + .and_then(|map| map.get(yaml_key("platform_toolsets"))) + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| validate_hermes_platform_toolsets(&value).ok()) + .unwrap_or_else(default_hermes_platform_toolsets); + + serde_json::json!({ + "platformToolsetsJson": serde_json::to_string_pretty(&Value::Object(platform_toolsets)).unwrap_or_else(|_| "{}".to_string()), + }) +} + +fn merge_hermes_platform_toolsets_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_platform_toolsets_config_values(config); + let platform_toolsets = parse_hermes_platform_toolsets_json( + form_string(form, "platformToolsetsJson").or_else(|| { + current["platformToolsetsJson"] + .as_str() + .map(ToString::to_string) + }), + )?; + let yaml_value = serde_yaml::to_value(Value::Object(platform_toolsets)) + .map_err(|err| format!("platform_toolsets 转换 YAML 失败: {err}"))?; + + let root = ensure_yaml_object(config)?; + root.insert(yaml_key("platform_toolsets"), yaml_value); + Ok(()) +} + fn merge_hermes_agent_toolsets_config( config: &mut serde_yaml::Value, form: &Value, @@ -7223,6 +7351,30 @@ pub fn hermes_agent_toolsets_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_platform_toolsets_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_platform_toolsets_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_platform_toolsets_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_platform_toolsets_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_platform_toolsets_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_agent_runtime_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -14539,6 +14691,135 @@ agent: } } +#[cfg(test)] +mod hermes_platform_toolsets_config_tests { + use super::{ + build_hermes_platform_toolsets_config_values, merge_hermes_platform_toolsets_config, + }; + use serde_json::json; + + #[test] + fn platform_toolsets_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_platform_toolsets_config_values(&config); + let mapping: serde_json::Value = + serde_json::from_str(values["platformToolsetsJson"].as_str().unwrap()).unwrap(); + + assert_eq!(mapping["cli"][0].as_str(), Some("hermes-cli")); + assert_eq!(mapping["telegram"][0].as_str(), Some("hermes-telegram")); + assert_eq!(mapping["discord"][0].as_str(), Some("hermes-discord")); + assert_eq!(mapping["whatsapp"][0].as_str(), Some("hermes-whatsapp")); + assert_eq!( + mapping["google_chat"][0].as_str(), + Some("hermes-google_chat") + ); + } + + #[test] + fn platform_toolsets_values_read_yaml_mapping() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +platform_toolsets: + cli: + - web + - terminal + - file + telegram: + - hermes-telegram + custom_platform: + - safe +"#, + ) + .unwrap(); + let values = build_hermes_platform_toolsets_config_values(&config); + let mapping: serde_json::Value = + serde_json::from_str(values["platformToolsetsJson"].as_str().unwrap()).unwrap(); + + assert_eq!(mapping["cli"][0].as_str(), Some("web")); + assert_eq!(mapping["cli"][1].as_str(), Some("terminal")); + assert_eq!(mapping["cli"][2].as_str(), Some("file")); + assert_eq!(mapping["telegram"][0].as_str(), Some("hermes-telegram")); + assert_eq!(mapping["custom_platform"][0].as_str(), Some("safe")); + } + + #[test] + fn merge_platform_toolsets_config_preserves_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +platform_toolsets: + cli: + - hermes-cli +agent: + max_turns: 80 +"#, + ) + .unwrap(); + + merge_hermes_platform_toolsets_config( + &mut config, + &json!({ + "platformToolsetsJson": serde_json::to_string(&json!({ + "cli": ["web", "terminal", "file", "web"], + "telegram": ["hermes-telegram"], + "custom_platform": ["safe"] + })).unwrap() + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["agent"]["max_turns"].as_i64(), Some(80)); + assert_eq!(config["platform_toolsets"]["cli"][0].as_str(), Some("web")); + assert_eq!( + config["platform_toolsets"]["cli"][1].as_str(), + Some("terminal") + ); + assert_eq!(config["platform_toolsets"]["cli"][2].as_str(), Some("file")); + assert_eq!( + config["platform_toolsets"]["telegram"][0].as_str(), + Some("hermes-telegram") + ); + assert_eq!( + config["platform_toolsets"]["custom_platform"][0].as_str(), + Some("safe") + ); + } + + #[test] + fn merge_platform_toolsets_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_platform_toolsets_config( + &mut config, + &json!({ "platformToolsetsJson": "[" }), + ) + .unwrap_err(); + assert!(err.contains("platform_toolsets JSON")); + + let err = merge_hermes_platform_toolsets_config( + &mut config, + &json!({ "platformToolsetsJson": r#"{"bad platform":["web"]}"# }), + ) + .unwrap_err(); + assert!(err.contains("platform_toolsets.bad platform")); + + let err = merge_hermes_platform_toolsets_config( + &mut config, + &json!({ "platformToolsetsJson": r#"{"cli":["bad tool"]}"# }), + ) + .unwrap_err(); + assert!(err.contains("platform_toolsets.cli")); + + let err = merge_hermes_platform_toolsets_config( + &mut config, + &json!({ "platformToolsetsJson": r#"{"cli":[]}"# }), + ) + .unwrap_err(); + assert!(err.contains("platform_toolsets.cli")); + } +} + #[cfg(test)] mod hermes_agent_runtime_config_tests { use super::{build_hermes_agent_runtime_config_values, merge_hermes_agent_runtime_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fadd2c..8f21b88 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,8 @@ pub fn run() { hermes::hermes_quick_commands_config_save, hermes::hermes_agent_toolsets_config_read, hermes::hermes_agent_toolsets_config_save, + hermes::hermes_platform_toolsets_config_read, + hermes::hermes_platform_toolsets_config_save, hermes::hermes_agent_runtime_config_read, hermes::hermes_agent_runtime_config_save, hermes::hermes_unauthorized_dm_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index c9bb972..f72d3cc 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -60,6 +60,10 @@ const AGENT_TOOLSETS_DEFAULTS = { disabledToolsets: '', } +const PLATFORM_TOOLSETS_DEFAULTS = { + platformToolsetsJson: '{}', +} + const AGENT_RUNTIME_DEFAULTS = { agentMaxTurns: 90, gatewayTimeout: 1800, @@ -237,6 +241,7 @@ export function render() { let skillsValues = { ...SKILLS_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let agentToolsetsValues = { ...AGENT_TOOLSETS_DEFAULTS } + let platformToolsetsValues = { ...PLATFORM_TOOLSETS_DEFAULTS } let agentRuntimeValues = { ...AGENT_RUNTIME_DEFAULTS } let unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS } let securityValues = { ...SECURITY_DEFAULTS } @@ -262,6 +267,7 @@ export function render() { let skillsLoading = true let quickCommandsLoading = true let agentToolsetsLoading = true + let platformToolsetsLoading = true let agentRuntimeLoading = true let unauthorizedDmLoading = true let securityLoading = true @@ -287,6 +293,7 @@ export function render() { let skillsSaving = false let quickCommandsSaving = false let agentToolsetsSaving = false + let platformToolsetsSaving = false let agentRuntimeSaving = false let unauthorizedDmSaving = false let securitySaving = false @@ -312,6 +319,7 @@ export function render() { let skillsError = null let quickCommandsError = null let agentToolsetsError = null + let platformToolsetsError = null let agentRuntimeError = null let unauthorizedDmError = null let securityError = null @@ -338,7 +346,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || agentToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -653,7 +661,7 @@ export function render() { } function renderAgentToolsetsConfigPanel() { - const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -678,8 +686,34 @@ export function render() { ` } + function renderPlatformToolsetsConfigPanel() { + const disabled = loading || saving || platformToolsetsLoading || platformToolsetsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesPlatformToolsetsConfigTitle')}
+
${t('engine.hermesPlatformToolsetsConfigDesc')}
+
+
+ ${platformToolsetsSaving ? t('engine.hermesConfigStatusSaving') : platformToolsetsLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesPlatformToolsetsConfigStatusReady')} + +
+
+
+ ${renderError(platformToolsetsError)} + +
${t('engine.hermesPlatformToolsetsConfigFootnote')}
+
+
+ ` + } + function renderAgentRuntimeConfigPanel() { - const disabled = loading || saving || agentRuntimeLoading || agentRuntimeSaving || agentToolsetsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving + const disabled = loading || saving || agentRuntimeLoading || agentRuntimeSaving || agentToolsetsSaving || platformToolsetsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || runtimeSaving || compressionSaving || promptCachingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving return `
@@ -1536,6 +1570,7 @@ export function render() { ${renderSkillsConfigPanel()} ${renderQuickCommandsConfigPanel()} ${renderAgentToolsetsConfigPanel()} + ${renderPlatformToolsetsConfigPanel()} ${renderAgentRuntimeConfigPanel()} ${renderUnauthorizedDmConfigPanel()} ${renderSecurityConfigPanel()} @@ -1568,6 +1603,7 @@ export function render() { el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig) el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) el.querySelector('#hm-agent-toolsets-save')?.addEventListener('click', saveAgentToolsetsConfig) + el.querySelector('#hm-platform-toolsets-save')?.addEventListener('click', savePlatformToolsetsConfig) el.querySelector('#hm-agent-runtime-save')?.addEventListener('click', saveAgentRuntimeConfig) el.querySelector('#hm-unauthorized-dm-save')?.addEventListener('click', saveUnauthorizedDmConfig) el.querySelector('#hm-security-save')?.addEventListener('click', saveSecurityConfig) @@ -1631,6 +1667,11 @@ export function render() { agentToolsetsValues = { ...AGENT_TOOLSETS_DEFAULTS, ...(data?.values || {}) } } + async function loadPlatformToolsetsConfig() { + const data = await api.hermesPlatformToolsetsConfigRead() + platformToolsetsValues = { ...PLATFORM_TOOLSETS_DEFAULTS, ...(data?.values || {}) } + } + async function loadAgentRuntimeConfig() { const data = await api.hermesAgentRuntimeConfigRead() agentRuntimeValues = { ...AGENT_RUNTIME_DEFAULTS, ...(data?.values || {}) } @@ -1721,6 +1762,7 @@ export function render() { skillsLoading = true quickCommandsLoading = true agentToolsetsLoading = true + platformToolsetsLoading = true agentRuntimeLoading = true unauthorizedDmLoading = true securityLoading = true @@ -1746,6 +1788,7 @@ export function render() { skillsError = null quickCommandsError = null agentToolsetsError = null + platformToolsetsError = null agentRuntimeError = null unauthorizedDmError = null securityError = null @@ -1922,6 +1965,14 @@ export function render() { agentToolsetsLoading = false draw() } + try { + await loadPlatformToolsetsConfig() + } catch (err) { + platformToolsetsError = humanizeError(err, t('engine.hermesPlatformToolsetsConfigLoadFailed') || 'Load platform toolsets config failed') + } finally { + platformToolsetsLoading = false + draw() + } try { await loadAgentRuntimeConfig() } catch (err) { @@ -2007,6 +2058,9 @@ export function render() { try { await loadAgentToolsetsConfig() } catch {} + try { + await loadPlatformToolsetsConfig() + } catch {} try { await loadAgentRuntimeConfig() } catch {} @@ -2283,6 +2337,31 @@ export function render() { } } + async function savePlatformToolsetsConfig() { + const form = { + platformToolsetsJson: el.querySelector('#hm-platform-toolsets-json')?.value || '{}', + } + platformToolsetsSaving = true + platformToolsetsError = null + draw() + try { + const result = await api.hermesPlatformToolsetsConfigSave(form) + platformToolsetsValues = { ...PLATFORM_TOOLSETS_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesPlatformToolsetsConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + platformToolsetsError = humanizeError(err, t('engine.hermesPlatformToolsetsConfigSaveFailed') || 'Save platform toolsets config failed') + toast(platformToolsetsError, 'error') + } finally { + platformToolsetsSaving = false + draw() + } + } + async function saveAgentRuntimeConfig() { const form = { agentMaxTurns: el.querySelector('#hm-agent-max-turns')?.value || '90', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index c886662..e8b969a 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -525,6 +525,8 @@ export const api = { hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }), hermesAgentToolsetsConfigRead: () => invoke('hermes_agent_toolsets_config_read'), hermesAgentToolsetsConfigSave: (form) => invoke('hermes_agent_toolsets_config_save', { form }), + hermesPlatformToolsetsConfigRead: () => invoke('hermes_platform_toolsets_config_read'), + hermesPlatformToolsetsConfigSave: (form) => invoke('hermes_platform_toolsets_config_save', { form }), hermesAgentRuntimeConfigRead: () => invoke('hermes_agent_runtime_config_read'), hermesAgentRuntimeConfigSave: (form) => invoke('hermes_agent_runtime_config_save', { form }), hermesUnauthorizedDmConfigRead: () => invoke('hermes_unauthorized_dm_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index c48e973..4ab6533 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -775,6 +775,15 @@ export default { hermesAgentToolsetsConfigSaveFailed: _('保存全局工具集配置失败', 'Save global toolset settings failed', '儲存全域工具集設定失敗'), hermesAgentToolsetsConfigDisabledToolsets: _('禁用工具集(每行一个)', 'Disabled toolsets, one per line', '停用工具集(每行一個)'), hermesAgentToolsetsConfigFootnote: _('常见值包括 terminal、browser、memory、web。该设置会覆盖平台级工具配置;留空表示不做全局禁用。高级 agent 字段会保留在 raw YAML 中。', 'Common values include terminal, browser, memory, and web. This setting overrides platform-level tool configuration; leave it empty for no global disables. Advanced agent fields stay in raw YAML.', '常見值包括 terminal、browser、memory、web。此設定會覆蓋平台級工具設定;留空表示不做全域停用。進階 agent 欄位會保留在 raw YAML 中。'), + hermesPlatformToolsetsConfigTitle: _('平台工具集', 'Platform toolsets', '平台工具集'), + hermesPlatformToolsetsConfigDesc: _('为 CLI、Telegram、Discord、Slack 等入口指定可用工具集,适合按渠道收紧权限或接入自定义平台。', 'Set available toolsets for CLI, Telegram, Discord, Slack, and custom platforms. Useful for tightening permissions per channel.', '為 CLI、Telegram、Discord、Slack 等入口指定可用工具集,適合依渠道收緊權限或接入自訂平台。'), + hermesPlatformToolsetsConfigStatusReady: _('JSON 映射', 'JSON map', 'JSON 映射'), + hermesPlatformToolsetsConfigSave: _('保存平台工具集', 'Save platform toolsets', '儲存平台工具集'), + hermesPlatformToolsetsConfigSaveSuccess: _('平台工具集配置已保存,建议重启 Hermes Gateway 生效', 'Platform toolsets saved. Restart Hermes Gateway to take effect.', '平台工具集設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesPlatformToolsetsConfigLoadFailed: _('加载平台工具集配置失败', 'Load platform toolsets failed', '載入平台工具集設定失敗'), + hermesPlatformToolsetsConfigSaveFailed: _('保存平台工具集配置失败', 'Save platform toolsets failed', '儲存平台工具集設定失敗'), + hermesPlatformToolsetsConfigJson: _('platform_toolsets JSON 映射', 'platform_toolsets JSON map', 'platform_toolsets JSON 映射'), + hermesPlatformToolsetsConfigFootnote: _('键名是平台名,例如 cli、telegram、discord。值必须是工具集数组,例如 ["hermes-cli"];平台名和工具集名只能使用字母、数字、下划线、点和短横线。', 'Keys are platform names, for example cli, telegram, and discord. Values must be toolset arrays such as ["hermes-cli"]; names may only use letters, numbers, underscores, dots, and hyphens.', '鍵名是平台名,例如 cli、telegram、discord。值必須是工具集陣列,例如 ["hermes-cli"];平台名和工具集名只能使用字母、數字、底線、點和短橫線。'), hermesAgentRuntimeConfigTitle: _('Agent 长跑保护', 'Agent runtime guards', 'Agent 長跑保護'), hermesAgentRuntimeConfigDesc: _('控制 Agent 轮次上限、Gateway 等待、重启排水、重试、超时预警、澄清等待和自动续跑新鲜度,减少长时间任务无人值守失控。', 'Control turn limits, Gateway waits, restart drain, retries, timeout warnings, clarification waits, and auto-continue freshness to keep unattended long runs bounded.', '控制 Agent 輪次上限、Gateway 等待、重啟排水、重試、逾時預警、澄清等待和自動續跑新鮮度,減少長時間任務無人值守失控。'), hermesAgentRuntimeConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-agent-toolsets-config.test.js b/tests/hermes-agent-toolsets-config.test.js index 1c5a8ae..df7f483 100644 --- a/tests/hermes-agent-toolsets-config.test.js +++ b/tests/hermes-agent-toolsets-config.test.js @@ -3,7 +3,9 @@ import assert from 'node:assert/strict' import { buildHermesAgentToolsetsConfigValues, + buildHermesPlatformToolsetsConfigValues, mergeHermesAgentToolsetsConfig, + mergeHermesPlatformToolsetsConfig, } from '../scripts/dev-api.js' test('Hermes Agent 工具集配置读取会提供上游默认值', () => { @@ -68,3 +70,70 @@ test('Hermes Agent 工具集配置保存会拒绝非法工具集名称', () => { /agent\.disabled_toolsets/, ) }) + +test('Hermes 平台工具集配置读取会提供上游默认映射', () => { + const values = buildHermesPlatformToolsetsConfigValues({}) + const mapping = JSON.parse(values.platformToolsetsJson) + + assert.deepEqual(mapping.cli, ['hermes-cli']) + assert.deepEqual(mapping.telegram, ['hermes-telegram']) + assert.deepEqual(mapping.discord, ['hermes-discord']) + assert.deepEqual(mapping.whatsapp, ['hermes-whatsapp']) + assert.deepEqual(mapping.google_chat, ['hermes-google_chat']) +}) + +test('Hermes 平台工具集配置读取会回显 YAML 映射', () => { + const values = buildHermesPlatformToolsetsConfigValues({ + platform_toolsets: { + cli: ['web', 'terminal', 'file'], + telegram: ['hermes-telegram'], + custom_platform: ['safe'], + }, + }) + const mapping = JSON.parse(values.platformToolsetsJson) + + assert.deepEqual(mapping.cli, ['web', 'terminal', 'file']) + assert.deepEqual(mapping.telegram, ['hermes-telegram']) + assert.deepEqual(mapping.custom_platform, ['safe']) +}) + +test('Hermes 平台工具集配置保存会保留未知字段并写入平台映射', () => { + const next = mergeHermesPlatformToolsetsConfig({ + model: { provider: 'anthropic' }, + platform_toolsets: { + cli: ['hermes-cli'], + }, + agent: { max_turns: 80 }, + }, { + platformToolsetsJson: JSON.stringify({ + cli: ['web', 'terminal', 'file', 'web'], + telegram: ['hermes-telegram'], + custom_platform: ['safe'], + }), + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.agent, { max_turns: 80 }) + assert.deepEqual(next.platform_toolsets.cli, ['web', 'terminal', 'file']) + assert.deepEqual(next.platform_toolsets.telegram, ['hermes-telegram']) + assert.deepEqual(next.platform_toolsets.custom_platform, ['safe']) +}) + +test('Hermes 平台工具集配置保存会拒绝非法 JSON、平台名和工具集名', () => { + assert.throws( + () => mergeHermesPlatformToolsetsConfig({}, { platformToolsetsJson: '[' }), + /platform_toolsets JSON/, + ) + assert.throws( + () => mergeHermesPlatformToolsetsConfig({}, { platformToolsetsJson: JSON.stringify({ 'bad platform': ['web'] }) }), + /platform_toolsets\.bad platform/, + ) + assert.throws( + () => mergeHermesPlatformToolsetsConfig({}, { platformToolsetsJson: JSON.stringify({ cli: ['bad tool'] }) }), + /platform_toolsets\.cli/, + ) + assert.throws( + () => mergeHermesPlatformToolsetsConfig({}, { platformToolsetsJson: JSON.stringify({ cli: [] }) }), + /platform_toolsets\.cli/, + ) +}) diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 126897f..e21f716 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -62,6 +62,8 @@ test('Hermes 配置页会暴露全局禁用工具集结构化配置字段', () = for (const id of [ 'hm-agent-toolsets-save', 'hm-agent-disabled-toolsets', + 'hm-platform-toolsets-save', + 'hm-platform-toolsets-json', ]) { assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) }