chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
This commit is contained in:
2026-10-16 10:34:35 +08:00
commit 1e9ea89d86
912 changed files with 287328 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
import Koa from 'koa'
import type { Context } 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, startAgentBridgeManager } 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 { PetStateSocketServer } from './services/hermes/pet-state-socket'
import { logger } from './services/logger'
import { createStaticCompressionMiddleware } from './middleware/static-compression'
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
import { createCorsOriginResolver, securityHeaders } from './security'
import type { ShutdownHandler } from './services/shutdown'
// 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 petStateSocketServer: PetStateSocketServer | null = null
let agentBridgeManager: any = null
let desktopShutdownHandler: ShutdownHandler | null = 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 isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
function isLoopbackAddress(address?: string | null): boolean {
if (!address) return false
return address === '127.0.0.1'
|| address === '::1'
|| address === '::ffff:127.0.0.1'
|| address.startsWith('::ffff:127.')
}
function bearerToken(ctx: Context): string {
const header = ctx.get('authorization')
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1]?.trim() || ''
}
function registerDesktopShutdownRoute(app: Koa): void {
app.use(async (ctx, next) => {
if (ctx.method !== 'POST' || ctx.path !== '/api/desktop/shutdown') {
await next()
return
}
if (!isDesktopRuntime()) {
ctx.status = 404
ctx.body = { error: 'not_found' }
return
}
const remoteAddress = ctx.req.socket.remoteAddress
if (!isLoopbackAddress(remoteAddress)) {
ctx.status = 403
ctx.body = { error: 'forbidden' }
return
}
const expectedToken = String(process.env.AUTH_TOKEN || '').trim()
if (!expectedToken || bearerToken(ctx) !== expectedToken) {
ctx.status = 401
ctx.body = { error: 'unauthorized' }
return
}
if (!desktopShutdownHandler) {
ctx.status = 503
ctx.body = { error: 'shutdown_not_ready' }
return
}
ctx.status = 202
ctx.body = { ok: true }
setTimeout(() => {
void desktopShutdownHandler?.('desktop-api')
}, 50).unref?.()
})
}
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 startRuntimeServicesBeforeListen(): 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 {
agentBridgeManager = await startAgentBridgeManager()
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 startRuntimeServicesAfterListen(): void {
if (gatewayAutostartDisabled()) {
console.log('[bootstrap] profile gateway check disabled by HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
} else {
void (async () => {
try {
await ensureProfileGatewaysRunning()
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)
}
})()
}
void (async () => {
try {
agentBridgeManager = await startAgentBridgeManager()
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)
return
}
})()
}
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-web-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)
}
if (!isDesktopRuntime()) {
await startRuntimeServicesBeforeListen()
}
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')
registerDesktopShutdownRoute(app)
// 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()
petStateSocketServer = new PetStateSocketServer(groupChatServer.getIO())
petStateSocketServer.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')
if (isDesktopRuntime()) {
agentBridgeManager = getAgentBridgeManager()
startRuntimeServicesAfterListen()
}
// 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')
})
})
desktopShutdownHandler = bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
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)
})