fix: DevTools Windows兼容性修复 + 日志UI重构 (7服务并排显示)

- process-manager: 移除ESM中的require(), 跨平台spawn(.exe/.cmd), path.dirname修复
- config: 自动检测Go二进制路径, Windows构建产物使用.exe后缀
- index: 移除SSH隧道代码, 改用Docker容器检查数据库状态
- index.html: 日志默认并排网格布局, 7个服务横向滚动, 数据库面板改用Docker控制
- docs: 更新Migration.md启动顺序(7服务+DevTools自动编译), README添加Windows用法

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 21:05:07 +08:00
parent 697ed72db4
commit e4d2eab9ad
6 changed files with 228 additions and 203 deletions
+49 -19
View File
@@ -244,17 +244,46 @@ docker-compose -f docker-compose.dev.db.yml up -d
### 6.5 启动顺序
服务启动顺序很重要,请按以下顺序启动
**推荐使用 DevTools 一键启动**(自动编译 + 按序启动 + 健康检查)
| 顺序 | 服务 | 默认端口 | 命令 |
|------|------|---------|------|
| 1 | memory-service | — | `cd backend\memory-service && .\main.exe` |
| 2 | iot-debug-service | 8083 | `cd backend\iot-debug-service && .\main.exe` |
| 3 | tool-engine | — | `cd backend\tool-engine && .\main.exe` |
| 4 | ai-core | 8081 | `cd backend\ai-core && .\main.exe` |
| 5 | gateway | 8080 | `cd backend\gateway && .\main.exe` |
```cmd
cd devtools
node src\index.js
:: 浏览器打开 http://localhost:9090,点击「一键启动」
```
> 每个服务需要在独立的终端窗口中启动。
或使用启动脚本:
```cmd
devtools.bat
```
DevTools 会按以下顺序自动编译并启动所有 7 个服务:
| 顺序 | 服务 | 端口 | 说明 |
|------|------|------|------|
| 1 | memory-service | 8091 | 记忆 CRUD 与检索 |
| 2 | tool-engine | 8092 | 工具执行引擎 |
| 3 | iot-debug-service | 8083 | 模拟智能家居设备 |
| 4 | voice-service | 8093 | TTS/STT 语音服务 |
| 5 | ai-core | 8081 | LLM 推理与编排 |
| 6 | gateway | 8080 | API 网关 / JWT / WebSocket |
| 7 | frontend | 5173 | React 开发服务器 |
> 每个步骤会自动等待健康检查通过后再启动下一个服务。如果 Go 二进制未编译,DevTools 会自动先编译再启动。
如需手动逐个启动:
```powershell
# 按顺序执行,每个在独立终端中运行
cd backend\memory-service && go build -o main.exe .\cmd\main.go && .\main.exe
cd backend\tool-engine && go build -o main.exe .\cmd\main.go && .\main.exe
cd backend\iot-debug-service && go build -o main.exe .\cmd\main.go && .\main.exe
cd backend\voice-service && go build -o main.exe .\cmd\main.go && .\main.exe
cd backend\ai-core && go build -o main.exe .\cmd\main.go && .\main.exe
cd backend\gateway && go build -o main.exe .\cmd\main.go && .\main.exe
cd frontend\web && npm install && npm run dev
```
---
@@ -364,16 +393,17 @@ find backend -name "*.go" -exec dos2unix {} \;
| 2 | ✅ 前端构建成功 | `cd frontend\web && npm run build` 无报错 |
| 3 | ✅ 数据库连接正常 | 使用 `psql` 或数据库客户端连接 PostgreSQL |
| 4 | ✅ pgvector 扩展已安装 | `SELECT * FROM pg_extension WHERE extname='vector';` 返回一行 |
| 5 | ✅ memory-service 启动成功 | 无 panic 日志,监听预期端口 |
| 6 | ✅ iot-debug-service 启动成功 | 访问 `http://localhost:8083/health` 返回 200 |
| 7 | ✅ tool-engine 启动成功 | 无 panic 日志 |
| 8 | ✅ ai-core 启动成功 | 访问 `http://localhost:8081/health` 返回 200 |
| 9 | ✅ gateway 启动成功 | 访问 `http://localhost:8080/health` 返回 200 |
| 10 | ✅ 前端开发服务器启动 | 访问 `http://localhost:5173` 显示登录页面 |
| 11 | ✅ WebSocket 连接正常 | 登录后聊天功能正常,能收到 AI 回复 |
| 12 | ✅ IoT 设备控制正常 | 发送 IoT 控制指令,设备响应正确 |
| 13 | ✅ 语音合成 (TTS) 正常 | AI 回复能正常播放语音 |
| 14 | ✅ 语音识别 (ASR) 正常 | 语音输入能被正确识别 |
| 5 | ✅ memory-service 启动成功 | 无 panic 日志,监听 8091 端口 |
| 6 | ✅ tool-engine 启动成功 | 无 panic 日志,监听 8092 端口 |
| 7 | ✅ iot-debug-service 启动成功 | 访问 `http://localhost:8083/api/v1/health` 返回 200 |
| 8 | ✅ voice-service 启动成功 | 访问 `http://localhost:8093/api/v1/health` 返回 200 |
| 9 | ✅ ai-core 启动成功 | 访问 `http://localhost:8081/api/v1/health` 返回 200 |
| 10 | ✅ gateway 启动成功 | 访问 `http://localhost:8080/api/v1/health` 返回 200 |
| 11 | ✅ 前端开发服务器启动 | 访问 `http://localhost:5173` 显示登录页面 |
| 12 | ✅ WebSocket 连接正常 | 登录后聊天功能正常,能收到 AI 回复 |
| 13 | ✅ IoT 设备控制正常 | 发送 IoT 控制指令,设备响应正确 |
| 14 | ✅ 语音合成 (TTS) 正常 | AI 回复能正常播放语音 |
| 15 | ✅ 语音识别 (ASR) 正常 | 语音输入能被正确识别 |
---
+5
View File
@@ -94,9 +94,14 @@ docker compose -f docker-compose.dev.db.yml up -d
使用 DevTools 一键管理:
```bash
# Linux / macOS
./devtools.sh start all # 编译并启动所有后端 + 前端
./devtools.sh status # 查看服务状态
./devtools.sh logs ai-core # 查看 AI-Core 日志
# Windows
devtools.bat # 启动 DevTools Web 面板
# 浏览器打开 http://localhost:9090,点击「一键启动」
```
或手动逐个启动:
+64 -54
View File
@@ -695,11 +695,11 @@ const STATE = {
serviceStatus: {},
// 日志
activeLogTab: 'ai-core',
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [] },
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
maxLogLines: 500,
logLayout: 'tabs',
logLayout: 'grid',
// 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [] },
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
// 会话
sessionsData: [],
sessionsAutoRefresh: null,
@@ -868,8 +868,10 @@ function statusBadge(status) {
return map[status] || 'badge-stopped';
}
const ALL_SVC_IDS = ['ai-core', 'gateway', 'frontend', 'iot-debug-service', 'memory-service', 'tool-engine', 'voice-service'];
function escapeId(id) {
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug' };
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug', 'memory-service': 'Memory', 'tool-engine': 'Tool Engine', 'voice-service': 'Voice' };
return map[id] || id;
}
@@ -2033,21 +2035,24 @@ function renderServicesPanel() {
<span class="card-title">📋 实时日志</span>
<div class="btn-group">
<div class="log-tabs" id="services-log-tabs" style="margin:0"></div>
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📐 并列</button>
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📋 标签页</button>
<button class="btn btn-xs" onclick="clearSvcLogs()">🗑 清空</button>
</div>
</div>
<div id="services-log-tabs-panel">
<div id="services-log-tabs-panel" style="display:none">
<div class="log-container" id="services-log-panel">
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
</div>
</div>
<div id="services-log-grid" style="display:none">
<div class="cards-grid cards-4">
<div class="log-container" id="log-panel-ai-core" style="height:280px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div>
<div class="log-container" id="log-panel-gateway" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
<div class="log-container" id="log-panel-iot-debug-service" style="height:280px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div>
<div class="log-container" id="log-panel-frontend" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
<div id="services-log-grid">
<div class="svc-log-grid" style="display:flex;gap:12px;overflow-x:auto;padding-bottom:8px">
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-ai-core" style="height:300px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-gateway" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-iot-debug-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-memory-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Memory</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-tool-engine" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Tool Engine</div></div></div>
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-voice-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Voice</div></div></div>
</div>
</div>
</div>
@@ -2055,14 +2060,19 @@ function renderServicesPanel() {
renderServiceCards();
initSvcLogTabs();
renderServiceLog();
if (STATE.logLayout === 'grid') {
document.getElementById('services-log-tabs').style.display = 'none';
ALL_SVC_IDS.forEach(id => renderGridLog(id));
} else {
renderServiceLog();
}
}
function renderServiceCards() {
const container = document.getElementById('services-svc-cards');
if (!container) return;
const status = STATE.serviceStatus;
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ALL_SVC_IDS;
container.innerHTML = ids.map(id => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
@@ -2096,7 +2106,7 @@ function renderServiceCards() {
function initSvcLogTabs() {
const tabs = document.getElementById('services-log-tabs');
if (!tabs) return;
tabs.innerHTML = ['ai-core', 'gateway', 'iot-debug-service', 'frontend'].map(id =>
tabs.innerHTML = ALL_SVC_IDS.map(id =>
`<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>`
).join('');
}
@@ -2141,20 +2151,20 @@ function toggleSvcLogLayout() {
const gridPanel = document.getElementById('services-log-grid');
const logTabs = document.getElementById('services-log-tabs');
if (STATE.logLayout === 'tabs') {
if (STATE.logLayout === 'grid') {
STATE.logLayout = 'tabs';
gridPanel.style.display = 'none';
tabsPanel.style.display = '';
logTabs.style.display = '';
btn.textContent = '📐 并列';
renderServiceLog();
} else {
STATE.logLayout = 'grid';
tabsPanel.style.display = 'none';
gridPanel.style.display = '';
logTabs.style.display = 'none';
btn.textContent = '📋 标签页';
['ai-core', 'gateway', 'iot-debug-service', 'frontend'].forEach(id => renderGridLog(id));
} else {
STATE.logLayout = 'tabs';
tabsPanel.style.display = '';
gridPanel.style.display = 'none';
logTabs.style.display = '';
btn.textContent = '📐 并列';
renderServiceLog();
ALL_SVC_IDS.forEach(id => renderGridLog(id));
}
}
@@ -2233,7 +2243,7 @@ async function refreshPerf() {
function renderPerfPanels(snap) {
const container = document.getElementById('perf-panels');
if (!container) return;
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ALL_SVC_IDS;
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
const isFirstRender = !container.querySelector('.perf-card');
@@ -2315,7 +2325,6 @@ async function renderDatabasePanel() {
}
const ports = data.ports || [];
const tunnelRunning = data.tunnelRunning;
const allAlive = data.allAlive;
const aliveCount = data.aliveCount;
const totalPorts = data.totalPorts;
@@ -2342,13 +2351,13 @@ async function renderDatabasePanel() {
'<!-- 概览 -->' +
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">🔌 SSH 隧道状态</span>' +
'<span class="badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped') + '" id="db-tunnel-badge">' + (tunnelRunning ? '运行中' : '未运行') + '</span>' +
'<span class="card-title">🐳 数据库连接状态</span>' +
'<span class="badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error')) + '" id="db-tunnel-badge">' + (allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线')) + '</span>' +
'</div>' +
'<div class="db-summary">' +
'<div class="db-summary-stat">' +
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
'<div class="lbl">数据库端口通联</div>' +
'<div class="lbl">容器端口通联</div>' +
'</div>' +
(pg ?
'<div class="db-summary-stat">' +
@@ -2377,16 +2386,15 @@ async function renderDatabasePanel() {
'</div>' +
'</div>' +
'<!-- 隧道操作 -->' +
'<!-- 数据库容器控制 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>' +
'<div class="card-header"><span class="card-title">🕹️ 数据库容器控制</span></div>' +
'<div class="btn-group" style="margin-bottom:8px">' +
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="tunnelAction(\'start\')"' + (tunnelRunning && allAlive ? ' disabled' : '') + '>▶ 启动隧道</button>' +
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="tunnelAction(\'stop\')"' + (!tunnelRunning ? ' disabled' : '') + '>⏹ 停止隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'restart\')">🔄 重启隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'status\')">📋 查看状态</button>' +
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="dbContainerAction(\'start\')"' + (allAlive ? ' disabled' : '') + '>▶ 启动</button>' +
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="dbContainerAction(\'stop\')"' + (aliveCount === 0 ? ' disabled' : '') + '>⏹ 停止</button>' +
'<button class="btn btn-sm" onclick="dbContainerAction(\'restart\')">🔄 重启</button>' +
'</div>' +
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (tunnelRunning && !allAlive ? 'block' : 'none') + '">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</div>' +
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (aliveCount > 0 && !allAlive ? 'block' : 'none') + '">⚠️ 部分容器端口不通,请尝试重启 Docker 容器</div>' +
'<div id="tunnel-log-container" style="display:none">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
@@ -2396,13 +2404,13 @@ async function renderDatabasePanel() {
'</div>' +
'</div>' +
'<!-- 数据库连接信息 -->' +
'<!-- 连接信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
'<div>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>' +
'<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>' +
'<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>' +
'<div>🐳 Docker Compose: <code style="color:var(--text)">docker compose -f docker-compose.dev.db.yml up -d</code></div>' +
'<div>📁 配置文件: <code style="color:var(--text)">docker-compose.dev.db.yml</code></div>' +
'<div>💡 所有数据库服务运行在本地 Docker 容器中,端口映射至 <code style="color:var(--text)">localhost</code></div>' +
'</div>' +
'</div>';
STATE.dbInitialized = true;
@@ -2410,11 +2418,11 @@ async function renderDatabasePanel() {
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
var el;
// 隧道状态徽章
// 数据库状态徽章
el = document.getElementById('db-tunnel-badge');
if (el) {
el.className = 'badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped');
el.textContent = tunnelRunning ? '运行中' : '未运行';
el.className = 'badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error'));
el.textContent = allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线');
}
// 端口通联计数
@@ -2453,13 +2461,13 @@ async function renderDatabasePanel() {
// 更新按钮 disable 状态
el = document.getElementById('db-tunnel-start');
if (el) el.disabled = !!(tunnelRunning && allAlive);
if (el) el.disabled = !!allAlive;
el = document.getElementById('db-tunnel-stop');
if (el) el.disabled = !tunnelRunning;
if (el) el.disabled = !(aliveCount > 0);
// 僵尸警告
// 部分在线警告
el = document.getElementById('db-zombie-warn');
if (el) el.style.display = (tunnelRunning && !allAlive) ? 'block' : 'none';
if (el) el.style.display = (aliveCount > 0 && !allAlive) ? 'block' : 'none';
}
}
@@ -2467,23 +2475,25 @@ function refreshDatabasePanel() {
renderDatabasePanel();
}
async function tunnelAction(action) {
showToast(`正在执行: ${action} 隧道...`, 'info');
async function dbContainerAction(action) {
var labelMap = { start: '启动', stop: '停止', restart: '重启' };
var label = labelMap[action] || action;
showToast('正在' + label + '数据库容器...', 'info');
const logContainer = document.getElementById('tunnel-log-container');
const logEl = document.getElementById('tunnel-log');
logEl.textContent = '执行中...';
logContainer.style.display = 'block';
const data = await api(`/api/tunnel/${action}`, { method: 'POST' });
const data = await api('/api/db/' + action, { method: 'POST' });
if (data.error && !data.output) {
logEl.textContent = `错误: ${data.error}`;
showToast(`操作失败: ${data.error}`, 'error');
logEl.textContent = '错误: ' + data.error;
showToast('操作失败: ' + data.error, 'error');
} else {
logEl.textContent = data.output || data.error || '(无输出)';
if (data.success) {
showToast(`${action} 隧道完成`, 'success');
showToast(label + '数据库完成', 'success');
} else {
showToast(`${action} 完成 (查看日志)`, 'info');
showToast(label + '完成 (查看日志)', 'info');
}
setTimeout(refreshDatabasePanel, 1500);
}
+34 -14
View File
@@ -5,11 +5,31 @@
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';
/** 跨平台 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 DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
export const LOGS_DIR = path.resolve(__dirname, '../logs');
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
@@ -31,8 +51,8 @@ export const SERVICES = {
healthUrl: 'http://localhost:8081/api/v1/health',
port: 8081,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'iot-debug-service': {
name: 'IoT Debug',
@@ -44,8 +64,8 @@ export const SERVICES = {
healthUrl: 'http://localhost:8083/api/v1/health',
port: 8083,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
gateway: {
name: 'Gateway',
@@ -64,8 +84,8 @@ export const SERVICES = {
healthUrl: 'http://localhost:8080/api/v1/health',
port: 8080,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'memory-service': {
name: '记忆服务',
@@ -78,8 +98,8 @@ export const SERVICES = {
healthUrl: 'http://localhost:8091/api/v1/health',
port: 8091,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'tool-engine': {
name: '工具引擎',
@@ -93,8 +113,8 @@ export const SERVICES = {
healthUrl: 'http://localhost:8092/api/v1/health',
port: 8092,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'voice-service': {
name: '语音识别服务',
@@ -109,8 +129,8 @@ export const SERVICES = {
healthUrl: 'http://localhost:8093/api/v1/health',
port: 8093,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
frontend: {
name: 'Frontend',
@@ -122,8 +142,8 @@ export const SERVICES = {
},
healthUrl: 'http://localhost:5173',
port: 5173,
nodeBin: '/usr/local/node/bin/node',
npmBin: '/usr/local/node/bin/npx',
nodeBin: 'node',
npmBin: 'npx',
// frontend不需要预编译,dev server即可
buildCommand: null,
},
+37 -106
View File
@@ -23,7 +23,6 @@ const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TUNNEL_SCRIPT = path.join(ROOT, 'scripts/tunnel.sh');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -222,17 +221,11 @@ app.get('/api/dashboard', async (_req, res) => {
}
} catch { /* 忽略 */ }
// 数据库状态(快速检查,不阻塞
// 数据库状态(通过 TCP 端口检查 Docker 容器是否在运行
let dbStatus = { checked: false };
try {
let tunnelRunning = false;
try {
// ^ssh 锚点确保只匹配实际的 SSH 进程,排除 pgrep 自身的 shell 包装器
const out = execSync('pgrep -f "^ssh .*cyrene-tunnel"', { encoding: 'utf-8', timeout: 2000 });
tunnelRunning = out.trim().length > 0;
} catch { /* 未运行 */ }
const port5432Alive = checkPort(5432);
dbStatus = { checked: true, tunnelRunning, postgresAlive: port5432Alive };
const port5432Alive = await isPortOpen(5432);
dbStatus = { checked: true, postgresAlive: port5432Alive };
} catch { /* 忽略 */ }
const sysMem = process.memoryUsage();
@@ -1036,65 +1029,42 @@ app.get('/api/proxy/:id/health', async (req, res) => {
});
// ---- 数据库状态检查 ----
// 需要检查的远程数据库端口映射 (对应 tunnel.sh 中的 SERVICES)
const DB_PORTS = [
{ port: 5432, name: 'PostgreSQL' },
{ port: 6379, name: 'Redis' },
{ port: 6334, name: 'Qdrant HTTP' },
{ port: 9000, name: 'MinIO API' },
{ port: 4222, name: 'NATS' },
];
import net from 'net';
/**
* 检查本地端口是否在监听 (对应 tunnel 转发的远程服务)
*/
function checkPort(port) {
try {
const hexPort = port.toString(16).padStart(4, '0').toUpperCase();
const tcpContent = fs.readFileSync('/proc/net/tcp', 'utf-8');
for (const line of tcpContent.split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length > 1 && parts[1]) {
const localAddr = parts[1].split(':')[1];
if (localAddr === hexPort) {
return true;
}
}
}
} catch { /* fallback: try TCP connect */ }
// 备用方案: 使用 /dev/tcp (在 bash 中可用)
try {
execSync(`timeout 1 bash -c "echo >/dev/tcp/127.0.0.1/${port}" 2>/dev/null`, { timeout: 1500 });
return true;
} catch {
return false;
}
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');
});
}
app.get('/api/database/status', (_req, res) => {
// 检查 SSH 隧道进程是否运行
let tunnelRunning = false;
try {
const out = execSync('pgrep -f "^ssh .*cyrene-tunnel"', { encoding: 'utf-8', timeout: 3000 });
tunnelRunning = out.trim().length > 0;
} catch { /* 没找到进程 */ }
app.get('/api/database/status', async (_req, res) => {
const DB_PORTS = [
{ port: 5432, name: 'PostgreSQL' },
{ port: 6379, name: 'Redis' },
{ port: 6334, name: 'Qdrant gRPC' },
{ port: 9000, name: 'MinIO API' },
{ port: 4222, name: 'NATS' },
];
// 检查各端口
const ports = DB_PORTS.map(({ port, name }) => {
const alive = checkPort(port);
const results = await Promise.all(DB_PORTS.map(async ({ port, name }) => {
const alive = await isPortOpen(port);
return { port, name, alive };
});
}));
const allAlive = ports.every(p => p.alive);
const aliveCount = ports.filter(p => p.alive).length;
const allAlive = results.every(p => p.alive);
const aliveCount = results.filter(p => p.alive).length;
// 尝试获取 PostgreSQL 详情
let pgDetails = null;
if (ports.find(p => p.port === 5432)?.alive) {
const pgPort = results.find(p => p.port === 5432);
if (pgPort?.alive) {
try {
// 读取 .env 获取凭据
const envPath = path.join(ROOT, 'backend', '.env');
let pgUser = 'cyrene', pgPass = 'change_me', pgDb = 'cyrene_ai';
let pgUser = 'cyrene', pgPass = 'cyrene_pass', pgDb = 'cyrene_ai';
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const mUser = envContent.match(/^POSTGRES_USER=(.+)$/m);
@@ -1105,8 +1075,8 @@ app.get('/api/database/status', (_req, res) => {
if (mDb) pgDb = mDb[1];
}
const out = execSync(
`PGPASSWORD="${pgPass}" psql -h localhost -p 5432 -U "${pgUser}" -d "${pgDb}" -t -c "SELECT count(*) FROM memories;" 2>/dev/null`,
{ encoding: 'utf-8', timeout: 5000 }
`docker exec cyrene_postgres psql -U "${pgUser}" -d "${pgDb}" -t -c "SELECT count(*) FROM memories;" 2>nul`,
{ encoding: 'utf-8', timeout: 5000, windowsHide: true }
);
const match = out.match(/(\d+)/);
pgDetails = { memories: match ? parseInt(match[1]) : 0, database: pgDb };
@@ -1115,8 +1085,7 @@ app.get('/api/database/status', (_req, res) => {
res.json({
timestamp: Date.now(),
tunnelRunning,
ports,
ports: results,
allAlive,
aliveCount,
totalPorts: DB_PORTS.length,
@@ -1124,55 +1093,17 @@ app.get('/api/database/status', (_req, res) => {
});
});
// ---- 隧道控制 ----
app.post('/api/tunnel/:action', (req, res) => {
const { action } = req.params;
if (!['start', 'stop', 'restart', 'status'].includes(action)) {
return res.status(400).json({ error: `不支持的操作: ${action},支持: start/stop/restart/status` });
}
if (!fs.existsSync(TUNNEL_SCRIPT)) {
return res.status(404).json({ error: `隧道脚本不存在: ${TUNNEL_SCRIPT}` });
}
try {
const out = execSync(`bash "${TUNNEL_SCRIPT}" ${action}`, {
encoding: 'utf-8',
timeout: 20000,
cwd: path.join(ROOT, 'scripts'),
});
res.json({ success: true, action, output: out.trim() });
} catch (err) {
// tunnel.sh 可能返回非零退出码但仍成功(如 start 时发现已在运行)
const output = err.stdout || err.stderr || err.message;
res.json({
success: false,
action,
output: output.trim(),
error: err.message,
});
}
});
// ---- 数据库控制 (Docker Compose) ----
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
const DB_PORT = 5432;
// GET /api/db/status
app.get('/api/db/status', (_req, res) => {
app.get('/api/db/status', async (_req, res) => {
try {
const online = checkPort(DB_PORT);
res.json({
online,
port: DB_PORT,
checked_at: new Date().toISOString(),
});
} catch (err) {
res.json({
online: false,
port: DB_PORT,
checked_at: new Date().toISOString(),
});
const online = await isPortOpen(DB_PORT);
res.json({ online, port: DB_PORT, checked_at: new Date().toISOString() });
} catch {
res.json({ online: false, port: DB_PORT, checked_at: new Date().toISOString() });
}
});
+39 -10
View File
@@ -7,6 +7,7 @@ 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';
@@ -16,6 +17,7 @@ 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';
/**
* 通过 TCP 连接尝试判断端口是否被占用若被占用则尝试用 fuser 释放
@@ -149,7 +151,7 @@ class ProcessManager extends EventEmitter {
// 确保日志目录存在
const logPath = logFile(serviceId);
const logDir = logPath.substring(0, logPath.lastIndexOf('/'));
const logDir = path.dirname(logPath);
fs.mkdirSync(logDir, { recursive: true });
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
@@ -157,10 +159,10 @@ class ProcessManager extends EventEmitter {
// 确定二进制路径或命令
let command, args;
if (svc.command === './main') {
command = svc.command;
command = isWin ? './main.exe' : './main';
args = svc.args || [];
} else if (svc.command === 'npx') {
command = svc.npmBin || 'npx';
command = isWin ? 'npx.cmd' : (svc.npmBin || 'npx');
args = svc.args || [];
} else {
command = svc.command;
@@ -168,12 +170,14 @@ class ProcessManager extends EventEmitter {
}
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: false,
shell: needsShell,
});
child.stdout.on('data', (data) => {
@@ -407,13 +411,25 @@ class ProcessManager extends EventEmitter {
}
/**
* 按顺序启动所有服务 (ai-core gateway frontend)
* 检查服务是否需要编译 (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 tool-engine iot voice ai-core gateway frontend)
* 每步等待健康检查通过后再启动下一个
*/
async startAllSequential() {
const order = ['memory-service', 'tool-engine', 'iot-debug-service', 'ai-core', 'gateway', 'frontend'];
const order = ['memory-service', 'tool-engine', 'iot-debug-service', 'voice-service', 'ai-core', 'gateway', 'frontend'];
const results = [];
for (const id of order) {
const svc = SERVICES[id];
// 先尝试接管已运行的服务
@@ -422,12 +438,25 @@ class ProcessManager extends EventEmitter {
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;
@@ -453,7 +482,7 @@ class ProcessManager extends EventEmitter {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
}