diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 478417f..13a286d 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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 = [ diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 99cef1c..07808ee 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2968,6 +2968,140 @@ fn normalize_hermes_group_policy(value: Option) -> String { } } +fn yaml_i64_field(map: &serde_yaml::Mapping, key: &str) -> Option { + 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::().ok()) + } +} + +fn bounded_hermes_i64(value: Option, fallback: i64, min: i64, max: i64) -> i64 { + value + .filter(|value| *value >= min && *value <= max) + .unwrap_or(fallback) +} + +fn validate_hermes_i64( + value: Option, + key: &str, + fallback: i64, + min: i64, + max: i64, +) -> Result { + 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 { 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 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_session_runtime_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_session_runtime_config_save(form: Value) -> Result { + 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::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a9f64e..2f7121d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index a3c32bf..a59659d 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -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, '"') } + function isBusy() { + return loading || runtimeLoading || saving || runtimeSaving + } + + function option(labelKey, value, selected) { + return `` + } + + function renderError(err) { + if (!err) return '' + return `
+
${esc(err.message || err)}
+ ${err.hint ? `
${esc(err.hint)}
` : ''} + ${err.raw ? `
${esc(t('common.errorRawLabel'))}
${esc(err.raw)}
` : ''} +
` + } + + function renderRuntimePanel() { + const disabled = loading || saving || runtimeLoading || runtimeSaving + return ` +
+
+
+
${t('engine.hermesSessionRuntimeTitle')}
+
${t('engine.hermesSessionRuntimeDesc')}
+
+
+ ${runtimeSaving ? t('engine.hermesConfigStatusSaving') : runtimeLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesSessionRuntimeStatusReady')} + +
+
+
+ ${renderError(runtimeError)} +
+ + + +
+
+ + +
+
${t('engine.hermesSessionRuntimeFootnote')}
+
+
+ ` + } + function draw() { el.innerHTML = `
@@ -32,47 +111,73 @@ export function render() {
~/.hermes/config.yaml
- - + +
+ ${renderRuntimePanel()} +
-
config.yaml
+
+
config.yaml
+
${t('engine.hermesConfigRawDesc')}
+
${saving ? t('engine.hermesConfigStatusSaving') : loading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesConfigStatusReady')}
- ${error ? `
-
${esc(error.message || error)}
- ${error.hint ? `
${esc(error.hint)}
` : ''} - ${error.raw ? `
${esc(t('common.errorRawLabel'))}
${esc(error.raw)}
` : ''} -
` : ''} - + ${renderError(error)} +
` 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 diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 50c884e..5ec61de 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -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%; } diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 8e4caf4..e22a9c7 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -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 }), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 4b5d4b2..65d2ec0 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -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', '已匯出'), diff --git a/tests/hermes-session-runtime-config.test.js b/tests/hermes-session-runtime-config.test.js new file mode 100644 index 0000000..35a7293 --- /dev/null +++ b/tests/hermes-session-runtime-config.test.js @@ -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/, + ) +})