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

@@ -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 `<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', '已匯出'),