feat: profile-aware routes, provider sync, channel settings improvements

- Add hermes-profile.ts for dynamic profile path resolution (all backend
  routes now read from active profile directory instead of hardcoded ~/.hermes/)
- Add profile switcher dropdown in sidebar, reload page on switch
- Sync PROVIDER_PRESETS with Hermes CLI (fix keys: kimi-coding→kimi-for-coding,
  kilocode→kilo, ai-gateway→vercel, opencode-zen→opencode; remove moonshot)
- Sync PROVIDER_ENV_MAP with Hermes models.dev + overlays (correct env var names)
- Add gateway restart after adding model provider
- Don't write GLM_BASE_URL/KIMI_BASE_URL for zai/kimi (let Hermes auto-detect)
- Write API keys to .env and credential_pool for all providers
- Built-in providers skip custom_providers in config.yaml
- Add debounce + per-field loading state for channel settings inputs
- Run hermes setup --reset for profiles without config.yaml
- Create empty .env for new profiles (not copied from default)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-16 13:51:42 +08:00
parent 014168864f
commit 99a47cf1ad
23 changed files with 712 additions and 185 deletions
+75 -4
View File
@@ -5,6 +5,7 @@ import serve from 'koa-static'
import send from 'koa-send'
import { resolve } from 'path'
import { mkdir } from 'fs/promises'
import { readFileSync } from 'fs'
import { config } from './config'
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
import { uploadRoutes } from './routes/upload'
@@ -12,6 +13,43 @@ import { webhookRoutes } from './routes/webhook'
import * as hermesCli from './services/hermes-cli'
import { getToken, authMiddleware } from './services/auth'
function getLocalVersion(): string {
// production: dist/server → ../../package.json
// dev: packages/server/src → ../../../package.json
const candidates = [
resolve(__dirname, '../../package.json'),
resolve(__dirname, '../../../package.json'),
]
for (const p of candidates) {
try {
return JSON.parse(readFileSync(p, 'utf-8')).version
} catch { }
}
return '0.0.0'
}
const LOCAL_VERSION = getLocalVersion()
let cachedLatestVersion = ''
async function checkLatestVersion(): Promise<void> {
try {
const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', {
signal: AbortSignal.timeout(5000),
})
if (res.ok) {
const data = await res.json()
const latest = data.version || ''
if (latest && latest !== cachedLatestVersion) {
cachedLatestVersion = latest
if (latest !== LOCAL_VERSION) {
console.log(`⬆ New version available: v${LOCAL_VERSION} → v${latest}`)
}
}
}
} catch { }
}
const app = new Koa()
const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli
@@ -40,6 +78,32 @@ export async function bootstrap() {
app.use(webhookRoutes.routes())
app.use(uploadRoutes.routes())
// update (must be before hermesRoutes which includes proxy routes)
app.use(async (ctx, next) => {
if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') {
const isWin = process.platform === 'win32'
const cmd = isWin
? 'cmd /c hermes-web-ui update'
: 'hermes-web-ui update'
try {
const { execSync } = await import('child_process')
const output = execSync(cmd, {
encoding: 'utf-8',
timeout: 120000,
stdio: ['pipe', 'pipe', 'pipe'],
})
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { success: false, message: err.stderr || err.message }
}
return
}
await next()
})
app.use(hermesRoutes.routes())
app.use(proxyMiddleware)
@@ -47,7 +111,7 @@ export async function bootstrap() {
app.use(async (ctx, next) => {
if (ctx.path === '/health') {
const raw = await getVersion()
const version = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
let gatewayOk = false
try {
@@ -60,8 +124,11 @@ export async function bootstrap() {
ctx.body = {
status: gatewayOk ? 'ok' : 'error',
platform: 'hermes-agent',
version,
version: hermesVersion,
gateway: gatewayOk ? 'running' : 'stopped',
webui_version: LOCAL_VERSION,
webui_latest: cachedLatestVersion,
webui_update_available: cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION,
}
return
}
@@ -97,6 +164,10 @@ export async function bootstrap() {
// 👇 绑定退出信号
bindShutdown()
// Check for updates every 4 hours
checkLatestVersion()
setInterval(checkLatestVersion, 4 * 60 * 60 * 1000)
}
// ============================
@@ -159,10 +230,10 @@ function bindShutdown() {
// ============================
async function ensureApiServerConfig() {
const { homedir } = await import('os')
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
const yaml = (await import('js-yaml')).default
const configPath = resolve(homedir(), '.hermes/config.yaml')
const { getActiveConfigPath } = await import('./services/hermes-profile')
const configPath = getActiveConfigPath()
const defaults: Record<string, any> = {
enabled: true,