feat(hermes): add session runtime config form

This commit is contained in:
晴天
2026-05-24 06:49:17 +08:00
parent 4c29ed68ab
commit f500da39c1
8 changed files with 646 additions and 15 deletions

View File

@@ -3320,6 +3320,78 @@ function normalizeHermesPlatform(platform) {
return HERMES_CHANNEL_PLATFORMS.includes(p) ? p : ''
}
const HERMES_SESSION_RESET_MODES = new Set(['both', 'idle', 'daily', 'none'])
function parseHermesInteger(value, key, fallback, min, max, strict = false) {
const raw = String(value ?? '').trim()
if (!raw) {
if (strict) throw new Error(`${key} 不能为空`)
return fallback
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || String(parsed) !== raw.replace(/^\+/, '')) {
if (strict) throw new Error(`${key} 必须是整数`)
return fallback
}
if (parsed < min || parsed > max) {
if (strict) throw new Error(`${key} 必须在 ${min}-${max} 范围内`)
return fallback
}
return parsed
}
function readHermesBool(value, fallback) {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (['true', '1', 'yes', 'on'].includes(normalized)) return true
if (['false', '0', 'no', 'off'].includes(normalized)) return false
}
return fallback
}
function formHermesBool(form, key, fallback) {
return readHermesBool(form?.[key], fallback)
}
export function buildHermesSessionRuntimeConfigValues(config = {}) {
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
const sessionReset = root.session_reset && typeof root.session_reset === 'object' && !Array.isArray(root.session_reset)
? root.session_reset
: {}
const mode = HERMES_SESSION_RESET_MODES.has(String(sessionReset.mode || '').trim())
? String(sessionReset.mode).trim()
: 'both'
return {
sessionResetMode: mode,
idleMinutes: parseHermesInteger(sessionReset.idle_minutes, 'idle_minutes', 1440, 1, 525600, false),
atHour: parseHermesInteger(sessionReset.at_hour, 'at_hour', 4, 0, 23, false),
groupSessionsPerUser: readHermesBool(root.group_sessions_per_user, true),
threadSessionsPerUser: readHermesBool(root.thread_sessions_per_user, false),
}
}
export function mergeHermesSessionRuntimeConfig(config = {}, form = {}) {
const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {})
const currentValues = buildHermesSessionRuntimeConfigValues(next)
const mode = String(Object.hasOwn(form, 'sessionResetMode') ? form.sessionResetMode : currentValues.sessionResetMode).trim()
if (!HERMES_SESSION_RESET_MODES.has(mode)) {
throw new Error('session_reset.mode 必须是 both、idle、daily 或 none')
}
const idleMinutes = parseHermesInteger(Object.hasOwn(form, 'idleMinutes') ? form.idleMinutes : currentValues.idleMinutes, 'idle_minutes', 1440, 1, 525600, true)
const atHour = parseHermesInteger(Object.hasOwn(form, 'atHour') ? form.atHour : currentValues.atHour, 'at_hour', 4, 0, 23, true)
const sessionReset = next.session_reset && typeof next.session_reset === 'object' && !Array.isArray(next.session_reset)
? mergeConfigsPreservingFields(next.session_reset, {})
: {}
sessionReset.mode = mode
sessionReset.idle_minutes = idleMinutes
sessionReset.at_hour = atHour
next.session_reset = sessionReset
next.group_sessions_per_user = formHermesBool(form, 'groupSessionsPerUser', currentValues.groupSessionsPerUser)
next.thread_sessions_per_user = formHermesBool(form, 'threadSessionsPerUser', currentValues.threadSessionsPerUser)
return next
}
function toCamelCaseKey(key) {
return String(key || '').replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase())
}
@@ -3753,10 +3825,13 @@ function readHermesConfigYamlObject() {
function writeHermesConfigYamlObject(configPath, config) {
fs.mkdirSync(path.dirname(configPath), { recursive: true })
let backup = ''
if (fs.existsSync(configPath)) {
fs.copyFileSync(configPath, `${configPath}.bak-${Math.floor(Date.now() / 1000)}`)
backup = `${configPath}.bak-${Math.floor(Date.now() / 1000)}`
fs.copyFileSync(configPath, backup)
}
fs.writeFileSync(configPath, YAML.stringify(config || {}, { lineWidth: 0 }), 'utf8')
return backup
}
function writeHermesEnvValues(updates = {}) {
@@ -9480,6 +9555,27 @@ const handlers = {
}
},
hermes_session_runtime_config_read() {
const { configPath, exists, config } = readHermesConfigYamlObject()
return {
exists,
configPath,
values: buildHermesSessionRuntimeConfigValues(config),
}
},
hermes_session_runtime_config_save({ form } = {}) {
const { configPath, config } = readHermesConfigYamlObject()
const next = mergeHermesSessionRuntimeConfig(config, form || {})
const backup = writeHermesConfigYamlObject(configPath, next)
return {
ok: true,
configPath,
backup,
values: buildHermesSessionRuntimeConfigValues(next),
}
},
// P1-3 lazy_deps: Web 模式下不能调 venv python但仍提供 feature 列表 + 提示用户走桌面端装
hermes_lazy_deps_features() {
const features = [

View File

@@ -2968,6 +2968,140 @@ fn normalize_hermes_group_policy(value: Option<String>) -> String {
}
}
fn yaml_i64_field(map: &serde_yaml::Mapping, key: &str) -> Option<i64> {
let value = yaml_get(map, key)?;
if let Some(value) = value.as_i64() {
Some(value)
} else if let Some(value) = value.as_u64() {
i64::try_from(value).ok()
} else if let Some(value) = value.as_f64() {
if value.is_finite() {
Some(value as i64)
} else {
None
}
} else {
value
.as_str()
.and_then(|value| value.trim().parse::<i64>().ok())
}
}
fn bounded_hermes_i64(value: Option<i64>, fallback: i64, min: i64, max: i64) -> i64 {
value
.filter(|value| *value >= min && *value <= max)
.unwrap_or(fallback)
}
fn validate_hermes_i64(
value: Option<i64>,
key: &str,
fallback: i64,
min: i64,
max: i64,
) -> Result<i64, String> {
let value = value.unwrap_or(fallback);
if value < min || value > max {
return Err(format!("{key} 必须在 {min}-{max} 范围内"));
}
Ok(value)
}
fn build_hermes_session_runtime_config_values(config: &serde_yaml::Value) -> Value {
let root = config.as_mapping();
let session_reset = root.and_then(|map| yaml_get_mapping(map, "session_reset"));
let mode = session_reset
.and_then(|map| yaml_string_field(map, "mode"))
.map(|value| value.trim().to_string())
.filter(|value| matches!(value.as_str(), "both" | "idle" | "daily" | "none"))
.unwrap_or_else(|| "both".to_string());
let idle_minutes = session_reset
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "idle_minutes"), 1440, 1, 525600))
.unwrap_or(1440);
let at_hour = session_reset
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "at_hour"), 4, 0, 23))
.unwrap_or(4);
let group_sessions_per_user = root
.and_then(|map| yaml_bool_field(map, "group_sessions_per_user"))
.unwrap_or(true);
let thread_sessions_per_user = root
.and_then(|map| yaml_bool_field(map, "thread_sessions_per_user"))
.unwrap_or(false);
serde_json::json!({
"sessionResetMode": mode,
"idleMinutes": idle_minutes,
"atHour": at_hour,
"groupSessionsPerUser": group_sessions_per_user,
"threadSessionsPerUser": thread_sessions_per_user,
})
}
fn merge_hermes_session_runtime_config(
config: &mut serde_yaml::Value,
form: &Value,
) -> Result<(), String> {
let current = build_hermes_session_runtime_config_values(config);
let current_mode = current["sessionResetMode"].as_str().unwrap_or("both");
let mode = if form.get("sessionResetMode").is_some() {
form_string(form, "sessionResetMode")
.map(|value| value.trim().to_string())
.filter(|value| matches!(value.as_str(), "both" | "idle" | "daily" | "none"))
.ok_or_else(|| "session_reset.mode 必须是 both、idle、daily 或 none".to_string())?
} else {
current_mode.to_string()
};
let current_idle_minutes = current["idleMinutes"].as_i64().unwrap_or(1440);
let idle_minutes = validate_hermes_i64(
if form.get("idleMinutes").is_some() {
form_i64(form, "idleMinutes")
} else {
Some(current_idle_minutes)
},
"idle_minutes",
1440,
1,
525600,
)?;
let current_at_hour = current["atHour"].as_i64().unwrap_or(4);
let at_hour = validate_hermes_i64(
if form.get("atHour").is_some() {
form_i64(form, "atHour")
} else {
Some(current_at_hour)
},
"at_hour",
4,
0,
23,
)?;
let group_sessions_per_user = form_bool(form, "groupSessionsPerUser")
.unwrap_or_else(|| current["groupSessionsPerUser"].as_bool().unwrap_or(true));
let thread_sessions_per_user = form_bool(form, "threadSessionsPerUser")
.unwrap_or_else(|| current["threadSessionsPerUser"].as_bool().unwrap_or(false));
let root = ensure_yaml_object(config)?;
let session_reset = yaml_child_object(root, "session_reset")?;
session_reset.insert(yaml_key("mode"), serde_yaml::Value::String(mode));
session_reset.insert(
yaml_key("idle_minutes"),
serde_yaml::Value::Number(idle_minutes.into()),
);
session_reset.insert(
yaml_key("at_hour"),
serde_yaml::Value::Number(at_hour.into()),
);
root.insert(
yaml_key("group_sessions_per_user"),
serde_yaml::Value::Bool(group_sessions_per_user),
);
root.insert(
yaml_key("thread_sessions_per_user"),
serde_yaml::Value::Bool(thread_sessions_per_user),
);
Ok(())
}
fn merge_hermes_channel_config(
config: &mut serde_yaml::Value,
platform: &str,
@@ -3181,21 +3315,25 @@ fn read_hermes_channel_yaml_config() -> Result<(PathBuf, bool, serde_yaml::Value
Ok((config_path, true, config))
}
fn write_hermes_yaml_config(path: &PathBuf, config: &serde_yaml::Value) -> Result<(), String> {
fn write_hermes_yaml_config(path: &PathBuf, config: &serde_yaml::Value) -> Result<String, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?;
}
let mut backup_path = String::new();
if path.exists() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let backup = path.with_extension(format!("yaml.bak-{ts}"));
let _ = std::fs::copy(path, backup);
if std::fs::copy(path, &backup).is_ok() {
backup_path = backup.to_string_lossy().to_string();
}
}
let yaml =
serde_yaml::to_string(config).map_err(|e| format!("序列化 config.yaml 失败: {e}"))?;
std::fs::write(path, yaml).map_err(|e| format!("写入 config.yaml 失败: {e}"))
std::fs::write(path, yaml).map_err(|e| format!("写入 config.yaml 失败: {e}"))?;
Ok(backup_path)
}
fn csv_env_value(form: &Value, key: &str) -> String {
@@ -3655,6 +3793,30 @@ pub fn hermes_channel_config_save(platform: String, form: Value) -> Result<Value
}))
}
#[tauri::command]
pub fn hermes_session_runtime_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_session_runtime_config_values(&config),
}))
}
#[tauri::command]
pub fn hermes_session_runtime_config_save(form: Value) -> Result<Value, String> {
let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?;
merge_hermes_session_runtime_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_session_runtime_config_values(&config),
}))
}
// ---------------------------------------------------------------------------
// hermes_read_config — 读取 Hermes config.yaml + .env
// ---------------------------------------------------------------------------
@@ -8553,6 +8715,85 @@ mod hermes_config_raw_tests {
}
}
#[cfg(test)]
mod hermes_session_runtime_config_tests {
use super::{build_hermes_session_runtime_config_values, merge_hermes_session_runtime_config};
use serde_json::json;
#[test]
fn session_runtime_values_have_safe_defaults() {
let config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let values = build_hermes_session_runtime_config_values(&config);
assert_eq!(values["sessionResetMode"], "both");
assert_eq!(values["idleMinutes"], 1440);
assert_eq!(values["atHour"], 4);
assert_eq!(values["groupSessionsPerUser"], true);
assert_eq!(values["threadSessionsPerUser"], false);
}
#[test]
fn merge_session_runtime_config_preserves_unrelated_yaml() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
model:
provider: anthropic
default: claude-sonnet-4-6
session_reset:
mode: idle
idle_minutes: 60
custom_flag: keep-me
streaming:
enabled: true
"#,
)
.unwrap();
merge_hermes_session_runtime_config(
&mut config,
&json!({
"sessionResetMode": "both",
"idleMinutes": "90",
"atHour": "6",
"groupSessionsPerUser": false,
"threadSessionsPerUser": true,
}),
)
.unwrap();
assert_eq!(config["model"]["provider"].as_str(), Some("anthropic"));
assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true));
assert_eq!(config["session_reset"]["mode"].as_str(), Some("both"));
assert_eq!(config["session_reset"]["idle_minutes"].as_i64(), Some(90));
assert_eq!(config["session_reset"]["at_hour"].as_i64(), Some(6));
assert_eq!(
config["session_reset"]["custom_flag"].as_str(),
Some("keep-me")
);
assert_eq!(config["group_sessions_per_user"].as_bool(), Some(false));
assert_eq!(config["thread_sessions_per_user"].as_bool(), Some(true));
}
#[test]
fn merge_session_runtime_config_rejects_invalid_values() {
let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let err = merge_hermes_session_runtime_config(
&mut config,
&json!({ "sessionResetMode": "weekly" }),
)
.unwrap_err();
assert!(err.contains("session_reset.mode"));
let err = merge_hermes_session_runtime_config(&mut config, &json!({ "idleMinutes": 0 }))
.unwrap_err();
assert!(err.contains("idle_minutes"));
let err =
merge_hermes_session_runtime_config(&mut config, &json!({ "atHour": 24 })).unwrap_err();
assert!(err.contains("at_hour"));
}
}
#[cfg(test)]
mod hermes_channel_tests {
use super::{

View File

@@ -257,6 +257,8 @@ pub fn run() {
hermes::hermes_read_config_full,
hermes::hermes_channel_config_read,
hermes::hermes_channel_config_save,
hermes::hermes_session_runtime_config_read,
hermes::hermes_session_runtime_config_save,
hermes::hermes_lazy_deps_features,
hermes::hermes_lazy_deps_status,
hermes::hermes_lazy_deps_ensure,

View File

@@ -6,14 +6,28 @@ import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { humanizeError } from '../../../lib/humanize-error.js'
const SESSION_RUNTIME_DEFAULTS = {
sessionResetMode: 'both',
idleMinutes: 1440,
atHour: 4,
groupSessionsPerUser: true,
threadSessionsPerUser: false,
}
const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none']
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.dataset.engine = 'hermes'
let yaml = ''
let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS }
let loading = true
let runtimeLoading = true
let saving = false
let runtimeSaving = false
let error = null
let runtimeError = null
function esc(value) {
return String(value || '')
@@ -23,6 +37,71 @@ export function render() {
.replace(/"/g, '&quot;')
}
function isBusy() {
return loading || runtimeLoading || saving || runtimeSaving
}
function option(labelKey, value, selected) {
return `<option value="${esc(value)}" ${selected === value ? 'selected' : ''}>${esc(t(labelKey))}</option>`
}
function renderError(err) {
if (!err) return ''
return `<div class="hm-config-alert is-error">
<div>${esc(err.message || err)}</div>
${err.hint ? `<div class="hm-config-alert-hint">${esc(err.hint)}</div>` : ''}
${err.raw ? `<details><summary>${esc(t('common.errorRawLabel'))}</summary><pre>${esc(err.raw)}</pre></details>` : ''}
</div>`
}
function renderRuntimePanel() {
const disabled = loading || saving || runtimeLoading || runtimeSaving
return `
<div class="hm-panel hm-config-runtime-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesSessionRuntimeTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesSessionRuntimeDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${runtimeSaving ? t('engine.hermesConfigStatusSaving') : runtimeLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesSessionRuntimeStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-runtime-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesSessionRuntimeSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(runtimeError)}
<div class="hm-config-runtime-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesSessionResetMode')}</span>
<select id="hm-session-reset-mode" class="hm-input" ${disabled ? 'disabled' : ''}>
${SESSION_RESET_MODES.map(mode => option(`engine.hermesSessionResetMode_${mode}`, mode, runtimeValues.sessionResetMode)).join('')}
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesSessionIdleMinutes')}</span>
<input id="hm-session-idle-minutes" class="hm-input" type="number" inputmode="numeric" min="1" max="525600" step="1" value="${esc(runtimeValues.idleMinutes)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesSessionAtHour')}</span>
<input id="hm-session-at-hour" class="hm-input" type="number" inputmode="numeric" min="0" max="23" step="1" value="${esc(runtimeValues.atHour)}" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-group-sessions-per-user" type="checkbox" ${runtimeValues.groupSessionsPerUser ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesGroupSessionsPerUser')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-thread-sessions-per-user" type="checkbox" ${runtimeValues.threadSessionsPerUser ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesThreadSessionsPerUser')}</span>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesSessionRuntimeFootnote')}</div>
</div>
</div>
`
}
function draw() {
el.innerHTML = `
<div class="hm-hero">
@@ -32,47 +111,73 @@ export function render() {
<div class="hm-hero-sub">~/.hermes/config.yaml</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-config-reload" ${loading || saving ? 'disabled' : ''}>${t('engine.hermesConfigReload')}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-config-save" ${loading || saving ? 'disabled' : ''}>${t('engine.hermesConfigSave')}</button>
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-config-reload" ${isBusy() ? 'disabled' : ''}>${t('engine.hermesConfigReload')}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-config-save" ${isBusy() ? 'disabled' : ''}>${t('engine.hermesConfigSave')}</button>
</div>
</div>
${renderRuntimePanel()}
<div class="hm-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">config.yaml</div>
<div>
<div class="hm-panel-title">config.yaml</div>
<div class="hm-channel-panel-desc">${t('engine.hermesConfigRawDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${saving ? t('engine.hermesConfigStatusSaving') : loading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesConfigStatusReady')}</span>
</div>
</div>
<div class="hm-panel-body" style="padding:0">
${error ? `<div style="margin:16px 18px;padding:10px 14px;border-radius:var(--hm-radius-sm);background:var(--hm-error-soft);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px;line-height:1.6">
<div>${esc(error.message || error)}</div>
${error.hint ? `<div style="margin-top:4px;color:var(--hm-text-tertiary)">${esc(error.hint)}</div>` : ''}
${error.raw ? `<details style="margin-top:6px"><summary>${esc(t('common.errorRawLabel'))}</summary><pre style="white-space:pre-wrap;word-break:break-word;margin:6px 0 0">${esc(error.raw)}</pre></details>` : ''}
</div>` : ''}
<textarea id="hm-config-yaml" class="hm-input" spellcheck="false" ${loading || saving ? 'disabled' : ''} style="width:100%;min-height:560px;border:0;border-radius:0;background:var(--hm-surface-0);font-family:var(--hm-font-mono);font-size:12px;line-height:1.7;padding:18px 20px;resize:vertical">${esc(yaml)}</textarea>
${renderError(error)}
<textarea id="hm-config-yaml" class="hm-input" spellcheck="false" ${isBusy() ? 'disabled' : ''} style="width:100%;min-height:560px;border:0;border-radius:0;background:var(--hm-surface-0);font-family:var(--hm-font-mono);font-size:12px;line-height:1.7;padding:18px 20px;resize:vertical">${esc(yaml)}</textarea>
</div>
</div>
`
el.querySelector('#hm-config-reload')?.addEventListener('click', load)
el.querySelector('#hm-config-save')?.addEventListener('click', save)
el.querySelector('#hm-runtime-save')?.addEventListener('click', saveRuntime)
}
async function loadRaw() {
const data = await api.hermesConfigRawRead()
yaml = data?.yaml || ''
}
async function loadRuntime() {
const data = await api.hermesSessionRuntimeConfigRead()
runtimeValues = { ...SESSION_RUNTIME_DEFAULTS, ...(data?.values || {}) }
}
async function load() {
loading = true
runtimeLoading = true
error = null
runtimeError = null
draw()
try {
const data = await api.hermesConfigRawRead()
yaml = data?.yaml || ''
await loadRaw()
} catch (err) {
error = humanizeError(err, t('engine.hermesConfigLoadFailed') || 'Load config failed')
} finally {
loading = false
}
try {
await loadRuntime()
} catch (err) {
runtimeError = humanizeError(err, t('engine.hermesSessionRuntimeLoadFailed') || 'Load runtime config failed')
} finally {
runtimeLoading = false
draw()
}
}
async function refreshRawAfterStructuredSave() {
try {
await loadRaw()
} catch {}
}
async function save() {
const textarea = el.querySelector('#hm-config-yaml')
yaml = textarea?.value || ''
@@ -86,6 +191,9 @@ export function render() {
message: t('engine.hermesConfigSaveSuccess'),
hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '',
}, 'success')
try {
await loadRuntime()
} catch {}
} catch (err) {
error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed')
toast(error, 'error')
@@ -95,6 +203,35 @@ export function render() {
}
}
async function saveRuntime() {
const form = {
sessionResetMode: el.querySelector('#hm-session-reset-mode')?.value || 'both',
idleMinutes: el.querySelector('#hm-session-idle-minutes')?.value || '1440',
atHour: el.querySelector('#hm-session-at-hour')?.value || '4',
groupSessionsPerUser: !!el.querySelector('#hm-group-sessions-per-user')?.checked,
threadSessionsPerUser: !!el.querySelector('#hm-thread-sessions-per-user')?.checked,
}
runtimeSaving = true
runtimeError = null
draw()
try {
const result = await api.hermesSessionRuntimeConfigSave(form)
runtimeValues = { ...SESSION_RUNTIME_DEFAULTS, ...(result?.values || form) }
await refreshRawAfterStructuredSave()
const backup = result?.backup || ''
toast({
message: t('engine.hermesSessionRuntimeSaveSuccess'),
hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '',
}, 'success')
} catch (err) {
runtimeError = humanizeError(err, t('engine.hermesSessionRuntimeSaveFailed') || 'Save runtime config failed')
toast(runtimeError, 'error')
} finally {
runtimeSaving = false
draw()
}
}
draw()
load()
return el

View File

@@ -6744,6 +6744,48 @@ body[data-active-engine="hermes"][data-theme="dark"] {
[data-engine="hermes"] .hm-channel-form-panel .hm-panel-header {
align-items: flex-start;
}
[data-engine="hermes"] .hm-config-runtime-panel .hm-panel-header {
align-items: flex-start;
}
[data-engine="hermes"] .hm-config-runtime-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(2, minmax(140px, 0.8fr));
gap: 16px;
align-items: end;
}
[data-engine="hermes"] .hm-config-check-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
margin-top: 18px;
}
[data-engine="hermes"] .hm-config-alert {
margin: 0 0 18px;
padding: 12px 14px;
border-radius: var(--hm-radius-sm);
border: 1px solid color-mix(in srgb, var(--hm-error) 24%, transparent);
background: var(--hm-error-soft);
color: var(--hm-error);
font-family: var(--hm-font-mono);
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
}
[data-engine="hermes"] .hm-panel-body > .hm-config-alert {
margin: 16px 18px;
}
[data-engine="hermes"] .hm-config-alert-hint {
margin-top: 4px;
color: var(--hm-text-tertiary);
}
[data-engine="hermes"] .hm-config-alert details {
margin-top: 6px;
}
[data-engine="hermes"] .hm-config-alert pre {
margin: 6px 0 0;
white-space: pre-wrap;
word-break: break-word;
}
[data-engine="hermes"] .hm-channel-panel-desc {
margin-top: 6px;
color: var(--hm-text-tertiary);
@@ -6833,6 +6875,10 @@ body[data-active-engine="hermes"][data-theme="dark"] {
[data-engine="hermes"] .hm-channel-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-engine="hermes"] .hm-config-runtime-grid,
[data-engine="hermes"] .hm-config-check-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
@@ -6888,6 +6934,16 @@ body[data-active-engine="hermes"][data-theme="dark"] {
[data-engine="hermes"] .hm-channel-form-panel .hm-panel-header {
flex-direction: column;
}
[data-engine="hermes"] .hm-config-runtime-panel .hm-panel-header {
flex-direction: column;
}
[data-engine="hermes"] .hm-config-runtime-panel .hm-panel-actions {
width: 100%;
flex-wrap: wrap;
}
[data-engine="hermes"] .hm-config-runtime-panel .hm-panel-actions .hm-btn {
width: 100%;
}
[data-engine="hermes"] .hm-channel-switch {
width: 100%;
}

View File

@@ -509,6 +509,8 @@ export const api = {
hermesReadConfigFull: () => invoke('hermes_read_config_full'),
hermesChannelConfigRead: () => invoke('hermes_channel_config_read'),
hermesChannelConfigSave: (platform, form) => invoke('hermes_channel_config_save', { platform, form }),
hermesSessionRuntimeConfigRead: () => invoke('hermes_session_runtime_config_read'),
hermesSessionRuntimeConfigSave: (form) => invoke('hermes_session_runtime_config_save', { form }),
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),
hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }),
hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }),

View File

@@ -480,6 +480,24 @@ export default {
hermesConfigStatusSaving: _('保存中…', 'Saving…', '儲存中…'),
hermesConfigStatusLoading: _('加载中…', 'Loading…', '載入中…'),
hermesConfigStatusReady: _('raw yaml 编辑器', 'raw yaml editor', 'raw yaml 編輯器'),
hermesConfigRawDesc: _('高级入口,适合编辑尚未做成表单的 Hermes 配置项。保存前会校验 YAML 并保留备份。', 'Advanced editor for Hermes settings that are not exposed as forms yet. YAML is validated and backed up before saving.', '進階入口,適合編輯尚未做成表單的 Hermes 設定項。儲存前會驗證 YAML 並保留備份。'),
hermesSessionRuntimeTitle: _('会话安全', 'Session safety', '會話安全'),
hermesSessionRuntimeDesc: _('控制自动换新会话和群聊上下文隔离,降低长期运行时的串话、误中断和上下文膨胀风险。', 'Control automatic session reset and group chat isolation to reduce context bleed, accidental interrupts, and long-running context growth.', '控制自動換新會話和群聊上下文隔離,降低長期執行時的串話、誤中斷和上下文膨脹風險。'),
hermesSessionRuntimeStatusReady: _('结构化配置', 'structured settings', '結構化設定'),
hermesSessionRuntimeSave: _('保存会话配置', 'Save session settings', '儲存會話設定'),
hermesSessionRuntimeSaveSuccess: _('会话配置已保存,建议重启 Hermes Gateway 生效', 'Session settings saved. Restart Hermes Gateway to take effect.', '會話設定已儲存,建議重啟 Hermes Gateway 生效'),
hermesSessionRuntimeLoadFailed: _('加载会话配置失败', 'Load session settings failed', '載入會話設定失敗'),
hermesSessionRuntimeSaveFailed: _('保存会话配置失败', 'Save session settings failed', '儲存會話設定失敗'),
hermesSessionResetMode: _('自动换新会话', 'Auto reset sessions', '自動換新會話'),
hermesSessionResetMode_both: _('空闲或每日任一触发', 'Idle or daily, whichever comes first', '閒置或每日任一觸發'),
hermesSessionResetMode_idle: _('仅按空闲时间', 'Idle only', '僅依閒置時間'),
hermesSessionResetMode_daily: _('仅按每日时间', 'Daily only', '僅依每日時間'),
hermesSessionResetMode_none: _('不自动换新', 'Never auto reset', '不自動換新'),
hermesSessionIdleMinutes: _('空闲分钟数', 'Idle minutes', '閒置分鐘數'),
hermesSessionAtHour: _('每日换新小时', 'Daily reset hour', '每日換新小時'),
hermesGroupSessionsPerUser: _('群聊按成员隔离会话', 'Isolate group sessions per user', '群聊依成員隔離會話'),
hermesThreadSessionsPerUser: _('线程也按成员隔离', 'Isolate thread sessions per user', '討論串也依成員隔離'),
hermesSessionRuntimeFootnote: _('推荐保持群聊隔离开启。关闭后,同一群/频道会共用上下文和中断状态。', 'Keeping group isolation on is recommended. Turning it off shares context and interrupt state across the same group or channel.', '建議保持群聊隔離開啟。關閉後,同一群組/頻道會共用上下文和中斷狀態。'),
// Batch 1 §E: 会话导出
sessionsExport: _('导出', 'Export', '匯出'),
sessionsExportSuccess: _('已导出', 'Exported', '已匯出'),

View File

@@ -0,0 +1,79 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildHermesSessionRuntimeConfigValues,
mergeHermesSessionRuntimeConfig,
} from '../scripts/dev-api.js'
test('Hermes 会话运行时配置读取会提供稳定表单默认值', () => {
const values = buildHermesSessionRuntimeConfigValues({})
assert.deepEqual(values, {
sessionResetMode: 'both',
idleMinutes: 1440,
atHour: 4,
groupSessionsPerUser: true,
threadSessionsPerUser: false,
})
})
test('Hermes 会话运行时配置读取会回显 session_reset 与隔离开关', () => {
const values = buildHermesSessionRuntimeConfigValues({
session_reset: {
mode: 'daily',
idle_minutes: 720,
at_hour: 3,
},
group_sessions_per_user: false,
thread_sessions_per_user: true,
})
assert.equal(values.sessionResetMode, 'daily')
assert.equal(values.idleMinutes, 720)
assert.equal(values.atHour, 3)
assert.equal(values.groupSessionsPerUser, false)
assert.equal(values.threadSessionsPerUser, true)
})
test('Hermes 会话运行时配置保存会保留无关 YAML 并写入 snake_case 字段', () => {
const next = mergeHermesSessionRuntimeConfig({
model: { provider: 'anthropic', default: 'claude-sonnet-4-6' },
session_reset: {
mode: 'idle',
idle_minutes: 60,
custom_flag: 'keep-me',
},
streaming: { enabled: true },
}, {
sessionResetMode: 'both',
idleMinutes: '90',
atHour: '6',
groupSessionsPerUser: false,
threadSessionsPerUser: true,
})
assert.deepEqual(next.model, { provider: 'anthropic', default: 'claude-sonnet-4-6' })
assert.deepEqual(next.streaming, { enabled: true })
assert.equal(next.session_reset.mode, 'both')
assert.equal(next.session_reset.idle_minutes, 90)
assert.equal(next.session_reset.at_hour, 6)
assert.equal(next.session_reset.custom_flag, 'keep-me')
assert.equal(next.group_sessions_per_user, false)
assert.equal(next.thread_sessions_per_user, true)
})
test('Hermes 会话运行时配置保存会拒绝非法模式和范围', () => {
assert.throws(
() => mergeHermesSessionRuntimeConfig({}, { sessionResetMode: 'weekly' }),
/session_reset\.mode/,
)
assert.throws(
() => mergeHermesSessionRuntimeConfig({}, { idleMinutes: '0' }),
/idle_minutes/,
)
assert.throws(
() => mergeHermesSessionRuntimeConfig({}, { atHour: '24' }),
/at_hour/,
)
})