diff --git a/app.js b/app.js
index fffe29e..739c28c 100644
--- a/app.js
+++ b/app.js
@@ -437,7 +437,10 @@
${name}
${statusText}
-
+
+
+
+
`;
})
.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 = '加载中…
';
+ try {
+ const { keys } = await api('/apikeys');
+ if (keys.length === 0) {
+ listEl.innerHTML = '还没有 API Key
';
+ 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 `
+
+
+
+ ${name}
+ ${prefix}…
+
+
+
+
+ 创建于 ${created} · 最后使用:${lastUsed}
+
+
`;
+ })
+ .join('');
+ } catch (err) {
+ listEl.innerHTML = `加载失败:${escapeHtml(err.message)}
`;
+ }
+ }
+
+ 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 = `
+
+
+
Hermes Agent 部署指南
+
+
+
+
+ 角色「${escapeHtml(roleName)}」的配置文件可通过以下命令拉取到 Hermes Agent 所在的机器。
+
+
+
+
前提条件
+
+ - 已安装 Hermes Agent(
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash)
+ - 已在 EternalAI 设置页生成 API Key
+
+
+
+
1. 创建 Hermes Profile
+
hermes profile create ${escapeHtml(profileName)}
+
+
2. 拉取 SOUL.md
+
+
${escapeHtml(soulCmd)}
+
+
+
+
3. 拉取 config.yaml
+
+
${escapeHtml(configCmd)}
+
+
+
+
4. 配置 API 密钥
+
echo "OPENROUTER_API_KEY=sk-or-your-key" > ~/.hermes/profiles/${escapeHtml(profileName)}/.env
+
+
5. 启动对话
+
${escapeHtml(profileName)} chat
+
+
+
+ 提示:将命令中的 YOUR_API_KEY 替换为你在设置页生成的 API Key(以 eak_ 开头)。
+
+
+
+ `;
+
+ // 点击遮罩关闭
+ 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();
diff --git a/docs/brainstorms/2026-06-20-hermes-cross-machine-deploy-requirements.md b/docs/brainstorms/2026-06-20-hermes-cross-machine-deploy-requirements.md
new file mode 100644
index 0000000..cb2693c
--- /dev/null
+++ b/docs/brainstorms/2026-06-20-hermes-cross-machine-deploy-requirements.md
@@ -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//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//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.yaml(text/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 适配在后端完成,用户拉取到的文件可直接使用
diff --git a/index.html b/index.html
index cf44bad..efe8198 100644
--- a/index.html
+++ b/index.html
@@ -387,6 +387,19 @@
+
+
+
+
API Key 管理
+
+ 用于在 Hermes 机器上通过 curl 拉取角色配置文件
+
+
+
+
+
+
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e0ffd10..f1a2ed0 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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 {
diff --git a/server.js b/server.js
index 644d262..e575eb0 100644
--- a/server.js
+++ b/server.js
@@ -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('.'));
diff --git a/src/lib/auth.js b/src/lib/auth.js
index 62e5e4c..51ff165 100644
--- a/src/lib/auth.js
+++ b/src/lib/auth.js
@@ -55,10 +55,57 @@ function authMiddleware(req, res, next) {
next();
}
+// Express 中间件:验证 API Key(用于 Hermes 配置拉取)
+// 支持 JWT token 和 API Key(eak_ 前缀)两种认证方式
+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 Key(eak_ 前缀),查数据库验证
+ 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,
};
diff --git a/src/routes/apikeys.js b/src/routes/apikeys.js
new file mode 100644
index 0000000..30de3cf
--- /dev/null
+++ b/src/routes/apikeys.js
@@ -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 Key:eak_ + 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;
diff --git a/src/routes/hermes.js b/src/routes/hermes.js
new file mode 100644
index 0000000..4544220
--- /dev/null
+++ b/src/routes/hermes.js
@@ -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;