refactor remove upstream env dependency (#551)

This commit is contained in:
ekko
2026-05-08 20:46:22 +08:00
committed by GitHub
parent bba4920fee
commit 51fde26797
13 changed files with 90 additions and 165 deletions
+1 -3
View File
@@ -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
+47
View File
@@ -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": [
-1
View File
@@ -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 || '*',
+4 -2
View File
@@ -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 { }
+13 -3
View File
@@ -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<unknown> {
async function proxyRequest(ctx: Context, upstreamPath: string, method?: string): Promise<void> {
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()
@@ -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 }
}
-2
View File
@@ -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()
@@ -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<string, string> {
@@ -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')
@@ -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<string, string> = {
'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<any> {
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<any> {
const headers: Record<string, string> = {}
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<void>
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'))
}
}
}
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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: '配置文件',
+4 -5
View File
@@ -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()
+4 -6
View File
@@ -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