diff --git a/docs/docker.md b/docs/docker.md index a6b149a..5a38929 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -35,7 +35,6 @@ All key runtime settings are configured from compose variables. |---|---|---| | `PORT` | `6060` | Web UI listen port | | `BIND_HOST` | `0.0.0.0` | Optional Web UI bind host. Defaults to IPv4 for stable WSL/Windows access. Set `::` explicitly if you want IPv6 listening. | -| `UPSTREAM` | `http://hermes-agent:8642` | Hermes gateway URL (container internal) | | `HERMES_BIN` | `/opt/hermes/.venv/bin/hermes` | Path to Hermes CLI binary | | `HERMES_AGENT_IMAGE` | `nousresearch/hermes-agent:latest` | Hermes Agent base image | | `WEBUI_IMAGE` | `hermes-web-ui-local:latest` | Web UI image (set to `ekkoye8888/hermes-web-ui:latest` to use pre-built) | @@ -79,10 +78,9 @@ AUTH_DISABLED=false ## 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`. -- Profile switching dynamically resolves upstream URLs via `GatewayManager` — the `UPSTREAM` env only sets the default profile gateway. +- Profile switching dynamically resolves upstream URLs via `GatewayManager`. ## Common Operations diff --git a/docs/openapi.json b/docs/openapi.json index 05b65fe..dbbfb92 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -162,6 +162,53 @@ } } }, + "/api/auth/locked-ips": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Get locked-ips", + "description": "GET /api/auth/locked-ips", + "operationId": "listLockedIps", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, + "delete": { + "tags": [ + "Auth" + ], + "summary": "Delete locked-ips", + "description": "DELETE /api/auth/locked-ips", + "operationId": "unlockIpHandler", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/api/auth/login": { "post": { "tags": [ diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index a14fcf6..55472b0 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -10,7 +10,6 @@ export const config = { port: parseInt(process.env.PORT || '8648', 10), // Default to IPv4 for stable WSL/Windows browser access. Use BIND_HOST=:: explicitly for IPv6. host: getListenHost(), - upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642', uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'), dataDir: resolve(__dirname, '..', 'data'), corsOrigins: process.env.CORS_ORIGINS || '*', diff --git a/packages/server/src/controllers/health.ts b/packages/server/src/controllers/health.ts index 9cdb0cf..bb2e645 100644 --- a/packages/server/src/controllers/health.ts +++ b/packages/server/src/controllers/health.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'fs' import { resolve } from 'path' import * as hermesCli from '../services/hermes/hermes-cli' import { getGatewayManagerInstance } from '../services/gateway-bootstrap' -import { config } from '../config' declare const __APP_VERSION__: string @@ -73,7 +72,10 @@ export async function healthCheck(ctx: any) { let gatewayOk = false try { const mgr = getGatewayManagerInstance() - const upstream = mgr?.getUpstream() || config.upstream + const upstream = mgr?.getUpstream() + if (!upstream) { + throw new Error('GatewayManager not initialized') + } const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000) }) gatewayOk = res.ok } catch { } diff --git a/packages/server/src/controllers/hermes/jobs.ts b/packages/server/src/controllers/hermes/jobs.ts index 0d47098..6af75e3 100644 --- a/packages/server/src/controllers/hermes/jobs.ts +++ b/packages/server/src/controllers/hermes/jobs.ts @@ -1,10 +1,12 @@ import type { Context } from 'koa' import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' -import { config } from '../../config' function getUpstream(profile: string): string { const mgr = getGatewayManagerInstance() - return mgr ? mgr.getUpstream(profile) : config.upstream.replace(/\/$/, '') + if (!mgr) { + throw new Error('GatewayManager not initialized') + } + return mgr.getUpstream(profile) } function getApiKey(profile: string): string | null { @@ -54,7 +56,15 @@ async function readUpstreamError(res: Response): Promise { async function proxyRequest(ctx: Context, upstreamPath: string, method?: string): Promise { const profile = resolveProfile(ctx) - const upstream = getUpstream(profile) + let upstream: string + try { + upstream = getUpstream(profile) + } catch (e: any) { + ctx.status = 503 + ctx.set('Content-Type', 'application/json') + ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } } + return + } const params = new URLSearchParams(ctx.search || '') params.delete('token') const search = params.toString() diff --git a/packages/server/src/controllers/webhook.ts b/packages/server/src/controllers/webhook.ts index 2d1b98e..ac77729 100644 --- a/packages/server/src/controllers/webhook.ts +++ b/packages/server/src/controllers/webhook.ts @@ -1,4 +1,3 @@ -import { emitWebhook } from '../services/hermes/hermes' import { logger } from '../services/logger' export async function handleWebhook(ctx: any) { @@ -9,6 +8,5 @@ export async function handleWebhook(ctx: any) { return } logger.info('Received webhook event: %s', payload.event) - emitWebhook(payload) ctx.body = { ok: true } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3b47edc..52e0793 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -163,10 +163,8 @@ export async function bootstrap() { const interfaces = safeNetworkInterfaces() const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost' console.log(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`) - console.log(`Upstream: ${config.upstream}`) console.log(`Log: ~/.hermes-web-ui/logs/server.log`) logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port) - logger.info('Upstream: %s', config.upstream) // Restore group chat agents after server is ready. groupChatServer.restoreWhenReady() diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index b0e4b44..0ed6171 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -1,5 +1,4 @@ import type { Context } from 'koa' -import { config } from '../../config' import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' import { updateUsage } from '../../db/hermes/usage-store' @@ -68,14 +67,14 @@ function resolveProfile(ctx: Context): string { /** Resolve upstream URL for a request based on profile header/query */ function resolveUpstream(ctx: Context): string { const mgr = getGatewayManager() - if (mgr) { - const profile = resolveProfile(ctx) - if (profile && profile !== 'default') { - return mgr.getUpstream(profile) - } - return mgr.getUpstream() + if (!mgr) { + throw new Error('GatewayManager not initialized') } - return config.upstream.replace(/\/$/, '') + const profile = resolveProfile(ctx) + if (profile && profile !== 'default') { + return mgr.getUpstream(profile) + } + return mgr.getUpstream() } function buildProxyHeaders(ctx: Context, upstream: string): Record { @@ -185,7 +184,14 @@ async function streamSSE(ctx: Context, res: Response, profile: string): Promise< export async function proxy(ctx: Context) { const profile = resolveProfile(ctx) - const upstream = resolveUpstream(ctx) + let upstream: string + try { + upstream = resolveUpstream(ctx) + } catch (e: any) { + ctx.status = 503 + ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } } + return + } const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api') const params = new URLSearchParams(ctx.search || '') params.delete('token') diff --git a/packages/server/src/services/hermes/hermes.ts b/packages/server/src/services/hermes/hermes.ts deleted file mode 100644 index d9859a8..0000000 --- a/packages/server/src/services/hermes/hermes.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { config } from '../../config' -import { logger } from '../logger' - -const UPSTREAM = config.upstream.replace(/\/$/, '') - -/** - * Send an instruction to Hermes Agent via /v1/runs - */ -export async function sendInstruction(params: { - input: string | any[] - instructions?: string - conversationHistory?: any[] - sessionId?: string - authToken?: string -}): Promise<{ run_id: string; status: string }> { - const headers: Record = { - 'Content-Type': 'application/json', - } - if (params.authToken) { - headers['Authorization'] = `Bearer ${params.authToken}` - } - - const body: any = { input: params.input } - if (params.instructions) body.instructions = params.instructions - if (params.conversationHistory) body.conversation_history = params.conversationHistory - if (params.sessionId) body.session_id = params.sessionId - - const res = await fetch(`${UPSTREAM}/v1/runs`, { - method: 'POST', - headers, - body: JSON.stringify(body), - }) - - if (!res.ok) { - const text = await res.text() - throw new Error(`Hermes API error ${res.status}: ${text}`) - } - - return res.json() -} - -/** - * Get run status (poll /v1/runs/:id if supported) - */ -export async function getRunStatus(runId: string): Promise { - const res = await fetch(`${UPSTREAM}/v1/runs/${runId}`) - if (!res.ok) { - throw new Error(`Failed to get run status: ${res.status}`) - } - return res.json() -} - -/** - * Subscribe to SSE events for a run - */ -export async function* streamRunEvents(runId: string, authToken?: string): AsyncGenerator { - const headers: Record = {} - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}` - } - - const res = await fetch(`${UPSTREAM}/v1/runs/${runId}/events`, { headers }) - if (!res.ok || !res.body) { - throw new Error(`Failed to stream run events: ${res.status}`) - } - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6).trim() - if (data === '[DONE]') return - try { - const event = JSON.parse(data) - yield event - if (event.event === 'run.completed' || event.event === 'run.failed') return - } catch { /* skip malformed lines */ } - } - } - } - } finally { - reader.releaseLock() - } -} - -/** - * Health check - */ -export async function healthCheck(): Promise<{ status: string; version?: string }> { - const res = await fetch(`${UPSTREAM}/health`) - return res.json() -} - -/** - * Fetch available models - */ -export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> { - const res = await fetch(`${UPSTREAM}/v1/models`) - return res.json() -} - -// Webhook callback registry -type WebhookCallback = (payload: any) => void | Promise -const webhookCallbacks: WebhookCallback[] = [] - -export function onWebhook(callback: WebhookCallback) { - webhookCallbacks.push(callback) -} - -export function emitWebhook(payload: any) { - for (const cb of webhookCallbacks) { - const result = cb(payload) - if (result && typeof result.catch === 'function') { - result.catch((err: Error) => logger.error(err, 'Webhook callback error')) - } - } -} diff --git a/packages/website/src/i18n/en.ts b/packages/website/src/i18n/en.ts index 95dd5df..d0371ec 100644 --- a/packages/website/src/i18n/en.ts +++ b/packages/website/src/i18n/en.ts @@ -137,7 +137,6 @@ export default { ['AUTH_TOKEN', 'Custom auth token (overrides auto-generated)'], ['PORT', 'Server listen port (default: 8648)'], ['BIND_HOST', 'Server bind host (default: 0.0.0.0). Set :: explicitly to enable IPv6 listening.'], - ['UPSTREAM', 'Hermes gateway URL (default: http://127.0.0.1:8642)'], ['UPLOAD_DIR', 'Custom upload directory path'], ['CORS_ORIGINS', 'CORS origin config (default: *)'], ['HERMES_BIN', 'Custom path to hermes CLI binary'], @@ -145,7 +144,7 @@ export default { }, gateway: { title: 'Gateway Management', - content: 'The gateway is the Hermes Agent process that handles AI conversations. Hermes Web UI manages the gateway lifecycle — start, stop, and monitor from the Gateways page. Multiple gateways can run with different profiles.', + content: 'The gateway is the Hermes Agent process that handles AI conversations. Hermes Web UI manages the gateway lifecycle — start, stop, and monitor from the Gateways page. Multiple gateways can run with different profiles, and each profile resolves its own gateway host/port from its Hermes config.', }, profiles: { title: 'Profiles', diff --git a/packages/website/src/i18n/zh.ts b/packages/website/src/i18n/zh.ts index 3489fb6..cce31ef 100644 --- a/packages/website/src/i18n/zh.ts +++ b/packages/website/src/i18n/zh.ts @@ -137,7 +137,6 @@ export default { ['AUTH_TOKEN', '自定义认证令牌(覆盖自动生成的令牌)'], ['PORT', '服务器监听端口(默认:8648)'], ['BIND_HOST', '服务器绑定地址(默认:0.0.0.0)。如需 IPv6,请显式设置为 ::。'], - ['UPSTREAM', 'Hermes 网关 URL(默认:http://127.0.0.1:8642)'], ['UPLOAD_DIR', '自定义上传目录路径'], ['CORS_ORIGINS', 'CORS 来源配置(默认:*)'], ['HERMES_BIN', '自定义 hermes CLI 二进制路径'], @@ -145,7 +144,7 @@ export default { }, gateway: { title: '网关管理', - content: '网关是处理 AI 对话的 Hermes Agent 进程。Hermes Web UI 管理网关生命周期——在网关页面启动、停止和监控。不同配置可运行多个网关。', + content: '网关是处理 AI 对话的 Hermes Agent 进程。Hermes Web UI 管理网关生命周期——在网关页面启动、停止和监控。不同配置可运行多个网关,且每个 profile 都会从各自的 Hermes 配置中解析网关 host/port。', }, profiles: { title: '配置文件', diff --git a/tests/server/jobs-controller.test.ts b/tests/server/jobs-controller.test.ts index 7f8a3be..11cce3f 100644 --- a/tests/server/jobs-controller.test.ts +++ b/tests/server/jobs-controller.test.ts @@ -1,11 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('../../packages/server/src/config', () => ({ - config: { upstream: 'http://127.0.0.1:8642' }, -})) - vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({ - getGatewayManagerInstance: () => null, + getGatewayManagerInstance: () => ({ + getUpstream: () => 'http://127.0.0.1:8642', + getApiKey: () => null, + }), })) const mockFetch = vi.fn() diff --git a/tests/server/proxy-handler.test.ts b/tests/server/proxy-handler.test.ts index 445eada..c45905f 100644 --- a/tests/server/proxy-handler.test.ts +++ b/tests/server/proxy-handler.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -// Mock config -vi.mock('../../packages/server/src/config', () => ({ - config: { upstream: 'http://127.0.0.1:8642' }, -})) - vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({ - getGatewayManagerInstance: () => null, + getGatewayManagerInstance: () => ({ + getUpstream: () => 'http://127.0.0.1:8642', + getApiKey: () => null, + }), })) // Mock updateUsage so we can assert calls without real DB