Files
hermes-web/packages/server/src/index.ts
T
toor 3ca960f388
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
优化页面交互并增强 Provider 配置能力
- 表单下拉框不再用 label 包裹,改为 div + aria-label,避免点击后
  下拉面板瞬间关闭
- Provider 卡片新增「从上游获取模型」和「修改」入口,支持编辑
  base URL、API Key 与 api_mode,凭据写入对应的环境变量
- 服务端抽出 provider-model-discovery 统一不同 api_mode 的模型发现,
  旧的 custom_providers 与新的 providers 字典结构都能正确更新
- Agent Bridge 改为后台启动,HTTP 接口在 Python 运行时就绪前即可用;
  worker 连接被拒时按类型重试,不重放可能已产生副作用的请求
- 断线重连后恢复 profiles、models 与会话列表,健康检查改为自调度
  的 setTimeout,离线时 2 秒后重试
- 批量删除确认文案改用 Popconfirm content,避免变成侧栏触发器文本
2026-10-22 14:29:48 +08:00

305 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Koa from 'koa'
import cors from '@koa/cors'
import bodyParser from '@koa/bodyparser'
import serve from 'koa-static'
import send from 'koa-send'
import os from 'os'
import { resolve } from 'path'
import { mkdir } from 'fs/promises'
import { readFileSync } from 'fs'
import { config, shouldCreateWebUiDataDir } from './config'
import { initLoginLimiter } from './services/login-limiter'
import { bindShutdown } from './services/shutdown'
import { setupTerminalWebSocket } from './routes/hermes/terminal'
import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events'
import { startVersionCheck } from './routes/health'
import { registerRoutes } from './routes'
import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { getAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { injectBundledMcpServer } from './services/hermes/studio-mcp-autoinject'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
import { refreshConfiguredProviderModelCatalogsInBackground } from './services/hermes/model-catalog-cache'
import { scanLanDevices, startLanDiscoveryResponder } from './services/lan-discovery'
import { getLanPeerSocketManager, getLanPeerSocketPath } from './services/lan-peer-socket'
import { startGlobalAgentServer } from './services/global-agent/server'
import { WorkflowSocketServer } from './services/workflow-socket'
import { logger } from './services/logger'
import { createStaticCompressionMiddleware } from './middleware/static-compression'
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
import { createCorsOriginResolver, securityHeaders } from './security'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
declare const __APP_VERSION__: string
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return 'dev' } })()
// Global error handlers
process.on('uncaughtException', (err) => {
console.error('FATAL: Uncaught exception')
console.error(err)
logger.fatal(err, 'Uncaught exception')
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection')
console.error(reason)
logger.error(reason, 'Unhandled rejection')
})
let server: any = null
let servers: any[] = []
let chatRunServer: any = null
let workflowSocketServer: WorkflowSocketServer | null = null
let agentBridgeManager: any = null
interface ListenResult {
primary: any
servers: any[]
}
function listen(app: Koa, port: number, host: string): Promise<any> {
return new Promise((resolve, reject) => {
const s = app.listen(port, host)
s.once('listening', () => resolve(s))
s.once('error', reject)
})
}
async function listenWithFallback(app: Koa, port: number, host?: string): Promise<ListenResult> {
const bindHost = host || '0.0.0.0'
console.log(`[bootstrap] listening on ${bindHost}:${port}`)
const primary = await listen(app, port, bindHost)
return { primary, servers: [primary] }
}
function getLoopbackBaseUrl(httpServer: any): string {
const address = httpServer?.address?.()
const port = typeof address === 'object' && address?.port ? address.port : config.port
return `http://127.0.0.1:${port}`
}
/**
* 安全获取网络接口信息(兼容 Termux/proot 环境)
* 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13)
*/
function safeNetworkInterfaces() {
try {
return os.networkInterfaces()
} catch {
return {}
}
}
function envFlagEnabled(name: string): boolean {
const value = String(process.env[name] || '').trim().toLowerCase()
return ['1', 'true', 'yes', 'on'].includes(value)
}
function gatewayAutostartDisabled(): boolean {
return envFlagEnabled('HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
}
function skillInjectionDisabled(): boolean {
return envFlagEnabled('HERMES_WEB_UI_DISABLE_SKILL_INJECTION')
}
async function startRuntimeServices(): Promise<void> {
if (gatewayAutostartDisabled()) {
console.log('[bootstrap] profile gateway check disabled by HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
} else {
void ensureProfileGatewaysRunning()
.then(() => console.log('[bootstrap] profile gateways checked'))
.catch((err) => {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
})
}
try {
await agentBridgeManager.start()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
}
}
function startLanDiscovery(): void {
const discoverySocket = startLanDiscoveryResponder({ httpPort: config.port })
let initialScanStarted = false
const runInitialScan = () => {
if (initialScanStarted) return
initialScanStarted = true
void scanLanDevices().catch(err => logger.warn(err, '[lan-discovery] initial scan failed'))
}
if (discoverySocket) {
discoverySocket.once('listening', runInitialScan)
const fallbackTimer = setTimeout(runInitialScan, 500)
fallbackTimer.unref?.()
} else {
runInitialScan()
}
}
export async function bootstrap() {
console.log(`hermes-ui v${APP_VERSION} starting...`)
await mkdir(config.uploadDir, { recursive: true })
if (shouldCreateWebUiDataDir()) {
await mkdir(config.dataDir, { recursive: true })
}
await initLoginLimiter()
if (skillInjectionDisabled()) {
console.log('[bootstrap] bundled skill injection disabled by HERMES_WEB_UI_DISABLE_SKILL_INJECTION')
} else {
try {
const skillInjector = new HermesSkillInjector()
const injectionResult = await skillInjector.injectMissingSkills()
if (injectionResult.injected.length > 0) {
logger.info({
injected: [...new Set(injectionResult.injected)],
targetCount: injectionResult.targets.length,
}, '[bootstrap] bundled skills injected')
}
if (injectionResult.updated.length > 0) {
logger.info({
updated: [...new Set(injectionResult.updated)],
targetCount: injectionResult.targets.length,
}, '[bootstrap] bundled skills updated')
}
} catch (err) {
logger.warn(err, '[bootstrap] failed to inject bundled skills')
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
}
}
try {
await injectBundledMcpServer()
} catch (err) {
logger.warn(err, '[bootstrap] failed to inject bundled MCP server')
console.warn('[bootstrap] failed to inject bundled MCP server:', err instanceof Error ? err.message : err)
}
const app = new Koa()
// Initialize all web-ui SQLite tables
const { initAllStores } = await import('./db/hermes/init')
initAllStores()
console.log('[bootstrap] all stores initialized')
app.use(securityHeaders())
app.use(cors({ origin: createCorsOriginResolver(config.corsOrigins) }))
// Raise body limits above the default 1mb: profile avatars and MiMo voice-clone
// reference audio are posted as base64 data URLs before reaching handlers.
app.use(bodyParser({
encoding: 'utf-8',
jsonLimit: '20mb',
formLimit: '20mb',
textLimit: '20mb',
parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
}))
console.log('[bootstrap] cors + bodyParser registered')
// Register all routes (handles auth internally)
registerRoutes(app, [requireUserJwt, resolveUserProfile])
console.log('[bootstrap] routes registered')
// SPA fallback
const distDir = resolve(__dirname, '..', 'client')
app.use(createStaticCompressionMiddleware())
app.use(serve(distDir))
app.use(async (ctx) => {
if (!ctx.path.startsWith('/api') &&
ctx.path !== '/health' &&
ctx.path !== '/upload' &&
ctx.path !== '/webhook') {
await send(ctx, 'index.html', { root: distDir })
}
})
console.log('[bootstrap] SPA fallback registered')
// Start server using the configured bind host. Default is IPv4 for WSL stability.
const listenResult = await listenWithFallback(app, config.port, config.host)
server = listenResult.primary
servers = listenResult.servers
console.log('[bootstrap] app.listen called')
setupTerminalWebSocket(servers)
setupKanbanEventsWebSocket(servers)
getLanPeerSocketManager().setupServer(servers)
console.log('[bootstrap] terminal + kanban + LAN peer websocket setup')
// Group chat Socket.IO (must be after server is created)
const groupChatServer = new GroupChatServer(servers)
setGroupChatServer(groupChatServer)
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
setChatRunServer(chatRunServer)
chatRunServer.init()
workflowSocketServer = new WorkflowSocketServer(groupChatServer.getIO())
workflowSocketServer.init()
const loopbackBaseUrl = getLoopbackBaseUrl(server)
startGlobalAgentServer(groupChatServer.getIO(), { localBaseUrl: loopbackBaseUrl })
console.log('[bootstrap] global agent server ready')
// Session deleter — periodically drain pending session deletes
const { SessionDeleter } = await import('./services/hermes/session-deleter')
const sessionDeleter = SessionDeleter.getInstance()
const activeProfile = process.env.PROFILE || 'default'
sessionDeleter.start(activeProfile)
console.log('[bootstrap] session deleter started, profile=%s', activeProfile)
// Catch-all: destroy upgrade requests not handled by terminal or Socket.IO
servers.forEach((httpServer) => {
httpServer.on('upgrade', (req: any, socket: any) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/terminal' &&
url.pathname !== '/api/hermes/kanban/events' &&
url.pathname !== getLanPeerSocketPath() &&
!url.pathname.startsWith('/socket.io/')) {
socket.destroy()
}
})
})
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(`Log: ${config.appHome}/logs/server.log`)
logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port)
startLanDiscovery()
refreshConfiguredProviderModelCatalogsInBackground('bootstrap')
// Restore group chat agents after server is ready.
groupChatServer.restoreWhenReady()
servers.forEach((httpServer) => {
httpServer.on('error', (err: any) => {
console.error('[bootstrap] server error:', err.code || err.message)
logger.error({ err }, 'Server error')
})
})
agentBridgeManager = getAgentBridgeManager()
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
// Session/history APIs must remain available while the Python runtime starts.
void startRuntimeServices()
startVersionCheck()
}
bootstrap().catch((error) => {
console.error('FATAL: Failed to start Hermes Web UI')
console.error(error)
logger.fatal(error, 'Fatal error during bootstrap')
process.exit(1)
})