feat(hermes): add browser tool settings

This commit is contained in:
晴天
2026-05-25 00:27:06 +08:00
parent 26d6ad18bc
commit 8d5d21f908
8 changed files with 500 additions and 3 deletions

View File

@@ -3324,6 +3324,7 @@ const HERMES_SESSION_RESET_MODES = new Set(['both', 'idle', 'daily', 'none'])
const HERMES_STREAMING_TRANSPORTS = new Set(['auto', 'draft', 'edit', 'off'])
const HERMES_CODE_EXECUTION_MODES = new Set(['project', 'strict'])
const HERMES_TERMINAL_BACKENDS = new Set(['local', 'ssh', 'docker', 'singularity', 'modal', 'daytona', 'vercel_sandbox'])
const HERMES_BROWSER_ENGINES = new Set(['auto', 'lightpanda', 'chrome'])
const HERMES_DISPLAY_TOOL_PROGRESS_VALUES = new Set(['off', 'new', 'all', 'verbose'])
const HERMES_DISPLAY_STREAMING_VALUES = new Set(['inherit', 'true', 'false'])
const HERMES_DISPLAY_RESUME_VALUES = new Set(['full', 'minimal'])
@@ -3401,6 +3402,13 @@ function normalizeHermesTerminalBackend(value, strict = false) {
return 'local'
}
function normalizeHermesBrowserEngine(value, strict = false) {
const engine = String(value ?? '').trim().toLowerCase() || 'auto'
if (HERMES_BROWSER_ENGINES.has(engine)) return engine
if (strict) throw new Error('browser.engine 必须是 auto、lightpanda 或 chrome')
return 'auto'
}
function normalizeHermesDisplayToolProgress(value, strict = false, key = 'display.tool_progress') {
const progress = String(value ?? '').trim().toLowerCase() || 'all'
if (HERMES_DISPLAY_TOOL_PROGRESS_VALUES.has(progress)) return progress
@@ -3943,6 +3951,33 @@ export function mergeHermesPrivacyConfig(config = {}, form = {}) {
return next
}
export function buildHermesBrowserConfigValues(config = {}) {
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
const browser = root.browser && typeof root.browser === 'object' && !Array.isArray(root.browser)
? root.browser
: {}
return {
browserInactivityTimeout: parseHermesInteger(browser.inactivity_timeout, 'browser.inactivity_timeout', 120, 1, 86400, false),
browserCommandTimeout: parseHermesInteger(browser.command_timeout, 'browser.command_timeout', 30, 5, 3600, false),
browserRecordSessions: readHermesBool(browser.record_sessions, false),
browserEngine: normalizeHermesBrowserEngine(browser.engine, false),
}
}
export function mergeHermesBrowserConfig(config = {}, form = {}) {
const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {})
const currentValues = buildHermesBrowserConfigValues(next)
const browser = next.browser && typeof next.browser === 'object' && !Array.isArray(next.browser)
? mergeConfigsPreservingFields(next.browser, {})
: {}
browser.inactivity_timeout = parseHermesInteger(Object.hasOwn(form, 'browserInactivityTimeout') ? form.browserInactivityTimeout : currentValues.browserInactivityTimeout, 'browser.inactivity_timeout', 120, 1, 86400, true)
browser.command_timeout = parseHermesInteger(Object.hasOwn(form, 'browserCommandTimeout') ? form.browserCommandTimeout : currentValues.browserCommandTimeout, 'browser.command_timeout', 30, 5, 3600, true)
browser.record_sessions = formHermesBool(form, 'browserRecordSessions', currentValues.browserRecordSessions)
browser.engine = normalizeHermesBrowserEngine(Object.hasOwn(form, 'browserEngine') ? form.browserEngine : currentValues.browserEngine, true)
next.browser = browser
return next
}
export function buildHermesTerminalConfigValues(config = {}) {
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
const terminal = root.terminal && typeof root.terminal === 'object' && !Array.isArray(root.terminal)
@@ -10566,6 +10601,27 @@ const handlers = {
}
},
hermes_browser_config_read() {
const { configPath, exists, config } = readHermesConfigYamlObject()
return {
exists,
configPath,
values: buildHermesBrowserConfigValues(config),
}
},
hermes_browser_config_save({ form } = {}) {
const { configPath, config } = readHermesConfigYamlObject()
const next = mergeHermesBrowserConfig(config, form || {})
const backup = writeHermesConfigYamlObject(configPath, next)
return {
ok: true,
configPath,
backup,
values: buildHermesBrowserConfigValues(next),
}
},
hermes_terminal_config_read() {
const { configPath, exists, config } = readHermesConfigYamlObject()
return {

View File

@@ -4391,6 +4391,23 @@ fn normalize_hermes_terminal_backend(
}
}
fn normalize_hermes_browser_engine(value: Option<String>, strict: bool) -> Result<String, String> {
let engine = value.unwrap_or_default().trim().to_ascii_lowercase();
let engine = if engine.is_empty() {
"auto".to_string()
} else {
engine
};
if matches!(engine.as_str(), "auto" | "lightpanda" | "chrome") {
return Ok(engine);
}
if strict {
Err("browser.engine 必须是 auto、lightpanda 或 chrome".to_string())
} else {
Ok("auto".to_string())
}
}
fn hermes_streaming_config_source(config: &serde_yaml::Value) -> Option<&serde_yaml::Mapping> {
let root = config.as_mapping()?;
if let Some(streaming) = yaml_get_mapping(root, "streaming") {
@@ -4690,6 +4707,88 @@ fn merge_hermes_privacy_config(config: &mut serde_yaml::Value, form: &Value) ->
Ok(())
}
fn build_hermes_browser_config_values(config: &serde_yaml::Value) -> Value {
let root = config.as_mapping();
let browser = root.and_then(|map| yaml_get_mapping(map, "browser"));
let browser_inactivity_timeout = browser
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "inactivity_timeout"), 120, 1, 86400))
.unwrap_or(120);
let browser_command_timeout = browser
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "command_timeout"), 30, 5, 3600))
.unwrap_or(30);
let browser_record_sessions = browser
.and_then(|map| yaml_bool_field(map, "record_sessions"))
.unwrap_or(false);
let browser_engine = normalize_hermes_browser_engine(
browser.and_then(|map| yaml_string_field(map, "engine")),
false,
)
.unwrap_or_else(|_| "auto".to_string());
serde_json::json!({
"browserInactivityTimeout": browser_inactivity_timeout,
"browserCommandTimeout": browser_command_timeout,
"browserRecordSessions": browser_record_sessions,
"browserEngine": browser_engine,
})
}
fn merge_hermes_browser_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> {
let current = build_hermes_browser_config_values(config);
let browser_inactivity_timeout = validate_hermes_i64(
if form.get("browserInactivityTimeout").is_some() {
form_i64(form, "browserInactivityTimeout")
} else {
Some(current["browserInactivityTimeout"].as_i64().unwrap_or(120))
},
"browser.inactivity_timeout",
120,
1,
86400,
)?;
let browser_command_timeout = validate_hermes_i64(
if form.get("browserCommandTimeout").is_some() {
form_i64(form, "browserCommandTimeout")
} else {
Some(current["browserCommandTimeout"].as_i64().unwrap_or(30))
},
"browser.command_timeout",
30,
5,
3600,
)?;
let browser_record_sessions = form_bool(form, "browserRecordSessions")
.unwrap_or_else(|| current["browserRecordSessions"].as_bool().unwrap_or(false));
let browser_engine = normalize_hermes_browser_engine(
if form.get("browserEngine").is_some() {
form_string(form, "browserEngine")
} else {
current["browserEngine"].as_str().map(ToString::to_string)
},
true,
)?;
let root = ensure_yaml_object(config)?;
let browser = yaml_child_object(root, "browser")?;
browser.insert(
yaml_key("inactivity_timeout"),
serde_yaml::Value::Number(browser_inactivity_timeout.into()),
);
browser.insert(
yaml_key("command_timeout"),
serde_yaml::Value::Number(browser_command_timeout.into()),
);
browser.insert(
yaml_key("record_sessions"),
serde_yaml::Value::Bool(browser_record_sessions),
);
browser.insert(
yaml_key("engine"),
serde_yaml::Value::String(browser_engine),
);
Ok(())
}
fn merge_hermes_execution_limits_config(
config: &mut serde_yaml::Value,
form: &Value,
@@ -6161,6 +6260,30 @@ pub fn hermes_privacy_config_save(form: Value) -> Result<Value, String> {
}))
}
#[tauri::command]
pub fn hermes_browser_config_read() -> Result<Value, String> {
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_browser_config_values(&config),
}))
}
#[tauri::command]
pub fn hermes_browser_config_save(form: Value) -> Result<Value, String> {
let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?;
merge_hermes_browser_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_browser_config_values(&config),
}))
}
#[tauri::command]
pub fn hermes_terminal_config_read() -> Result<Value, String> {
let (config_path, exists, config) = read_hermes_channel_yaml_config()?;
@@ -11830,6 +11953,108 @@ streaming:
}
}
#[cfg(test)]
mod hermes_browser_config_tests {
use super::{build_hermes_browser_config_values, merge_hermes_browser_config};
use serde_json::json;
#[test]
fn browser_values_have_upstream_defaults() {
let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap();
let values = build_hermes_browser_config_values(&config);
assert_eq!(values["browserInactivityTimeout"], 120);
assert_eq!(values["browserCommandTimeout"], 30);
assert_eq!(values["browserRecordSessions"], false);
assert_eq!(values["browserEngine"], "auto");
}
#[test]
fn browser_values_read_yaml_fields() {
let config: serde_yaml::Value = serde_yaml::from_str(
r#"
browser:
inactivity_timeout: 300
command_timeout: 45
record_sessions: true
engine: lightpanda
"#,
)
.unwrap();
let values = build_hermes_browser_config_values(&config);
assert_eq!(values["browserInactivityTimeout"], 300);
assert_eq!(values["browserCommandTimeout"], 45);
assert_eq!(values["browserRecordSessions"], true);
assert_eq!(values["browserEngine"], "lightpanda");
}
#[test]
fn merge_browser_config_preserves_unknown_fields() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
model:
provider: anthropic
browser:
inactivity_timeout: 120
command_timeout: 30
record_sessions: false
engine: auto
cdp_url: ws://127.0.0.1:9222/devtools/browser/demo
camofox:
managed_persistence: true
custom_flag: keep-browser
streaming:
enabled: true
"#,
)
.unwrap();
merge_hermes_browser_config(
&mut config,
&json!({
"browserInactivityTimeout": "180",
"browserCommandTimeout": "60",
"browserRecordSessions": true,
"browserEngine": "chrome",
}),
)
.unwrap();
assert_eq!(config["model"]["provider"].as_str(), Some("anthropic"));
assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true));
assert_eq!(config["browser"]["inactivity_timeout"].as_i64(), Some(180));
assert_eq!(config["browser"]["command_timeout"].as_i64(), Some(60));
assert_eq!(config["browser"]["record_sessions"].as_bool(), Some(true));
assert_eq!(config["browser"]["engine"].as_str(), Some("chrome"));
assert_eq!(
config["browser"]["cdp_url"].as_str(),
Some("ws://127.0.0.1:9222/devtools/browser/demo")
);
assert_eq!(
config["browser"]["camofox"]["managed_persistence"].as_bool(),
Some(true)
);
assert_eq!(
config["browser"]["custom_flag"].as_str(),
Some("keep-browser")
);
}
#[test]
fn merge_browser_config_rejects_invalid_values() {
let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let err = merge_hermes_browser_config(&mut config, &json!({ "browserEngine": "firefox" }))
.unwrap_err();
assert!(err.contains("browser.engine"));
let err =
merge_hermes_browser_config(&mut config, &json!({ "browserInactivityTimeout": 0 }))
.unwrap_err();
assert!(err.contains("browser.inactivity_timeout"));
let err = merge_hermes_browser_config(&mut config, &json!({ "browserCommandTimeout": 4 }))
.unwrap_err();
assert!(err.contains("browser.command_timeout"));
}
}
#[cfg(test)]
mod hermes_terminal_config_tests {
use super::{build_hermes_terminal_config_values, merge_hermes_terminal_config};

View File

@@ -285,6 +285,8 @@ pub fn run() {
hermes::hermes_io_safety_config_save,
hermes::hermes_privacy_config_read,
hermes::hermes_privacy_config_save,
hermes::hermes_browser_config_read,
hermes::hermes_browser_config_save,
hermes::hermes_terminal_config_read,
hermes::hermes_terminal_config_save,
hermes::hermes_lazy_deps_features,

View File

@@ -113,6 +113,13 @@ const PRIVACY_DEFAULTS = {
redactPii: false,
}
const BROWSER_DEFAULTS = {
browserInactivityTimeout: 120,
browserCommandTimeout: 30,
browserRecordSessions: false,
browserEngine: 'auto',
}
const TERMINAL_DEFAULTS = {
terminalBackend: 'local',
terminalCwd: '.',
@@ -130,6 +137,7 @@ const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none']
const STREAMING_TRANSPORTS = ['edit', 'auto', 'draft', 'off']
const CODE_EXECUTION_MODES = ['project', 'strict']
const TERMINAL_BACKENDS = ['local', 'ssh', 'docker', 'singularity', 'modal', 'daytona', 'vercel_sandbox']
const BROWSER_ENGINES = ['auto', 'lightpanda', 'chrome']
const UNAUTHORIZED_DM_BEHAVIORS = ['pair', 'ignore']
const DISPLAY_TOOL_PROGRESS_VALUES = ['off', 'new', 'all', 'verbose']
const DISPLAY_LANGUAGE_VALUES = ['en', 'zh', 'zh-hant', 'ja', 'de', 'es', 'fr', 'tr', 'uk', 'af', 'ko', 'it', 'ga', 'pt', 'ru', 'hu']
@@ -155,6 +163,7 @@ export function render() {
let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS }
let ioSafetyValues = { ...IO_SAFETY_DEFAULTS }
let privacyValues = { ...PRIVACY_DEFAULTS }
let browserValues = { ...BROWSER_DEFAULTS }
let terminalValues = { ...TERMINAL_DEFAULTS }
let loading = true
let runtimeLoading = true
@@ -171,6 +180,7 @@ export function render() {
let executionLimitsLoading = true
let ioSafetyLoading = true
let privacyLoading = true
let browserLoading = true
let terminalLoading = true
let saving = false
let runtimeSaving = false
@@ -187,6 +197,7 @@ export function render() {
let executionLimitsSaving = false
let ioSafetySaving = false
let privacySaving = false
let browserSaving = false
let terminalSaving = false
let error = null
let runtimeError = null
@@ -203,6 +214,7 @@ export function render() {
let executionLimitsError = null
let ioSafetyError = null
let privacyError = null
let browserError = null
let terminalError = null
function esc(value) {
@@ -214,7 +226,7 @@ export function render() {
}
function isBusy() {
return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || privacyLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || privacySaving || terminalSaving
return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || privacyLoading || browserLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || privacySaving || browserSaving || terminalSaving
}
function option(labelKey, value, selected) {
@@ -837,7 +849,7 @@ export function render() {
}
function renderPrivacyPanel() {
const disabled = loading || saving || privacyLoading || privacySaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving
const disabled = loading || saving || privacyLoading || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-privacy-panel">
<div class="hm-panel-header">
@@ -864,8 +876,52 @@ export function render() {
`
}
function renderBrowserPanel() {
const disabled = loading || saving || browserLoading || browserSaving || privacySaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-browser-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesBrowserConfigTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesBrowserConfigDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${browserSaving ? t('engine.hermesConfigStatusSaving') : browserLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesBrowserConfigStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-browser-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesBrowserConfigSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(browserError)}
<div class="hm-config-runtime-grid hm-config-browser-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesBrowserConfigEngine')}</span>
<select id="hm-browser-engine" class="hm-input" ${disabled ? 'disabled' : ''}>
${BROWSER_ENGINES.map(mode => option(`engine.hermesBrowserConfigEngine_${mode}`, mode, browserValues.browserEngine)).join('')}
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesBrowserConfigInactivityTimeout')}</span>
<input id="hm-browser-inactivity-timeout" class="hm-input" type="number" inputmode="numeric" min="1" max="86400" step="1" value="${esc(browserValues.browserInactivityTimeout)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesBrowserConfigCommandTimeout')}</span>
<input id="hm-browser-command-timeout" class="hm-input" type="number" inputmode="numeric" min="5" max="3600" step="1" value="${esc(browserValues.browserCommandTimeout)}" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-browser-record-sessions" type="checkbox" ${browserValues.browserRecordSessions ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesBrowserConfigRecordSessions')}</span>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesBrowserConfigFootnote')}</div>
</div>
</div>
`
}
function renderTerminalPanel() {
const disabled = loading || saving || terminalLoading || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving
const disabled = loading || saving || terminalLoading || terminalSaving || browserSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-terminal-panel">
<div class="hm-panel-header">
@@ -955,6 +1011,7 @@ export function render() {
${renderExecutionLimitsPanel()}
${renderIoSafetyPanel()}
${renderPrivacyPanel()}
${renderBrowserPanel()}
${renderCompressionPanel()}
${renderToolGuardrailsPanel()}
${renderMemoryPanel()}
@@ -997,6 +1054,7 @@ export function render() {
el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits)
el.querySelector('#hm-io-safety-save')?.addEventListener('click', saveIoSafety)
el.querySelector('#hm-privacy-save')?.addEventListener('click', savePrivacyConfig)
el.querySelector('#hm-browser-save')?.addEventListener('click', saveBrowserConfig)
el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal)
}
@@ -1075,6 +1133,11 @@ export function render() {
privacyValues = { ...PRIVACY_DEFAULTS, ...(data?.values || {}) }
}
async function loadBrowserConfig() {
const data = await api.hermesBrowserConfigRead()
browserValues = { ...BROWSER_DEFAULTS, ...(data?.values || {}) }
}
async function loadTerminal() {
const data = await api.hermesTerminalConfigRead()
terminalValues = { ...TERMINAL_DEFAULTS, ...(data?.values || {}) }
@@ -1096,6 +1159,7 @@ export function render() {
executionLimitsLoading = true
ioSafetyLoading = true
privacyLoading = true
browserLoading = true
terminalLoading = true
error = null
runtimeError = null
@@ -1112,6 +1176,7 @@ export function render() {
executionLimitsError = null
ioSafetyError = null
privacyError = null
browserError = null
terminalError = null
draw()
try {
@@ -1177,6 +1242,14 @@ export function render() {
privacyLoading = false
draw()
}
try {
await loadBrowserConfig()
} catch (err) {
browserError = humanizeError(err, t('engine.hermesBrowserConfigLoadFailed') || 'Load browser config failed')
} finally {
browserLoading = false
draw()
}
try {
await loadTerminal()
} catch (err) {
@@ -1304,6 +1377,9 @@ export function render() {
try {
await loadPrivacyConfig()
} catch {}
try {
await loadBrowserConfig()
} catch {}
try {
await loadTerminal()
} catch {}
@@ -1717,6 +1793,34 @@ export function render() {
}
}
async function saveBrowserConfig() {
const form = {
browserInactivityTimeout: el.querySelector('#hm-browser-inactivity-timeout')?.value || '120',
browserCommandTimeout: el.querySelector('#hm-browser-command-timeout')?.value || '30',
browserRecordSessions: !!el.querySelector('#hm-browser-record-sessions')?.checked,
browserEngine: el.querySelector('#hm-browser-engine')?.value || 'auto',
}
browserSaving = true
browserError = null
draw()
try {
const result = await api.hermesBrowserConfigSave(form)
browserValues = { ...BROWSER_DEFAULTS, ...(result?.values || form) }
await refreshRawAfterStructuredSave()
const backup = result?.backup || ''
toast({
message: t('engine.hermesBrowserConfigSaveSuccess'),
hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '',
}, 'success')
} catch (err) {
browserError = humanizeError(err, t('engine.hermesBrowserConfigSaveFailed') || 'Save browser config failed')
toast(browserError, 'error')
} finally {
browserSaving = false
draw()
}
}
async function saveTerminal() {
const form = {
terminalBackend: el.querySelector('#hm-terminal-backend')?.value || 'local',

View File

@@ -537,6 +537,8 @@ export const api = {
hermesIoSafetyConfigSave: (form) => invoke('hermes_io_safety_config_save', { form }),
hermesPrivacyConfigRead: () => invoke('hermes_privacy_config_read'),
hermesPrivacyConfigSave: (form) => invoke('hermes_privacy_config_save', { form }),
hermesBrowserConfigRead: () => invoke('hermes_browser_config_read'),
hermesBrowserConfigSave: (form) => invoke('hermes_browser_config_save', { form }),
hermesTerminalConfigRead: () => invoke('hermes_terminal_config_read'),
hermesTerminalConfigSave: (form) => invoke('hermes_terminal_config_save', { form }),
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),

View File

@@ -585,6 +585,21 @@ export default {
hermesPrivacyConfigSaveFailed: _('保存隐私脱敏配置失败', 'Save privacy redaction failed', '儲存隱私脫敏設定失敗'),
hermesPrivacyConfigRedactPii: _('送入模型前脱敏用户标识和手机号', 'Redact user identifiers and phone numbers before model context', '送入模型前脫敏使用者識別和電話號碼'),
hermesPrivacyConfigFootnote: _('目前主要作用于 WhatsApp、Signal 和 TelegramDiscord 与 Slack 为保持 mention 语义不会脱敏真实用户 ID。其他 privacy 高级字段会保留在 raw YAML 中。', 'Currently applies mainly to WhatsApp, Signal, and Telegram. Discord and Slack keep real user IDs to preserve mention semantics. Other advanced privacy fields are preserved in raw YAML.', '目前主要作用於 WhatsApp、Signal 和 TelegramDiscord 與 Slack 為保持 mention 語義不會脫敏真實使用者 ID。其他 privacy 進階欄位會保留在 raw YAML 中。'),
hermesBrowserConfigTitle: _('浏览器工具', 'Browser tools', '瀏覽器工具'),
hermesBrowserConfigDesc: _('控制浏览器工具的空闲回收、单次命令超时、会话录制和本地引擎,降低长跑浏览器任务卡死和隐私误录风险。', 'Control browser-tool idle cleanup, per-command timeout, session recording, and local engine to reduce stuck long-run browsing tasks and accidental recording risk.', '控制瀏覽器工具的閒置回收、單次命令逾時、工作階段錄製和本機引擎,降低長跑瀏覽器任務卡死和隱私誤錄風險。'),
hermesBrowserConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'),
hermesBrowserConfigSave: _('保存浏览器配置', 'Save browser settings', '儲存瀏覽器設定'),
hermesBrowserConfigSaveSuccess: _('浏览器工具配置已保存,建议重启 Hermes Gateway 生效', 'Browser tool settings saved. Restart Hermes Gateway to take effect.', '瀏覽器工具設定已儲存,建議重啟 Hermes Gateway 生效'),
hermesBrowserConfigLoadFailed: _('加载浏览器工具配置失败', 'Load browser tool settings failed', '載入瀏覽器工具設定失敗'),
hermesBrowserConfigSaveFailed: _('保存浏览器工具配置失败', 'Save browser tool settings failed', '儲存瀏覽器工具設定失敗'),
hermesBrowserConfigInactivityTimeout: _('空闲回收秒数', 'Idle cleanup seconds', '閒置回收秒數'),
hermesBrowserConfigCommandTimeout: _('单次命令超时秒数', 'Command timeout seconds', '單次命令逾時秒數'),
hermesBrowserConfigRecordSessions: _('录制浏览器会话', 'Record browser sessions', '錄製瀏覽器工作階段'),
hermesBrowserConfigEngine: _('浏览器引擎', 'Browser engine', '瀏覽器引擎'),
hermesBrowserConfigEngine_auto: _('自动选择', 'Auto select', '自動選擇'),
hermesBrowserConfigEngine_lightpanda: _('Lightpanda 快速导航', 'Lightpanda fast navigation', 'Lightpanda 快速導覽'),
hermesBrowserConfigEngine_chrome: _('Chrome 完整浏览器', 'Chrome full browser', 'Chrome 完整瀏覽器'),
hermesBrowserConfigFootnote: _('Lightpanda 导航更快但不支持截图;录制会把 WebM 写入 Hermes browser_recordings 目录请只在需要审计时开启。CDP、Dialog 和 Camofox 高级字段会保留在 raw YAML 中。', 'Lightpanda navigates faster but does not support screenshots. Recording writes WebM files into the Hermes browser_recordings directory, so enable it only for audits. Advanced CDP, Dialog, and Camofox fields stay in raw YAML.', 'Lightpanda 導覽更快但不支援截圖;錄製會把 WebM 寫入 Hermes browser_recordings 目錄請只在需要稽核時開啟。CDP、Dialog 和 Camofox 進階欄位會保留在 raw YAML 中。'),
hermesCompressionTitle: _('上下文压缩', 'Context compression', '上下文壓縮'),
hermesCompressionDesc: _('控制长对话何时触发压缩、压缩目标和保留范围,降低上下文过长导致的失败与费用浪费。', 'Control when long conversations are compressed, the target size, and protected message ranges to reduce failures and wasted cost from oversized context.', '控制長對話何時觸發壓縮、壓縮目標和保留範圍,降低上下文過長導致的失敗與費用浪費。'),
hermesCompressionStatusReady: _('结构化配置', 'structured settings', '結構化設定'),

View File

@@ -0,0 +1,80 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildHermesBrowserConfigValues,
mergeHermesBrowserConfig,
} from '../scripts/dev-api.js'
test('Hermes 浏览器配置读取会提供上游默认值', () => {
const values = buildHermesBrowserConfigValues({})
assert.deepEqual(values, {
browserInactivityTimeout: 120,
browserCommandTimeout: 30,
browserRecordSessions: false,
browserEngine: 'auto',
})
})
test('Hermes 浏览器配置读取会回显 YAML 字段', () => {
const values = buildHermesBrowserConfigValues({
browser: {
inactivity_timeout: 300,
command_timeout: 45,
record_sessions: true,
engine: 'lightpanda',
},
})
assert.equal(values.browserInactivityTimeout, 300)
assert.equal(values.browserCommandTimeout, 45)
assert.equal(values.browserRecordSessions, true)
assert.equal(values.browserEngine, 'lightpanda')
})
test('Hermes 浏览器配置保存会保留未知字段并写入上游结构', () => {
const next = mergeHermesBrowserConfig({
model: { provider: 'anthropic' },
browser: {
inactivity_timeout: 120,
command_timeout: 30,
record_sessions: false,
engine: 'auto',
cdp_url: 'ws://127.0.0.1:9222/devtools/browser/demo',
camofox: { managed_persistence: true },
custom_flag: 'keep-browser',
},
streaming: { enabled: true },
}, {
browserInactivityTimeout: '180',
browserCommandTimeout: '60',
browserRecordSessions: true,
browserEngine: 'chrome',
})
assert.deepEqual(next.model, { provider: 'anthropic' })
assert.deepEqual(next.streaming, { enabled: true })
assert.equal(next.browser.inactivity_timeout, 180)
assert.equal(next.browser.command_timeout, 60)
assert.equal(next.browser.record_sessions, true)
assert.equal(next.browser.engine, 'chrome')
assert.equal(next.browser.cdp_url, 'ws://127.0.0.1:9222/devtools/browser/demo')
assert.deepEqual(next.browser.camofox, { managed_persistence: true })
assert.equal(next.browser.custom_flag, 'keep-browser')
})
test('Hermes 浏览器配置保存会拒绝非法引擎和越界值', () => {
assert.throws(
() => mergeHermesBrowserConfig({}, { browserEngine: 'firefox' }),
/browser\.engine/,
)
assert.throws(
() => mergeHermesBrowserConfig({}, { browserInactivityTimeout: '0' }),
/browser\.inactivity_timeout/,
)
assert.throws(
() => mergeHermesBrowserConfig({}, { browserCommandTimeout: '4' }),
/browser\.command_timeout/,
)
})

View File

@@ -159,6 +159,18 @@ test('Hermes 配置页会暴露隐私脱敏结构化配置字段', () => {
}
})
test('Hermes 配置页会暴露浏览器基础结构化配置字段', () => {
for (const id of [
'hm-browser-save',
'hm-browser-inactivity-timeout',
'hm-browser-command-timeout',
'hm-browser-record-sessions',
'hm-browser-engine',
]) {
assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`)
}
})
test('Hermes 配置页会暴露终端执行结构化配置字段', () => {
for (const id of [
'hm-terminal-save',
@@ -194,6 +206,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => {
key.includes('StreamingConfig') ||
key.includes('ExecutionLimits') ||
key.includes('PrivacyConfig') ||
key.includes('BrowserConfig') ||
key.includes('TerminalConfig')
)))