diff --git a/packages/server/src/services/hermes/gateway-autostart.ts b/packages/server/src/services/hermes/gateway-autostart.ts index efbe90f..58dc7ea 100644 --- a/packages/server/src/services/hermes/gateway-autostart.ts +++ b/packages/server/src/services/hermes/gateway-autostart.ts @@ -7,6 +7,7 @@ import { logger } from '../logger' import { safeFileStore } from '../safe-file-store' import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile' import { startGatewayRunManaged } from './gateway-runner' +import { parseGatewayStatusesFromProfileList } from './profile-list-parser' const execFileAsync = promisify(execFile) @@ -109,21 +110,8 @@ export function gatewayStateLooksRunningForProfile(profileDir: string): boolean return pid !== null && isProcessAlive(pid) } -export function parseGatewayStatusesFromProfileListOutput(stdout: string): Map { - const statuses = new Map() - const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - const lines = normalized.trim().split('\n').filter(Boolean) - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue - - const body = trimmed.startsWith('◆') ? trimmed.slice(1).trim() : trimmed - const columns = body.split(/\s{2,}/).map(part => part.trim()) - if (columns.length >= 3 && columns[0]) { - statuses.set(columns[0], columns[2]) - } - } - return statuses +export function parseGatewayStatusesFromProfileListOutput(stdout: string, profileNames = listProfileNamesFromDisk()): Map { + return parseGatewayStatusesFromProfileList(stdout, profileNames) } async function listGatewayStatusesFromProfileList(hermesBin: string): Promise> { diff --git a/packages/server/src/services/hermes/hermes-cli.ts b/packages/server/src/services/hermes/hermes-cli.ts index 6a55177..b98a309 100644 --- a/packages/server/src/services/hermes/hermes-cli.ts +++ b/packages/server/src/services/hermes/hermes-cli.ts @@ -2,11 +2,13 @@ import { execFile, spawn } from 'child_process' import { existsSync, readFileSync, unlinkSync } from 'fs' import { join } from 'path' import { promisify } from 'util' +import YAML from 'js-yaml' import { logger } from '../logger' import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers' -import { getActiveProfileDir, getProfileDir } from './hermes-profile' +import { getActiveProfileDir, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from './hermes-profile' import { startGatewayRunManaged } from './gateway-runner' import { isGatewayRunningForProfile } from './gateway-autostart' +import { parseProfileListRuntimeInfo, type ProfileListRuntimeInfo } from './profile-list-parser' const execFileAsync = promisify(execFile) @@ -582,45 +584,50 @@ export interface HermesProfileDetail { hasSoulMd: boolean } +function readProfileDefaultModel(name: string): string { + const configPath = join(getProfileDir(name), 'config.yaml') + if (!existsSync(configPath)) return '—' + try { + const config = YAML.load(readFileSync(configPath, 'utf-8'), { json: true }) as Record | null + const model = config?.model + if (typeof model === 'string') return model.trim() || '—' + if (model && typeof model === 'object') { + return String(model.default || '').trim() || '—' + } + } catch (err) { + logger.warn(err, 'Hermes CLI: failed to read profile config model for %s', name) + } + return '—' +} + /** * List all profiles */ export async function listProfiles(): Promise { + const profileNames = listProfileNamesFromDisk() + const activeProfileName = getActiveProfileName() + let runtimeInfo = new Map() try { const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], { timeout: 10000, ...execOpts, }) - - // Windows 可能使用 \r\n 换行符,统一处理 - const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - const lines = normalized.trim().split('\n').filter(Boolean) - const profiles: HermesProfile[] = [] - - // Skip header lines (starts with " Profile" or " ─") - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue - - const active = trimmed.startsWith('◆') - const body = active ? trimmed.slice(1).trim() : trimmed - const columns = body.split(/\s{2,}/).map(part => part.trim()) - if (columns.length >= 2) { - profiles.push({ - name: columns[0], - active, - model: columns[1] || '—', - gatewayStatus: columns[2] && columns[2] !== '—' ? columns[2] : undefined, - alias: columns[3] && columns[3] !== '—' ? columns[3] : '', - }) - } - } - - return profiles + runtimeInfo = parseProfileListRuntimeInfo(stdout, profileNames) } catch (err: any) { - logger.error(err, 'Hermes CLI: profile list failed') - throw new Error(`Failed to list profiles: ${err.message}`) + logger.warn(err, 'Hermes CLI: profile list failed; falling back to disk profile list') } + + return profileNames.map(name => { + const runtime = runtimeInfo.get(name) + const gatewayStatus = runtime?.gatewayStatus + return { + name, + active: runtime?.active ?? name === activeProfileName, + model: readProfileDefaultModel(name), + gatewayStatus: gatewayStatus && gatewayStatus !== '—' && gatewayStatus !== '-' ? gatewayStatus : undefined, + alias: runtime?.alias || '', + } + }) } /** diff --git a/packages/server/src/services/hermes/profile-list-parser.ts b/packages/server/src/services/hermes/profile-list-parser.ts new file mode 100644 index 0000000..5995271 --- /dev/null +++ b/packages/server/src/services/hermes/profile-list-parser.ts @@ -0,0 +1,81 @@ +export interface ProfileListRuntimeInfo { + active: boolean + gatewayStatus?: string + alias?: string +} + +const GATEWAY_STATUS_TOKENS = new Set([ + 'running', + 'stopped', + 'starting', + 'active', + 'stop', + '—', + '-', +]) + +function normalizeProfileLine(line: string): { active: boolean; body: string } | null { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('Profile') || trimmed.match(/^─/)) return null + const active = trimmed.startsWith('◆') + return { + active, + body: active ? trimmed.slice(1).trim() : trimmed, + } +} + +function matchProfileLine(body: string, profileNames: string[]): { profile: string; rest: string } | null { + for (const profile of profileNames) { + if (body === profile) return { profile, rest: '' } + if (body.startsWith(profile) && /\s/.test(body.charAt(profile.length))) { + return { profile, rest: body.slice(profile.length).trim() } + } + } + return null +} + +function extractGatewayInfo(rest: string): { gatewayStatus?: string; alias?: string } { + const parts = rest.split(/\s+/).filter(Boolean) + for (let i = 0; i < parts.length; i += 1) { + const token = parts[i] + if (GATEWAY_STATUS_TOKENS.has(token.toLowerCase())) { + const alias = parts[i + 1] + return { + gatewayStatus: token, + alias: alias && alias !== '—' && alias !== '-' ? alias : undefined, + } + } + } + return {} +} + +export function parseProfileListRuntimeInfo(stdout: string, profileNames: string[]): Map { + const result = new Map() + const sortedProfiles = [...new Set(profileNames.map(name => name.trim()).filter(Boolean))] + .sort((a, b) => b.length - a.length) + const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const lines = normalized.trim().split('\n').filter(Boolean) + + for (const line of lines) { + const parsed = normalizeProfileLine(line) + if (!parsed) continue + const matched = matchProfileLine(parsed.body, sortedProfiles) + if (!matched) continue + const gateway = extractGatewayInfo(matched.rest) + result.set(matched.profile, { + active: parsed.active, + ...gateway, + }) + } + + return result +} + +export function parseGatewayStatusesFromProfileList(stdout: string, profileNames: string[]): Map { + const runtimes = parseProfileListRuntimeInfo(stdout, profileNames) + const statuses = new Map() + for (const [profile, info] of runtimes) { + if (info.gatewayStatus) statuses.set(profile, info.gatewayStatus) + } + return statuses +} diff --git a/tests/server/gateway-autostart.test.ts b/tests/server/gateway-autostart.test.ts index 0382755..679522c 100644 --- a/tests/server/gateway-autostart.test.ts +++ b/tests/server/gateway-autostart.test.ts @@ -30,12 +30,24 @@ describe('gateway autostart status parsing', () => { akri glm-5-turbo running akri — tester gpt-5.5 stopped tester — ` - const statuses = parseGatewayStatusesFromProfileListOutput(output) + const statuses = parseGatewayStatusesFromProfileListOutput(output, ['default', 'akri', 'tester']) expect(statuses.get('default')).toBe('running') expect(statuses.get('akri')).toBe('running') expect(statuses.get('tester')).toBe('stopped') }) + it('parses gateway status when profile or model fills the table column', () => { + const output = ` + Profile Model Gateway Alias Distribution + ─────────────── ─────────────────────────── ─────────── ─────────── ──────────────────── + daily_assistant deepseek-v4-flash running — — + long_model provider/model-name-that-fills-column stopped — — +` + const statuses = parseGatewayStatusesFromProfileListOutput(output, ['daily_assistant', 'long_model']) + expect(statuses.get('daily_assistant')).toBe('running') + expect(statuses.get('long_model')).toBe('stopped') + }) + it('uses profile-list gateway status text for running checks', () => { expect(gatewayStatusLooksRunning('running')).toBe(true) expect(gatewayStatusLooksRunning('stopped')).toBe(false)