diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b5209e2d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +node_modules +dist +hermes_data +*.log +.DS_Store diff --git a/.gitignore b/.gitignore index 6d8608fb..e617e9c8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ ROADMAP.md packages/server/data/ packages/server/node_modules/ .hermes-web-ui/ +hermes_data/ # Editor directories and files .vscode/* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..3ed25fe2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 7a78eed6..70fd03b6 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README_zh.md b/README_zh.md index 02923c75..49065801 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 命令 | 命令 | 说明 | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..0873ce65 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 00000000..e0dfbd9f --- /dev/null +++ b/docs/docker.md @@ -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 +``` diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 19ab2e56..2925cb66 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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() \ No newline at end of file +bootstrap() diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index b5f2918d..da5b8608 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -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 { + 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 = {} diff --git a/packages/server/src/services/hermes-cli.ts b/packages/server/src/services/hermes-cli.ts index 2d2c77b3..a4014202 100644 --- a/packages/server/src/services/hermes-cli.ts +++ b/packages/server/src/services/hermes-cli.ts @@ -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 { 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 { */ export async function deleteSession(id: string): Promise { 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 { */ export async function renameSession(id: string, title: string): Promise { 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 { 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 { * Start Hermes gateway (uses launchd/systemd) */ export async function startGateway(): Promise { - 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 { */ export async function startGatewayBackground(): Promise { 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 { * Restart Hermes gateway */ export async function restartGateway(): Promise { - 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 { * Stop Hermes gateway */ export async function stopGateway(): Promise { - 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 { */ export async function listLogFiles(): Promise { 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 { 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 { */ export async function getProfile(name: string): Promise { 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 { 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 { */ export async function renameProfile(oldName: string, newName: string): Promise { 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 { 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 { 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, })