mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-24 21:10:15 +00:00
feat: add docker-compose deployment and harden gateway startup
- add docker-compose setup with hermes-agent + hermes-webui - make runtime config env-driven (compose vars + HERMES_BIN) - improve gateway startup/restart resilience in docker - make base image configurable via BASE_IMAGE/HERMES_AGENT_IMAGE Closes https://github.com/EKKOLearnAI/hermes-web-ui/issues/14
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
hermes_data
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -18,6 +18,7 @@ ROADMAP.md
|
||||
packages/server/data/
|
||||
packages/server/node_modules/
|
||||
.hermes-web-ui/
|
||||
hermes_data/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
ARG BASE_IMAGE=nousresearch/hermes-agent:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
python3 \
|
||||
python3-yaml \
|
||||
make \
|
||||
g++ \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOME=/home/agent
|
||||
ENV HERMES_HOME=/home/agent/.hermes
|
||||
|
||||
EXPOSE 6060
|
||||
|
||||
CMD ["node", "dist/server/index.js"]
|
||||
@@ -144,6 +144,32 @@ hermes-web-ui start
|
||||
|
||||
> WSL auto-detects and uses `hermes gateway run` for background startup (no launchd/systemd).
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Run Web UI together with Hermes Agent:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build hermes-agent hermes-webui
|
||||
docker compose logs -f hermes-webui
|
||||
```
|
||||
|
||||
Open **http://localhost:6060**
|
||||
|
||||
- Persistent Hermes data is stored in `./hermes_data`
|
||||
- The web UI service is built from this repository's `Dockerfile`
|
||||
- All runtime settings are environment-variable driven in `docker-compose.yml`
|
||||
|
||||
Override compose variables directly from command line (no `.env` file required):
|
||||
|
||||
```bash
|
||||
PORT=16060 \
|
||||
UPSTREAM=http://127.0.0.1:8642 \
|
||||
HERMES_BIN=/opt/hermes/.venv/bin/hermes \
|
||||
docker compose up -d --build hermes-agent hermes-webui
|
||||
```
|
||||
|
||||
For detailed notes and troubleshooting, see [`docs/docker.md`](./docs/docker.md).
|
||||
|
||||
### CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -145,6 +145,32 @@ hermes-web-ui start
|
||||
|
||||
> WSL 会自动检测并使用 `hermes gateway run` 进行后台启动(无需 launchd/systemd)。
|
||||
|
||||
### Docker Compose
|
||||
|
||||
使用仓库内置的 compose 文件联合运行 Hermes Agent + Web UI:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build hermes-agent hermes-webui
|
||||
docker compose logs -f hermes-webui
|
||||
```
|
||||
|
||||
打开 **http://localhost:6060**
|
||||
|
||||
- Hermes 持久化数据目录:`./hermes_data`
|
||||
- Web UI 服务镜像由本仓库 `Dockerfile` 本地构建
|
||||
- 运行参数全部由 `docker-compose.yml` 环境变量驱动
|
||||
|
||||
可直接在命令行覆盖 compose 变量(不依赖 `.env` 文件):
|
||||
|
||||
```bash
|
||||
PORT=16060 \
|
||||
UPSTREAM=http://127.0.0.1:8642 \
|
||||
HERMES_BIN=/opt/hermes/.venv/bin/hermes \
|
||||
docker compose up -d --build hermes-agent hermes-webui
|
||||
```
|
||||
|
||||
更详细的说明与排错见:[`docs/docker.md`](./docs/docker.md)
|
||||
|
||||
### CLI 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
hermes-agent:
|
||||
image: ${HERMES_AGENT_IMAGE:-nousresearch/hermes-agent:latest}
|
||||
container_name: ${HERMES_AGENT_CONTAINER_NAME:-hermes-agent}
|
||||
volumes:
|
||||
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/home/agent/.hermes
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
hermes-webui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: ${HERMES_AGENT_IMAGE:-nousresearch/hermes-agent:latest}
|
||||
image: ${WEBUI_IMAGE:-hermes-web-ui-local:latest}
|
||||
container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui}
|
||||
entrypoint: ["node", "dist/server/index.js"]
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
ports:
|
||||
- "${PORT:-6060}:${PORT:-6060}"
|
||||
volumes:
|
||||
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- PORT=${PORT:-6060}
|
||||
- UPSTREAM=${UPSTREAM:-http://127.0.0.1:8642}
|
||||
- HERMES_HOME=/home/agent/.hermes
|
||||
- HERMES_BIN=${HERMES_BIN:-/opt/hermes/.venv/bin/hermes}
|
||||
- AUTH_DISABLED=${AUTH_DISABLED:-true}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hermes-agent-src:
|
||||
@@ -0,0 +1,62 @@
|
||||
# Docker Compose Guide
|
||||
|
||||
This repository ships an environment-variable driven Docker Compose setup.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker compose up -d --build hermes-agent hermes-webui
|
||||
docker compose logs -f hermes-webui
|
||||
```
|
||||
|
||||
Open: `http://localhost:6060`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All key runtime settings are configured from compose variables.
|
||||
This compose file runs two services together:
|
||||
|
||||
- `hermes-agent` (image: `nousresearch/hermes-agent`)
|
||||
- `hermes-webui` (built from this repository)
|
||||
|
||||
Compose mapping highlights:
|
||||
|
||||
- Host/browser port: `${PORT}:${PORT}`
|
||||
- Server `PORT` is set from `${PORT}`
|
||||
- Upstream is set from `${UPSTREAM}`
|
||||
- Hermes CLI binary is set from `${HERMES_BIN}`
|
||||
- Hermes base image is set from `${HERMES_AGENT_IMAGE}` (used by both `hermes-agent` and webui build base)
|
||||
|
||||
Override variables directly from shell when running compose:
|
||||
|
||||
```bash
|
||||
PORT=16060 \
|
||||
UPSTREAM=http://127.0.0.1:8642 \
|
||||
HERMES_BIN=/opt/hermes/.venv/bin/hermes \
|
||||
docker compose up -d --build hermes-agent hermes-webui
|
||||
```
|
||||
|
||||
## Data Persistence
|
||||
|
||||
- Hermes runtime data persists in `${HERMES_DATA_DIR}`.
|
||||
- Default path is `./hermes_data`.
|
||||
|
||||
## Code Runtime Behavior
|
||||
|
||||
- Server upstream comes from `UPSTREAM` env (`packages/server/src/config.ts`).
|
||||
- Hermes CLI binary comes from `HERMES_BIN` env (`packages/server/src/services/hermes-cli.ts`).
|
||||
- If `HERMES_BIN` is not provided, code falls back to `hermes` in `PATH`.
|
||||
|
||||
## Common Operations
|
||||
|
||||
Recreate webui:
|
||||
|
||||
```bash
|
||||
docker compose up -d --no-deps --force-recreate hermes-webui
|
||||
```
|
||||
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
@@ -268,6 +268,17 @@ async function ensureApiServerConfig() {
|
||||
|
||||
async function ensureGatewayRunning() {
|
||||
const upstream = config.upstream.replace(/\/$/, '')
|
||||
const waitForGatewayReady = async (timeoutMs: number = 15000) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
if (res.ok) return true
|
||||
} catch { }
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
@@ -279,16 +290,14 @@ async function ensureGatewayRunning() {
|
||||
try {
|
||||
// 👉 关键:保存 PID
|
||||
gatewayPid = await startGatewayBackground()
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
if (res.ok) {
|
||||
if (await waitForGatewayReady()) {
|
||||
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
|
||||
} else {
|
||||
console.error('gateway start failed: timed out waiting for health')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('gateway start failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
bootstrap()
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Context } from 'koa'
|
||||
import { config } from '../../config'
|
||||
|
||||
function isTransientGatewayError(err: any): boolean {
|
||||
const msg = String(err?.message || '')
|
||||
const causeCode = String(err?.cause?.code || '')
|
||||
return (
|
||||
causeCode === 'ECONNREFUSED' ||
|
||||
causeCode === 'ECONNRESET' ||
|
||||
/ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(msg)
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const healthUrl = `${upstream}/health`
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(1200),
|
||||
})
|
||||
if (res.ok) return true
|
||||
} catch { }
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function proxy(ctx: Context) {
|
||||
const upstream = config.upstream.replace(/\/$/, '')
|
||||
// Rewrite path for upstream gateway:
|
||||
@@ -36,11 +62,23 @@ export async function proxy(ctx: Context) {
|
||||
body = (ctx as any).request.rawBody as string | undefined
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const requestInit: RequestInit = {
|
||||
method: ctx.req.method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, requestInit)
|
||||
} catch (err: any) {
|
||||
// Gateway may be restarting; wait briefly and retry once.
|
||||
if (isTransientGatewayError(err) && await waitForGatewayReady(upstream)) {
|
||||
res = await fetch(url, requestInit)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
const resHeaders: Record<string, string> = {}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const execOpts = { windowsHide: true }
|
||||
const isDocker = existsSync('/.dockerenv')
|
||||
|
||||
function resolveHermesBin(): string {
|
||||
const envBin = process.env.HERMES_BIN?.trim()
|
||||
if (envBin) return envBin
|
||||
return 'hermes'
|
||||
}
|
||||
|
||||
const HERMES_BIN = resolveHermesBin()
|
||||
|
||||
export interface HermesSession {
|
||||
id: string
|
||||
@@ -64,7 +74,7 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
|
||||
if (source) args.push('--source', source)
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -128,7 +138,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
|
||||
const args = ['sessions', 'export', '-', '--session-id', id]
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -174,7 +184,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
|
||||
*/
|
||||
export async function deleteSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['sessions', 'delete', id, '--yes'], {
|
||||
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -190,7 +200,7 @@ export async function deleteSession(id: string): Promise<boolean> {
|
||||
*/
|
||||
export async function renameSession(id: string, title: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['sessions', 'rename', id, title], {
|
||||
await execFileAsync(HERMES_BIN, ['sessions', 'rename', id, title], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -212,7 +222,7 @@ export interface LogFileInfo {
|
||||
*/
|
||||
export async function getVersion(): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000, ...execOpts })
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts })
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return ''
|
||||
@@ -223,7 +233,12 @@ export async function getVersion(): Promise<string> {
|
||||
* Start Hermes gateway (uses launchd/systemd)
|
||||
*/
|
||||
export async function startGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'start'], {
|
||||
if (isDocker) {
|
||||
const pid = await startGatewayBackground()
|
||||
return pid ? `Gateway started (PID: ${pid})` : 'Gateway start triggered'
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -236,7 +251,7 @@ export async function startGateway(): Promise<string> {
|
||||
*/
|
||||
export async function startGatewayBackground(): Promise<number | null> {
|
||||
const { spawn } = require('child_process') as typeof import('child_process')
|
||||
const child = spawn('hermes', ['gateway', 'run'], {
|
||||
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
@@ -249,7 +264,13 @@ export async function startGatewayBackground(): Promise<number | null> {
|
||||
* Restart Hermes gateway
|
||||
*/
|
||||
export async function restartGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], {
|
||||
if (isDocker) {
|
||||
try { await stopGateway() } catch { }
|
||||
const pid = await startGatewayBackground()
|
||||
return pid ? `Gateway restarted (PID: ${pid})` : 'Gateway restart triggered'
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -260,7 +281,7 @@ export async function restartGateway(): Promise<string> {
|
||||
* Stop Hermes gateway
|
||||
*/
|
||||
export async function stopGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'stop'], {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -272,7 +293,7 @@ export async function stopGateway(): Promise<string> {
|
||||
*/
|
||||
export async function listLogFiles(): Promise<LogFileInfo[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['logs', 'list'], {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['logs', 'list'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -311,7 +332,7 @@ export async function readLogs(
|
||||
if (since) args.push('--since', since)
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 15000,
|
||||
...execOpts,
|
||||
@@ -349,7 +370,7 @@ export interface HermesProfileDetail {
|
||||
*/
|
||||
export async function listProfiles(): Promise<HermesProfile[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['profile', 'list'], {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -385,7 +406,7 @@ export async function listProfiles(): Promise<HermesProfile[]> {
|
||||
*/
|
||||
export async function getProfile(name: string): Promise<HermesProfileDetail> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['profile', 'show', name], {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'show', name], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -429,7 +450,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
|
||||
if (clone) args.push('--clone')
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', args, {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
|
||||
timeout: 15000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -445,7 +466,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
|
||||
*/
|
||||
export async function deleteProfile(name: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['profile', 'delete', name, '--yes'], {
|
||||
await execFileAsync(HERMES_BIN, ['profile', 'delete', name, '--yes'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -461,7 +482,7 @@ export async function deleteProfile(name: string): Promise<boolean> {
|
||||
*/
|
||||
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['profile', 'rename', oldName, newName], {
|
||||
await execFileAsync(HERMES_BIN, ['profile', 'rename', oldName, newName], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -477,7 +498,7 @@ export async function renameProfile(oldName: string, newName: string): Promise<b
|
||||
*/
|
||||
export async function useProfile(name: string): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['profile', 'use', name], {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['profile', 'use', name], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -496,7 +517,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
|
||||
if (outputPath) args.push('--output', outputPath)
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', args, {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
|
||||
timeout: 60000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -512,7 +533,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
|
||||
*/
|
||||
export async function setupReset(): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['setup', '--non-interactive', '--reset'], {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['setup', '--non-interactive', '--reset'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
@@ -531,7 +552,7 @@ export async function importProfile(archivePath: string, name?: string): Promise
|
||||
if (name) args.push('--name', name)
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', args, {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
|
||||
timeout: 60000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user