mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-26 05:50:18 +00:00
feat: WSL support, js-yaml migration, and stability improvements
- PID/log files moved to ~/.hermes-web-ui/ for WSL compatibility - Replace all regex YAML parsing with js-yaml in filesystem.ts - Auto-detect WSL and use hermes gateway run for background startup - Stop command: SIGTERM with SIGKILL fallback, clean stale PIDs - Setup script: auto-install Node.js and hermes-web-ui Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+25
-9
@@ -3,10 +3,11 @@ import { spawn } from 'child_process'
|
||||
import { resolve, dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
|
||||
const PID_DIR = resolve(__dirname, '..', '.hermes-web-ui')
|
||||
const PID_DIR = resolve(homedir(), '.hermes-web-ui')
|
||||
const PID_FILE = join(PID_DIR, 'server.pid')
|
||||
const LOG_FILE = join(PID_DIR, 'server.log')
|
||||
const DEFAULT_PORT = 8648
|
||||
@@ -49,7 +50,7 @@ function startDaemon(port) {
|
||||
console.log(` Use "hermes-web-ui stop" to stop it first`)
|
||||
process.exit(1)
|
||||
}
|
||||
removePid() // stale pid file
|
||||
removePid()
|
||||
mkdirSync(PID_DIR, { recursive: true })
|
||||
|
||||
const logStream = openSync(LOG_FILE, 'a')
|
||||
@@ -59,10 +60,15 @@ function startDaemon(port) {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(` ✗ Failed to start: ${err.message}`)
|
||||
removePid()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
child.unref()
|
||||
writePid(child.pid)
|
||||
|
||||
// Wait a moment and check if the process is still alive
|
||||
setTimeout(() => {
|
||||
if (isRunning(child.pid)) {
|
||||
console.log(` ✓ hermes-web-ui started (PID: ${child.pid}, port: ${port})`)
|
||||
@@ -70,7 +76,6 @@ function startDaemon(port) {
|
||||
console.log(` Log: ${LOG_FILE}`)
|
||||
} else {
|
||||
console.log(' ✗ Failed to start hermes-web-ui')
|
||||
console.log(` Check log: ${LOG_FILE}`)
|
||||
removePid()
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -85,13 +90,24 @@ function stopDaemon() {
|
||||
}
|
||||
|
||||
if (!isRunning(pid)) {
|
||||
console.log(` ✗ Process ${pid} is not alive (stale PID file)`)
|
||||
removePid()
|
||||
process.exit(1)
|
||||
console.log(` ✓ hermes-web-ui was not running (cleaned stale PID)`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
// Wait briefly for graceful shutdown
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!isRunning(pid)) break
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500)
|
||||
}
|
||||
} catch {}
|
||||
// Force kill if still alive
|
||||
if (isRunning(pid)) {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
}
|
||||
removePid()
|
||||
console.log(` ✓ hermes-web-ui stopped (PID: ${pid})`)
|
||||
} catch (err) {
|
||||
@@ -104,8 +120,9 @@ function showStatus() {
|
||||
const pid = getPid()
|
||||
if (pid && isRunning(pid)) {
|
||||
console.log(` ✓ hermes-web-ui is running (PID: ${pid})`)
|
||||
console.log(` PID file: ${PID_FILE}`)
|
||||
} else {
|
||||
if (pid) removePid() // clean stale
|
||||
if (pid) removePid()
|
||||
console.log(' ✗ hermes-web-ui is not running')
|
||||
}
|
||||
}
|
||||
@@ -127,7 +144,6 @@ switch (command) {
|
||||
showStatus()
|
||||
break
|
||||
default:
|
||||
// Direct run (foreground): hermes-web-ui [port]
|
||||
const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT
|
||||
const child = spawn(process.execPath, [serverEntry], {
|
||||
stdio: 'inherit',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-web-ui",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.0",
|
||||
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
+46
-53
@@ -15,7 +15,7 @@ import { fsRoutes } from './routes/filesystem'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { weixinRoutes } from './routes/weixin'
|
||||
import * as hermesCli from './services/hermes-cli'
|
||||
const { restartGateway, startGateway, getVersion } = hermesCli
|
||||
const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli
|
||||
|
||||
export async function bootstrap() {
|
||||
await mkdir(config.uploadDir, { recursive: true })
|
||||
@@ -80,71 +80,43 @@ export async function bootstrap() {
|
||||
|
||||
async function ensureApiServerConfig() {
|
||||
const { homedir } = await import('os')
|
||||
const { readFileSync, writeFileSync, existsSync } = await import('fs')
|
||||
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
|
||||
const yaml = (await import('js-yaml')).default
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
const apiServerConfig = {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8642,
|
||||
key: '',
|
||||
cors_origins: '*',
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(configPath)) {
|
||||
console.log(' ✗ config.yaml not found, skipping')
|
||||
console.log(' ✗ config.yaml not found, run "hermes setup" first')
|
||||
return
|
||||
}
|
||||
|
||||
const content = readFileSync(configPath, 'utf-8')
|
||||
const config = yaml.load(content) as any || {}
|
||||
|
||||
// Case 1: api_server section exists, check if enabled is true
|
||||
if (/api_server:/.test(content)) {
|
||||
// Check specifically under api_server: look for a direct child `enabled: false`
|
||||
// Match api_server block and find enabled at the correct indent level
|
||||
const blockMatch = content.match(/api_server:\n((?:[ \t]+.*\n)*?)(?=\S|$)/)
|
||||
if (blockMatch) {
|
||||
const block = blockMatch[1]
|
||||
if (/^([ \t]*)enabled:\s*true/m.test(block)) {
|
||||
console.log(' ✓ api_server.enabled is true')
|
||||
return
|
||||
}
|
||||
if (/^([ \t]*)enabled:\s*false/m.test(block)) {
|
||||
// Backup before modifying
|
||||
const { copyFileSync } = await import('fs')
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
const updated = content.replace(
|
||||
/(api_server:\n(?:[ \t]*.*\n)*?[ \t]*)enabled:\s*false/,
|
||||
'$1enabled: true'
|
||||
)
|
||||
writeFileSync(configPath, updated, 'utf-8')
|
||||
console.log(' ✓ api_server.enabled changed to true (backup saved to config.yaml.bak)')
|
||||
await restartGateway()
|
||||
return
|
||||
}
|
||||
}
|
||||
// api_server exists but no enabled key — don't touch, assume default
|
||||
console.log(' ✓ api_server section exists')
|
||||
// Check if api_server is already correct
|
||||
if (config.platforms?.api_server?.enabled === true) {
|
||||
console.log(' ✓ api_server config is correct')
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: api_server section exists and enabled is true (or missing but default true)
|
||||
if (/api_server:/.test(content)) {
|
||||
console.log(' ✓ api_server section exists')
|
||||
return
|
||||
}
|
||||
|
||||
// Case 3: platforms section exists but no api_server — append api_server block
|
||||
if (/platforms:/.test(content)) {
|
||||
const { copyFileSync } = await import('fs')
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
const append = `\n api_server:\n enabled: true\n host: "127.0.0.1"\n port: 8642\n key: ""\n cors_origins: "*"\n`
|
||||
const updated = content.replace(/(platforms:)/, '$1' + append)
|
||||
writeFileSync(configPath, updated, 'utf-8')
|
||||
console.log(' ✓ api_server block appended to platforms (backup saved to config.yaml.bak)')
|
||||
await restartGateway()
|
||||
return
|
||||
}
|
||||
|
||||
// Case 4: No platforms section at all — append at end of file
|
||||
const { copyFileSync } = await import('fs')
|
||||
// Backup before modifying
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
const append = `\nplatforms:\n api_server:\n enabled: true\n host: "127.0.0.1"\n port: 8642\n key: ""\n cors_origins: "*"\n`
|
||||
writeFileSync(configPath, content + append, 'utf-8')
|
||||
console.log(' ✓ platforms.api_server block appended (backup saved to config.yaml.bak)')
|
||||
|
||||
// Ensure platforms.api_server with correct values
|
||||
if (!config.platforms) config.platforms = {}
|
||||
config.platforms.api_server = apiServerConfig
|
||||
|
||||
const updated = yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' })
|
||||
writeFileSync(configPath, updated, 'utf-8')
|
||||
console.log(' ✓ api_server config ensured (backup saved to config.yaml.bak)')
|
||||
await restartGateway()
|
||||
} catch (err: any) {
|
||||
console.error(' ✗ Failed to update config:', err.message)
|
||||
@@ -163,6 +135,27 @@ async function ensureGatewayRunning() {
|
||||
// Gateway not reachable
|
||||
}
|
||||
|
||||
// Detect WSL — no launchd/systemd, hermes gateway start won't work
|
||||
const { existsSync, readFileSync } = await import('fs')
|
||||
const isWSL = existsSync('/proc/version') && readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft')
|
||||
|
||||
if (isWSL) {
|
||||
console.log(' ⚠ WSL detected — Gateway not reachable, starting in background...')
|
||||
try {
|
||||
const pid = await startGatewayBackground()
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
if (res.ok) {
|
||||
console.log(` ✓ Gateway started in background (PID: ${pid})`)
|
||||
return
|
||||
}
|
||||
console.log(' ✗ Gateway start attempted but still not reachable')
|
||||
} catch (err: any) {
|
||||
console.error(' ✗ Failed to start gateway:', err.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.log(' ⚠ Gateway not reachable, starting...')
|
||||
try {
|
||||
await startGateway()
|
||||
|
||||
+96
-116
@@ -2,6 +2,7 @@ import Router from '@koa/router'
|
||||
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
// --- Auth / Credential Pool ---
|
||||
|
||||
@@ -28,6 +29,10 @@ async function loadAuthJson(): Promise<AuthJson | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAuthJson(auth: AuthJson): Promise<void> {
|
||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
||||
@@ -74,13 +79,7 @@ interface SkillCategory {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
// SKILL.md format: YAML frontmatter between --- delimiters, then markdown body
|
||||
// Extract first non-empty, non-frontmatter, non-heading line as description
|
||||
const lines = content.split('\n')
|
||||
let inFrontmatter = false
|
||||
let bodyStarted = false
|
||||
@@ -99,7 +98,6 @@ function extractDescription(content: string): string {
|
||||
if (inFrontmatter) continue
|
||||
if (line.trim() === '') continue
|
||||
if (line.startsWith('#')) continue
|
||||
// Return first meaningful line, truncated
|
||||
return line.trim().slice(0, 80)
|
||||
}
|
||||
return ''
|
||||
@@ -122,6 +120,26 @@ async function safeStat(filePath: string): Promise<{ mtime: number } | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config YAML helpers ---
|
||||
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
async function readConfigYaml(): Promise<Record<string, any>> {
|
||||
const raw = await safeReadFile(configPath)
|
||||
if (!raw) return {}
|
||||
return (YAML.load(raw) as Record<string, any>) || {}
|
||||
}
|
||||
|
||||
async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
const yamlStr = YAML.dump(config, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
})
|
||||
await writeFile(configPath, yamlStr, 'utf-8')
|
||||
}
|
||||
|
||||
// --- Skills Routes ---
|
||||
|
||||
// List all skills grouped by category
|
||||
@@ -158,7 +176,6 @@ fsRoutes.get('/api/skills', async (ctx) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort categories alphabetically
|
||||
categories.sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const cat of categories) {
|
||||
cat.skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -171,8 +188,7 @@ fsRoutes.get('/api/skills', async (ctx) => {
|
||||
}
|
||||
})
|
||||
|
||||
// List files in a skill directory (for references/templates/scripts)
|
||||
// Must be registered before the wildcard route
|
||||
// List files in a skill directory
|
||||
async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> {
|
||||
const result: { path: string; name: string }[] = []
|
||||
let entries
|
||||
@@ -211,7 +227,6 @@ fsRoutes.get('/api/skills/:path(.+)', async (ctx) => {
|
||||
const filePath = ctx.params.path
|
||||
const fullPath = resolve(join(hermesDir, 'skills', filePath))
|
||||
|
||||
// Security: ensure path stays within skills directory
|
||||
if (!fullPath.startsWith(join(hermesDir, 'skills'))) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Access denied' }
|
||||
@@ -230,7 +245,6 @@ fsRoutes.get('/api/skills/:path(.+)', async (ctx) => {
|
||||
|
||||
// --- Memory Routes ---
|
||||
|
||||
// Read MEMORY.md and USER.md
|
||||
fsRoutes.get('/api/memory', async (ctx) => {
|
||||
const memoryPath = join(hermesDir, 'memories', 'MEMORY.md')
|
||||
const userPath = join(hermesDir, 'memories', 'USER.md')
|
||||
@@ -250,7 +264,6 @@ fsRoutes.get('/api/memory', async (ctx) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Write MEMORY.md or USER.md
|
||||
fsRoutes.post('/api/memory', async (ctx) => {
|
||||
const { section, content } = ctx.request.body as { section: string; content: string }
|
||||
|
||||
@@ -280,8 +293,6 @@ fsRoutes.post('/api/memory', async (ctx) => {
|
||||
|
||||
// --- Config Model Routes ---
|
||||
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
label: string
|
||||
@@ -292,58 +303,42 @@ interface ModelGroup {
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
// Build model list from user's actual config.yaml configuration
|
||||
// Only shows models the user has explicitly configured, not entire provider catalogs
|
||||
function buildModelGroups(yaml: string): { default: string; groups: ModelGroup[] } {
|
||||
// Build model list from user's actual config.yaml using js-yaml
|
||||
function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
|
||||
let defaultModel = ''
|
||||
let defaultProvider = ''
|
||||
const groups: ModelGroup[] = []
|
||||
const allModelIds = new Set<string>()
|
||||
|
||||
// 1. Extract current model from `model:` section
|
||||
const defaultMatch = yaml.match(/^model:\s*\n\s+default:\s*(.+)/m)
|
||||
if (defaultMatch) defaultModel = defaultMatch[1].trim()
|
||||
const providerMatch = yaml.match(/^model:\s*\n(?:.*\n)*?\s+provider:\s*(.+)/m)
|
||||
if (providerMatch) defaultProvider = providerMatch[1].trim()
|
||||
|
||||
// 2. Extract providers: section (user-defined endpoints)
|
||||
const providersSection = yaml.match(/^providers:\s*\n((?: .+\n(?: .+\n)*)*)/m)
|
||||
if (providersSection) {
|
||||
const entries = providersSection[1].match(/^ (\S+):\s*\n((?: .+\n)*)/gm)
|
||||
if (entries) {
|
||||
for (const entry of entries) {
|
||||
const nameMatch = entry.match(/^ (\S+):/)
|
||||
const baseUrlMatch = entry.match(/base_url:\s*(.+)/)
|
||||
const name = nameMatch?.[1]?.trim()
|
||||
if (name) {
|
||||
// Provider entry itself — mark as available but don't add model yet
|
||||
// (it's an endpoint the user can switch to, models are fetched at runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1. Extract current model
|
||||
const modelSection = config.model
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
defaultModel = String(modelSection.default || '').trim()
|
||||
defaultProvider = String(modelSection.provider || '').trim()
|
||||
} else if (typeof modelSection === 'string') {
|
||||
defaultModel = modelSection.trim()
|
||||
}
|
||||
|
||||
// 3. Extract custom_providers: section
|
||||
const customSection = yaml.match(/^custom_providers:\s*\n((?:\s*- .+\n(?: .+\n)*)*)/m)
|
||||
if (customSection) {
|
||||
const entryBlocks = customSection[1].match(/\s*- name:\s*(.+)\n((?: .+\n)*)/g)
|
||||
if (entryBlocks) {
|
||||
const customModels: ModelInfo[] = []
|
||||
for (const block of entryBlocks) {
|
||||
const cName = block.match(/name:\s*(.+)/)?.[1]?.trim()
|
||||
const cModel = block.match(/model:\s*(.+)/)?.[1]?.trim()
|
||||
// 2. Extract custom_providers section
|
||||
const customProviders = config.custom_providers
|
||||
if (Array.isArray(customProviders)) {
|
||||
const customModels: ModelInfo[] = []
|
||||
for (const entry of customProviders) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
const cName = String(entry.name || '').trim()
|
||||
const cModel = String(entry.model || '').trim()
|
||||
if (cName && cModel) {
|
||||
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
|
||||
allModelIds.add(cModel)
|
||||
}
|
||||
}
|
||||
if (customModels.length > 0) {
|
||||
groups.push({ provider: 'Custom', models: customModels })
|
||||
}
|
||||
}
|
||||
if (customModels.length > 0) {
|
||||
groups.push({ provider: 'Custom', models: customModels })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add current default model (if not already in custom_providers)
|
||||
// 3. Add current default model (if not already in custom_providers)
|
||||
if (defaultModel && !allModelIds.has(defaultModel)) {
|
||||
groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] })
|
||||
}
|
||||
@@ -357,10 +352,14 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
||||
const auth = await loadAuthJson()
|
||||
const pool = auth?.credential_pool || {}
|
||||
|
||||
// Read current default model from config.yaml
|
||||
const yaml = await safeReadFile(configPath) || ''
|
||||
const defaultMatch = yaml.match(/^model:\s*\n\s+default:\s*(.+)/m)
|
||||
const currentDefault = defaultMatch?.[1]?.trim() || ''
|
||||
const config = await readConfigYaml()
|
||||
const modelSection = config.model
|
||||
let currentDefault = ''
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
currentDefault = String(modelSection.default || '').trim()
|
||||
} else if (typeof modelSection === 'string') {
|
||||
currentDefault = modelSection.trim()
|
||||
}
|
||||
|
||||
// Collect unique endpoints from credential pool
|
||||
const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = []
|
||||
@@ -394,7 +393,6 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Only probe endpoints not in the catalog
|
||||
if (liveEndpoints.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
liveEndpoints.map(async ep => {
|
||||
@@ -415,7 +413,7 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
||||
|
||||
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(yaml)
|
||||
const fallback = buildModelGroups(config)
|
||||
ctx.body = fallback
|
||||
return
|
||||
}
|
||||
@@ -430,8 +428,8 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
||||
// GET /api/config/models
|
||||
fsRoutes.get('/api/config/models', async (ctx) => {
|
||||
try {
|
||||
const yaml = await safeReadFile(configPath)
|
||||
ctx.body = yaml ? buildModelGroups(yaml) : { default: '', groups: [] }
|
||||
const config = await readConfigYaml()
|
||||
ctx.body = buildModelGroups(config)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
@@ -452,24 +450,18 @@ fsRoutes.put('/api/config/model', async (ctx) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
const config = await readConfigYaml()
|
||||
|
||||
// Rebuild the model: block
|
||||
const modelBlockMatch = yaml.match(/^(model:\s*\n(?: .+\n)*)/m)
|
||||
if (modelBlockMatch) {
|
||||
const lines = [`model:`, ` default: ${defaultModel}`]
|
||||
|
||||
if (reqProvider) {
|
||||
// Provider from credential pool key (e.g. "zai" or "custom:subrouter.ai")
|
||||
// Hermes resolves base_url/api_key from auth.json automatically
|
||||
lines.push(` provider: ${reqProvider}`)
|
||||
}
|
||||
|
||||
yaml = yaml.replace(modelBlockMatch[1], lines.join('\n') + '\n')
|
||||
if (typeof config.model !== 'object' || config.model === null) {
|
||||
config.model = {}
|
||||
}
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
config.model.default = defaultModel
|
||||
if (reqProvider) {
|
||||
config.model.provider = reqProvider
|
||||
}
|
||||
|
||||
await writeConfigYaml(config)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -501,26 +493,21 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
|
||||
try {
|
||||
// 1. Write to config.yaml custom_providers
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
const config = await readConfigYaml()
|
||||
|
||||
const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key}\n model: ${model}\n`
|
||||
|
||||
if (/^custom_providers:/m.test(yaml)) {
|
||||
yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`)
|
||||
} else {
|
||||
yaml = yaml.trimEnd() + `\n\ncustom_providers:\n${newEntry}\n`
|
||||
if (!Array.isArray(config.custom_providers)) {
|
||||
config.custom_providers = []
|
||||
}
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
config.custom_providers.push({ name, base_url, api_key, model })
|
||||
await writeConfigYaml(config)
|
||||
|
||||
// 2. Write to auth.json credential_pool so GET /api/available-models sees it immediately
|
||||
// 2. Write to auth.json credential_pool
|
||||
const poolKey = providerKey
|
||||
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
const auth = await loadAuthJson() || { credential_pool: {} }
|
||||
if (!auth.credential_pool) auth.credential_pool = {}
|
||||
|
||||
// Don't overwrite existing entries for built-in providers
|
||||
if (!auth.credential_pool[poolKey]) {
|
||||
auth.credential_pool[poolKey] = []
|
||||
}
|
||||
@@ -533,16 +520,16 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
last_status: null,
|
||||
})
|
||||
|
||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
await saveAuthJson(auth)
|
||||
|
||||
// 3. Auto-switch model to the newly added provider
|
||||
let yaml2 = await safeReadFile(configPath) || ''
|
||||
const modelBlockMatch = yaml2.match(/^(model:\s*\n(?: .+\n)*)/m)
|
||||
if (modelBlockMatch) {
|
||||
const lines = [`model:`, ` default: ${model}`, ` provider: ${poolKey}`]
|
||||
yaml2 = yaml2.replace(modelBlockMatch[1], lines.join('\n') + '\n')
|
||||
await writeFile(configPath, yaml2, 'utf-8')
|
||||
const config2 = await readConfigYaml()
|
||||
if (typeof config2.model !== 'object' || config2.model === null) {
|
||||
config2.model = {}
|
||||
}
|
||||
config2.model.default = model
|
||||
config2.model.provider = poolKey
|
||||
await writeConfigYaml(config2)
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
@@ -565,7 +552,6 @@ fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
|
||||
|
||||
const keys = Object.keys(auth.credential_pool)
|
||||
|
||||
// Guard: cannot delete the last provider
|
||||
if (keys.length <= 1) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Cannot delete the last provider' }
|
||||
@@ -579,28 +565,23 @@ fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
|
||||
}
|
||||
|
||||
// Check if this is the current active provider
|
||||
const yaml = await safeReadFile(configPath) || ''
|
||||
const providerMatch = yaml.match(/^ provider:\s*(.+)$/m)
|
||||
const isCurrent = providerMatch && providerMatch[1].trim() === poolKey
|
||||
const config = await readConfigYaml()
|
||||
const currentProvider = config.model?.provider
|
||||
const isCurrent = currentProvider === poolKey
|
||||
|
||||
// Save base_url before deleting (needed for config.yaml cleanup)
|
||||
// Save base_url before deleting
|
||||
const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url
|
||||
|
||||
// 1. Delete from auth.json
|
||||
delete auth.credential_pool[poolKey]
|
||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
await saveAuthJson(auth)
|
||||
|
||||
// 2. Remove matching entry from config.yaml custom_providers
|
||||
// Use base_url to match — more reliable than name (preset key ≠ display name)
|
||||
if (deletedBaseUrl) {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let newYaml = await safeReadFile(configPath) || ''
|
||||
const entryRegex = new RegExp(
|
||||
`^- name:.*\\n(?:[ \\t]+.*\\n)*? base_url:\\s*${escapeRegExp(deletedBaseUrl)}\\s*\\n(?:[ \\t]+.*\\n)*`,
|
||||
'gm',
|
||||
if (deletedBaseUrl && Array.isArray(config.custom_providers)) {
|
||||
config.custom_providers = (config.custom_providers as any[]).filter(
|
||||
(entry: any) => entry.base_url !== deletedBaseUrl,
|
||||
)
|
||||
newYaml = newYaml.replace(entryRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
|
||||
await writeFile(configPath, newYaml, 'utf-8')
|
||||
await writeConfigYaml(config)
|
||||
}
|
||||
|
||||
// 3. If was the current provider, switch to first remaining
|
||||
@@ -612,14 +593,13 @@ fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
|
||||
const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || []
|
||||
const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback
|
||||
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let newYaml = await safeReadFile(configPath) || ''
|
||||
const modelBlockMatch = newYaml.match(/^(model:\s*\n(?: .+\n)*)/m)
|
||||
if (modelBlockMatch) {
|
||||
const lines = [`model:`, ` default: ${fallbackModel}`, ` provider: ${fallback}`]
|
||||
newYaml = newYaml.replace(modelBlockMatch[1], lines.join('\n') + '\n')
|
||||
await writeFile(configPath, newYaml, 'utf-8')
|
||||
const config2 = await readConfigYaml()
|
||||
if (typeof config2.model !== 'object' || config2.model === null) {
|
||||
config2.model = {}
|
||||
}
|
||||
config2.model.default = fallbackModel
|
||||
config2.model.provider = fallback
|
||||
await writeConfigYaml(config2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,4 +608,4 @@ fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function getVersion(): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Hermes gateway
|
||||
* Start Hermes gateway (uses launchd/systemd)
|
||||
*/
|
||||
export async function startGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'start'], {
|
||||
@@ -191,6 +191,20 @@ export async function startGateway(): Promise<string> {
|
||||
return stdout || stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Hermes gateway in background (for WSL where launchd/systemd is unavailable)
|
||||
* Uses "hermes gateway run" as a detached background process
|
||||
*/
|
||||
export async function startGatewayBackground(): Promise<number | null> {
|
||||
const { spawn } = require('child_process') as typeof import('child_process')
|
||||
const child = spawn('hermes', ['gateway', 'run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
child.unref()
|
||||
return child.pid ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart Hermes gateway
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user