feat: add multi-gateway management with auto port detection

- Add GatewayManager for multi-profile gateway lifecycle management
- Auto-detect running gateways on startup via PID + health check
- Port conflict detection: check managed gateways, allocated ports, and
  system-level port availability (TCP bind test)
- Two-phase startup: sequential port resolution, parallel process launch
- Use `gateway start/restart` on normal systems, `gateway run --replace`
  on WSL/Docker
- Wait for health check before returning start/stop responses
- Add Gateways page with card-based layout showing profile status
- Reorganize sidebar navigation into collapsible groups
- Hide API server settings (now auto-managed by GatewayManager)
- Profile switch reloads page; Ctrl+C no longer stops gateways
- Remove redundant ensureApiServerConfig from index.ts and profiles.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-18 13:07:12 +08:00
parent 35481e452d
commit 4b6de351bd
15 changed files with 1170 additions and 467 deletions
@@ -0,0 +1,29 @@
import { request } from '../client'
export interface GatewayStatus {
profile: string
port: number
host: string
url: string
running: boolean
pid?: number
}
export async function fetchGateways(): Promise<GatewayStatus[]> {
const res = await request<{ gateways: GatewayStatus[] }>('/api/hermes/gateways')
return res.gateways
}
export async function startGateway(name: string): Promise<GatewayStatus> {
const res = await request<{ success: boolean; gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/start`, { method: 'POST' })
return res.gateway
}
export async function stopGateway(name: string): Promise<void> {
await request(`/api/hermes/gateways/${name}/stop`, { method: 'POST' })
}
export async function checkGatewayHealth(name: string): Promise<GatewayStatus> {
const res = await request<{ gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/health`)
return res.gateway
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { NButton, useMessage } from "naive-ui";
@@ -21,6 +21,16 @@ const router = useRouter();
const appStore = useAppStore();
const selectedKey = computed(() => route.name as string);
const collapsedGroups = reactive<Record<string, boolean>>({});
function toggleGroup(key: string) {
collapsedGroups[key] = !collapsedGroups[key];
}
function isGroupCollapsed(key: string) {
return !!collapsedGroups[key];
}
function handleNav(key: string) {
router.push({ name: key });
}
@@ -44,255 +54,154 @@ async function handleUpdate() {
</div>
<nav class="sidebar-nav">
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.chat' }"
@click="handleNav('hermes.chat')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
<!-- Chat (standalone) -->
<button class="nav-item" :class="{ active: selectedKey === 'hermes.chat' }" @click="handleNav('hermes.chat')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{{ t("sidebar.chat") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.jobs' }"
@click="handleNav('hermes.jobs')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<!-- Agent -->
<div class="nav-group">
<div class="nav-group-label" @click="toggleGroup('agent')">
<span>{{ t("sidebar.groupAgent") }}</span>
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('agent') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<div v-show="!isGroupCollapsed('agent')">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.jobs' }" @click="handleNav('hermes.jobs')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.channels' }" @click="handleNav('hermes.channels')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t("sidebar.channels") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.skills' }" @click="handleNav('hermes.skills')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t("sidebar.skills") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.memory' }" @click="handleNav('hermes.memory')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>{{ t("sidebar.memory") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.models' }" @click="handleNav('hermes.models')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>{{ t("sidebar.models") }}</span>
</button>
</div>
</div>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.models' }"
@click="handleNav('hermes.models')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>{{ t("sidebar.models") }}</span>
</button>
<!-- Monitoring -->
<div class="nav-group">
<div class="nav-group-label" @click="toggleGroup('monitoring')">
<span>{{ t("sidebar.groupMonitoring") }}</span>
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('monitoring') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<div v-show="!isGroupCollapsed('monitoring')">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.logs' }" @click="handleNav('hermes.logs')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>{{ t("sidebar.logs") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.usage' }" @click="handleNav('hermes.usage')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
</div>
</div>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.channels' }"
@click="handleNav('hermes.channels')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t("sidebar.channels") }}</span>
</button>
<!-- Tools -->
<div class="nav-group">
<div class="nav-group-label" @click="toggleGroup('tools')">
<span>{{ t("sidebar.groupTools") }}</span>
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('tools') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<div v-show="!isGroupCollapsed('tools')">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.terminal' }" @click="handleNav('hermes.terminal')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span>{{ t("sidebar.terminal") }}</span>
</button>
</div>
</div>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.skills' }"
@click="handleNav('hermes.skills')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t("sidebar.skills") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.memory' }"
@click="handleNav('hermes.memory')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>{{ t("sidebar.memory") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.logs' }"
@click="handleNav('hermes.logs')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>{{ t("sidebar.logs") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.usage' }"
@click="handleNav('hermes.usage')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.profiles' }"
@click="handleNav('hermes.profiles')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{{ t("sidebar.profiles") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.terminal' }"
@click="handleNav('hermes.terminal')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span>{{ t("sidebar.terminal") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.settings' }"
@click="handleNav('hermes.settings')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span>{{ t("sidebar.settings") }}</span>
</button>
<!-- System -->
<div class="nav-group">
<div class="nav-group-label" @click="toggleGroup('system')">
<span>{{ t("sidebar.groupSystem") }}</span>
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('system') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<div v-show="!isGroupCollapsed('system')">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.gateways' }" @click="handleNav('hermes.gateways')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
<span>{{ t("sidebar.gateways") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{{ t("sidebar.profiles") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.settings' }" @click="handleNav('hermes.settings')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>{{ t("sidebar.settings") }}</span>
</button>
</div>
</div>
</nav>
<ProfileSelector />
@@ -394,7 +303,7 @@ async function handleUpdate() {
display: flex;
padding-top: 12px;
flex-direction: column;
gap: 4px;
gap: 6px;
overflow-y: auto;
min-height: 0;
scrollbar-width: none;
@@ -404,6 +313,51 @@ async function handleUpdate() {
}
}
.nav-group {
display: flex;
flex-direction: column;
gap: 2px;
&.nav-group-bottom {
margin-top: auto;
padding-top: 8px;
border-top: 1px solid $border-color;
}
}
.nav-group-label {
font-size: 10px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.8px;
padding: 8px 12px 4px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
border-radius: $radius-sm;
transition: color $transition-fast;
&:hover {
color: $text-secondary;
}
.nav-group:first-child & {
padding-top: 0;
}
}
.nav-group-arrow {
transition: transform $transition-fast;
flex-shrink: 0;
&.collapsed {
transform: rotate(-90deg);
}
}
.nav-item {
display: flex;
align-items: center;
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { NSelect } from 'naive-ui'
import { NSelect, useMessage } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const message = useMessage()
const profilesStore = useProfilesStore()
const options = computed(() =>
@@ -20,6 +21,7 @@ function handleChange(value: string | number | Array<string | number>) {
if (typeof value === 'string' && value !== activeName.value) {
profilesStore.switchProfile(value).then(ok => {
if (ok) {
message.success(t('profiles.switchSuccess', { name: value }))
window.location.reload()
}
})
+19
View File
@@ -35,6 +35,8 @@ export default {
confirm: 'Confirm',
expand: 'Expand',
collapse: 'Collapse',
start: 'Start',
stop: 'Stop',
},
// Sidebar
@@ -48,7 +50,14 @@ export default {
logs: 'Logs',
usage: 'Usage',
channels: 'Channels',
gateways: 'Gateways',
terminal: 'Terminal',
groupConversation: 'Conversation',
groupPlatform: 'Platform',
groupAgent: 'Agent',
groupSystem: 'System',
groupMonitoring: 'Monitoring',
groupTools: 'Tools',
settings: 'Settings',
connected: 'Connected',
disconnected: 'Disconnected',
@@ -65,6 +74,8 @@ export default {
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
attachFiles: 'Attach files',
stop: 'Stop',
start: 'Start',
stopGateway: 'Stop Gateway',
send: 'Send',
contextUsed: 'Context used:',
sessions: 'Sessions',
@@ -218,6 +229,14 @@ export default {
},
// Profiles
gateways: {
title: 'Gateways',
running: 'Running',
stopped: 'Stopped',
started: 'Started',
startFailed: 'Failed to start gateway',
stopFailed: 'Failed to stop gateway',
},
profiles: {
title: 'Profiles',
create: 'Create Profile',
+21
View File
@@ -35,6 +35,8 @@ export default {
confirm: '确定',
expand: '展开',
collapse: '收起',
start: '启动',
stop: '停止',
},
// 侧边栏
@@ -48,7 +50,14 @@ export default {
logs: '日志',
usage: '用量',
channels: '频道',
gateways: '网关',
terminal: '终端',
groupConversation: '对话',
groupPlatform: '平台',
groupAgent: '代理',
groupSystem: '系统',
groupMonitoring: '监控',
groupTools: '工具',
settings: '设置',
connected: '已连接',
disconnected: '未连接',
@@ -65,6 +74,8 @@ export default {
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
attachFiles: '添加附件',
stop: '停止',
start: '启动',
stopGateway: '停止网关',
send: '发送',
contextUsed: '上下文已用:',
sessions: '会话',
@@ -417,6 +428,16 @@ export default {
qrScanedHint: '已扫描,请在手机上确认...',
},
// 网关
gateways: {
title: '网关',
running: '运行中',
stopped: '已停止',
started: '已启动',
startFailed: '启动失败',
stopFailed: '停止失败',
},
// 语言
language: {
label: '语言',
+5
View File
@@ -55,6 +55,11 @@ const router = createRouter({
name: 'hermes.settings',
component: () => import('@/views/hermes/SettingsView.vue'),
},
{
path: '/hermes/gateways',
name: 'hermes.gateways',
component: () => import('@/views/hermes/GatewaysView.vue'),
},
{
path: '/hermes/channels',
name: 'hermes.channels',
@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchGateways, startGateway, stopGateway, type GatewayStatus } from '@/api/hermes/gateways'
export const useGatewayStore = defineStore('gateways', () => {
const gateways = ref<GatewayStatus[]>([])
const loading = ref(false)
async function fetchStatus() {
loading.value = true
try {
const data = await fetchGateways()
gateways.value = Array.isArray(data) ? data : Object.values(data || {})
} finally {
loading.value = false
}
}
async function start(name: string) {
loading.value = true
try {
const status = await startGateway(name)
// Update the specific gateway in the list
const idx = gateways.value.findIndex(g => g.profile === name)
if (idx >= 0) {
gateways.value[idx] = status
} else {
gateways.value.push(status)
}
} finally {
loading.value = false
}
}
async function stop(name: string) {
loading.value = true
try {
await stopGateway(name)
// Update the specific gateway in the list
const gw = gateways.value.find(g => g.profile === name)
if (gw) {
gw.running = false
gw.pid = undefined
}
} finally {
loading.value = false
}
}
return { gateways, loading, fetchStatus, start, stop }
})
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { NSpin, NButton, NTag, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGatewayStore } from '@/stores/hermes/gateways'
const { t } = useI18n()
const message = useMessage()
const gatewayStore = useGatewayStore()
onMounted(() => {
gatewayStore.fetchStatus()
})
async function handleToggle(name: string, running: boolean) {
try {
if (running) {
await gatewayStore.stop(name)
message.success(`${t('gateways.stopped')}: ${name}`)
} else {
await gatewayStore.start(name)
message.success(`${t('gateways.started')}: ${name}`)
}
} catch (err: any) {
message.error(err.message)
}
}
</script>
<template>
<div class="gateways-view">
<header class="page-header">
<h2 class="header-title">{{ t('gateways.title') }}</h2>
</header>
<div class="gateways-content">
<NSpin :show="gatewayStore.loading" size="large">
<div v-if="gatewayStore.gateways.length === 0" class="empty-state">
{{ t('common.noData') }}
</div>
<div v-else class="gateway-list">
<div v-for="gw in gatewayStore.gateways" :key="gw.profile" class="gateway-card">
<div class="gateway-info">
<div class="gateway-name">{{ gw.profile }}</div>
<div class="gateway-meta">
<span class="meta-item">{{ gw.host }}:{{ gw.port }}</span>
<span v-if="gw.pid" class="meta-item">PID: {{ gw.pid }}</span>
</div>
</div>
<div class="gateway-actions">
<NTag :type="gw.running ? 'success' : 'default'" size="small" round>
{{ gw.running ? t('gateways.running') : t('gateways.stopped') }}
</NTag>
<NButton
size="small"
:type="gw.running ? 'warning' : 'default'"
:color="gw.running ? undefined : '#18181b'"
round
@click="handleToggle(gw.profile, gw.running)"
>
{{ gw.running ? t('common.stop') : t('common.start') }}
</NButton>
</div>
</div>
</div>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.gateways-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.gateways-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.empty-state {
text-align: center;
color: $text-muted;
padding: 40px 0;
}
.gateway-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.gateway-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
transition: border-color $transition-fast;
&:hover {
border-color: $text-muted;
}
}
.gateway-name {
font-size: 14px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
}
.gateway-meta {
display: flex;
gap: 12px;
}
.meta-item {
font-size: 12px;
color: $text-muted;
}
.gateway-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -63,72 +63,6 @@ async function saveApiServer(values: Record<string, any>) {
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
<PrivacySettings />
</NTabPane>
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
<section class="settings-section">
<SettingRow
:label="t('settings.apiServer.enable')"
:hint="t('settings.apiServer.enableHint')"
>
<NSwitch
:value="settingsStore.platforms?.api_server?.enabled"
@update:value="(v) => saveApiServer({ enabled: v })"
/>
</SettingRow>
<SettingRow
:label="t('settings.apiServer.host')"
:hint="t('settings.apiServer.hostHint')"
>
<NInput
:default-value="settingsStore.platforms?.api_server?.host || ''"
size="small"
class="input-md"
@change="(v: string) => saveApiServer({ host: v })"
/>
</SettingRow>
<SettingRow
:label="t('settings.apiServer.port')"
:hint="t('settings.apiServer.portHint')"
>
<NInputNumber
:default-value="settingsStore.platforms?.api_server?.port"
:min="1024"
:max="65535"
size="small"
class="input-sm"
@blur="(e: FocusEvent) => {
const val = (e.target as HTMLInputElement).value
if (val) saveApiServer({ port: Number(val) })
}"
/>
</SettingRow>
<SettingRow
:label="t('settings.apiServer.key')"
:hint="t('settings.apiServer.keyHint')"
>
<NInput
:default-value="settingsStore.platforms?.api_server?.key || ''"
type="password"
show-password-on="click"
size="small"
class="input-md"
@change="(v: string) => saveApiServer({ key: v })"
/>
</SettingRow>
<SettingRow
:label="t('settings.apiServer.cors')"
:hint="t('settings.apiServer.corsHint')"
>
<NInput
:default-value="
settingsStore.platforms?.api_server?.cors_origins || ''
"
size="small"
class="input-md"
@change="(v: string) => saveApiServer({ cors_origins: v })"
/>
</SettingRow>
</section>
</NTabPane>
</NTabs>
</NSpin>
</div>
+17 -77
View File
@@ -58,6 +58,7 @@ let isShuttingDown = false
// 👉 如果你有子进程,一定要存
let gatewayPid: number | null = null
let gatewayManager: any = null
export async function bootstrap() {
await mkdir(config.uploadDir, { recursive: true })
@@ -70,8 +71,7 @@ export async function bootstrap() {
console.log(`🔐 Auth enabled — token: ${authToken}`)
}
await ensureApiServerConfig()
await ensureGatewayRunning()
await initGatewayManager()
app.use(cors({ origin: config.corsOrigins }))
app.use(bodyParser())
@@ -115,7 +115,8 @@ export async function bootstrap() {
let gatewayOk = false
try {
const res = await fetch(`${config.upstream.replace(/\/$/, '')}/health`, {
const upstream = gatewayManager?.getUpstream() || config.upstream
const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, {
signal: AbortSignal.timeout(5000),
})
gatewayOk = res.ok
@@ -191,13 +192,7 @@ function bindShutdown() {
})
}
// ✅ 2. 关闭子进程(如果有)
if (gatewayPid) {
try {
process.kill(gatewayPid)
console.log(`✓ gateway process killed: ${gatewayPid}`)
} catch { }
}
// gateway 是系统服务,不随 dev server 退出而停止
} catch (err) {
console.error('shutdown error:', err)
@@ -226,78 +221,23 @@ function bindShutdown() {
}
// ============================
// 你的原逻辑(基本不动)
// Gateway Manager
// ============================
async function ensureApiServerConfig() {
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
const yaml = (await import('js-yaml')).default
const { getActiveConfigPath } = await import('./services/hermes/hermes-profile')
const configPath = getActiveConfigPath()
async function initGatewayManager() {
const { GatewayManager } = await import('./services/hermes/gateway-manager')
const { getActiveProfileName } = await import('./services/hermes/hermes-profile')
const { setGatewayManager } = await import('./routes/hermes/gateways')
const defaults: Record<string, any> = {
enabled: true,
host: '127.0.0.1',
port: 8642,
key: '',
cors_origins: '*',
}
const activeProfile = getActiveProfileName()
gatewayManager = new GatewayManager(activeProfile)
setGatewayManager(gatewayManager)
try {
if (!existsSync(configPath)) {
console.log('✗ config.yaml not found')
return
}
// Detect all running gateways
await gatewayManager.detectAllOnStartup()
const content = readFileSync(configPath, 'utf-8')
const cfg = yaml.load(content) as any || {}
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
cfg.platforms.api_server = defaults
copyFileSync(configPath, configPath + '.bak')
writeFileSync(configPath, yaml.dump(cfg), 'utf-8')
await restartGateway()
} catch (err: any) {
console.error('config error:', err.message)
}
}
async function ensureGatewayRunning() {
const upstream = config.upstream.replace(/\/$/, '')
const waitForGatewayReady = async (timeoutMs: number = 15000) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(2000) })
if (res.ok) return true
} catch { }
await new Promise(r => setTimeout(r, 300))
}
return false
}
try {
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
if (res.ok) return
} catch { }
console.log('⚠ Gateway not running, starting...')
try {
// 👉 关键:保存 PID
gatewayPid = await startGatewayBackground()
if (await waitForGatewayReady()) {
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
} else {
console.error('gateway start failed: timed out waiting for health')
}
} catch (err: any) {
console.error('gateway start failed:', err.message)
}
// Start all gateways that aren't running
await gatewayManager.startAll()
}
bootstrap()
@@ -0,0 +1,71 @@
import Router from '@koa/router'
export const gatewayRoutes = new Router()
// Get singleton instance — set during bootstrap
let manager: any = null
export function setGatewayManager(mgr: any) {
manager = mgr
}
export function getGatewayManager(): any {
return manager
}
// List all gateway statuses
gatewayRoutes.get('/api/hermes/gateways', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const gateways = await manager.listAll()
ctx.body = { gateways }
})
// Start a profile's gateway
gatewayRoutes.post('/api/hermes/gateways/:name/start', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const { name } = ctx.params
try {
const status = await manager.start(name)
ctx.body = { success: true, gateway: status }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// Stop a profile's gateway
gatewayRoutes.post('/api/hermes/gateways/:name/stop', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const { name } = ctx.params
try {
await manager.stop(name)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// Check a profile's gateway health
gatewayRoutes.get('/api/hermes/gateways/:name/health', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const { name } = ctx.params
const status = await manager.detectStatus(name)
ctx.body = { gateway: status }
})
@@ -6,6 +6,7 @@ import { fsRoutes } from './filesystem'
import { logRoutes } from './logs'
import { weixinRoutes } from './weixin'
import { codexAuthRoutes } from './codex-auth'
import { gatewayRoutes } from './gateways'
import { proxyRoutes, proxyMiddleware } from './proxy'
import { setupTerminalWebSocket } from './terminal'
@@ -18,6 +19,7 @@ hermesRoutes.use(fsRoutes.routes())
hermesRoutes.use(logRoutes.routes())
hermesRoutes.use(weixinRoutes.routes())
hermesRoutes.use(codexAuthRoutes.routes())
hermesRoutes.use(gatewayRoutes.routes())
hermesRoutes.use(proxyRoutes.routes())
export { setupTerminalWebSocket, proxyMiddleware }
+17 -78
View File
@@ -5,36 +5,7 @@ import { basename, join } from 'path'
import { tmpdir, homedir } from 'os'
import YAML from 'js-yaml'
import * as hermesCli from '../../services/hermes/hermes-cli'
const apiServerDefaults = {
enabled: true,
host: '127.0.0.1',
port: 8642,
key: '',
cors_origins: '*',
}
function ensureApiServerConfig(profilePath: string) {
const configPath = join(profilePath, 'config.yaml')
try {
if (!existsSync(configPath)) {
// Profile has no config.yaml — run hermes setup --reset to generate full defaults,
// then inject api_server config (setup itself doesn't add it)
console.log(`[Profile] No config.yaml for ${profilePath}, running setup --reset`)
return { needSetup: true, path: profilePath }
}
const content = readFileSync(configPath, 'utf-8')
const cfg = YAML.load(content) as any || {}
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) {
cfg.platforms.api_server = { ...apiServerDefaults }
writeFileSync(configPath, YAML.dump(cfg), 'utf-8')
console.log(`[Profile] Ensured api_server config for: ${profilePath}`)
}
return { needSetup: false, path: profilePath }
} catch { }
return { needSetup: false, path: profilePath }
}
import { getGatewayManager } from './gateways'
export const profileRoutes = new Router()
@@ -92,6 +63,12 @@ profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => {
}
try {
// Stop gateway for this profile before deleting
const mgr = getGatewayManager()
if (mgr) {
try { await mgr.stop(name) } catch { }
}
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
@@ -141,49 +118,26 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
}
try {
// 1. Stop gateway
try { await hermesCli.stopGateway() } catch { }
// 2. Kill gateway by port if still running
try {
const { execSync } = await import('child_process')
const isWin = process.platform === 'win32'
let pids = ''
if (isWin) {
const out = execSync('netstat -aon | findstr :8642', { encoding: 'utf-8', timeout: 5000 }).trim()
const lines = out.split('\n').filter(l => l.includes('LISTENING'))
pids = Array.from(new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))).join(' ')
} else {
pids = execSync('lsof -ti:8642', { encoding: 'utf-8', timeout: 5000 }).trim()
}
if (pids) {
if (isWin) {
execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { timeout: 5000 })
} else {
execSync(`kill -9 ${pids}`, { timeout: 5000 })
}
await new Promise(r => setTimeout(r, 2000))
}
} catch { }
// 3. Switch profile
// 1. Switch profile only (no gateway stop/restart)
const output = await hermesCli.useProfile(name)
await new Promise(r => setTimeout(r, 1000))
// 4. Ensure api_server config for new profile
// 2. Update GatewayManager active profile
const mgr = getGatewayManager()
if (mgr) {
mgr.setActiveProfile(name)
}
// 3. Ensure api_server config for new profile
try {
const detail = await hermesCli.getProfile(name)
console.log(`[Profile] detail.path = ${detail.path}`)
const result = ensureApiServerConfig(detail.path)
if (result?.needSetup) {
// No config.yaml — run setup --reset to create full default config,
// then ensure api_server is present
if (!existsSync(join(detail.path, 'config.yaml'))) {
// No config.yaml — run setup --reset to create full default config
try { await hermesCli.setupReset() } catch { }
ensureApiServerConfig(detail.path)
}
// Create .env if target has none
const profileEnv = join(detail.path, '.env')
console.log(`[Profile] .env exists: ${existsSync(profileEnv)}, path: ${profileEnv}`)
if (!existsSync(profileEnv)) {
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
console.log(`[Profile] Created .env for: ${detail.path}`)
@@ -192,21 +146,6 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
console.error(`[Profile] Ensure config failed:`, err.message)
}
// 5. Start gateway
try {
await hermesCli.startGateway()
console.log('[Profile] Gateway started')
} catch {
// Fallback: background mode (for WSL etc.)
try {
const pid = await hermesCli.startGatewayBackground()
await new Promise(r => setTimeout(r, 3000))
console.log(`[Profile] Gateway started in background mode (PID: ${pid})`)
} catch (err: any) {
console.error('[Profile] Gateway start failed:', err.message)
}
}
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
@@ -1,5 +1,6 @@
import type { Context } from 'koa'
import { config } from '../../config'
import { getGatewayManager } from './gateways'
function isTransientGatewayError(err: any): boolean {
const msg = String(err?.message || '')
@@ -27,8 +28,24 @@ async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000):
return false
}
/** Resolve upstream URL for a request based on profile header/query */
function resolveUpstream(ctx: Context): string {
const mgr = getGatewayManager()
if (mgr) {
// Check X-Hermes-Profile header or ?profile= query param
const profile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
if (profile) {
return mgr.getUpstream(profile)
}
// Default to active profile's upstream
return mgr.getUpstream()
}
// Fallback: static upstream from config
return config.upstream.replace(/\/$/, '')
}
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
const upstream = resolveUpstream(ctx)
// Rewrite path for upstream gateway:
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)
@@ -0,0 +1,583 @@
/**
* GatewayManager Profile
*
*
* 1. profile PID
* 2.
* 3. /
*
* detectStatus
* gateway.pid PID
* config.yaml (platforms.api_server.extra.port/host)
* PID
* - stopped
* -
* health check
* -
* - lsof PID
*
* - config.yaml health check
* - stopped
*
* resolvePort
*
*
* TCP bind
* base+1 config.yaml
*
*
* - macOS/Linuxhermes gateway start/stop
* - WSL / Dockerhermes gateway rundetached kill
*/
import { spawn, type ChildProcess } from 'child_process'
import { resolve, join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { createServer } from 'net'
const execFileAsync = promisify(execFile)
// ============================
// 常量 & 环境检测
// ============================
const HERMES_BASE = resolve(homedir(), '.hermes')
const HERMES_BIN = process.env.HERMES_BIN?.trim() || 'hermes'
// WSL / Docker 没有 systemd 或 launchd,需要用 "gateway run" 代替 "gateway start"
const isWsl = existsSync('/proc/version') && require('fs').readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft')
const isDocker = existsSync('/.dockerenv')
const needsRunMode = isWsl || isDocker
// ============================
// 类型定义
// ============================
export interface GatewayStatus {
profile: string
port: number
host: string
url: string
running: boolean
pid?: number
}
interface ManagedGateway {
pid: number
port: number
host: string
url: string
process?: ChildProcess
}
// ============================
// GatewayManager
// ============================
export class GatewayManager {
/** 已注册的网关:profile name → { pid, port, host, url } */
private gateways = new Map<string, ManagedGateway>()
/** 本次启动过程中已分配的端口集合(防止并发分配到相同端口) */
private allocatedPorts = new Set<number>()
/** 当前活跃的 profile(用于代理路由的默认上游) */
private activeProfile: string
constructor(activeProfile: string) {
this.activeProfile = activeProfile
}
// ============================
// Profile 目录 & 配置读取
// ============================
/** 获取 profile 的 home 目录路径 */
private profileDir(name: string): string {
if (name === 'default') return HERMES_BASE
return join(HERMES_BASE, 'profiles', name)
}
/**
* profile config.yaml api_server
* platforms.api_server.extra.port / extra.host
*/
private readProfilePort(name: string): { port: number; host: string } {
const configPath = join(this.profileDir(name), 'config.yaml')
if (!existsSync(configPath)) return { port: 8642, host: '127.0.0.1' }
try {
const yaml = require('js-yaml')
const content = readFileSync(configPath, 'utf-8')
const cfg = yaml.load(content) as any || {}
const extra = cfg?.platforms?.api_server?.extra
const rawPort = extra?.port || 8642
const port = typeof rawPort === 'number' ? rawPort : parseInt(rawPort, 10) || 8642
const host = extra?.host || '127.0.0.1'
// 端口超出合法范围时回退到默认值
return { port: port > 0 && port <= 65535 ? port : 8642, host }
} catch {
return { port: 8642, host: '127.0.0.1' }
}
}
/** 从 profile 的 gateway.pid 文件读取 PIDJSON 格式 { "pid": 12345 } */
private readPidFile(name: string): number | null {
const pidPath = join(this.profileDir(name), 'gateway.pid')
if (!existsSync(pidPath)) return null
try {
const content = readFileSync(pidPath, 'utf-8').trim()
const data = JSON.parse(content)
return typeof data.pid === 'number' ? data.pid : parseInt(data.pid, 10) || null
} catch {
return null
}
}
// ============================
// 进程 & 端口检测工具
// ============================
/** 检查进程是否存活(发送信号 0,不实际杀死进程) */
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/** 请求 /health 端点,判断网关是否真正就绪 */
private async checkHealth(url: string, timeoutMs = 3000): Promise<boolean> {
try {
const res = await fetch(`${url.replace(/\/$/, '')}/health`, {
signal: AbortSignal.timeout(timeoutMs),
})
return res.ok
} catch {
return false
}
}
/** 尝试绑定端口,检测端口是否被系统级进程占用 */
private checkPortAvailable(port: number, host: string): Promise<boolean> {
if (port < 0 || port > 65535) return Promise.resolve(false)
return new Promise((resolve) => {
const server = createServer()
server.once('error', () => {
server.close()
resolve(false)
})
server.once('listening', () => {
server.close()
resolve(true)
})
server.listen(port, host)
})
}
/** 从 base 端口开始递增查找空闲端口(上限 65535) */
private findFreePort(base: number, host = '127.0.0.1'): Promise<number> {
return new Promise((resolve, reject) => {
const tryPort = (port: number) => {
if (port > 65535) {
reject(new Error(`No free port found in range ${base}-65535`))
return
}
const server = createServer()
server.once('error', () => {
server.close()
tryPort(port + 1)
})
server.once('listening', () => {
server.close()
resolve(port)
})
server.listen(port, host)
}
tryPort(base)
})
}
// ============================
// 配置写入
// ============================
/**
* profile config.yaml
*
* platforms:
* api_server:
* enabled: true
* key: ''
* cors_origins: '*'
* extra:
* port: <port>
* host: <host>
* port/host Hermes
*/
private writeProfilePort(name: string, port: number, host: string): void {
const configPath = join(this.profileDir(name), 'config.yaml')
try {
const yaml = require('js-yaml')
const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
const cfg = (yaml.load(content) as any) || {}
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {}
cfg.platforms.api_server.enabled = true
cfg.platforms.api_server.key = ''
cfg.platforms.api_server.cors_origins = '*'
cfg.platforms.api_server.extra.port = port
cfg.platforms.api_server.extra.host = host
// 清理旧的顶层 port/hostHermes 只从 extra 读取
if (cfg.platforms.api_server.port !== undefined) {
delete cfg.platforms.api_server.port
}
if (cfg.platforms.api_server.host !== undefined) {
delete cfg.platforms.api_server.host
}
writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8')
console.log(`[GatewayManager] Updated ${configPath}: api_server.extra.port = ${port}`)
} catch (err) {
console.error(`[GatewayManager] Failed to write config for profile "${name}":`, err)
}
}
// ============================
// 端口分配
// ============================
/**
* profile
*
*
* 1. +
* 2. TCP bind
* 3. base+1 config.yaml
*/
private async resolvePort(name: string): Promise<{ port: number; host: string }> {
let { port, host } = this.readProfilePort(name)
// 收集已占用端口:正在运行的网关 + 本次启动已分配的端口
const usedPorts = new Set<number>(this.allocatedPorts)
for (const gw of Array.from(this.gateways.values())) {
if (gw.host === host && this.isProcessAlive(gw.pid)) {
usedPorts.add(gw.port)
}
}
if (usedPorts.has(port)) {
// 已管理端口冲突 → 找空闲端口
const newPort = await this.findFreePort(port, host)
console.log(`[GatewayManager] Port ${port} is in use for profile "${name}", reassigning to ${newPort}`)
this.writeProfilePort(name, newPort, host)
port = newPort
} else {
// 检查系统级端口占用(外部进程)
const available = await this.checkPortAvailable(port, host)
if (!available) {
const newPort = await this.findFreePort(port, host)
console.log(`[GatewayManager] Port ${port} is occupied by another process for profile "${name}", reassigning to ${newPort}`)
this.writeProfilePort(name, newPort, host)
port = newPort
} else {
// 端口空闲,写入完整配置(确保 api_server 配置齐全)
this.writeProfilePort(name, port, host)
}
}
this.allocatedPorts.add(port)
return { port, host }
}
// ============================
// 公开方法:状态查询
// ============================
/** 获取指定 profile 的网关 URL(代理路由使用) */
getUpstream(profileName?: string): string {
const name = profileName || this.activeProfile
const gw = this.gateways.get(name)
if (gw?.url) return gw.url
const { port, host } = this.readProfilePort(name)
return `http://${host}:${port}`
}
getActiveProfile(): string {
return this.activeProfile
}
setActiveProfile(name: string) {
this.activeProfile = name
}
/** 列出所有已知 profile 名称(通过 hermes CLI 或文件系统扫描) */
async listProfiles(): Promise<string[]> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
timeout: 10000,
windowsHide: true,
})
const profiles: string[] = []
for (const line of stdout.trim().split('\n')) {
if (line.startsWith(' Profile') || line.match(/^ ─/)) continue
const match = line.match(/^\s+(?:◆)?(\S+)\s{2,}/)
if (match) profiles.push(match[1])
}
return profiles
} catch {
// CLI 不可用时回退到文件系统扫描
const profiles = ['default']
const profilesDir = join(HERMES_BASE, 'profiles')
const { existsSync, readdirSync } = require('fs')
if (existsSync(profilesDir)) {
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
if (entry.isDirectory() && existsSync(join(profilesDir, entry.name, 'config.yaml'))) {
profiles.push(entry.name)
}
}
}
return profiles
}
}
/**
* profile
*
*
* PID
* health check
*
* startAll
*/
async detectStatus(name: string): Promise<GatewayStatus> {
const pid = this.readPidFile(name)
const { port, host } = this.readProfilePort(name)
const url = `http://${host}:${port}`
if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) {
this.gateways.set(name, { pid, port, host, url })
return { profile: name, port, host, url, running: true, pid }
}
// 未运行或端口不匹配
this.gateways.delete(name)
return { profile: name, port, host, url, running: false }
}
/** 检测所有 profile 的网关状态 */
async listAll(): Promise<GatewayStatus[]> {
const profiles = await this.listProfiles()
const statuses = await Promise.all(profiles.map(name => this.detectStatus(name)))
return statuses
}
// ============================
// 公开方法:启动 & 停止
// ============================
/**
* profile
* resolvePort()
*/
async start(name: string): Promise<GatewayStatus> {
const { port, host } = await this.resolvePort(name)
const hermesHome = this.profileDir(name)
const url = `http://${host}:${port}`
if (needsRunMode) {
// WSL / Docker:无 systemd/launchd,用 "gateway run" 作为 detached 子进程
return new Promise((resolve, reject) => {
const env = { ...process.env, HERMES_HOME: hermesHome }
const child = spawn(HERMES_BIN, ['gateway', 'run', '--replace'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env,
})
child.unref()
const pid = child.pid ?? 0
console.log(`[GatewayManager] Starting gateway for profile "${name}" (run mode, PID: ${pid}, port: ${port})`)
this.waitForReady(name, pid, port, host, url)
.then(resolve)
.catch(reject)
})
}
// 正常系统:先 start,失败则 restart(处理服务已运行的情况)
console.log(`[GatewayManager] Starting gateway for profile "${name}" (start mode, port: ${port})`)
const env = { ...process.env, HERMES_HOME: hermesHome }
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
env,
windowsHide: true,
})
console.log(`[GatewayManager] gateway start output: ${stdout?.trim()}`)
} catch {
// start 失败(可能服务已运行),用 restart
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
env,
windowsHide: true,
})
console.log(`[GatewayManager] gateway restart output: ${stdout?.trim()}`)
} catch (err: any) {
console.log(`[GatewayManager] gateway start/restart (non-fatal): ${err.stderr?.trim() || err.message}`)
}
}
return this.waitForReady(name, 0, port, host, url)
}
/** 等待网关健康检查通过,最多 15 秒 */
private async waitForReady(name: string, pid: number, port: number, host: string, url: string): Promise<GatewayStatus> {
const deadline = Date.now() + 15000
while (Date.now() < deadline) {
if (pid && !this.isProcessAlive(pid)) {
throw new Error(`Gateway process exited unexpectedly (PID: ${pid})`)
}
if (await this.checkHealth(url, 2000)) {
// "gateway start" 自行管理进程,重新从 pid 文件读取实际 PID
const actualPid = this.readPidFile(name) ?? pid
this.gateways.set(name, { pid: actualPid, port, host, url })
return { profile: name, port, host, url, running: true, pid: actualPid || undefined }
}
await new Promise(r => setTimeout(r, 500))
}
throw new Error(`Gateway health check timed out after 15000ms`)
}
/**
* profile
* "gateway stop"WSL/Docker kill
* health check
*/
async stop(name: string, timeoutMs = 10000): Promise<void> {
// 记录当前 URL,用于确认停止
const gw = this.gateways.get(name)
const url = gw?.url || (() => {
const { port, host } = this.readProfilePort(name)
return `http://${host}:${port}`
})()
if (!needsRunMode) {
// 正常系统:通过 hermes CLI 停止系统服务
try {
const hermesHome = this.profileDir(name)
const env = { ...process.env, HERMES_HOME: hermesHome }
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 10000,
env,
windowsHide: true,
})
} catch { }
} else {
// WSL / Docker:直接杀进程组
let pid = gw?.pid
if (!pid) {
pid = this.readPidFile(name) ?? undefined
}
if (pid) {
try { process.kill(-pid, 'SIGTERM') } catch {
try { process.kill(pid, 'SIGTERM') } catch { }
}
}
}
// 等待 health check 失败,确认网关已真正停止
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!(await this.checkHealth(url, 1000))) {
this.gateways.delete(name)
console.log(`[GatewayManager] Stopped gateway for profile "${name}"`)
return
}
await new Promise(r => setTimeout(r, 300))
}
// 超时也清理
this.gateways.delete(name)
console.log(`[GatewayManager] Stopped gateway for profile "${name}" (timeout)`)
}
/** 停止所有已管理的网关(并行执行) */
async stopAll(): Promise<void> {
const entries = Array.from(this.gateways.keys())
await Promise.allSettled(entries.map(name => this.stop(name)))
}
// ============================
// 批量操作(启动时调用)
// ============================
/** 扫描所有 profile,检测网关运行状态并注册 */
async detectAllOnStartup(): Promise<void> {
console.log('[GatewayManager] Scanning profiles for running gateways...')
const profiles = await this.listProfiles()
for (const name of profiles) {
const status = await this.detectStatus(name)
if (status.running) {
console.log(`[GatewayManager] ✓ ${name}: running (PID: ${status.pid}, port: ${status.port})`)
} else {
console.log(`[GatewayManager] ○ ${name}: stopped`)
}
}
}
/**
*
*
*
* Phase 1
* Phase 2
*/
async startAll(): Promise<void> {
const profiles = await this.listProfiles()
// Phase 1: 顺序处理
const toStart: string[] = []
for (const name of profiles) {
const existing = this.gateways.get(name)
if (existing && this.isProcessAlive(existing.pid)) {
console.log(`[GatewayManager] ${name}: already running (PID: ${existing.pid})`)
continue
}
// 有 PID 文件但进程未在正确端口运行 → 旧进程,先停掉
const pid = this.readPidFile(name)
if (pid && this.isProcessAlive(pid)) {
console.log(`[GatewayManager] ${name}: stale process (PID: ${pid}), stopping`)
try { await this.stop(name) } catch { }
}
await this.resolvePort(name)
toStart.push(name)
}
// Phase 2: 并行启动
const tasks = toStart.map(async (name) => {
try {
await this.start(name)
} catch (err: any) {
console.error(`[GatewayManager] ✗ ${name}: failed to start — ${err.message}`)
}
})
await Promise.allSettled(tasks)
}
}