feat: add Hermes Agent cross-machine deployment via CLI pull
This commit is contained in:
parent
d6f222c2e0
commit
6037bf2bd6
181
app.js
181
app.js
|
|
@ -437,7 +437,10 @@
|
||||||
<h3 class="role-card__name">${name}</h3>
|
<h3 class="role-card__name">${name}</h3>
|
||||||
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
||||||
</div>
|
</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="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>`;
|
</article>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
@ -474,6 +477,143 @@
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
document.getElementById('settings-name').value = state.creatorName || '';
|
document.getElementById('settings-name').value = state.creatorName || '';
|
||||||
document.getElementById('settings-library').value = state.libraryName === '我的 [XXX]' ? '' : state.libraryName;
|
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) {
|
function switchCenterTab(tab) {
|
||||||
|
|
@ -934,6 +1074,47 @@ ${data.secrets || '无'}
|
||||||
return;
|
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') {
|
if (action === 'logout') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
logout();
|
logout();
|
||||||
|
|
|
||||||
|
|
@ -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.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 适配在后端完成,用户拉取到的文件可直接使用
|
||||||
13
index.html
13
index.html
|
|
@ -387,6 +387,19 @@
|
||||||
<button class="btn btn--primary btn--block" type="submit">保存设置</button>
|
<button class="btn btn--primary btn--block" type="submit">保存设置</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<div class="form-actions">
|
||||||
<button class="btn btn--ghost btn--block" type="button" data-action="logout">退出登录</button>
|
<button class="btn btn--ghost btn--block" type="button" data-action="logout">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,19 @@ model User {
|
||||||
|
|
||||||
roles Role[]
|
roles Role[]
|
||||||
orders Order[]
|
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 {
|
model Role {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ app.use(express.json());
|
||||||
// API 路由
|
// API 路由
|
||||||
app.use('/api/auth', require('./src/routes/auth'));
|
app.use('/api/auth', require('./src/routes/auth'));
|
||||||
app.use('/api/roles', require('./src/routes/roles'));
|
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('.'));
|
app.use(express.static('.'));
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,57 @@ function authMiddleware(req, res, next) {
|
||||||
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 = {
|
module.exports = {
|
||||||
hashPassword,
|
hashPassword,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
signToken,
|
signToken,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
apiKeyMiddleware,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue