mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
集中发版: 新功能(10) - 心甜Claw 引擎入口(第 3 个引擎模式) - Hermes 22 个 Provider 注册表 + 安装/仪表盘动态加载 - Hermes .env 高级编辑(拒绝触碰托管 Provider 密钥) - Hermes 会话与用量分析增强 - Hermes Dashboard 自动拉起 + Windows POSIX-only 兼容模态 - Hermes Skills 工具集面板 - 官网 Hermes Agent 黑金特色区 + 图文指南 - Boot Manifest 启动页(双语 + 错峰动画) - 官网 Markdown 阅读器图片 lightbox - Hermes Memory 概览卡 改进(9) - Hermes 仪表盘/扩展页全面本地化 - 记忆编辑大尺寸模态 - 日志下载 Web/桌面分流 - 侧边栏导航补全 - 模型备选管理 UI(PR #232) - 模型加载错误 UX 重做(错误卡 + 详情 + 重试) - .page 布局 clamp + .page-narrow - Memory 单列断点提早到 1100px - Web 模式跳过前端热更新检查 修复(12) - Gateway 启动 platforms.api_server.enabled 自修复(含 7 unit test) - Memory 页 overview 卡穿模(旧 flex 列约束 → 自然块流) - Skills 页 hero/toolsets 被压缩(flex-shrink:0) - Web 模式 Skills ReferenceError(补 _readHermesDisabledSkills) - 日志/记忆下载行为分流 - src/pages/models.js 5 处 typo - 删除 56 行 .hm-memory-* 死代码 + line-clamp 标准属性 - Dependabot rustls-webpki / postcss / rand
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
/**
|
|
* Hermes Agent — Memory editor (three-section: MEMORY / USER / SOUL)
|
|
*
|
|
* Mirrors the data contract used by the official `hermes-web-ui`:
|
|
* GET /api/hermes/memory → { memory, user, soul, mtimes }
|
|
* POST /api/hermes/memory → { section, content }
|
|
*
|
|
* ClawPanel calls the equivalent Rust/Web-stub commands (`hermes_memory_read_all`
|
|
* + `hermes_memory_write`) so the page works on Tauri and Web modes.
|
|
*
|
|
* All three files live in `~/.hermes/memories/` and are plain Markdown.
|
|
*/
|
|
import { t } from '../../../lib/i18n.js'
|
|
import { api } from '../../../lib/tauri-api.js'
|
|
import { toast } from '../../../components/toast.js'
|
|
import { showContentModal } from '../../../components/modal.js'
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
/**
|
|
* Markdown → HTML. Intentionally minimal (no external dep). Good enough for
|
|
* short agent persona notes. Code blocks preserved. Tables NOT supported.
|
|
*/
|
|
function mdToHtml(text) {
|
|
return text
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
|
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
.replace(/\n/g, '<br>')
|
|
}
|
|
|
|
const ICONS = {
|
|
memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
|
|
user: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
|
soul: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M12 6v6l4 2"/></svg>',
|
|
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
|
|
save: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
|
|
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
|
|
}
|
|
|
|
/** Format epoch-seconds → relative/short local time (serif-friendly). */
|
|
function fmtMtime(epoch) {
|
|
if (!epoch) return ''
|
|
const now = Date.now() / 1000
|
|
const diff = now - epoch
|
|
if (diff < 60) return t('engine.memoryJustNow')
|
|
if (diff < 3600) return t('engine.memoryMinAgo').replace('{n}', Math.floor(diff / 60))
|
|
if (diff < 86400) return t('engine.memoryHrAgo').replace('{n}', Math.floor(diff / 3600))
|
|
const d = new Date(epoch * 1000)
|
|
const pad = (n) => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
}
|
|
|
|
/** Rough word + char count. CJK counted per character. */
|
|
function contentStats(text) {
|
|
const t = text || ''
|
|
const chars = t.length
|
|
// Split on whitespace OR CJK character boundary
|
|
const words = (t.match(/[\u4e00-\u9fff]|[A-Za-z0-9_]+/g) || []).length
|
|
return { chars, words }
|
|
}
|
|
|
|
export function render() {
|
|
const el = document.createElement('div')
|
|
el.className = 'hermes-memory-page'
|
|
el.dataset.engine = 'hermes'
|
|
|
|
// --- State ---
|
|
const SECTIONS = [
|
|
{ key: 'memory', titleKey: 'engine.memoryNotes', icon: ICONS.memory, descKey: 'engine.memoryNotesDesc' },
|
|
{ key: 'user', titleKey: 'engine.memoryProfile', icon: ICONS.user, descKey: 'engine.memoryProfileDesc' },
|
|
{ key: 'soul', titleKey: 'engine.memorySoul', icon: ICONS.soul, descKey: 'engine.memorySoulDesc' },
|
|
]
|
|
const data = { memory: '', user: '', soul: '' }
|
|
const mtimes = { memory: null, user: null, soul: null }
|
|
let editing = null // { key, buffer }
|
|
let loading = true
|
|
let saving = false
|
|
let loadError = null
|
|
|
|
async function loadAll() {
|
|
loading = true
|
|
loadError = null
|
|
draw()
|
|
try {
|
|
const res = await api.hermesMemoryReadAll()
|
|
data.memory = res?.memory || ''
|
|
data.user = res?.user || ''
|
|
data.soul = res?.soul || ''
|
|
mtimes.memory = res?.memory_mtime ?? null
|
|
mtimes.user = res?.user_mtime ?? null
|
|
mtimes.soul = res?.soul_mtime ?? null
|
|
} catch (e) {
|
|
loadError = String(e?.message || e).replace(/^Error:\s*/, '')
|
|
}
|
|
loading = false
|
|
draw()
|
|
}
|
|
|
|
function startEdit(key) {
|
|
const section = SECTIONS.find(s => s.key === key)
|
|
editing = { key, buffer: data[key] || '' }
|
|
const { chars, words } = contentStats(editing.buffer)
|
|
const overlay = showContentModal({
|
|
title: `${t(section?.titleKey || 'engine.hermesMemoryTitle')} · ${t('engine.memoryEdit')}`,
|
|
width: 920,
|
|
content: `
|
|
<div class="hm-mem-modal-wrap">
|
|
<div class="hm-mem-desc">${t(section?.descKey || 'engine.memoryNotesDesc')}</div>
|
|
<textarea id="hm-mem-modal-textarea" class="hm-input hm-mem-editor hm-mem-modal-editor" spellcheck="false" placeholder="${t('engine.memoryPlaceholder')}">${escHtml(editing.buffer)}</textarea>
|
|
<div class="hm-mem-modal-foot">
|
|
<span class="hm-mem-stats" id="hm-mem-modal-stats">
|
|
<span>${words} ${t('engine.memoryWords')}</span>
|
|
<span class="hm-mem-sep">·</span>
|
|
<span>${chars} ${t('engine.memoryChars')}</span>
|
|
</span>
|
|
<span class="hm-spacer"></span>
|
|
<span class="hm-muted">${t('engine.memorySaveHint')}</span>
|
|
</div>
|
|
</div>
|
|
`,
|
|
buttons: [{ id: 'hm-mem-modal-save', className: 'btn btn-primary btn-sm', label: t('engine.memorySave') }],
|
|
})
|
|
overlay.classList.add('hm-mem-modal-overlay')
|
|
overlay.dataset.engine = 'hermes'
|
|
const ta = overlay.querySelector('#hm-mem-modal-textarea')
|
|
const cancelBtn = overlay.querySelector('[data-action="cancel"]')
|
|
const saveBtn = overlay.querySelector('#hm-mem-modal-save')
|
|
const closeWithConfirm = () => {
|
|
if (!editing) {
|
|
overlay.remove()
|
|
return
|
|
}
|
|
const dirty = editing.buffer !== (data[editing.key] || '')
|
|
if (dirty && !confirm(t('engine.memoryUnsaved'))) return
|
|
editing = null
|
|
overlay.remove()
|
|
}
|
|
cancelBtn.textContent = t('engine.memoryCancel')
|
|
cancelBtn.onclick = closeWithConfirm
|
|
saveBtn.onclick = save
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target !== overlay) return
|
|
e.stopImmediatePropagation()
|
|
closeWithConfirm()
|
|
}, true)
|
|
overlay.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
closeWithConfirm()
|
|
}
|
|
}, true)
|
|
ta.focus()
|
|
ta.setSelectionRange(ta.value.length, ta.value.length)
|
|
ta.addEventListener('input', (e) => {
|
|
if (!editing) return
|
|
editing.buffer = e.target.value
|
|
const statsEl = overlay.querySelector('#hm-mem-modal-stats')
|
|
const stats = contentStats(editing.buffer)
|
|
if (statsEl) {
|
|
statsEl.innerHTML = `
|
|
<span>${stats.words} ${t('engine.memoryWords')}</span>
|
|
<span class="hm-mem-sep">·</span>
|
|
<span>${stats.chars} ${t('engine.memoryChars')}</span>
|
|
`
|
|
}
|
|
})
|
|
ta.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault()
|
|
save()
|
|
}
|
|
})
|
|
}
|
|
|
|
function cancelEdit() {
|
|
if (!editing) return
|
|
const dirty = editing.buffer !== (data[editing.key] || '')
|
|
if (dirty && !confirm(t('engine.memoryUnsaved'))) return
|
|
editing = null
|
|
document.querySelector('.hm-mem-modal-overlay')?.remove()
|
|
draw()
|
|
}
|
|
|
|
async function save() {
|
|
if (!editing || saving) return
|
|
saving = true
|
|
const saveBtn = document.querySelector('#hm-mem-modal-save')
|
|
if (saveBtn) {
|
|
saveBtn.disabled = true
|
|
saveBtn.textContent = t('engine.memorySaving')
|
|
}
|
|
const { key, buffer } = editing
|
|
try {
|
|
await api.hermesMemoryWrite(key, buffer)
|
|
data[key] = buffer
|
|
mtimes[key] = Math.floor(Date.now() / 1000)
|
|
editing = null
|
|
document.querySelector('.hm-mem-modal-overlay')?.remove()
|
|
toast(t('engine.memorySaved'), 'success')
|
|
} catch (e) {
|
|
if (saveBtn) {
|
|
saveBtn.disabled = false
|
|
saveBtn.textContent = t('engine.memorySave')
|
|
}
|
|
toast(t('engine.memorySaveFailed') + ': ' + (e?.message || e), 'error')
|
|
}
|
|
saving = false
|
|
draw()
|
|
}
|
|
|
|
function renderOverview() {
|
|
const all = SECTIONS.map(section => ({
|
|
section,
|
|
stats: contentStats(data[section.key] || ''),
|
|
filled: Boolean((data[section.key] || '').trim()),
|
|
}))
|
|
const totalWords = all.reduce((sum, item) => sum + item.stats.words, 0)
|
|
const filledCount = all.filter(item => item.filled).length
|
|
const latest = Math.max(0, ...SECTIONS.map(section => mtimes[section.key] || 0))
|
|
return `
|
|
<div class="hm-mem-overview">
|
|
<div class="hm-mem-overview-copy">
|
|
<div class="hm-mem-kicker">${t('engine.memoryOverviewKicker')}</div>
|
|
<div class="hm-mem-overview-title">${t('engine.memoryOverviewTitle')}</div>
|
|
<div class="hm-mem-overview-desc">${t('engine.memoryOverviewDesc')}</div>
|
|
</div>
|
|
<div class="hm-mem-overview-stats">
|
|
<div class="hm-mem-stat">
|
|
<span class="hm-mem-stat-label">${t('engine.memoryFiles')}</span>
|
|
<strong>3</strong>
|
|
</div>
|
|
<div class="hm-mem-stat">
|
|
<span class="hm-mem-stat-label">${t('engine.memoryFilled')}</span>
|
|
<strong>${filledCount}/3</strong>
|
|
</div>
|
|
<div class="hm-mem-stat">
|
|
<span class="hm-mem-stat-label">${t('engine.memoryTotalWords')}</span>
|
|
<strong>${totalWords}</strong>
|
|
</div>
|
|
<div class="hm-mem-stat">
|
|
<span class="hm-mem-stat-label">${t('engine.memoryLatest')}</span>
|
|
<strong>${latest ? escHtml(fmtMtime(latest)) : '—'}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function renderSection(section) {
|
|
const content = data[section.key] || ''
|
|
const { chars, words } = contentStats(content)
|
|
const mtime = mtimes[section.key]
|
|
const statsMarkup = `<span class="hm-mem-stats">
|
|
<span>${words} ${t('engine.memoryWords')}</span>
|
|
<span class="hm-mem-sep">·</span>
|
|
<span>${chars} ${t('engine.memoryChars')}</span>
|
|
${mtime ? `<span class="hm-mem-sep">·</span><span>${escHtml(fmtMtime(mtime))}</span>` : ''}
|
|
</span>`
|
|
|
|
return `
|
|
<div class="hm-panel hm-mem-panel hm-mem-panel--${section.key}" data-key="${section.key}">
|
|
<div class="hm-panel-header">
|
|
<div class="hm-panel-title">
|
|
<span class="hm-panel-title-icon">${section.icon}</span>
|
|
${t(section.titleKey)}
|
|
</div>
|
|
<div class="hm-panel-actions">
|
|
${statsMarkup}
|
|
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-edit" data-key="${section.key}">${ICONS.edit} ${t('engine.memoryEdit')}</button>
|
|
</div>
|
|
</div>
|
|
<div class="hm-panel-body">
|
|
<div class="hm-mem-card-topline">
|
|
<div class="hm-mem-card-index">${section.key.toUpperCase()}</div>
|
|
<div class="hm-mem-card-meter"><span style="width:${Math.min(100, Math.max(8, words / 8))}%"></span></div>
|
|
</div>
|
|
<div class="hm-mem-desc">${t(section.descKey)}</div>
|
|
${content.trim()
|
|
? `<div class="hm-mem-rendered markdown-body">${mdToHtml(content)}</div>`
|
|
: `<div class="hm-mem-empty">
|
|
<span class="hm-mem-empty-title">${t('engine.memoryEmpty')}</span>
|
|
<span class="hm-muted">${t(section.descKey)}</span>
|
|
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-edit hm-mem-empty-cta" data-key="${section.key}">${ICONS.edit} ${t('engine.memoryEdit')}</button>
|
|
</div>`}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function draw() {
|
|
el.innerHTML = `
|
|
<div class="hm-hero">
|
|
<div class="hm-hero-title">
|
|
<div class="hm-hero-eyebrow">
|
|
<span class="hm-dot hm-dot--run"></span>
|
|
${t('engine.memoryEyebrow')}
|
|
</div>
|
|
<h1 class="hm-hero-h1">${t('engine.hermesMemoryTitle')}</h1>
|
|
<div class="hm-hero-sub">~/.hermes/memories/ · 3 files</div>
|
|
</div>
|
|
<div class="hm-hero-actions">
|
|
<button class="hm-btn hm-btn--ghost hm-btn--sm hm-mem-refresh" ${loading ? 'disabled' : ''} title="${t('engine.logsRefresh')}">
|
|
${ICONS.refresh} ${t('engine.logsRefresh')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
${!loading && !loadError ? renderOverview() : ''}
|
|
|
|
${loadError ? `
|
|
<div class="hm-panel" style="margin-bottom:18px">
|
|
<div class="hm-panel-body hm-panel-body--tight">
|
|
<div style="color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12.5px">
|
|
${escHtml(loadError)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${loading ? `
|
|
<div class="hm-panel"><div class="hm-panel-body">
|
|
<div class="hm-skel" style="width:40%;height:14px;margin-bottom:12px"></div>
|
|
<div class="hm-skel" style="width:100%;height:80px"></div>
|
|
</div></div>
|
|
<div class="hm-panel"><div class="hm-panel-body">
|
|
<div class="hm-skel" style="width:30%;height:14px;margin-bottom:12px"></div>
|
|
<div class="hm-skel" style="width:100%;height:60px"></div>
|
|
</div></div>
|
|
` : SECTIONS.map(renderSection).join('')}
|
|
`
|
|
bind()
|
|
}
|
|
|
|
function bind() {
|
|
el.querySelector('.hm-mem-refresh')?.addEventListener('click', () => loadAll())
|
|
el.querySelectorAll('.hm-mem-edit').forEach(btn => {
|
|
btn.addEventListener('click', () => startEdit(btn.dataset.key))
|
|
})
|
|
}
|
|
|
|
loadAll()
|
|
return el
|
|
}
|