feat: add Hermes Agent cross-machine deployment via CLI pull

This commit is contained in:
chiguyong 2026-06-21 13:31:19 +08:00
parent d6f222c2e0
commit 6037bf2bd6
8 changed files with 521 additions and 1 deletions

181
app.js
View File

@ -437,7 +437,10 @@
<h3 class="role-card__name">${name}</h3>
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
</div>
<div style="display:flex;gap:0.5rem;">
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>
<button class="btn btn--small btn--outline" type="button" data-action="hermes-deploy" data-role-id="${id}" data-role-name="${name}">Hermes 部署</button>
</div>
</article>`;
})
.join('');
@ -474,6 +477,143 @@
function renderSettings() {
document.getElementById('settings-name').value = state.creatorName || '';
document.getElementById('settings-library').value = state.libraryName === '我的 [XXX]' ? '' : state.libraryName;
renderApiKeys();
}
// --- API Key 管理 ---
async function renderApiKeys() {
const listEl = document.getElementById('apikey-list');
if (!listEl) return;
listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">加载中…</p>';
try {
const { keys } = await api('/apikeys');
if (keys.length === 0) {
listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">还没有 API Key</p>';
return;
}
listEl.innerHTML = keys
.map((k) => {
const id = escapeHtml(k.id);
const name = escapeHtml(k.name);
const prefix = escapeHtml(k.keyPrefix);
const created = new Date(k.createdAt).toLocaleDateString('zh-CN');
const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString('zh-CN') : '从未使用';
return `
<div class="apikey-item" style="padding:0.75rem 0;border-bottom:1px solid var(--border);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${name}</strong>
<code style="margin-left:0.5rem;color:var(--text-muted);">${prefix}</code>
</div>
<button class="btn btn--small btn--ghost" type="button" data-action="delete-apikey" data-apikey-id="${id}">删除</button>
</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:0.25rem;">
创建于 ${created} · 最后使用${lastUsed}
</div>
</div>`;
})
.join('');
} catch (err) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:0.875rem;">加载失败:${escapeHtml(err.message)}</p>`;
}
}
async function generateApiKey() {
try {
const { apiKey } = await api('/apikeys', {
method: 'POST',
body: JSON.stringify({ name: 'hermes-deploy' }),
});
// 明文 Key 只显示一次
alert(`API Key 已生成(仅显示一次,请妥善保存):\n\n${apiKey.key}\n\n复制此 Key在 Hermes 机器上使用 curl 命令时需要带上。`);
renderApiKeys();
} catch (err) {
alert(`生成失败:${err.message}`);
}
}
async function deleteApiKey(id) {
if (!confirm('确定删除此 API Key删除后无法恢复。')) return;
try {
await api(`/apikeys/${id}`, { method: 'DELETE' });
renderApiKeys();
} catch (err) {
alert(`删除失败:${err.message}`);
}
}
// --- Hermes 部署指南 ---
function showHermesDeployGuide(roleId, roleName) {
// 获取当前服务器地址
const baseUrl = window.location.origin;
const profileName = `role-${roleName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20)}`;
const soulUrl = `${baseUrl}/api/hermes/roles/${roleId}/SOUL.md`;
const configUrl = `${baseUrl}/api/hermes/roles/${roleId}/config.yaml`;
const soulCmd = `curl -H "Authorization: Bearer YOUR_API_KEY" \\\n ${soulUrl} \\\n -o ~/.hermes/profiles/${profileName}/SOUL.md`;
const configCmd = `curl -H "Authorization: Bearer YOUR_API_KEY" \\\n ${configUrl} \\\n -o ~/.hermes/profiles/${profileName}/config.yaml`;
// 移除已有弹窗
const existing = document.getElementById('hermes-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'hermes-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;padding:1rem;';
modal.innerHTML = `
<div style="background:var(--bg-card,#fff);border-radius:12px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;padding:1.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.2);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="margin:0;">Hermes Agent 部署指南</h3>
<button class="btn btn--small btn--ghost" type="button" data-action="close-hermes-modal"></button>
</div>
<p style="color:var(--text-muted);font-size:0.875rem;margin-bottom:1rem;">
角色${escapeHtml(roleName)}的配置文件可通过以下命令拉取到 Hermes Agent 所在的机器
</p>
<div style="background:var(--bg-muted,#f5f5f5);border-radius:8px;padding:0.75rem;margin-bottom:1rem;">
<strong style="font-size:0.875rem;">前提条件</strong>
<ul style="margin:0.5rem 0 0 1.25rem;padding:0;font-size:0.8125rem;color:var(--text-muted);">
<li>已安装 Hermes Agent<code>curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash</code></li>
<li>已在 EternalAI 设置页生成 API Key</li>
</ul>
</div>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">1. 创建 Hermes Profile</h4>
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin-bottom:1rem;"><code>hermes profile create ${escapeHtml(profileName)}</code></pre>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">2. 拉取 SOUL.md</h4>
<div style="position:relative;margin-bottom:1rem;">
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin:0;"><code>${escapeHtml(soulCmd)}</code></pre>
<button class="btn btn--small btn--outline" type="button" data-action="copy-hermes-cmd" data-cmd="${escapeHtml(soulCmd)}" style="position:absolute;top:0.5rem;right:0.5rem;">复制</button>
</div>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">3. 拉取 config.yaml</h4>
<div style="position:relative;margin-bottom:1rem;">
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin:0;"><code>${escapeHtml(configCmd)}</code></pre>
<button class="btn btn--small btn--outline" type="button" data-action="copy-hermes-cmd" data-cmd="${escapeHtml(configCmd)}" style="position:absolute;top:0.5rem;right:0.5rem;">复制</button>
</div>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">4. 配置 API 密钥</h4>
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin-bottom:1rem;"><code>echo "OPENROUTER_API_KEY=sk-or-your-key" > ~/.hermes/profiles/${escapeHtml(profileName)}/.env</code></pre>
<h4 style="font-size:0.9375rem;margin-bottom:0.5rem;">5. 启动对话</h4>
<pre style="background:var(--bg-muted,#1e1e1e);color:var(--text-light,#eee);border-radius:8px;padding:0.75rem;overflow-x:auto;font-size:0.8125rem;margin-bottom:1rem;"><code>${escapeHtml(profileName)} chat</code></pre>
<div style="border-top:1px solid var(--border);padding-top:1rem;margin-top:0.5rem;">
<p style="font-size:0.8125rem;color:var(--text-muted);margin:0;">
提示将命令中的 <code>YOUR_API_KEY</code> API Key <code>eak_</code>
</p>
</div>
</div>
`;
// 点击遮罩关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
document.body.appendChild(modal);
}
function switchCenterTab(tab) {
@ -934,6 +1074,47 @@ ${data.secrets || '无'}
return;
}
if (action === 'hermes-deploy') {
e.preventDefault();
const roleId = target.dataset.roleId;
const roleName = target.dataset.roleName || 'role';
showHermesDeployGuide(roleId, roleName);
return;
}
if (action === 'generate-apikey') {
e.preventDefault();
await generateApiKey();
return;
}
if (action === 'delete-apikey') {
e.preventDefault();
const keyId = target.dataset.apikeyId;
await deleteApiKey(keyId);
return;
}
if (action === 'close-hermes-modal') {
e.preventDefault();
const modal = document.getElementById('hermes-modal');
if (modal) modal.remove();
return;
}
if (action === 'copy-hermes-cmd') {
e.preventDefault();
const cmd = target.dataset.cmd || '';
try {
await navigator.clipboard.writeText(cmd);
target.textContent = '已复制';
setTimeout(() => { target.textContent = '复制'; }, 2000);
} catch {
alert('复制失败,请手动选择文本复制');
}
return;
}
if (action === 'logout') {
e.preventDefault();
logout();

View File

@ -0,0 +1,100 @@
# Hermes Agent 跨机器部署 — 需求文档
**日期**: 2026-06-20
**状态**: 已确认,待实现
## 概述
EternalAI 生成的角色配置Soul.md + config.yaml需要能直接复制到远程 Hermes Agent 中使用。EternalAI 服务器与 Hermes Agent 可能不在同一台机器上,用户通过 CLI 拉取命令curl跨机器获取配置文件。
## 用户场景
用户在 EternalAI Web UI 创建角色后,需要在另一台安装了 Hermes Agent 的机器上部署该角色。用户不希望 EternalAI 直接写入 Hermes 目录,而是生成可直接使用的文件,通过 curl 命令拉取后手动放置到 Hermes profile 目录。
## 工作流程
```
服务器 A (EternalAI) 服务器 B (Hermes Agent)
┌─────────────────────┐ ┌─────────────────────┐
│ 1. 设置页生成 API Key│ │ │
│ (eak_xxxxx) │ │ 5. hermes profile │
│ │ │ create role-mio │
│ 2. 创建角色 → 发布 │ │ │
│ 生成 Soul.md + │ │ 6. curl 拉取文件 │
│ config.yaml │ │ SOUL.md │
│ │ │ config.yaml │
│ 3. 角色管理页显示 │ 4. 用户复制 │ │
│ curl 命令模板 │──── curl ────▶│ 7. 配置 .env │
│ (含 API Key) │ 命令执行 │ (API 密钥) │
│ │ │ │
│ │ │ 8. role-mio chat │
└─────────────────────┘ └─────────────────────┘
```
### 用户在 Hermes 机器上的操作
```bash
# 1. 创建 profile
hermes profile create role-mio
# 2. 拉取 SOUL.md
curl -H "Authorization: Bearer eak_xxx" \
https://eternalai.example.com/api/hermes/roles/<id>/SOUL.md \
-o ~/.hermes/profiles/role-mio/SOUL.md
# 3. 拉取 config.yaml
curl -H "Authorization: Bearer eak_xxx" \
https://eternalai.example.com/api/hermes/roles/<id>/config.yaml \
-o ~/.hermes/profiles/role-mio/config.yaml
# 4. 配置 API 密钥
echo "OPENROUTER_API_KEY=sk-or-xxx" > ~/.hermes/profiles/role-mio/.env
# 5. 启动
role-mio chat
```
## 功能需求
### 1. API Key 管理
- 用户在设置页生成长期 API Key格式`eak_` + 32 位随机 hex
- API Key 列表显示名称、创建时间、最后使用时间、Key脱敏显示
- 支持删除 API Key
- API Key 存储在数据库中,使用 bcrypt 哈希存储(只明文显示一次)
### 2. Hermes 配置 API
两个端点,均需 API Key 认证:
- `GET /api/hermes/roles/:id/SOUL.md` — 返回角色的 SOUL.md 内容text/plain
- `GET /api/hermes/roles/:id/config.yaml` — 返回适配后的 Hermes config.yamltext/plain
认证方式:`Authorization: Bearer eak_xxxxx`
权限API Key 所属用户只能拉取自己创建的角色。
### 3. config.yaml 格式适配
EternalAI 数据库中的角色数据 → Hermes config.yaml 格式:
| EternalAI 字段 | Hermes config.yaml 字段 |
|---|---|
| `role.model` | `model:` |
| `role.temperature` | `temperature:` |
| `role.maxTokens` | `max_tokens:` |
| `role.enableMemory` | `memory.enabled:` |
| `role.enableTools` | `tools.enabled:` |
### 4. Web UI 部署指南
角色管理页每个角色卡片增加"Hermes 部署"区域:
- 显示完整的 curl 命令模板(自动填入服务器地址、角色 ID、API Key
- 操作步骤说明
- 复制按钮
## 非功能需求
- API Key 使用 bcrypt 哈希存储,明文只在生成时显示一次
- Hermes API 端点返回 `text/plain` 格式,适合 curl 直接输出到文件
- config.yaml 适配在后端完成,用户拉取到的文件可直接使用

View File

@ -387,6 +387,19 @@
<button class="btn btn--primary btn--block" type="submit">保存设置</button>
</div>
</form>
<!-- API Key 管理 -->
<div class="api-key-section" style="margin-top: 2rem;">
<h3 class="settings-section-title">API Key 管理</h3>
<p class="settings-section-desc" style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
用于在 Hermes 机器上通过 curl 拉取角色配置文件
</p>
<div class="form-actions" style="margin-bottom: 1rem;">
<button class="btn btn--outline btn--block" type="button" data-action="generate-apikey">生成新 API Key</button>
</div>
<div id="apikey-list"></div>
</div>
<div class="form-actions">
<button class="btn btn--ghost btn--block" type="button" data-action="logout">退出登录</button>
</div>

View File

@ -20,6 +20,19 @@ model User {
roles Role[]
orders Order[]
apiKeys ApiKey[]
}
model ApiKey {
id String @id @default(uuid())
userId String
name String @default("default")
keyHash String @unique
keyPrefix String // 前 8 位,用于脱敏显示
lastUsedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Role {

View File

@ -13,6 +13,8 @@ app.use(express.json());
// API 路由
app.use('/api/auth', require('./src/routes/auth'));
app.use('/api/roles', require('./src/routes/roles'));
app.use('/api/apikeys', require('./src/routes/apikeys'));
app.use('/api/hermes', require('./src/routes/hermes'));
// 静态文件
app.use(express.static('.'));

View File

@ -55,10 +55,57 @@ function authMiddleware(req, res, next) {
next();
}
// Express 中间件:验证 API Key用于 Hermes 配置拉取)
// 支持 JWT token 和 API Keyeak_ 前缀)两种认证方式
async function apiKeyMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '需要认证' });
}
const token = authHeader.slice(7);
// 如果是 API Keyeak_ 前缀),查数据库验证
if (token.startsWith('eak_')) {
try {
const prisma = require('./prisma');
const crypto = require('crypto');
const keyHash = crypto.createHash('sha256').update(token).digest('hex');
const apiKey = await prisma.apiKey.findUnique({
where: { keyHash },
select: { id: true, userId: true },
});
if (!apiKey) {
return res.status(401).json({ error: 'API Key 无效' });
}
// 更新最后使用时间(不阻塞请求)
prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: new Date() },
}).catch(() => {});
req.userId = apiKey.userId;
req.authMethod = 'apikey';
return next();
} catch (err) {
console.error('API Key 验证失败:', err);
return res.status(500).json({ error: '认证失败' });
}
}
// 否则尝试 JWT 认证
const userId = verifyToken(token);
if (!userId) {
return res.status(401).json({ error: '认证无效' });
}
req.userId = userId;
req.authMethod = 'jwt';
next();
}
module.exports = {
hashPassword,
verifyPassword,
signToken,
verifyToken,
authMiddleware,
apiKeyMiddleware,
};

78
src/routes/apikeys.js Normal file
View File

@ -0,0 +1,78 @@
const express = require('express');
const crypto = require('crypto');
const prisma = require('../lib/prisma');
const { authMiddleware } = require('../lib/auth');
const router = express.Router();
// 生成 API Key
router.post('/', authMiddleware, async (req, res) => {
try {
const { name } = req.body;
// 生成随机 API Keyeak_ + 32 位 hex
const rawKey = `eak_${crypto.randomBytes(16).toString('hex')}`;
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const keyPrefix = rawKey.slice(0, 12); // eak_xxxxxxxx
const apiKey = await prisma.apiKey.create({
data: {
userId: req.userId,
name: name || 'default',
keyHash,
keyPrefix,
},
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
},
});
// 明文 Key 只返回一次
res.json({ apiKey: { ...apiKey, key: rawKey } });
} catch (err) {
console.error('生成 API Key 失败:', err);
res.status(500).json({ error: '生成失败' });
}
});
// 列出当前用户的所有 API Key
router.get('/', authMiddleware, async (req, res) => {
try {
const keys = await prisma.apiKey.findMany({
where: { userId: req.userId },
select: {
id: true,
name: true,
keyPrefix: true,
lastUsedAt: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
res.json({ keys });
} catch (err) {
console.error('获取 API Key 列表失败:', err);
res.status(500).json({ error: '获取失败' });
}
});
// 删除 API Key
router.delete('/:id', authMiddleware, async (req, res) => {
try {
const existing = await prisma.apiKey.findUnique({
where: { id: req.params.id },
});
if (!existing || existing.userId !== req.userId) {
return res.status(404).json({ error: 'API Key 不存在' });
}
await prisma.apiKey.delete({ where: { id: req.params.id } });
res.json({ deleted: true });
} catch (err) {
console.error('删除 API Key 失败:', err);
res.status(500).json({ error: '删除失败' });
}
});
module.exports = router;

86
src/routes/hermes.js Normal file
View File

@ -0,0 +1,86 @@
const express = require('express');
const prisma = require('../lib/prisma');
const { apiKeyMiddleware } = require('../lib/auth');
const router = express.Router();
// 将 EternalAI 角色数据适配为 Hermes config.yaml 格式
function adaptToHermesConfig(role) {
const lines = [
'# Hermes Agent Config — generated by EternalAI',
`# Character: ${role.displayName}`,
`# Agent ID: ${role.agentId || 'default'}`,
`# Generated at: ${new Date().toISOString()}`,
'',
`model: ${role.model || 'hermes-3-llama-3.1-70b'}`,
`temperature: ${role.temperature ?? 0.85}`,
`max_tokens: ${role.maxTokens ?? 2048}`,
'',
'memory:',
` enabled: ${role.enableMemory ?? true}`,
' storage: local',
];
if (role.enableTools) {
lines.push('', 'tools:', ' enabled: true', ' toolsets:', ' - memory');
}
lines.push('', 'terminal:', ' backend: local', '');
return lines.join('\n');
}
// GET /api/hermes/roles/:id/SOUL.md — 返回 SOUL.md 内容
router.get('/roles/:id/SOUL.md', apiKeyMiddleware, async (req, res) => {
try {
const role = await prisma.role.findUnique({
where: { id: req.params.id },
select: { id: true, creatorId: true, displayName: true, soulMd: true },
});
if (!role) {
return res.status(404).type('text/plain').send('Role not found');
}
if (role.creatorId !== req.userId) {
return res.status(403).type('text/plain').send('Forbidden: not the role owner');
}
if (!role.soulMd) {
return res.status(404).type('text/plain').send('SOUL.md not generated for this role');
}
res.type('text/plain').send(role.soulMd);
} catch (err) {
console.error('获取 SOUL.md 失败:', err);
res.status(500).type('text/plain').send('Server error');
}
});
// GET /api/hermes/roles/:id/config.yaml — 返回适配后的 Hermes config.yaml
router.get('/roles/:id/config.yaml', apiKeyMiddleware, async (req, res) => {
try {
const role = await prisma.role.findUnique({
where: { id: req.params.id },
select: {
id: true,
creatorId: true,
displayName: true,
agentId: true,
model: true,
temperature: true,
maxTokens: true,
enableMemory: true,
enableTools: true,
},
});
if (!role) {
return res.status(404).type('text/plain').send('Role not found');
}
if (role.creatorId !== req.userId) {
return res.status(403).type('text/plain').send('Forbidden: not the role owner');
}
const config = adaptToHermesConfig(role);
res.type('text/plain').send(config);
} catch (err) {
console.error('获取 config.yaml 失败:', err);
res.status(500).type('text/plain').send('Server error');
}
});
module.exports = router;