feat(hermes): add channel configuration editor

This commit is contained in:
晴天
2026-05-23 01:51:08 +08:00
parent 27b35b6298
commit eccf91ed1e
11 changed files with 1775 additions and 12 deletions

View File

@@ -89,6 +89,7 @@ export default {
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
{ route: '/h/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' },
{ route: '/h/gateways', label: t('engine.hermesGatewaysTitle'), icon: 'gateway' },
{ route: '/h/channels', label: t('engine.hermesChannelsTitle'), icon: 'channels' },
{ route: '/h/kanban', label: t('engine.hermesKanbanTitle'), icon: 'inbox' },
{ route: '/h/oauth', label: t('engine.hermesOAuthTitle'), icon: 'memory' },
{ route: '/h/files', label: t('engine.hermesFilesTitle'), icon: 'folder' },
@@ -128,8 +129,6 @@ export default {
{ path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') },
{ path: '/h/services', loader: () => import('./pages/services.js') },
{ path: '/h/config', loader: () => import('./pages/config.js') },
// Batch 1 §A: /h/channels 当前是 placeholder487 字节 stub— 暂不挂 nav
// 完整实现见 Batch 3待 Hermes 渠道完整支持时启用 sidebar 入口
{ path: '/h/channels', loader: () => import('./pages/channels.js') },
{ path: '/h/env', loader: () => import('./pages/env-editor.js') },
// 共用页面(引擎无关)

View File

@@ -2,16 +2,339 @@
* Hermes Agent 渠道配置
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { humanizeErrorText } from '../../../lib/humanize-error.js'
import { icon } from '../../../lib/icons.js'
const CHANNELS = [
{
id: 'telegram',
icon: 'message-circle',
titleKey: 'engine.hermesChannelTelegram',
descKey: 'engine.hermesChannelTelegramDesc',
secretFields: ['botToken'],
fields: [
{ key: 'botToken', labelKey: 'engine.hermesChannelBotToken', type: 'password', placeholder: '123456:ABC-DEF...' },
],
},
{
id: 'discord',
icon: 'message-square',
titleKey: 'engine.hermesChannelDiscord',
descKey: 'engine.hermesChannelDiscordDesc',
secretFields: ['token'],
fields: [
{ key: 'token', labelKey: 'engine.hermesChannelBotToken', type: 'password', placeholder: 'MTA...' },
],
},
{
id: 'slack',
icon: 'hash',
titleKey: 'engine.hermesChannelSlack',
descKey: 'engine.hermesChannelSlackDesc',
secretFields: ['botToken', 'appToken', 'signingSecret'],
fields: [
{ key: 'botToken', labelKey: 'engine.hermesChannelSlackBotToken', type: 'password', placeholder: 'xoxb-...' },
{ key: 'appToken', labelKey: 'engine.hermesChannelSlackAppToken', type: 'password', placeholder: 'xapp-...' },
{ key: 'signingSecret', labelKey: 'engine.hermesChannelSigningSecret', type: 'password', placeholder: 'optional' },
{ key: 'webhookPath', labelKey: 'engine.hermesChannelWebhookPath', type: 'text', placeholder: '/slack/events' },
],
},
{
id: 'feishu',
icon: 'send',
titleKey: 'engine.hermesChannelFeishu',
descKey: 'engine.hermesChannelFeishuDesc',
secretFields: ['appSecret'],
fields: [
{ key: 'appId', labelKey: 'engine.hermesChannelFeishuAppId', type: 'text', placeholder: 'cli_xxx' },
{ key: 'appSecret', labelKey: 'engine.hermesChannelFeishuAppSecret', type: 'password', placeholder: 'app secret' },
{ key: 'domain', labelKey: 'engine.hermesChannelFeishuDomain', type: 'select', options: [['feishu', 'engine.hermesChannelFeishuDomainCn'], ['lark', 'engine.hermesChannelFeishuDomainIntl']] },
{ key: 'connectionMode', labelKey: 'engine.hermesChannelConnectionMode', type: 'select', options: [['websocket', 'WebSocket'], ['webhook', 'Webhook']] },
{ key: 'webhookPath', labelKey: 'engine.hermesChannelWebhookPath', type: 'text', placeholder: '/feishu/webhook' },
{ key: 'reactionNotifications', labelKey: 'engine.hermesChannelReactions', type: 'select', options: [['off', 'engine.hermesChannelReactionsOff'], ['basic', 'engine.hermesChannelReactionsBasic']] },
],
toggles: [
{ key: 'typingIndicator', labelKey: 'engine.hermesChannelTypingIndicator' },
{ key: 'resolveSenderNames', labelKey: 'engine.hermesChannelResolveSenderNames' },
],
},
]
const COMMON_FIELDS = [
{ key: 'dmPolicy', labelKey: 'engine.hermesChannelDmPolicy', type: 'select', options: [['pair', 'engine.hermesChannelPolicyPair'], ['open', 'engine.hermesChannelPolicyOpen'], ['allowlist', 'engine.hermesChannelPolicyAllowlist'], ['disabled', 'engine.hermesChannelPolicyDisabled']] },
{ key: 'groupPolicy', labelKey: 'engine.hermesChannelGroupPolicy', type: 'select', options: [['allowlist', 'engine.hermesChannelPolicyAllowlist'], ['open', 'engine.hermesChannelPolicyOpen'], ['disabled', 'engine.hermesChannelPolicyDisabled']] },
{ key: 'allowFrom', labelKey: 'engine.hermesChannelAllowFrom', type: 'textarea', placeholderKey: 'engine.hermesChannelAllowFromPlaceholder' },
{ key: 'groupAllowFrom', labelKey: 'engine.hermesChannelGroupAllowFrom', type: 'textarea', placeholderKey: 'engine.hermesChannelGroupAllowFromPlaceholder' },
]
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function channelMeta(id) {
return CHANNELS.find(channel => channel.id === id) || CHANNELS[0]
}
function defaultForm(platform) {
const form = {
enabled: false,
dmPolicy: 'pair',
groupPolicy: 'allowlist',
allowFrom: '',
groupAllowFrom: '',
requireMention: true,
}
if (platform === 'feishu') {
form.domain = 'feishu'
form.connectionMode = 'websocket'
form.webhookPath = '/feishu/webhook'
form.reactionNotifications = 'off'
form.typingIndicator = true
form.resolveSenderNames = true
}
if (platform === 'slack') form.webhookPath = '/slack/events'
return form
}
function normalizeForm(platform, form = {}) {
return { ...defaultForm(platform), ...(form || {}) }
}
function valueOf(form, key) {
const value = form?.[key]
return value == null ? '' : String(value)
}
function isConfigured(channel, form) {
return channel.secretFields.some(key => valueOf(form, key).trim())
}
function renderField(field, form, disabled) {
const value = valueOf(form, field.key)
const label = esc(t(field.labelKey))
if (field.type === 'select') {
return `
<label class="hm-field">
<span class="hm-field-label">${label}</span>
<select class="hm-input hm-channel-input" data-key="${esc(field.key)}" ${disabled ? 'disabled' : ''}>
${(field.options || []).map(([optionValue, optionLabel]) => `
<option value="${esc(optionValue)}" ${value === optionValue ? 'selected' : ''}>${esc(optionLabel.startsWith('engine.') ? t(optionLabel) : optionLabel)}</option>
`).join('')}
</select>
</label>
`
}
if (field.type === 'textarea') {
return `
<label class="hm-field">
<span class="hm-field-label">${label}</span>
<textarea class="hm-input hm-channel-input hm-channel-textarea" data-key="${esc(field.key)}" ${disabled ? 'disabled' : ''} placeholder="${esc(t(field.placeholderKey))}">${esc(value)}</textarea>
</label>
`
}
return `
<label class="hm-field">
<span class="hm-field-label">${label}</span>
<input class="hm-input hm-channel-input" data-key="${esc(field.key)}" type="${esc(field.type || 'text')}" value="${esc(value)}" ${disabled ? 'disabled' : ''} placeholder="${esc(field.placeholder || '')}" autocomplete="off">
</label>
`
}
function collectForm(el, platform) {
const form = normalizeForm(platform, {})
el.querySelectorAll('.hm-channel-input').forEach(input => {
const key = input.dataset.key
if (!key) return
if (input.type === 'checkbox') form[key] = input.checked
else form[key] = input.value
})
return form
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.className = 'page hm-channels-page'
el.dataset.engine = 'hermes'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesChannelsTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
let active = 'telegram'
let values = {}
let configPath = ''
let loading = true
let saving = false
let error = ''
let success = ''
function draw() {
const channel = channelMeta(active)
const form = normalizeForm(active, values[active])
const disabled = loading || saving
const enabledCount = CHANNELS.filter(item => normalizeForm(item.id, values[item.id]).enabled).length
const configuredCount = CHANNELS.filter(item => isConfigured(item, normalizeForm(item.id, values[item.id]))).length
el.innerHTML = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">${esc(t('engine.hermesChannelsEyebrow'))}</div>
<h1 class="hm-hero-h1">${esc(t('engine.hermesChannelsTitle'))}</h1>
<div class="hm-hero-sub">${esc(configPath || '~/.hermes/config.yaml')}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-channels-reload" ${disabled ? 'disabled' : ''}>${icon('refresh-cw', 14)}${esc(t('engine.hermesConfigReload'))}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-channels-save" ${disabled ? 'disabled' : ''}>${saving ? esc(t('engine.hermesChannelSaving')) : esc(t('engine.hermesChannelSave'))}</button>
</div>
</div>
<section class="hm-channel-summary" aria-label="${esc(t('engine.hermesChannelSummary'))}">
<div class="hm-channel-stat"><span>${esc(t('engine.hermesChannelEnabledCount'))}</span><strong>${enabledCount}</strong></div>
<div class="hm-channel-stat"><span>${esc(t('engine.hermesChannelConfiguredCount'))}</span><strong>${configuredCount}</strong></div>
<div class="hm-channel-stat"><span>${esc(t('engine.hermesChannelRuntimeWrite'))}</span><strong>YAML + .env</strong></div>
</section>
${(error || success) ? `
<div class="hm-channel-alert ${error ? 'is-error' : 'is-success'}">
${icon(error ? 'alert-triangle' : 'check-circle', 15)}
<span>${esc(error || success)}</span>
</div>
` : ''}
<div class="hm-channel-layout">
<section class="hm-panel hm-channel-list-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">${esc(t('engine.hermesChannelPlatforms'))}</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-channel-list" role="tablist" aria-label="${esc(t('engine.hermesChannelPlatforms'))}">
${CHANNELS.map(item => {
const itemForm = normalizeForm(item.id, values[item.id])
return `
<button class="hm-channel-tab ${item.id === active ? 'is-active' : ''}" data-channel="${esc(item.id)}" role="tab" aria-selected="${item.id === active ? 'true' : 'false'}" ${disabled ? 'disabled' : ''}>
<span class="hm-channel-tab-icon">${icon(item.icon, 16)}</span>
<span class="hm-channel-tab-main">
<strong>${esc(t(item.titleKey))}</strong>
<small>${esc(itemForm.enabled ? t('engine.hermesChannelEnabled') : t('engine.hermesChannelDisabled'))}</small>
</span>
<span class="hm-channel-dot ${itemForm.enabled ? 'is-on' : ''}" aria-hidden="true"></span>
</button>
`
}).join('')}
</div>
</div>
</section>
<section class="hm-panel hm-channel-form-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${icon(channel.icon, 15)}${esc(t(channel.titleKey))}</div>
<div class="hm-channel-panel-desc">${esc(t(channel.descKey))}</div>
</div>
<label class="hm-channel-switch">
<input class="hm-channel-input" data-key="enabled" type="checkbox" ${form.enabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${esc(form.enabled ? t('engine.hermesChannelEnabled') : t('engine.hermesChannelDisabled'))}</span>
</label>
</div>
<div class="hm-panel-body">
${loading ? `
<div class="hm-channel-loading">${esc(t('common.loading'))}...</div>
` : `
<div class="hm-channel-section">
<div class="hm-channel-section-title">${esc(t('engine.hermesChannelCredentials'))}</div>
<div class="hm-field-row">
${channel.fields.map(field => renderField(field, form, disabled)).join('')}
</div>
${(channel.toggles || []).length ? `
<div class="hm-channel-toggle-grid">
${channel.toggles.map(toggle => `
<label class="hm-channel-check">
<input class="hm-channel-input" data-key="${esc(toggle.key)}" type="checkbox" ${form[toggle.key] ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${esc(t(toggle.labelKey))}</span>
</label>
`).join('')}
</div>
` : ''}
</div>
<div class="hm-channel-section">
<div class="hm-channel-section-title">${esc(t('engine.hermesChannelAccessPolicy'))}</div>
<div class="hm-field-row">
${COMMON_FIELDS.slice(0, 2).map(field => renderField(field, form, disabled)).join('')}
</div>
<label class="hm-channel-check hm-channel-check--wide">
<input class="hm-channel-input" data-key="requireMention" type="checkbox" ${form.requireMention ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${esc(t('engine.hermesChannelRequireMention'))}</span>
</label>
<div class="hm-field-row">
${COMMON_FIELDS.slice(2).map(field => renderField(field, form, disabled)).join('')}
</div>
</div>
<div class="hm-channel-footnote">
${icon('info', 14)}
<span>${esc(t('engine.hermesChannelRestartHint'))}</span>
</div>
`}
</div>
</section>
</div>
`
el.querySelector('#hm-channels-reload')?.addEventListener('click', load)
el.querySelector('#hm-channels-save')?.addEventListener('click', save)
el.querySelectorAll('.hm-channel-tab').forEach(button => {
button.addEventListener('click', () => {
if (!loading && !saving) values = { ...values, [active]: collectForm(el, active) }
active = button.dataset.channel || active
error = ''
success = ''
draw()
})
})
}
async function load() {
loading = true
error = ''
success = ''
draw()
try {
const data = await api.hermesChannelConfigRead()
values = data?.values || {}
configPath = data?.configPath || ''
} catch (err) {
error = humanizeErrorText(err, t('engine.hermesChannelLoadFailed'))
} finally {
loading = false
draw()
}
}
async function save() {
const form = collectForm(el, active)
values = { ...values, [active]: form }
saving = true
error = ''
success = ''
draw()
try {
const result = await api.hermesChannelConfigSave(active, form)
values = { ...values, [active]: result?.values || form }
success = t('engine.hermesChannelSaved')
toast(success, 'success')
} catch (err) {
error = humanizeErrorText(err, t('engine.hermesChannelSaveFailed'))
toast(error, 'error')
} finally {
saving = false
draw()
}
}
draw()
load()
return el
}

View File

@@ -6593,6 +6593,210 @@ body[data-active-engine="hermes"][data-theme="dark"] {
appearance: none;
}
[data-engine="hermes"].hm-channels-page {
max-width: 1280px;
}
[data-engine="hermes"] .hm-channel-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: -12px 0 18px;
}
[data-engine="hermes"] .hm-channel-stat {
min-height: 72px;
border: 1px solid var(--hm-border);
border-radius: var(--hm-radius-md);
background: var(--hm-surface-1);
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
}
[data-engine="hermes"] .hm-channel-stat span {
font-family: var(--hm-font-serif);
font-size: 12px;
font-style: italic;
color: var(--hm-text-tertiary);
}
[data-engine="hermes"] .hm-channel-stat strong {
font-family: var(--hm-font-mono);
font-size: 18px;
font-weight: 600;
color: var(--hm-text-primary);
}
[data-engine="hermes"] .hm-channel-alert {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 0 18px;
padding: 12px 14px;
border-radius: var(--hm-radius-sm);
border: 1px solid var(--hm-border);
font-size: 13px;
line-height: 1.55;
}
[data-engine="hermes"] .hm-channel-alert.is-error {
color: var(--hm-error);
background: var(--hm-error-soft);
border-color: color-mix(in srgb, var(--hm-error) 24%, transparent);
}
[data-engine="hermes"] .hm-channel-alert.is-success {
color: var(--hm-success);
background: var(--hm-success-soft);
border-color: color-mix(in srgb, var(--hm-success) 24%, transparent);
}
[data-engine="hermes"] .hm-channel-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
[data-engine="hermes"] .hm-channel-list-panel {
position: sticky;
top: 18px;
}
[data-engine="hermes"] .hm-channel-list {
display: grid;
gap: 8px;
}
[data-engine="hermes"] .hm-channel-tab {
width: 100%;
min-height: 58px;
border: 1px solid var(--hm-border);
border-radius: var(--hm-radius-sm);
background: var(--hm-surface-0);
color: var(--hm-text-primary);
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 10px;
align-items: center;
gap: 10px;
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: border-color var(--hm-dur-fast) var(--hm-ease),
background var(--hm-dur-fast) var(--hm-ease);
}
[data-engine="hermes"] .hm-channel-tab:hover:not(:disabled) {
border-color: var(--hm-border-strong);
background: var(--hm-surface-2);
}
[data-engine="hermes"] .hm-channel-tab.is-active {
border-color: var(--hm-accent);
background: var(--hm-accent-soft);
}
[data-engine="hermes"] .hm-channel-tab:disabled {
cursor: not-allowed;
opacity: 0.58;
}
[data-engine="hermes"] .hm-channel-tab:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--hm-accent-ring);
}
[data-engine="hermes"] .hm-channel-tab-icon {
width: 34px;
height: 34px;
border: 1px solid var(--hm-border);
border-radius: var(--hm-radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--hm-accent);
background: var(--hm-surface-1);
}
[data-engine="hermes"] .hm-channel-tab-main {
display: grid;
gap: 2px;
min-width: 0;
}
[data-engine="hermes"] .hm-channel-tab-main strong {
font-size: 13px;
line-height: 1.25;
overflow-wrap: anywhere;
}
[data-engine="hermes"] .hm-channel-tab-main small {
color: var(--hm-text-tertiary);
font-size: 11px;
line-height: 1.25;
}
[data-engine="hermes"] .hm-channel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--hm-text-muted);
box-shadow: 0 0 0 3px var(--hm-border-subtle);
}
[data-engine="hermes"] .hm-channel-dot.is-on {
background: var(--hm-success);
box-shadow: 0 0 0 3px var(--hm-success-soft);
}
[data-engine="hermes"] .hm-channel-form-panel .hm-panel-header {
align-items: flex-start;
}
[data-engine="hermes"] .hm-channel-panel-desc {
margin-top: 6px;
color: var(--hm-text-tertiary);
font-size: 13px;
line-height: 1.6;
max-width: 720px;
}
[data-engine="hermes"] .hm-channel-switch,
[data-engine="hermes"] .hm-channel-check {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--hm-text-secondary);
font-size: 13px;
line-height: 1.45;
cursor: pointer;
}
[data-engine="hermes"] .hm-channel-switch input,
[data-engine="hermes"] .hm-channel-check input {
width: 18px;
height: 18px;
accent-color: var(--hm-accent);
flex: 0 0 auto;
}
[data-engine="hermes"] .hm-channel-section {
display: grid;
gap: 16px;
}
[data-engine="hermes"] .hm-channel-section + .hm-channel-section {
margin-top: 26px;
padding-top: 24px;
border-top: 1px solid var(--hm-border-subtle);
}
[data-engine="hermes"] .hm-channel-section-title {
font-family: var(--hm-font-serif);
font-size: 16px;
font-weight: 500;
color: var(--hm-text-primary);
}
[data-engine="hermes"] .hm-channel-textarea {
min-height: 104px;
height: auto;
resize: vertical;
}
[data-engine="hermes"] .hm-channel-toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
}
[data-engine="hermes"] .hm-channel-check--wide {
width: fit-content;
}
[data-engine="hermes"] .hm-channel-footnote,
[data-engine="hermes"] .hm-channel-loading {
margin-top: 20px;
display: flex;
align-items: flex-start;
gap: 9px;
color: var(--hm-text-tertiary);
font-size: 12.5px;
line-height: 1.6;
}
@media (max-width: 960px) {
[data-engine="hermes"].hm-usage-page {
padding: 24px 22px 34px;
@@ -6609,6 +6813,15 @@ body[data-active-engine="hermes"][data-theme="dark"] {
[data-engine="hermes"] .hm-services-action-grid {
grid-template-columns: 1fr;
}
[data-engine="hermes"] .hm-channel-layout {
grid-template-columns: 1fr;
}
[data-engine="hermes"] .hm-channel-list-panel {
position: static;
}
[data-engine="hermes"] .hm-channel-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
@@ -6642,6 +6855,31 @@ body[data-active-engine="hermes"][data-theme="dark"] {
[data-engine="hermes"] .hm-services-health-grid {
grid-template-columns: 1fr;
}
[data-engine="hermes"].hm-channels-page {
padding: 24px 18px 34px;
}
[data-engine="hermes"].hm-channels-page .hm-hero {
align-items: stretch;
}
[data-engine="hermes"].hm-channels-page .hm-hero-actions {
width: 100%;
flex-wrap: wrap;
}
[data-engine="hermes"].hm-channels-page .hm-hero-actions .hm-btn {
flex: 1 1 150px;
min-height: 44px;
}
[data-engine="hermes"] .hm-channel-summary,
[data-engine="hermes"] .hm-channel-list,
[data-engine="hermes"] .hm-channel-toggle-grid {
grid-template-columns: 1fr;
}
[data-engine="hermes"] .hm-channel-form-panel .hm-panel-header {
flex-direction: column;
}
[data-engine="hermes"] .hm-channel-switch {
width: 100%;
}
}
/* ---- Responsive breakpoints ---- */
@@ -6720,4 +6958,3 @@ body[data-active-engine="hermes"][data-theme="dark"] {
grid-template-columns: 1fr !important;
}
}