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 所在的机器。 +

+ +
+ 前提条件 + +
+ +

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;