refactor: DevTools → ethend 重命名 + 加入生产环境

- 目录 devtools/ → ethend/
- CLI 脚本 devtools.sh/.bat → ethend.sh/.bat
- 环境变量 DEVTOOLS_PORT → ETHEND_PORT
- docker-compose.yml 新增 ethend 服务(生产部署)
- 同步更新全部文档、注释和配置文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 09:32:36 +08:00
parent 27187997b3
commit 365f5ceb2f
30 changed files with 455 additions and 189 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
COPY public/ ./public/
ENV ETHEND_PORT=9090
ENV NODE_ENV=production
EXPOSE 9090
CMD ["node", "src/index.js"]
+17
View File
@@ -0,0 +1,17 @@
{
"name": "cyrene-ethend",
"version": "1.0.0",
"description": "Cyrene AI 高级管理控制台 - 服务管理、日志监控、性能分析",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.21.0",
"ws": "^8.18.1",
"pidusage": "^4.0.0",
"chokidar": "^4.0.3"
}
}
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
// ========== IoT 设备控制面板 ==========
function startIoTRefresh() {
stopIoTRefresh();
STATE.iotInterval = setInterval(function() {
if (STATE.activePanel === 'iot') renderIoTPanel();
}, 3000);
}
function stopIoTRefresh() {
if (STATE.iotInterval) { clearInterval(STATE.iotInterval); STATE.iotInterval = null; }
}
async function fetchIoTDevices() {
var data = await api('/api/iot/devices');
if (data.error) return { error: data };
var devices = [];
if (Array.isArray(data)) devices = data;
else if (data.devices) devices = data.devices;
return { devices: devices };
}
var IOT_DEVICE_TYPES = {
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
};
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
var IOT_COLOR_OPTIONS = [
{ name: '暖白', value: 'warm_white', bg: '#f5d0a9' },
{ name: '冷白', value: 'cool_white', bg: '#d4e6fc' },
{ name: '暖黄', value: 'warm_yellow', bg: '#fce4a6' },
{ name: '蓝色', value: 'blue', bg: '#3b82f6' },
{ name: '紫色', value: 'purple', bg: '#a855f7' },
{ name: '绿色', value: 'green', bg: '#22c55e' },
];
async function renderIoTPanel() {
var result = await fetchIoTDevices();
var panel = document.getElementById('panel-iot');
// 更新操作栏 (只更新时间戳文本)
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
'<span class="iot-last-update">⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
if (result.error) {
var hint = '';
if (result.error.errorType === 'iot_not_running') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
}
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
STATE.iotInitialized = false;
return;
}
// 数据未变则跳过 (设备列表不变)
var devices = result.devices;
var iotHash = simpleHash(JSON.stringify(devices));
if (STATE.renderHashes && STATE.renderHashes['iot'] === iotHash) return;
if (STATE.renderHashes) STATE.renderHashes['iot'] = iotHash;
// 固定设备排列顺序: 先按类型,同类型再按 device_id
var typeOrder = { 'sensor': 1, 'ac': 2, 'light': 3, 'curtain': 4, 'lock': 5, 'camera': 6, 'speaker': 7, 'thermostat': 8 };
devices.sort(function(a, b) {
var oa = typeOrder[a.type] || 99;
var ob = typeOrder[b.type] || 99;
if (oa !== ob) return oa - ob;
return (a.id || a.entity_id || '').localeCompare(b.id || b.entity_id || '');
});
var badge = document.getElementById('iot-badge');
if (badge) {
badge.textContent = devices.length;
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
}
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值
var grid = document.getElementById('iot-device-grid');
var firstRender = !STATE.iotInitialized;
if (firstRender || !grid) {
// 首次渲染: 创建完整结构
var html = '<div class="iot-refresh-bar">' +
'<span style="font-weight:600;font-size:14px" id="iot-device-count">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
'</div><div class="iot-device-grid" id="iot-device-grid">' +
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
'</div>';
panel.innerHTML = html;
STATE.iotInitialized = true;
} else {
// 增量更新: 只更新设备数量、最后更新时间
var countEl = document.getElementById('iot-device-count');
if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')';
// 对每个已有设备卡片做增量更新
devices.forEach(function(device) {
updateIoTDeviceCardInPlace(device);
});
}
}
// Bug 7 helper: 增量更新单个设备卡片的属性值,不重建 DOM
function updateIoTDeviceCardInPlace(device) {
var card = document.getElementById('iot-card-' + device.id);
if (!card) return;
var isOn = device.status === 'on';
// 更新卡片 class (on/off 边框)
card.className = 'iot-device-card ' + (isOn ? 'on' : 'off');
// 更新状态圆点和文字
var statusDot = card.querySelector('.iot-status-dot');
if (statusDot) statusDot.className = 'iot-status-dot ' + (isOn ? 'on' : 'off');
var statusText = card.querySelector('.iot-device-status span:last-child');
if (statusText) {
statusText.textContent = isOn ? '开启' : '关闭';
statusText.style.color = isOn ? 'var(--green)' : 'var(--text3)';
}
// 更新开关按钮
var toggleBtn = card.querySelector('.iot-toggle-btn');
if (toggleBtn) {
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启';
}
// 更新设备属性值 (温度、亮度、位置等)
var propValues = card.querySelectorAll('.iot-prop-value');
var type = device.type;
if (type === 'ac') {
// AC: 温度 slider + 模式按钮
var tempSlider = card.querySelector('.iot-prop-control input[type="range"]');
if (tempSlider) {
tempSlider.value = device.temperature || 26;
var tempVal = tempSlider.nextElementSibling;
if (tempVal) tempVal.textContent = (device.temperature || 26) + '°C';
}
// 更新模式按钮
var modeBtns = card.querySelectorAll('.iot-mode-btn');
modeBtns.forEach(function(btn) {
var btnMode = btn.textContent.trim();
if (btnMode === (device.mode || 'cool')) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
} else if (type === 'light') {
var brightSlider = card.querySelector('.iot-prop-control input[type="range"]');
if (brightSlider) {
brightSlider.value = device.brightness || 80;
var brightVal = brightSlider.nextElementSibling;
if (brightVal) brightVal.textContent = (device.brightness || 80) + '%';
}
// 更新颜色按钮
var colorBtns = card.querySelectorAll('.iot-color-btn');
colorBtns.forEach(function(btn) {
// 颜色值从 onclick 属性解析
var onclick = btn.getAttribute('onclick') || '';
var match = onclick.match(/iotSetProperty\('[^']+',\s*'color',\s*'([^']+)'\)/);
if (match && match[1] === (device.color || 'warm_white')) {
btn.classList.add('active');
} else if (!match || match[1] !== (device.color || 'warm_white')) {
btn.classList.remove('active');
}
});
} else if (type === 'curtain') {
var posSlider = card.querySelector('.iot-prop-control input[type="range"]');
if (posSlider) {
posSlider.value = device.position != null ? device.position : 100;
var posVal = posSlider.nextElementSibling;
if (posVal) posVal.textContent = (device.position != null ? device.position : 100) + '%';
}
} else if (device.temperature != null) {
// sensor/thermostat: 只读温度
if (propValues.length > 0) propValues[0].textContent = device.temperature + (device.unit || '°C');
}
// 更新电量
var batteryEls = card.querySelectorAll('.iot-prop-value');
batteryEls.forEach(function(el) {
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
if (device.battery != null) el.textContent = device.battery + '%';
}
});
// 更新 AC 温度按钮的 onclick 引用
if (type === 'ac') {
var acBtns = card.querySelectorAll('.iot-device-actions .btn-xs');
acBtns.forEach(function(btn) {
var text = btn.textContent.trim();
var currentTemp = device.temperature || 26;
if (text === '⬇ -2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
} else if (text === '⬆ +2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
}
});
}
}
function renderIoTDeviceCard(device) {
var isOn = device.status === 'on';
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
var propsHtml = '';
if (device.type === 'ac') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
'<span class="iot-prop-value">' + (device.temperature || 26) + '°C</span>' +
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔄 模式</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_MODE_OPTIONS.map(function(m) {
var active = (device.mode || 'cool') === m ? ' active' : '';
return '<button class="iot-mode-btn' + active + '" onclick="iotSetProperty(\'' + device.id + '\', \'mode\', \'' + m + '\');refreshIoTDeviceCard(\'' + device.id + '\')">' + m + '</button>';
}).join('') +
'</div>' +
'</div>';
} else if (device.type === 'light') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">💡 亮度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
'<span class="iot-prop-value">' + (device.brightness || 80) + '%</span>' +
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🎨 颜色</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_COLOR_OPTIONS.map(function(c) {
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
return '<button class="iot-color-btn' + active + '" style="background:' + c.bg + '" title="' + c.name + '"' +
' onclick="iotSetProperty(\'' + device.id + '\', \'color\', \'' + c.value + '\');refreshIoTDeviceCard(\'' + device.id + '\')"></button>';
}).join('') +
'</div>' +
'</div>';
} else if (device.type === 'curtain') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🪟 位置</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
'<span class="iot-prop-value">' + (device.position != null ? device.position : 100) + '%</span>' +
'</div>' +
'</div>';
} else if (device.temperature != null) {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
'</div>';
}
if (device.battery != null) {
propsHtml +=
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔋 电量</span>' +
'<span class="iot-prop-value">' + device.battery + '%</span>' +
'</div>';
}
var actionsHtml = '<div class="iot-device-actions">' +
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
(isOn ? '⏻ 关闭' : '⏻ 开启') +
'</button>';
if (device.type === 'ac') {
var currentTemp = device.temperature || 26;
actionsHtml +=
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬇ -2°C</button>' +
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬆ +2°C</button>';
}
actionsHtml +=
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">📋 历史</button>' +
'</div>' +
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
'<div class="iot-device-header">' +
'<div class="iot-device-name">' +
'<span style="font-size:24px">' + icon + '</span>' +
'<div>' +
'<div>' + escHtml(device.name) + '</div>' +
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
'</div>' +
'</div>' +
'<div class="iot-device-status">' +
'<span class="iot-status-dot ' + (isOn ? 'on' : 'off') + '"></span>' +
'<span style="font-size:12px;font-weight:600;color:' + (isOn ? 'var(--green)' : 'var(--text3)') + '">' + (isOn ? '开启' : '关闭') + '</span>' +
'</div>' +
'</div>' +
(propsHtml ? '<div class="iot-device-props">' + propsHtml + '</div>' : '') +
actionsHtml +
'</div>';
}
async function iotToggle(deviceId) {
console.log('[IoT] 切换设备开关: ' + deviceId);
var data = await api('/api/iot/devices/' + deviceId + '/toggle', { method: 'POST' });
if (data.error) {
showToast('切换失败: ' + data.error, 'error');
} else {
var device = data.device || {};
showToast((device.name || deviceId) + ': ' + (device.status === 'on' ? '已开启' : '已关闭'), 'success');
renderIoTPanel();
}
}
async function iotSetProperty(deviceId, field, value) {
console.log('[IoT] 设置设备属性: ' + deviceId + ' -> ' + field + ' = ' + value);
var data = await api('/api/iot/devices/' + deviceId + '/set', {
method: 'POST',
body: JSON.stringify({ field: field, value: value }),
});
if (data.error) {
showToast('设置失败: ' + data.error, 'error');
} else {
var device = data.device || {};
showToast((device.name || deviceId) + ': ' + field + ' = ' + value, 'success');
}
}
async function refreshIoTDeviceCard(deviceId) {
var data = await api('/api/iot/devices/' + deviceId);
if (data.error) return;
var device = data.device;
if (!device) return;
var card = document.getElementById('iot-card-' + deviceId);
if (!card) return;
card.outerHTML = renderIoTDeviceCard(device);
}
async function iotShowHistory(deviceId) {
var panel = document.getElementById('iot-history-' + deviceId);
if (!panel) return;
if (panel.style.display !== 'none') {
panel.style.display = 'none';
return;
}
console.log('[IoT] 获取设备历史: ' + deviceId);
var data = await api('/api/iot/devices/' + deviceId + '/history');
panel.style.display = '';
if (data.error) {
panel.innerHTML = '<div style="color:var(--red);font-size:11px;padding:4px">' + escHtml(data.error) + '</div>';
return;
}
var history = data.history || [];
if (history.length === 0) {
panel.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:4px">暂无操作历史</div>';
return;
}
panel.innerHTML = history.slice(-20).reverse().map(function(h) {
var timeStr = h.timestamp ? new Date(h.timestamp).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
return '<div class="iot-history-item">' +
'<span class="iot-hist-time">' + timeStr + '</span>' +
'<span class="iot-hist-action">' + escHtml(h.action || '操作') + '</span>' +
'<span class="iot-hist-detail">' + escHtml(h.detail || h.value || '') + '</span>' +
'</div>';
}).join('');
}
+198
View File
@@ -0,0 +1,198 @@
/**
* 管理控制台配置
* 定义各服务的启动参数、端口、健康检查等
*/
import { fileURLToPath } from 'url';
import path from 'path';
import fs from 'fs';
import os from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '../..');
const isWin = os.platform() === 'win32';
// 读取 backend/.env 文件,将值合并到 process.env(不覆盖已有的环境变量)
// 这样 ethend 启动各服务时能传递用户配置的凭据
function loadEnvFile() {
const envPath = path.join(ROOT, 'backend', '.env');
try {
const content = fs.readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.substring(0, eqIdx).trim();
const val = trimmed.substring(eqIdx + 1).trim();
if (key && val !== undefined) {
process.env[key] = process.env[key] || val;
}
}
} catch {
// .env 文件不存在,使用默认值
}
}
loadEnvFile();
/** 跨平台 Go 二进制路径 */
function findGoBin() {
// 优先使用环境变量
if (process.env.GOROOT) return path.join(process.env.GOROOT, 'bin', 'go');
// Windows 常见路径
const candidates = isWin
? ['C:\\Program Files\\Go\\bin\\go.exe', 'C:\\Go\\bin\\go.exe', 'go']
: ['/usr/local/go/bin/go', '/usr/bin/go', 'go'];
for (const p of candidates) {
if (p === 'go' || fs.existsSync(p)) return p;
}
return 'go';
}
const GO_BIN = findGoBin();
export const ETHEND_PORT = process.env.ETHEND_PORT || 9090;
export const LOGS_DIR = path.resolve(__dirname, '../logs');
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
export const PLUGIN_MANAGER_URL = process.env.PLUGIN_MANAGER_URL || 'http://localhost:8094';
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
export const SERVICES = {
'ai-core': {
name: 'AI-Core',
cwd: path.join(ROOT, 'backend/ai-core'),
command: './main',
env: {
AI_CORE_PORT: '8081',
PERSONA_DIR: './internal/persona',
SEARXNG_URL: process.env.SEARXNG_URL || 'http://localhost:8088',
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
ENABLE_BACKGROUND_THINKING: process.env.ENABLE_BACKGROUND_THINKING || 'true',
},
healthUrl: 'http://localhost:8081/api/v1/health',
port: 8081,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'iot-debug-service': {
name: 'IoT Debug',
cwd: path.join(ROOT, 'backend/iot-debug-service'),
command: './main',
env: {
IOT_DEBUG_PORT: '8083',
},
healthUrl: 'http://localhost:8083/api/v1/health',
port: 8083,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
gateway: {
name: 'Gateway',
cwd: path.join(ROOT, 'backend/gateway'),
command: './main',
env: {
GATEWAY_PORT: '8080',
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
AI_CORE_URL: 'http://localhost:8081',
MEMORY_SERVICE_URL: process.env.MEMORY_SERVICE_URL || 'http://localhost:8091',
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',
},
healthUrl: 'http://localhost:8080/api/v1/health',
port: 8080,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'memory-service': {
name: '记忆服务',
cwd: path.join(ROOT, 'backend/memory-service'),
command: './main',
env: {
PORT: '8091',
DB_URL: process.env.DB_URL || 'postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable',
},
healthUrl: 'http://localhost:8091/api/v1/health',
port: 8091,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'voice-service': {
name: '语音识别服务',
cwd: path.join(ROOT, 'backend/voice-service'),
command: './main',
env: {
PORT: '8093',
WHISPER_BINARY: './whisper.cpp/main',
WHISPER_MODEL: './whisper.cpp/models/ggml-small.bin',
WHISPER_LANGUAGE: 'zh',
DASHSCOPE_API_KEY: process.env.DASHSCOPE_API_KEY || '',
DASHSCOPE_STT_MODEL: 'gummy-chat-v1',
},
healthUrl: 'http://localhost:8093/api/v1/health',
port: 8093,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'plugin-manager': {
name: '插件管理器',
cwd: path.join(ROOT, 'backend/plugin-manager'),
command: './main',
env: {
PORT: '8094',
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
},
healthUrl: 'http://localhost:8094/api/v1/health',
port: 8094,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'platform-bridge': {
name: '多平台桥接',
cwd: path.join(ROOT, 'backend/platform-bridge'),
command: './main',
env: {
PORT: '8095',
AI_CORE_URL: 'http://localhost:8081',
QQ_BOT_PORT: process.env.QQ_BOT_PORT || '8096',
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
TELEGRAM_WEBHOOK_URL: process.env.TELEGRAM_WEBHOOK_URL || '',
QQ_ADMIN_UID: process.env.QQ_ADMIN_UID || '',
TELEGRAM_ADMIN_UID: process.env.TELEGRAM_ADMIN_UID || '',
},
healthUrl: 'http://localhost:8095/health',
port: 8095,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
frontend: {
name: 'Frontend',
cwd: path.join(ROOT, 'frontend/web'),
command: 'npx',
args: ['vite', '--host', '0.0.0.0'],
env: {
PATH: process.env.PATH,
},
healthUrl: 'http://localhost:5173',
port: 5173,
nodeBin: 'node',
npmBin: 'npx',
// frontend不需要预编译,dev server即可
buildCommand: null,
},
};
/** 各服务默认的日志文件路径 */
export function logFile(serviceId) {
return path.join(LOGS_DIR, `${serviceId}.log`);
}
+1752
View File
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
/**
* 性能监控模块
* 监控各服务进程的 CPU、内存使用情况
*/
import pidusage from 'pidusage';
import { processManager } from './process-manager.js';
import { SERVICES } from './config.js';
class PerformanceMonitor {
constructor() {
/** @type {Map<string, Array<{ts: number, cpu: number, mem: number}>>} */
this.history = new Map();
this.interval = null;
// Ring buffer for actual HTTP request latencies (ms)
this.latencyBuffer = [];
this.maxLatencySamples = 500;
for (const id of Object.keys(SERVICES)) {
this.history.set(id, []);
}
}
/** Record an HTTP request duration (ms). Called by middleware. */
recordLatency(durationMs) {
this.latencyBuffer.push(durationMs);
if (this.latencyBuffer.length > this.maxLatencySamples) {
this.latencyBuffer.splice(0, this.latencyBuffer.length - this.maxLatencySamples);
}
}
/** Get average request latency from recent samples. Returns null if no data. */
getAverageLatency() {
if (this.latencyBuffer.length === 0) return null;
const sum = this.latencyBuffer.reduce((a, b) => a + b, 0);
return Math.round(sum / this.latencyBuffer.length);
}
/**
* 开始定期采样 (每3秒)
*/
start() {
if (this.interval) return;
this.interval = setInterval(() => this.sample(), 3000);
this.interval.unref(); // 不阻止进程退出
}
/**
* 停止采样
*/
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* 采样一次
*/
async sample() {
for (const [id, info] of processManager.processes) {
if (!info.pid) continue;
try {
const stats = await pidusage(info.pid);
const history = this.history.get(id);
history.push({
ts: Date.now(),
cpu: Math.round(stats.cpu * 100) / 100,
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100, // MB
});
// 保留最近300条 (约15分钟)
if (history.length > 300) {
history.splice(0, history.length - 300);
}
} catch {
// 进程可能已退出
}
}
}
/**
* 获取当前性能快照
*/
async getSnapshot() {
const result = {};
for (const [id, info] of processManager.processes) {
if (!info.pid) {
result[id] = { pid: null, cpu: 0, mem: 0 };
continue;
}
try {
const stats = await pidusage(info.pid);
result[id] = {
pid: info.pid,
cpu: Math.round(stats.cpu * 100) / 100,
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100,
elapsed: stats.elapsed,
};
} catch {
result[id] = { pid: info.pid, cpu: 0, mem: 0 };
}
}
return result;
}
/**
* 获取历史数据
*/
getHistory(serviceId) {
return this.history.get(serviceId) || [];
}
/**
* 获取所有服务的历史数据
*/
getAllHistory() {
const result = {};
for (const id of this.history.keys()) {
result[id] = this.history.get(id);
}
return result;
}
/**
* 更新仪表盘数据 — 返回聚合的性能摘要供首页仪表盘使用
* 调用方负责将数据渲染到 #performance-dashboard 元素
* @returns {object} 仪表盘性能摘要
*/
async updateDashboard() {
const snapshot = await this.getSnapshot();
const entries = Object.entries(snapshot);
let totalCpu = 0, totalMem = 0, activeCount = 0;
for (const [, p] of entries) {
totalCpu += p.cpu || 0;
totalMem += p.mem || 0;
if (p.pid) activeCount++;
}
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
const totalMemRounded = Math.round(totalMem * 100) / 100;
// 计算平均请求延迟 (基于实际 HTTP 请求耗时,非进程 uptime)
const avgLatencyMs = this.getAverageLatency();
// 获取最近历史用于趋势判断
const recentHistory = this.getAllHistory();
let trendCpu = 'stable', trendMem = 'stable';
for (const [, hist] of Object.entries(recentHistory)) {
if (hist.length < 5) continue;
const recent = hist.slice(-5);
const firstCpu = recent[0].cpu, lastCpu = recent[recent.length - 1].cpu;
const firstMem = recent[0].mem, lastMem = recent[recent.length - 1].mem;
if (lastCpu > firstCpu * 1.15) trendCpu = 'up';
else if (lastCpu < firstCpu * 0.85) trendCpu = 'down';
if (lastMem > firstMem * 1.15) trendMem = 'up';
else if (lastMem < firstMem * 0.85) trendMem = 'down';
}
return {
timestamp: Date.now(),
summary: {
avgCpu,
totalMemMB: totalMemRounded,
activeProcesses: activeCount,
monitoredServices: entries.length,
avgLatencyMs,
trend: { cpu: trendCpu, mem: trendMem },
},
perService: snapshot,
};
}
}
export const performanceMonitor = new PerformanceMonitor();
+631
View File
@@ -0,0 +1,631 @@
/**
* 进程管理器
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
*/
import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events';
import fs from 'fs';
import net from 'net';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { SERVICES, logFile } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '../..');
const DB_PORTS = [5432, 5433, 5434];
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
const isWin = os.platform() === 'win32';
// ---- Docker 检测缓存 ----
let _dockerCache = null;
let _dockerCacheTime = 0;
const DOCKER_CACHE_TTL = 5000; // 5秒缓存,避免每次 status 轮询都执行 docker ps
/**
* 检测哪些服务运行在 Docker 容器中
* 通过 docker ps 获取所有容器端口映射,与 SERVICES 端口匹配
* @returns {Map<string, {containerName: string, containerId: string, status: string}>}
*/
function detectDockerServices() {
const now = Date.now();
if (_dockerCache && (now - _dockerCacheTime) < DOCKER_CACHE_TTL) {
return _dockerCache;
}
const result = new Map();
try {
const out = execSync('docker ps --format "{{.ID}}\\t{{.Names}}\\t{{.Ports}}\\t{{.Status}}"', {
timeout: 5000,
stdio: 'pipe',
}).toString().trim();
if (!out) return result;
for (const line of out.split('\n')) {
const [containerId, containerName, ports, status] = line.split('\t');
if (!ports) continue;
// 解析端口映射: "0.0.0.0:8080->8080/tcp, :::8080->8080/tcp"
const hostPorts = new Set();
for (const m of ports.matchAll(/:(\d+)->/g)) {
hostPorts.add(parseInt(m[1]));
}
// 匹配 SERVICES 中定义的端口
for (const [svcId, svc] of Object.entries(SERVICES)) {
if (svc.port && hostPorts.has(svc.port)) {
result.set(svcId, {
containerName,
containerId: containerId.substring(0, 12),
status,
});
}
}
}
} catch {
// Docker 不可用,返回空 Map
}
_dockerCache = result;
_dockerCacheTime = now;
return result;
}
/** 清除 Docker 缓存(用于手动刷新状态) */
export function clearDockerCache() {
_dockerCache = null;
_dockerCacheTime = 0;
}
/**
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
*/
function releasePort(port) {
return new Promise((resolve) => {
const sock = new net.Socket();
sock.setTimeout(1000);
sock.on('connect', () => {
sock.destroy();
// 端口被占用,尝试释放
try {
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { timeout: 3000 });
} catch { /* ignore */ }
setTimeout(resolve, 500);
});
sock.on('error', () => {
sock.destroy();
resolve(); // 端口空闲
});
sock.on('timeout', () => {
sock.destroy();
resolve();
});
sock.connect(port, '127.0.0.1');
});
}
/**
* 检查端口是否可连接 (TCP connect, 超时2秒)
*/
function isPortOpen(port) {
return new Promise((resolve) => {
const sock = new net.Socket();
sock.setTimeout(2000);
sock.on('connect', () => { sock.destroy(); resolve(true); });
sock.on('error', () => { sock.destroy(); resolve(false); });
sock.on('timeout', () => { sock.destroy(); resolve(false); });
sock.connect(port, '127.0.0.1');
});
}
/**
* 确保数据库在线
* 检查 DB_PORTS 中至少有一个端口可用,若不可用则尝试 docker compose up
* 等待最多 30 秒检查数据库就绪
* @param {string} serviceId - 正在启动的服务 ID
* @param {EventEmitter} emitter - 用于发送日志事件
*/
async function ensureDBOnline(serviceId, emitter) {
// 1. 快速检查:任意数据库端口是否已在线
for (const port of DB_PORTS) {
if (await isPortOpen(port)) {
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已在线`);
return;
}
}
// 2. 数据库不在线,尝试 docker compose up
emitter.emit('log', serviceId, 'system', '数据库未启动,正在通过 Docker Compose 启动...');
try {
execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
timeout: 60000,
stdio: 'pipe',
});
emitter.emit('log', serviceId, 'system', 'Docker Compose 启动命令已执行,等待数据库就绪...');
} catch (err) {
const stderr = err.stderr?.toString() || err.message;
emitter.emit('log', serviceId, 'error', `Docker Compose 启动失败: ${stderr}`);
}
// 3. 等待最多 30 秒检查数据库就绪
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
for (const port of DB_PORTS) {
if (await isPortOpen(port)) {
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已就绪 (等待 ${i + 1}s)`);
return;
}
}
}
// 4. 30 秒后仍不可用
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
}
class ProcessManager extends EventEmitter {
constructor() {
super();
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
this.processes = new Map();
for (const id of Object.keys(SERVICES)) {
this.processes.set(id, {
process: null,
status: 'stopped',
startTime: null,
pid: null,
buildLog: [],
});
}
}
/**
* 启动服务
*/
async start(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
// Docker 管理的服务拒绝本地操作
if (detectDockerServices().has(serviceId)) {
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
}
const procInfo = this.processes.get(serviceId);
if (procInfo.process) {
throw new Error(`${svc.name} 已在运行中`);
}
// 对需要数据库的服务做前置检查
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
await ensureDBOnline(serviceId, this);
}
// 启动前释放端口,避免 "address already in use"
if (svc.port) {
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
await releasePort(svc.port);
}
this.emit('log', serviceId, 'system', `正在启动 ${svc.name}...`);
procInfo.status = 'starting';
procInfo.buildLog = [];
// 确保日志目录存在
const logPath = logFile(serviceId);
const logDir = path.dirname(logPath);
fs.mkdirSync(logDir, { recursive: true });
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
// 确定二进制路径或命令
let command, args;
if (svc.command === './main') {
command = isWin ? './main.exe' : './main';
args = svc.args || [];
} else if (svc.command === 'npx') {
command = isWin ? 'npx.cmd' : (svc.npmBin || 'npx');
args = svc.args || [];
} else {
command = svc.command;
args = svc.args || [];
}
// 对使用 npm 的服务,检查 node_modules 是否完整
if (svc.command === 'npx' || svc.command === 'node') {
const modulesDir = path.join(svc.cwd, 'node_modules');
const installMarker = path.join(modulesDir, '.package-lock.json');
if (!fs.existsSync(installMarker)) {
// 如果 node_modules 目录存在但不完整,先删除
if (fs.existsSync(modulesDir)) {
this.emit('log', serviceId, 'system', 'node_modules 不完整,正在清理...');
fs.rmSync(modulesDir, { recursive: true, force: true });
}
this.emit('log', serviceId, 'system', '正在运行 npm install...');
// Windows: npm.cmd batch file has module resolution issues when
// cwd has a node_modules directory, even if empty. Use node+npm-cli.js directly.
const installCmd = isWin
? `"${process.execPath}" "${path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')}" install`
: 'npm install';
try {
execSync(installCmd, { cwd: svc.cwd, timeout: 120000, stdio: 'pipe' });
this.emit('log', serviceId, 'system', 'npm install 完成');
} catch (err) {
const stderr = err.stderr?.toString() || err.message;
this.emit('log', serviceId, 'error', `npm install 失败: ${stderr}`);
throw new Error(`npm install 失败: ${stderr}`);
}
}
}
const env = { ...process.env, ...svc.env };
// .cmd/.bat on Windows needs shell:true
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
const child = spawn(command, args, {
cwd: svc.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: needsShell,
});
child.stdout.on('data', (data) => {
const text = data.toString();
logStream.write(text);
this.emit('log', serviceId, 'stdout', text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
logStream.write(text);
this.emit('log', serviceId, 'stderr', text);
});
// spawn() 返回后进程已启动,立即记录 PID 和状态
// 注意: Node.js 没有 'spawn' 事件,spawn() 调用本身是同步的
procInfo.pid = child.pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = child;
this.emit('log', serviceId, 'system', `${svc.name} 已启动 (PID: ${child.pid})`);
child.on('error', (err) => {
const msg = `进程错误: ${err.message}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'error', msg);
procInfo.status = 'error';
procInfo.process = null;
procInfo.pid = null;
});
child.on('close', (code) => {
const msg = `进程退出,退出码: ${code}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'system', msg);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
logStream.end();
});
return { success: true, message: `${svc.name} 启动中...` };
}
/**
* 停止服务
*/
async stop(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
if (detectDockerServices().has(serviceId)) {
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
}
const procInfo = this.processes.get(serviceId);
if (!procInfo.process) {
// 可能已经崩溃了,重置状态
procInfo.status = 'stopped';
return { success: true, message: `${svc.name} 未在运行` };
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
// 强制杀死
if (procInfo.process) {
procInfo.process.kill('SIGKILL');
}
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
resolve({ success: true, message: `${svc.name} 已强制停止` });
}, 5000);
procInfo.process.on('close', () => {
clearTimeout(timeout);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
resolve({ success: true, message: `${svc.name} 已停止` });
});
procInfo.process.kill('SIGTERM');
this.emit('log', serviceId, 'system', `正在停止 ${svc.name}...`);
});
}
/**
* 重启服务
*/
async restart(serviceId) {
if (detectDockerServices().has(serviceId)) {
const svc = SERVICES[serviceId];
return { success: false, error: 'docker_managed', message: `${svc?.name || serviceId} 由 Docker 管理` };
}
await this.stop(serviceId);
await new Promise((r) => setTimeout(r, 1000));
return this.start(serviceId);
}
/**
* 构建服务 (Go服务需要预编译)
*/
async build(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
if (detectDockerServices().has(serviceId)) {
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请在容器内构建或重建镜像` };
}
if (!svc.buildCommand) {
return { success: false, message: `${svc.name} 不需要预编译` };
}
const procInfo = this.processes.get(serviceId);
procInfo.status = 'building';
procInfo.buildLog = [];
this.emit('log', serviceId, 'system', `正在编译 ${svc.name}...`);
return new Promise((resolve) => {
const buildCmd = svc.goBin || svc.buildCommand;
const buildArgs = svc.buildArgs || [];
const child = spawn(buildCmd, buildArgs, {
cwd: svc.cwd,
env: { ...process.env, GOPROXY: 'https://goproxy.cn,direct', GOWORK: 'off' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
procInfo.status = 'stopped';
procInfo.buildLog = [
...stdout.split('\n').filter(Boolean),
...stderr.split('\n').filter(Boolean),
];
if (code === 0) {
this.emit('log', serviceId, 'system', `${svc.name} 编译成功`);
resolve({ success: true, message: `${svc.name} 编译成功` });
} else {
this.emit('log', serviceId, 'error', `${svc.name} 编译失败:\n${stderr || stdout}`);
resolve({ success: false, message: '编译失败', buildLog: procInfo.buildLog });
}
});
child.on('error', (err) => {
procInfo.status = 'stopped';
resolve({ success: false, message: `编译错误: ${err.message}` });
});
});
}
/**
* 获取所有服务状态
*/
getStatus() {
const dockerSvcs = detectDockerServices();
const result = {};
for (const [id, info] of this.processes) {
const svc = SERVICES[id];
const docker = dockerSvcs.get(id);
let source = 'none';
if (docker) {
source = 'docker';
} else if (info.status === 'running' || info.status === 'starting') {
source = 'local';
}
result[id] = {
name: svc.name,
status: docker ? 'running' : info.status, // Docker 容器总是 running
pid: docker ? null : info.pid,
startTime: info.startTime,
uptime: info.startTime ? Date.now() - info.startTime : 0,
port: svc.port,
healthUrl: svc.healthUrl,
source,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
return result;
}
/**
* 获取单个服务状态
*/
getServiceStatus(serviceId) {
const info = this.processes.get(serviceId);
if (!info) return null;
const svc = SERVICES[serviceId];
const dockerSvcs = detectDockerServices();
const docker = dockerSvcs.get(serviceId);
let source = 'none';
if (docker) {
source = 'docker';
} else if (info.status === 'running' || info.status === 'starting') {
source = 'local';
}
return {
name: svc.name,
status: docker ? 'running' : info.status,
pid: docker ? null : info.pid,
startTime: info.startTime,
uptime: info.startTime ? Date.now() - info.startTime : 0,
port: svc.port,
healthUrl: svc.healthUrl,
source,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
/**
* 停止所有服务
*/
async stopAll() {
const results = [];
const dockerSvcs = detectDockerServices();
for (const id of Object.keys(SERVICES)) {
if (dockerSvcs.has(id)) {
const svc = SERVICES[id];
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理,跳过停止`, docker: true });
continue;
}
try {
const r = await this.stop(id);
results.push({ id, ...r });
} catch (err) {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
/**
* 尝试接管已运行的服务 (通过健康检查端点)
* 如果服务已在运行,直接标记为 running 而不是杀死重启
*/
async tryAdopt(serviceId) {
const svc = SERVICES[serviceId];
if (!svc || !svc.healthUrl) return false;
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const procInfo = this.processes.get(serviceId);
// 尝试通过 fuser 获取 PID
let pid = null;
try {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
} catch { /* ignore */ }
procInfo.pid = pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = null; // 不是我们的子进程,但标记为已接管
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管`);
return true;
}
} catch { /* 未运行或不可达 */ }
return false;
}
/**
* 检查服务是否需要编译 (Go 服务且二进制不存在)
*/
needsBuild(serviceId) {
const svc = SERVICES[serviceId];
if (!svc || !svc.buildCommand) return false;
const binaryPath = path.join(svc.cwd, 'main');
const exePath = binaryPath + '.exe';
return !fs.existsSync(binaryPath) && !fs.existsSync(exePath);
}
/**
* 按顺序启动所有服务 (memory → iot → voice → ai-core → gateway → frontend)
* 每步等待健康检查通过后再启动下一个
*/
async startAllSequential() {
const order = ['memory-service', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
const results = [];
const dockerSvcs = detectDockerServices();
for (const id of order) {
const svc = SERVICES[id];
// Docker 管理的服务:跳过
if (dockerSvcs.has(id)) {
const d = dockerSvcs.get(id);
this.emit('log', id, 'system', `${svc.name} 由 Docker 容器 ${d.containerName} 管理,跳过本地启动`);
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理 (${d.containerName}),已跳过`, docker: true });
continue;
}
// 先尝试接管已运行的服务
const adopted = await this.tryAdopt(id);
if (adopted) {
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
continue;
}
// 编译检查: 如果 Go 服务二进制不存在,先编译
if (this.needsBuild(id)) {
this.emit('log', id, 'system', `未找到编译产物,正在编译 ${svc.name}...`);
const buildResult = await this.build(id);
if (!buildResult.success) {
const errMsg = `编译失败: ${buildResult.message}`;
this.emit('log', id, 'error', errMsg);
results.push({ id, success: false, message: errMsg });
continue;
}
this.emit('log', id, 'system', `${svc.name} 编译完成`);
}
// 启动服务
try {
const r = await this.start(id);
results.push({ id, ...r });
// 等待健康检查通过
if (svc.healthUrl) {
let healthy = false;
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 1000));
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
if (resp.ok) { healthy = true; break; }
} catch { /* continue waiting */ }
}
if (!healthy) {
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
} else {
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
// Gateway 和 AI-Core 启动后额外等待 2 秒,确保内部路由和 Handler 完全初始化
if (id === 'gateway' || id === 'ai-core') {
await new Promise((r) => setTimeout(r, 2000));
this.emit('log', id, 'system', `${svc.name} 已就绪 (额外等待 2s 确保服务稳定)`);
}
}
}
} catch (err) {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
}
export const processManager = new ProcessManager();