/** * Cyrene ethend - 主入口 * * 提供: * - REST API: 服务管理、状态查询、性能分析、健康检查代理 * - WebSocket: 实时日志推送 * - Web UI: 管理控制台 */ import express from 'express'; import { WebSocketServer, WebSocket } from 'ws'; import http from 'http'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { execSync, spawn } from 'child_process'; import { processManager } from './process-manager.js'; import { performanceMonitor } from './performance.js'; import { SERVICES, ETHEND_PORT, LOGS_DIR, logFile, GATEWAY_URL, PLUGIN_MANAGER_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js'; const AI_CORE_URL = process.env.AI_CORE_URL || 'http://localhost:8081'; const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091'; const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093'; const PLATFORM_BRIDGE_URL = process.env.PLATFORM_BRIDGE_URL || 'http://localhost:8095'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ========== 初始化 ========== const app = express(); app.use(express.json()); // 请求延时追踪 — 记录每个 HTTP 请求的实际耗时 app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { performanceMonitor.recordLatency(Date.now() - start); }); next(); }); // 静态文件 - Web控制台 app.use(express.static(path.join(__dirname, '../public'))); // ========== WebSocket ========== const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); /** @type {Set} */ const wsClients = new Set(); wss.on('connection', (ws) => { wsClients.add(ws); ws.on('close', () => wsClients.delete(ws)); }); /** 广播到所有WebSocket客户端 */ function broadcast(type, data) { const msg = JSON.stringify({ type, data, ts: Date.now() }); for (const ws of wsClients) { if (ws.readyState === WebSocket.OPEN) { ws.send(msg); } } } // 日志事件 -> WebSocket广播 processManager.on('log', (serviceId, stream, text) => { broadcast('log', { service: serviceId, stream, text: text.trimEnd() }); }); // 状态变化 processManager.on('log', (serviceId, stream, text) => { if (stream === 'system') { broadcast('status', processManager.getStatus()); } }); // ========== 平台桥接实时日志流 ========== let logStreamWs = null; let logStreamReconnectTimer = null; function connectPlatformBridgeLogStream() { if (logStreamReconnectTimer) { clearTimeout(logStreamReconnectTimer); logStreamReconnectTimer = null; } if (logStreamWs && (logStreamWs.readyState === WebSocket.OPEN || logStreamWs.readyState === WebSocket.CONNECTING)) return; const wsUrl = PLATFORM_BRIDGE_URL.replace(/^http/, 'ws') + '/ws/logs'; console.log(`[LogStream] 连接 ${wsUrl} ...`); try { logStreamWs = new WebSocket(wsUrl); } catch (err) { console.error(`[LogStream] 创建连接失败: ${err.message}`); scheduleLogStreamReconnect(); return; } logStreamWs.on('open', () => { console.log('[LogStream] 已连接,实时日志推送中'); }); logStreamWs.on('message', (raw) => { try { const entry = JSON.parse(raw.toString()); broadcast('chat-log', entry); } catch {} }); logStreamWs.on('close', () => { console.log('[LogStream] 连接断开'); logStreamWs = null; scheduleLogStreamReconnect(); }); logStreamWs.on('error', (err) => { console.error(`[LogStream] 错误: ${err.message}`); logStreamWs = null; scheduleLogStreamReconnect(); }); } function scheduleLogStreamReconnect() { if (logStreamReconnectTimer) return; logStreamReconnectTimer = setTimeout(() => { logStreamReconnectTimer = null; connectPlatformBridgeLogStream(); }, 5000); } // 启动时连接,后续 platform-bridge 重启时通过状态变化自动重连。 connectPlatformBridgeLogStream(); // 监听服务状态:platform-bridge 上线后重连。 setInterval(() => { if (!logStreamWs || logStreamWs.readyState === WebSocket.CLOSED || logStreamWs.readyState === WebSocket.CLOSING) { connectPlatformBridgeLogStream(); } }, 15000); // ========== Gateway 代理辅助函数 ========== /** 缓存的 JWT token 和过期时间 */ let cachedToken = null; let tokenExpiry = 0; /** * 获取 Gateway JWT token (通过 admin 凭据登录,缓存直到过期,支持重试) */ async function getGatewayToken() { if (cachedToken && Date.now() < tokenExpiry - 60000) { return cachedToken; } const maxRetries = 3; const baseDelay = 1000; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }), signal: AbortSignal.timeout(5000), }); if (!resp.ok) { if (attempt < maxRetries - 1) { const delay = baseDelay * Math.pow(2, attempt); console.log(`[Gateway代理] 登录失败 (HTTP ${resp.status}),${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`); await new Promise((r) => setTimeout(r, delay)); continue; } console.error('[Gateway代理] 登录失败:', resp.status); return null; } const data = await resp.json(); cachedToken = data.token; tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000; console.log('[Gateway代理] 登录成功,token 已缓存'); return cachedToken; } catch (err) { if (attempt < maxRetries - 1) { const delay = baseDelay * Math.pow(2, attempt); console.log(`[Gateway代理] 登录异常: ${err.message},${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`); await new Promise((r) => setTimeout(r, delay)); continue; } console.error('[Gateway代理] 登录异常 (已达最大重试次数):', err.message); return null; } } return null; } /** * 代理请求到 Gateway,自动携带 JWT token * @param {string} path - Gateway API 路径 (如 /api/v1/memory/search?user_id=...) * @param {object} opts - fetch 选项 */ async function proxyToGateway(path, opts = {}) { const token = await getGatewayToken(); if (!token) { return { status: 502, body: { error: '无法连接到 Gateway 认证服务', errorType: 'gateway_auth_failed', hint: '请确认 Gateway 服务已启动 (端口 8080)', }, }; } const url = `${GATEWAY_URL}${path}`; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, ...opts.headers, }; try { const resp = await fetch(url, { ...opts, headers, signal: AbortSignal.timeout(15000) }); const body = await resp.json().catch(() => null); return { status: resp.status, body }; } catch (err) { const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; return { status: 502, body: { error: `Gateway 不可达: ${err.message}`, errorType: isConnRefused ? 'gateway_not_running' : 'gateway_unreachable', hint: isConnRefused ? 'Gateway 服务未启动,请先在「服务管理」面板中启动 Gateway' : 'Gateway 服务无响应,请检查网络连接和服务状态', }, }; } } /** * 代理请求到 AI-Core */ async function proxyToAICore(path, opts = {}) { const url = `${AI_CORE_URL}${path}`; try { const resp = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...opts.headers }, signal: AbortSignal.timeout(10000), }); const body = await resp.json().catch(() => null); return { status: resp.status, body }; } catch (err) { return { status: 502, body: { error: `AI-Core 不可达: ${err.message}`, errorType: 'ai_core_unreachable', hint: 'AI-Core 服务未启动,请先在「服务管理」面板中启动 AI-Core', }, }; } } // ========== REST API 路由 ========== // ---- 健康检查 ---- app.get('/api/health', (_req, res) => { res.json({ status: 'ok', service: 'cyrene-ethend', uptime: process.uptime(), wsClients: wsClients.size, }); }); // ---- ethend 自重启 ---- app.post('/api/ethend/restart', (_req, res) => { res.json({ success: true, message: 'ethend 正在重启...' }); // 延迟 500ms 确保响应已发送,然后 spawn 新进程并退出 setTimeout(() => { const scriptPath = path.join(__dirname, 'index.js'); const child = spawn(process.execPath, [scriptPath, ...process.argv.slice(2)], { cwd: ROOT, detached: true, stdio: 'ignore', windowsHide: true, }); child.unref(); process.exit(0); }, 500); }); // ---- 仪表盘数据 (必须在 /api/services/:id 之前以避免路由冲突) ---- app.get('/api/dashboard', async (_req, res) => { try { const [services, perfSnapshot, sessionsResult] = await Promise.all([ Promise.resolve(processManager.getStatus()), performanceMonitor.getSnapshot(), proxyToGateway('/api/v1/admin/sessions').catch(() => ({ status: 502, body: { sessions: [], total: 0 } })), ]); let runningCount = 0, totalCpu = 0, totalMem = 0; for (const svc of Object.values(services)) { if (svc.status === 'running') runningCount++; } for (const p of Object.values(perfSnapshot)) { totalCpu += p.cpu || 0; totalMem += p.mem || 0; } const sessionsData = sessionsResult.body || {}; const activeSessions = sessionsData.total || sessionsData.sessions?.length || 0; let totalMessages = 0; if (sessionsData.sessions) { for (const s of sessionsData.sessions) { totalMessages += (s.message_count || 0); } } let memoryCount = null; try { const token = await getGatewayToken(); if (token) { const memResp = await fetch(`${GATEWAY_URL}/api/v1/memory?user_id=admin`, { headers: { 'Authorization': `Bearer ${token}` }, signal: AbortSignal.timeout(5000), }); if (memResp.ok) { const memData = await memResp.json(); memoryCount = Array.isArray(memData) ? memData.length : (memData.memories ? memData.memories.length : null); } } } catch { /* 忽略 */ } // 数据库状态(通过 TCP 端口检查 Docker 容器是否在运行) let dbStatus = { checked: false }; try { const port5432Alive = await isPortOpen(5432, IS_DOCKER ? 'postgres' : undefined); dbStatus = { checked: true, postgresAlive: port5432Alive }; } catch { /* 忽略 */ } const sysMem = process.memoryUsage(); res.json({ timestamp: Date.now(), services: { total: Object.keys(services).length, running: runningCount, list: services }, performance: { totalCpu: Math.round(totalCpu * 100) / 100, totalMem: Math.round(totalMem * 100) / 100, perService: perfSnapshot }, sessions: { active: activeSessions, totalMessages }, memory: { total: memoryCount }, database: dbStatus, system: { heapUsedMB: Math.round(sysMem.heapUsed / 1024 / 1024 * 100) / 100, heapTotalMB: Math.round(sysMem.heapTotal / 1024 / 1024 * 100) / 100, uptime: process.uptime() }, }); } catch (err) { res.status(500).json({ error: `获取仪表盘数据失败: ${err.message}` }); } }); // ---- 会话监看代理 (必须在 /api/services/:id 之前) ---- app.get('/api/sessions', async (_req, res) => { const result = await proxyToGateway('/api/v1/admin/sessions'); res.status(result.status).json(result.body); }); // GET /api/sessions/active — 获取按用户分组的活跃会话 (管理员权限) app.get('/api/sessions/active', async (_req, res) => { const result = await proxyToGateway('/api/v1/admin/sessions/active'); res.status(result.status).json(result.body); }); app.get('/api/sessions/:id', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/sessions/${req.params.id}`); res.status(result.status).json(result.body); }); // ---- 记忆管理代理 (必须在 /api/services/:id 之前) ---- app.get('/api/memory/search', async (req, res) => { const { user_id, q } = req.query; if (!user_id || !q) return res.status(400).json({ error: '缺少 user_id 或 q 参数' }); const qs = new URLSearchParams({ user_id, q }).toString(); const result = await proxyToGateway(`/api/v1/memory/search?${qs}`); res.status(result.status).json(result.body); }); app.get('/api/memory/list', async (req, res) => { const { user_id, limit, offset } = req.query; if (!user_id) return res.status(400).json({ error: '缺少 user_id 参数' }); const qs = new URLSearchParams({ user_id }); if (limit) qs.set('limit', limit); if (offset) qs.set('offset', offset); const result = await proxyToGateway(`/api/v1/memory?${qs.toString()}`); res.status(result.status).json(result.body); }); app.post('/api/memory/add', async (req, res) => { const { user_id, content, category, priority } = req.body; if (!user_id || !content) return res.status(400).json({ error: '缺少 user_id 或 content' }); const result = await proxyToGateway('/api/v1/memory', { method: 'POST', body: JSON.stringify({ user_id, content, category: category || 'other', priority: priority || 1 }), }); res.status(result.status).json(result.body); }); app.delete('/api/memory/:id', async (req, res) => { const { id } = req.params; if (!id) return res.status(400).json({ error: '缺少 memory id' }); const qs = new URLSearchParams({ id }).toString(); const result = await proxyToGateway(`/api/v1/memory?${qs}`, { method: 'DELETE' }); res.status(result.status).json(result.body); }); // ---- 服务状态 ---- app.get('/api/services', (_req, res) => { res.json(processManager.getStatus()); }); app.get('/api/services/:id', (req, res) => { const status = processManager.getServiceStatus(req.params.id); if (!status) return res.status(404).json({ error: '未知服务' }); res.json(status); }); // ---- 服务控制 ---- app.post('/api/services/:id/start', async (req, res) => { try { const result = await processManager.start(req.params.id); if (result.error === 'docker_managed') { res.status(409).json(result); } else { broadcast('status', processManager.getStatus()); res.json(result); } } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); app.post('/api/services/:id/stop', async (req, res) => { try { const result = await processManager.stop(req.params.id); if (result.error === 'docker_managed') { res.status(409).json(result); } else { broadcast('status', processManager.getStatus()); res.json(result); } } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); app.post('/api/services/:id/restart', async (req, res) => { try { const result = await processManager.restart(req.params.id); if (result.error === 'docker_managed') { res.status(409).json(result); return; } // 异步重启,因为可能耗时较长 res.json({ success: true, message: '重启中...' }); broadcast('status', processManager.getStatus()); } catch (err) { // 已经在上面res了 } }); app.post('/api/services/:id/build', async (req, res) => { try { const result = await processManager.build(req.params.id); if (result.error === 'docker_managed') { res.status(409).json(result); } else { res.json(result); } } catch (err) { res.status(400).json({ success: false, message: err.message }); } }); // 批量操作 // 一键按顺序启动 (接管已运行 + 健康检查) app.post('/api/services/start-all', async (_req, res) => { const results = await processManager.startAllSequential(); broadcast('status', processManager.getStatus()); res.json(results); }); // 强制重启全部 (先杀后启) app.post('/api/services/start-all-fresh', async (_req, res) => { await processManager.stopAll(); await new Promise((r) => setTimeout(r, 1000)); const results = await processManager.startAllSequential(); broadcast('status', processManager.getStatus()); res.json(results); }); app.post('/api/services/stop-all', async (_req, res) => { const results = await processManager.stopAll(); broadcast('status', processManager.getStatus()); res.json(results); }); // ---- 性能监控 ---- app.get('/api/performance', async (_req, res) => { const snapshot = await performanceMonitor.getSnapshot(); res.json(snapshot); }); // 性能仪表盘聚合数据 (供首页仪表盘使用) app.get('/api/performance/dashboard', async (_req, res) => { try { const dashboardData = await performanceMonitor.updateDashboard(); res.json(dashboardData); } catch (err) { res.status(500).json({ error: `获取性能仪表盘数据失败: ${err.message}` }); } }); app.get('/api/performance/history', (_req, res) => { res.json(performanceMonitor.getAllHistory()); }); app.get('/api/performance/:id', (req, res) => { const history = performanceMonitor.getHistory(req.params.id); res.json(history); }); app.get('/api/performance/:id/summary', async (req, res) => { const snap = await performanceMonitor.getSnapshot(); const history = performanceMonitor.getHistory(req.params.id); const svc = snap[req.params.id]; if (!svc) return res.status(404).json({ error: '未知服务' }); // 计算汇总统计 let maxCpu = 0, maxMem = 0; const cpuValues = [], memValues = []; for (const h of history) { if (h.cpu > maxCpu) maxCpu = h.cpu; if (h.mem > maxMem) maxMem = h.mem; cpuValues.push(h.cpu); memValues.push(h.mem); } const avgCpu = cpuValues.length > 0 ? Math.round(cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length * 100) / 100 : 0; const avgMem = memValues.length > 0 ? Math.round(memValues.reduce((a, b) => a + b, 0) / memValues.length * 100) / 100 : 0; res.json({ current: svc, history: { count: history.length, maxCpu, maxMem, avgCpu, avgMem }, }); }); // ---- 日志查询 ---- app.get('/api/logs/:id', (req, res) => { const id = req.params.id; if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' }); const filePath = logFile(id); const lines = req.query.lines ? parseInt(req.query.lines) : 200; const offset = req.query.offset ? parseInt(req.query.offset) : 0; if (!fs.existsSync(filePath)) { return res.json({ service: id, lines: [], total: 0 }); } try { // 使用tail方式读取 const content = fs.readFileSync(filePath, 'utf-8'); const allLines = content.split('\n').filter(Boolean); const total = allLines.length; const sliced = allLines.slice(Math.max(0, total - lines - offset), total - offset); res.json({ service: id, lines: sliced, total }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/logs/:id/recent', (req, res) => { const id = req.params.id; if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' }); const filePath = logFile(id); if (!fs.existsSync(filePath)) { return res.json({ service: id, lines: [], total: 0 }); } try { const content = fs.readFileSync(filePath, 'utf-8'); const allLines = content.split('\n').filter(Boolean); const total = allLines.length; res.json({ service: id, lines: allLines.slice(-100), total }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 删除日志 app.delete('/api/logs/:id', (req, res) => { const id = req.params.id; if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' }); const filePath = logFile(id); try { if (fs.existsSync(filePath)) { fs.writeFileSync(filePath, ''); } res.json({ success: true, message: '日志已清空' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ---- IoT 设备管理 (代理到 iot-debug-service) ---- const IOT_SERVICE_URL = process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083'; /** 通用 IoT 代理:转发请求到 iot-debug-service */ async function proxyToIoT(path, opts = {}) { const url = `${IOT_SERVICE_URL}${path}`; const logPrefix = `[IoT代理]`; try { console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`); const resp = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...opts.headers }, signal: AbortSignal.timeout(10000), }); const body = await resp.json().catch(() => null); if (!resp.ok) { console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`); } return { status: resp.status, body }; } catch (err) { const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`); return { status: 502, body: { error: `IoT 调试服务不可达: ${err.message}`, errorType: isConnRefused ? 'iot_not_running' : 'iot_unreachable', hint: isConnRefused ? 'IoT 调试服务未启动,请先在「服务管理」面板中启动 IoT Debug 服务' : 'IoT 调试服务无响应,请检查网络连接和服务状态', }, }; } } // GET /api/iot/devices — 获取所有模拟设备 app.get('/api/iot/devices', async (_req, res) => { console.log('[IoT] 获取设备列表'); const result = await proxyToIoT('/api/v1/devices'); res.status(result.status).json(result.body); }); // GET /api/iot/devices/:id — 获取单个设备 app.get('/api/iot/devices/:id', async (req, res) => { console.log(`[IoT] 获取设备: ${req.params.id}`); const result = await proxyToIoT(`/api/v1/devices/${req.params.id}`); res.status(result.status).json(result.body); }); // POST /api/iot/devices/:id/toggle — 切换设备开关 app.post('/api/iot/devices/:id/toggle', async (req, res) => { console.log(`[IoT] 切换设备: ${req.params.id}`); const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/toggle`, { method: 'POST' }); res.status(result.status).json(result.body); }); // POST /api/iot/devices/:id/set — 设置设备属性 app.post('/api/iot/devices/:id/set', async (req, res) => { const { field, value } = req.body; if (!field) { return res.status(400).json({ error: '缺少 field 参数' }); } console.log(`[IoT] 设置设备属性: ${req.params.id} -> ${field} = ${value}`); const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/set`, { method: 'POST', body: JSON.stringify({ field, value }), }); res.status(result.status).json(result.body); }); // GET /api/iot/devices/:id/history — 获取设备操作历史 app.get('/api/iot/devices/:id/history', async (req, res) => { console.log(`[IoT] 获取设备历史: ${req.params.id}`); const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/history`); res.status(result.status).json(result.body); }); // ---- 插件管理代理 (转发到 plugin-manager) ---- /** * 代理请求到 Plugin-Manager */ async function proxyToPluginManager(path, opts = {}) { const url = `${PLUGIN_MANAGER_URL}${path}`; const logPrefix = `[PluginManager代理]`; try { console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`); const resp = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...opts.headers }, signal: AbortSignal.timeout(10000), }); const body = await resp.json().catch(() => null); if (!resp.ok) { console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`); } return { status: resp.status, body }; } catch (err) { const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`); return { status: 502, body: { error: `插件管理器不可达: ${err.message}`, errorType: isConnRefused ? 'plugin_manager_not_running' : 'plugin_manager_unreachable', hint: isConnRefused ? '插件管理器服务未启动,请先在「服务管理」面板中启动 plugin-manager' : '插件管理器服务无响应,请检查网络连接和服务状态', }, }; } } // ---- 第三方聊天平台配置代理 (转发到 platform-bridge) ---- /** * 代理请求到 Platform-Bridge * @param {string} path - Platform-Bridge API 路径 * @param {object} opts - fetch 选项 */ async function proxyToPlatformBridge(path, opts = {}) { const url = `${PLATFORM_BRIDGE_URL}${path}`; const logPrefix = `[PlatformBridge代理]`; try { console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`); const resp = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...opts.headers }, signal: AbortSignal.timeout(10000), }); const body = await resp.json().catch(() => null); if (!resp.ok) { console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`); } return { status: resp.status, body }; } catch (err) { const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`); return { status: 502, body: { error: `Platform-Bridge 不可达: ${err.message}`, errorType: isConnRefused ? 'bridge_not_running' : 'bridge_unreachable', hint: isConnRefused ? 'Platform-Bridge 服务未启动,请先在「服务管理」面板中启动该服务' : 'Platform-Bridge 服务无响应,请检查网络连接和服务状态', }, }; } } // GET /api/chat-platforms/configs — 列出所有平台配置 app.get('/api/chat-platforms/configs', async (_req, res) => { const result = await proxyToPlatformBridge('/api/v1/configs'); res.status(result.status).json(result.body); }); // GET /api/chat-platforms/configs/:name — 获取单个配置 app.get('/api/chat-platforms/configs/:name', async (req, res) => { const result = await proxyToPlatformBridge(`/api/v1/configs/${req.params.name}`); res.status(result.status).json(result.body); }); // POST /api/chat-platforms/configs/:name — 创建或更新配置 app.post('/api/chat-platforms/configs/:name', async (req, res) => { const result = await proxyToPlatformBridge(`/api/v1/configs/${req.params.name}`, { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); // DELETE /api/chat-platforms/configs/:name — 删除配置 app.delete('/api/chat-platforms/configs/:name', async (req, res) => { const result = await proxyToPlatformBridge(`/api/v1/configs/${req.params.name}`, { method: 'DELETE', }); res.status(result.status).json(result.body); }); // GET /api/chat-platforms/logs/:name — 获取消息日志 app.get('/api/chat-platforms/logs/:name', async (req, res) => { const limit = req.query.limit || '100'; const result = await proxyToPlatformBridge(`/api/v1/logs/${req.params.name}?limit=${limit}`); res.status(result.status).json(result.body); }); // GET /api/chat-platforms/platforms — 列出所有平台适配器 (含连接状态与能力) app.get('/api/chat-platforms/platforms', async (_req, res) => { const result = await proxyToPlatformBridge('/api/v1/platforms'); res.status(result.status).json(result.body); }); // GET /api/chat-platforms/platforms/:name — 获取单个平台适配器详情 app.get('/api/chat-platforms/platforms/:name', async (req, res) => { const result = await proxyToPlatformBridge(`/api/v1/platforms/${req.params.name}`); res.status(result.status).json(result.body); }); // GET /api/chat-platforms/identities — 列出所有已注册的身份映射 app.get('/api/chat-platforms/identities', async (_req, res) => { const result = await proxyToPlatformBridge('/api/v1/identities'); res.status(result.status).json(result.body); }); // GET /api/chat-platforms/health — platform-bridge 整体健康状态 app.get('/api/chat-platforms/health', async (_req, res) => { const result = await proxyToPlatformBridge('/health'); res.status(result.status).json(result.body); }); // ---- 黑名单/白名单设置代理 ---- // GET /api/chat-platforms/settings/blocklist app.get('/api/chat-platforms/settings/blocklist', async (_req, res) => { const result = await proxyToPlatformBridge('/api/v1/settings/blocklist'); res.status(result.status).json(result.body); }); // POST /api/chat-platforms/settings/blocklist app.post('/api/chat-platforms/settings/blocklist', async (req, res) => { const result = await proxyToPlatformBridge('/api/v1/settings/blocklist', { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); // ---- 多端客户端管理代理 (转发到 Gateway) ---- // GET /api/clients — 获取已知客户端列表 app.get('/api/clients', async (req, res) => { const userID = req.query.user_id || 'admin'; const result = await proxyToGateway(`/api/v1/admin/clients?user_id=${encodeURIComponent(userID)}`); res.status(result.status).json(result.body); }); // PUT /api/clients/:id/note — 更新客户端备注 app.put('/api/clients/:id/note', async (req, res) => { const { note } = req.body; const result = await proxyToGateway(`/api/v1/admin/clients/${req.params.id}/note`, { method: 'PUT', body: JSON.stringify({ note }), }); res.status(result.status).json(result.body); }); // ---- 模型配置管理代理 (转发到 Gateway admin) ---- // Providers app.get('/api/model-config/providers', async (_req, res) => { const result = await proxyToGateway('/api/v1/admin/models/providers'); res.status(result.status).json(result.body); }); app.get('/api/model-config/providers/:name', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/providers/${req.params.name}`); res.status(result.status).json(result.body); }); app.post('/api/model-config/providers/:name', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/providers/${req.params.name}`, { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); app.delete('/api/model-config/providers/:name', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/providers/${req.params.name}`, { method: 'DELETE', }); res.status(result.status).json(result.body); }); // Models app.get('/api/model-config/models', async (_req, res) => { const result = await proxyToGateway('/api/v1/admin/models/models'); res.status(result.status).json(result.body); }); app.get('/api/model-config/models/:id', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/models/${req.params.id}`); res.status(result.status).json(result.body); }); app.post('/api/model-config/models/:id', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/models/${req.params.id}`, { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); app.delete('/api/model-config/models/:id', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/models/${req.params.id}`, { method: 'DELETE', }); res.status(result.status).json(result.body); }); // Routing app.get('/api/model-config/routing', async (_req, res) => { const result = await proxyToGateway('/api/v1/admin/models/routing'); res.status(result.status).json(result.body); }); app.get('/api/model-config/routing/:purpose', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/routing/${req.params.purpose}`); res.status(result.status).json(result.body); }); app.post('/api/model-config/routing/:purpose', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/routing/${req.params.purpose}`, { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); app.delete('/api/model-config/routing/:purpose', async (req, res) => { const result = await proxyToGateway(`/api/v1/admin/models/routing/${req.params.purpose}`, { method: 'DELETE', }); res.status(result.status).json(result.body); }); // Health check app.post('/api/model-config/health-check', async (req, res) => { const result = await proxyToGateway('/api/v1/admin/models/health-check', { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); // GET /api/model-config/fetch-models/:name?url=... — 代理查询 Provider 模型列表 app.get('/api/model-config/fetch-models/:name', async (req, res) => { const urlParam = req.query.url ? '?url=' + encodeURIComponent(req.query.url) : ''; const result = await proxyToGateway('/api/v1/admin/models/fetch-models/' + encodeURIComponent(req.params.name) + urlParam); res.status(result.status).json(result.body); }); // ---- 思考调度配置代理 ---- app.get('/api/thinking-schedule', async (_req, res) => { const result = await proxyToGateway('/api/v1/admin/thinking-schedule'); res.status(result.status).json(result.body); }); app.put('/api/thinking-schedule', async (req, res) => { const result = await proxyToGateway('/api/v1/admin/thinking-schedule', { method: 'PUT', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); // GET /api/tool-calls — 查询工具调用记录 app.get('/api/tool-calls', async (req, res) => { const { tool_name, page, limit } = req.query; const params = new URLSearchParams(); if (tool_name) params.set('tool_name', tool_name); params.set('page', page || '1'); params.set('limit', limit || '20'); const result = await proxyToAICore(`/api/v1/tools/calls?${params.toString()}`); res.status(result.status).json(result.body); }); // GET /api/tool-calls/stats — 工具调用统计 app.get('/api/tool-calls/stats', async (_req, res) => { const result = await proxyToAICore('/api/v1/tools/calls/stats'); res.status(result.status).json(result.body); }); // ---- VM 监控 (OS 环境信息) ---- app.get('/api/vm-monitor/status', async (_req, res) => { const result = await proxyToAICore('/api/v1/system/info'); res.status(result.status).json(result.body); }); // ---- 插件管理代理 (转发到 plugin-manager) ---- app.get('/api/plugins', async (_req, res) => { const result = await proxyToPluginManager('/api/v1/plugins'); res.status(result.status).json(result.body); }); app.get('/api/plugins/:id', async (req, res) => { const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id); res.status(result.status).json(result.body); }); app.post('/api/plugins/:id/enable', async (req, res) => { const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/enable', { method: 'POST' }); res.status(result.status).json(result.body); }); app.post('/api/plugins/:id/disable', async (req, res) => { const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/disable', { method: 'POST' }); res.status(result.status).json(result.body); }); app.post('/api/plugins/:id/reload', async (req, res) => { const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/reload', { method: 'POST' }); res.status(result.status).json(result.body); }); app.get('/api/plugins/:id/tools', async (req, res) => { const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/tools'); res.status(result.status).json(result.body); }); app.get('/api/tools', async (_req, res) => { const result = await proxyToPluginManager('/api/v1/tools'); res.status(result.status).json(result.body); }); app.post('/api/tools/:id/execute', async (req, res) => { const result = await proxyToPluginManager('/api/v1/tools/' + req.params.id + '/execute', { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); // ---- STT 处理日志存储 (内存环形缓冲区) ---- const sttLogEntries = []; const MAX_STT_LOGS = 200; /** * 记录 STT 请求日志(ethend 自身维护,因为 voice-service 无持久化日志) */ function recordSTTLog(entry) { sttLogEntries.unshift(entry); if (sttLogEntries.length > MAX_STT_LOGS) { sttLogEntries.length = MAX_STT_LOGS; } // 通过 WebSocket 广播给前端面板实时更新 broadcast('stt-log', entry); } // GET /api/voice/logs — 获取 STT 处理日志 app.get('/api/voice/logs', (req, res) => { const limit = parseInt(req.query.limit) || 50; const logs = sttLogEntries.slice(0, limit); res.json({ total: sttLogEntries.length, logs, }); }); // POST /api/voice/transcribe — 代理到 Voice-Service 并记录日志 // 接受 JSON (base64 音频) 并转发为 multipart/form-data 到 Voice-Service app.post('/api/voice/transcribe', async (req, res) => { const startTime = Date.now(); let { audio_base64, language, filename } = req.body || {}; // 也支持直接通过 FormData 上传 (express.raw 中间件处理后手动解析) if (!audio_base64 && req.is('multipart/form-data')) { return res.status(400).json({ error: 'multipart/form-data 暂不支持,请使用 JSON 格式发送 base64 编码的音频' }); } if (!audio_base64) { return res.status(400).json({ error: '缺少 audio_base64 字段' }); } // 计算音频大小 (解码后) let audioBuffer; try { audioBuffer = Buffer.from(audio_base64, 'base64'); } catch { return res.status(400).json({ error: 'audio_base64 格式无效,无法解码' }); } const audioSizeBytes = audioBuffer.length; // 估算音频时长 (WAV 16kHz 16bit mono: ~32000 bytes/sec) const estimatedDurationSec = audioSizeBytes > 0 ? (audioSizeBytes / 32000).toFixed(1) : '0'; if (!filename) filename = 'audio.wav'; try { // 构建 multipart/form-data 请求转发到 Voice-Service const boundary = '----ethendFormBoundary' + Math.random().toString(36).slice(2); const crlf = '\r\n'; const headerParts = [ '--' + boundary + crlf, 'Content-Disposition: form-data; name="audio"; filename="' + filename + '"' + crlf, 'Content-Type: application/octet-stream' + crlf, crlf, ]; const headerBytes = Buffer.from(headerParts.join(''), 'utf-8'); const footerBytes = Buffer.from(crlf + '--' + boundary + '--' + crlf, 'utf-8'); // 如果有 language 参数 let languagePart = Buffer.alloc(0); if (language) { const langHeader = [ '--' + boundary + crlf, 'Content-Disposition: form-data; name="language"' + crlf, crlf, language + crlf, ]; languagePart = Buffer.from(langHeader.join(''), 'utf-8'); } const multipartBody = Buffer.concat([headerBytes, audioBuffer, footerBytes]); // 如果需要 language 字段,插入在 audio 字段之后 const finalBody = languagePart.length > 0 ? Buffer.concat([headerBytes, audioBuffer, languagePart, footerBytes]) : multipartBody; const voiceResp = await fetch(`${VOICE_SERVICE_URL}/api/v1/transcribe`, { method: 'POST', headers: { 'Content-Type': 'multipart/form-data; boundary=' + boundary, }, body: finalBody, signal: AbortSignal.timeout(60000), }); const voiceBody = await voiceResp.json().catch(() => null); const elapsedMs = Date.now() - startTime; if (!voiceResp.ok || (voiceBody && voiceBody.error)) { const logEntry = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), timestamp: new Date().toISOString(), status: 'error', audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2), estimatedDurationSec, language: language || 'zh', filename, durationMs: elapsedMs, text: null, error: voiceBody?.error || `HTTP ${voiceResp.status}`, }; recordSTTLog(logEntry); return res.status(voiceResp.status).json({ ...voiceBody, ethend_log_id: logEntry.id, }); } const logEntry = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), timestamp: new Date().toISOString(), status: 'success', audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2), estimatedDurationSec, language: voiceBody?.language || language || 'zh', filename, durationMs: voiceBody?.duration_ms || elapsedMs, text: voiceBody?.text || '', textLength: (voiceBody?.text || '').length, }; recordSTTLog(logEntry); return res.json({ ...voiceBody, ethend_log_id: logEntry.id, }); } catch (err) { const elapsedMs = Date.now() - startTime; const logEntry = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), timestamp: new Date().toISOString(), status: 'error', audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2), estimatedDurationSec, language: language || 'zh', filename, durationMs: elapsedMs, text: null, error: err.message, }; recordSTTLog(logEntry); const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; return res.status(502).json({ error: `Voice-Service 不可达: ${err.message}`, errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable', hint: isConnRefused ? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service' : 'Voice-Service 服务无响应,请检查网络连接和服务状态', ethend_log_id: logEntry.id, }); } }); // ---- 自主思考日志代理 (转发到 memory-service) ---- // ---- 语音识别服务代理 (转发到 voice-service) ---- /** * 代理请求到 Voice-Service * @param {string} path - Voice-Service API 路径 * @param {object} opts - fetch 选项 */ async function proxyToVoiceService(path, opts = {}) { const url = `${VOICE_SERVICE_URL}${path}`; const logPrefix = `[VoiceService代理]`; try { console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`); const resp = await fetch(url, { ...opts, signal: AbortSignal.timeout(60000), // 语音转录可能需要较长时间 }); const body = await resp.json().catch(() => null); if (!resp.ok) { console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`); } return { status: resp.status, body }; } catch (err) { const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`); return { status: 502, body: { error: `Voice-Service 不可达: ${err.message}`, errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable', hint: isConnRefused ? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service' : 'Voice-Service 服务无响应,请检查网络连接和服务状态', }, }; } } // GET /api/voice/status — 获取 STT 服务状态 app.get('/api/voice/status', async (_req, res) => { const result = await proxyToVoiceService('/api/v1/status'); res.status(result.status).json(result.body); }); // GET /api/voice/health — STT 健康检查 app.get('/api/voice/health', async (_req, res) => { const result = await proxyToVoiceService('/api/v1/health'); res.status(result.status).json(result.body); }); // GET /api/llm-calls — LLM 调用日志 (代理到 AI-Core) app.get('/api/llm-calls', async (req, res) => { const limit = parseInt(req.query.limit) || 50; const result = await proxyToAICore(`/api/v1/llm-calls?limit=${Math.min(limit, 500)}`); res.status(result.status).json(result.body); }); // ---- 全链路追踪 ---- /** * 从日志文件中搜索包含指定关键词的最近行 */ function searchLogFile(serviceId, keyword, maxLines = 200) { const filePath = logFile(serviceId); if (!fs.existsSync(filePath)) return []; try { const content = fs.readFileSync(filePath, 'utf-8'); const allLines = content.split('\n').filter(Boolean); const recent = allLines.slice(-maxLines); const kwLower = keyword.toLowerCase(); return recent .filter(line => line.toLowerCase().includes(kwLower)) .map(line => line.trim()); } catch { return []; } } /** * 解析日志时间戳 (支持常见格式) */ function parseLogTimestamp(line) { // 2024-01-01T12:00:00Z, 2024/01/01 12:00:00, [2024-01-01 12:00:00], 12:00:00 const match = line.match(/(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2})/); if (match) return new Date(match[1]).getTime(); const timeMatch = line.match(/(\d{2}:\d{2}:\d{2})/); if (timeMatch) { const today = new Date(); const [h, m, s] = timeMatch[1].split(':').map(Number); today.setHours(h, m, s, 0); return today.getTime(); } return Date.now(); } // GET /api/trace/recent — 最近的全链路追踪数据 app.get('/api/trace/recent', async (req, res) => { const limit = Math.min(parseInt(req.query.limit) || 50, 200); try { const [llmResult, toolResult, sessionsResult, bridgeLogsResult, traceEventsResult] = await Promise.all([ proxyToAICore(`/api/v1/llm-calls?limit=${limit}`).catch(() => ({ status: 502, body: [] })), proxyToAICore(`/api/v1/tools/calls?limit=${limit}`).catch(() => ({ status: 502, body: { calls: [] } })), proxyToGateway('/api/v1/admin/sessions/active').catch(() => ({ status: 502, body: { users: {} } })), // 拉 platform-bridge 最近的消息日志 Promise.all((['obv11']).map(platform => proxyToPlatformBridge(`/api/v1/logs/${platform}?limit=50`).catch(() => ({ status: 502, body: { logs: [] } })) )), // 拉 ai-core 追踪事件 proxyToAICore(`/api/v1/trace/events?limit=${limit}`).catch(() => ({ status: 502, body: { events: [] } })), ]); const traces = []; // === 消息收发 (platform-bridge 日志) === for (const logResult of bridgeLogsResult) { const entries = logResult.body?.logs || logResult.body?.entries || logResult.body || []; for (const entry of entries) { const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(); const isIncoming = entry.direction === 'incoming'; const hop = isIncoming ? 'msg_received' : 'msg_sent'; const icon = isIncoming ? '📥' : '📤'; const sender = entry.sender_name || entry.sender_id || '?'; const channel = entry.channel_id || '?'; const groupName = entry.group_name ? ` (${entry.group_name})` : ''; traces.push({ id: `msg-${ts}-${Math.random().toString(36).slice(2, 6)}`, timestamp: formatLocalTime(ts), ts, service: 'platform-bridge', hop, label: `${icon} ${isIncoming ? '收到' : '发送'}消息 · ${entry.platform || '?'}${groupName}`, status: entry.success ? 'success' : 'error', durationMs: 0, detail: `${isIncoming ? '来自' : '发给'} ${sender} | 频道 ${channel} | ${(entry.content || '').substring(0, 80)}`, data: { platform: entry.platform, sender, channel, content: entry.content, direction: entry.direction, contentType: entry.content_type }, }); } } // === LLM 调用 === const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []); for (const call of llmCalls) { const ts = call.time ? new Date(call.time).getTime() : Date.now(); traces.push({ id: `llm-${ts}-${Math.random().toString(36).slice(2, 6)}`, timestamp: formatLocalTime(ts), ts, service: 'ai-core', hop: 'llm_call', label: `🧠 LLM: ${call.model || 'unknown'}`, status: call.success ? 'success' : 'error', durationMs: call.duration_ms || (call.Duration ? Math.round(call.Duration / 1e6) : 0), detail: call.error || `${call.prompt_tokens || 0}→${call.completion_tokens || 0} tokens`, data: call, }); } // === 工具调用 === const toolCalls = toolResult.body?.calls || (Array.isArray(toolResult.body) ? toolResult.body : []); for (const tc of toolCalls) { const ts = tc.time || tc.timestamp || tc.created_at; const tsNum = ts ? new Date(ts).getTime() : Date.now(); traces.push({ id: `tool-${tsNum}-${Math.random().toString(36).slice(2, 6)}`, timestamp: new Date(tsNum).toISOString(), ts: tsNum, service: 'ai-core', hop: 'tool_call', label: `🔧 ${tc.tool_name || tc.name || 'unknown'}`, status: tc.error ? 'error' : 'success', durationMs: tc.duration_ms || (tc.Duration ? Math.round(tc.Duration / 1e6) : 0), detail: tc.error || tc.result?.substring?.(0, 100) || tc.output?.substring?.(0, 100) || '', data: tc, }); } // === ai-core 追踪事件 (intent, subsession, synthesis, etc.) === const traceEvents = traceEventsResult.body?.events || []; for (const ev of traceEvents) { const ts = ev.timestamp ? new Date(ev.timestamp).getTime() : Date.now(); traces.push({ id: ev.id || `trace-${ts}`, timestamp: formatLocalTime(ts), ts, service: 'ai-core', hop: ev.hop || 'trace', label: ev.label || '', status: ev.status || 'success', durationMs: ev.duration_ms || 0, detail: ev.detail || '', data: ev.data || {}, }); } // === 活跃会话 === const users = sessionsResult.body?.users || {}; for (const [userID, sessions] of Object.entries(users)) { for (const s of sessions) { const ts = s.last_activity ? new Date(s.last_activity).getTime() : Date.now(); traces.push({ id: `session-${s.session_id || ''}`, timestamp: formatLocalTime(ts), ts, service: 'gateway', hop: 'session', label: `👤 会话: ${userID}`, status: s.state === 'streaming' ? 'running' : 'success', durationMs: 0, detail: `${(s.session_id || '').substring(0, 20)}... [${s.state || 'idle'}]`, data: { userID, sessionId: s.session_id, state: s.state }, }); } } // 按时间倒序,按 hop 分组 traces.sort((a, b) => b.ts - a.ts); const recent = traces.slice(0, limit); // 按消息分组:将相近时间的 msg_received + llm_call + tool_call + msg_sent 聚合成消息管线 const pipelines = []; let currentPipeline = null; const sorted = [...recent].sort((a, b) => a.ts - b.ts); for (const t of sorted) { if (t.hop === 'msg_received') { if (currentPipeline) pipelines.push(currentPipeline); currentPipeline = { id: t.id, ts: t.ts, timestamp: t.timestamp, sender: t.data?.sender, channel: t.data?.channel, platform: t.data?.platform, content: t.data?.content, steps: [t] }; } else if (currentPipeline) { // 只聚合时间相近的事件 (60s 内) if (t.ts - currentPipeline.ts < 60000) { currentPipeline.steps.push(t); } } else { currentPipeline = { id: t.id, ts: t.ts, timestamp: t.timestamp, steps: [t] }; } } if (currentPipeline) pipelines.push(currentPipeline); const services = [...new Set(recent.map(t => t.service))]; const errors = recent.filter(t => t.status === 'error').length; res.json({ timestamp: Date.now(), total: traces.length, shown: recent.length, pipelines: pipelines.slice(-limit), stats: { services, errors, pipelineCount: pipelines.length }, traces: recent, }); } catch (err) { res.status(500).json({ error: `获取链路追踪数据失败: ${err.message}` }); } }); // GET /api/trace/session/:sessionId — 特定会话的全链路追踪 app.get('/api/trace/session/:sessionId', async (req, res) => { const { sessionId } = req.params; if (!sessionId) return res.status(400).json({ error: '缺少 sessionId' }); try { // 并行获取: 会话详情、LLM 调用、工具调用、日志搜索 const [sessionResult, llmResult, toolResult] = await Promise.all([ proxyToGateway(`/api/v1/admin/sessions/${sessionId}`).catch(() => ({ status: 502, body: null })), proxyToAICore('/api/v1/llm-calls?limit=500').catch(() => ({ status: 502, body: [] })), proxyToAICore('/api/v1/tools/calls?limit=200').catch(() => ({ status: 502, body: { calls: [] } })), ]); // 从日志文件中搜索 session 相关行 const gatewayLogLines = searchLogFile('gateway', sessionId, 500); const aiCoreLogLines = searchLogFile('ai-core', sessionId, 500); const traces = []; const sessionData = sessionResult.body; // Gateway 日志 → 追踪节点 for (const line of gatewayLogLines) { const ts = parseLogTimestamp(line); let hop = 'gateway_log'; let label = 'Gateway 日志'; if (line.includes('received') || line.includes('收到')) { hop = 'gateway_receive'; label = 'Gateway 接收消息'; } else if (line.includes('stream') || line.includes('流式')) { hop = 'gateway_stream'; label = 'Gateway 流式处理'; } else if (line.includes('send') || line.includes('发送') || line.includes('broadcast')) { hop = 'gateway_send'; label = 'Gateway 推送响应'; } else if (line.includes('error') || line.includes('错误')) { hop = 'gateway_error'; label = 'Gateway 错误'; } traces.push({ id: `gwlog-${ts}-${Math.random().toString(36).slice(2, 6)}`, timestamp: new Date(ts).toISOString(), ts, service: 'gateway', hop, label, status: hop === 'gateway_error' ? 'error' : 'success', durationMs: 0, detail: line.substring(0, 200), data: { raw: line }, }); } // AI-Core 日志 for (const line of aiCoreLogLines) { const ts = parseLogTimestamp(line); let hop = 'ai_core_log'; let label = 'AI-Core 日志'; if (line.includes('LLM') || line.includes('llm') || line.includes('chat')) { hop = 'ai_core_llm'; label = 'AI-Core LLM 处理'; } else if (line.includes('tool') || line.includes('Tool')) { hop = 'ai_core_tool'; label = 'AI-Core 工具调用'; } else if (line.includes('stream') || line.includes('SSE')) { hop = 'ai_core_stream'; label = 'AI-Core 流式输出'; } else if (line.includes('error') || line.includes('Error')) { hop = 'ai_core_error'; label = 'AI-Core 错误'; } traces.push({ id: `aclog-${ts}-${Math.random().toString(36).slice(2, 6)}`, timestamp: new Date(ts).toISOString(), ts, service: 'ai-core', hop, label, status: hop === 'ai_core_error' ? 'error' : 'success', durationMs: 0, detail: line.substring(0, 200), data: { raw: line }, }); } // LLM 调用记录中如果有 session 相关信息也加入 const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []); for (const call of llmCalls) { const ts = call.time ? new Date(call.time).getTime() : Date.now(); traces.push({ id: `llm-${ts}-${Math.random().toString(36).slice(2, 6)}`, timestamp: new Date(ts).toISOString(), ts, service: 'ai-core', hop: 'llm_call', label: `LLM: ${call.model || 'unknown'}`, status: call.success ? 'success' : 'error', durationMs: call.duration_ms || (call.Duration ? Math.round(call.Duration / 1e6) : 0), detail: call.error || `${call.prompt_tokens || 0}→${call.completion_tokens || 0} tokens`, data: call, }); } // 按时间排序 traces.sort((a, b) => a.ts - b.ts); // 计算每一跳的耗时 for (let i = 1; i < traces.length; i++) { const gap = traces[i].ts - traces[i - 1].ts; if (gap > 0 && !traces[i].durationMs) { traces[i]._gapFromPrev = gap; } } const totalSpan = traces.length >= 2 ? traces[traces.length - 1].ts - traces[0].ts : 0; const errors = traces.filter(t => t.status === 'error').length; res.json({ timestamp: Date.now(), sessionId, session: sessionData, stats: { totalHops: traces.length, errors, totalSpanMs: totalSpan, services: [...new Set(traces.map(t => t.service))], }, traces, }); } catch (err) { res.status(500).json({ error: `获取会话链路追踪失败: ${err.message}` }); } }); /** * 代理请求到 Memory-Service * @param {string} path - Memory-Service API 路径 * @param {object} opts - fetch 选项 */ async function proxyToMemoryService(path, opts = {}) { const url = `${MEMORY_SERVICE_URL}${path}`; const logPrefix = `[MemoryService代理]`; try { console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`); const resp = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...opts.headers }, signal: AbortSignal.timeout(10000), }); const body = await resp.json().catch(() => null); if (!resp.ok) { console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`); } return { status: resp.status, body }; } catch (err) { const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED'; console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`); return { status: 502, body: { error: `Memory-Service 不可达: ${err.message}`, errorType: isConnRefused ? 'memory_service_not_running' : 'memory_service_unreachable', hint: isConnRefused ? 'Memory-Service 服务未启动,请先在「服务管理」面板中启动 Memory-Service' : 'Memory-Service 服务无响应,请检查网络连接和服务状态', }, }; } } // GET /api/v1/thinking — 查询自主思考日志列表 app.get('/api/v1/thinking', async (req, res) => { const { user_id, limit, offset } = req.query; const params = new URLSearchParams(); if (user_id) params.set('user_id', user_id); if (limit) params.set('limit', limit); if (offset) params.set('offset', offset); const result = await proxyToMemoryService(`/api/v1/thinking?${params.toString()}`); res.status(result.status).json(result.body); }); // POST /api/v1/thinking — 创建自主思考日志 app.post('/api/v1/thinking', async (req, res) => { const result = await proxyToMemoryService('/api/v1/thinking', { method: 'POST', body: JSON.stringify(req.body), }); res.status(result.status).json(result.body); }); // GET /api/v1/thinking/stats — 自主思考统计 app.get('/api/v1/thinking/stats', async (req, res) => { const { user_id } = req.query; const params = user_id ? `?user_id=${encodeURIComponent(user_id)}` : ''; const result = await proxyToMemoryService(`/api/v1/thinking/stats${params}`); res.status(result.status).json(result.body); }); // GET /api/v1/thinking/:id — 获取单条自主思考日志 app.get('/api/v1/thinking/:id', async (req, res) => { const result = await proxyToMemoryService(`/api/v1/thinking/${req.params.id}`); res.status(result.status).json(result.body); }); // ---- 记忆时间线 (合并记忆 + 思考) ---- app.get('/api/memory-timeline', async (req, res) => { const { user_id, limit, offset } = req.query; if (!user_id) { return res.status(400).json({ error: '缺少 user_id 参数' }); } const maxItems = parseInt(limit) || 100; const pageOffset = parseInt(offset) || 0; try { // 并行调用记忆和思考 API (带 offset) const memQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: String(pageOffset) }).toString(); const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: String(pageOffset) }).toString(); const [memResult, thinkResult] = await Promise.all([ proxyToMemoryService(`/api/v1/memories?${memQs}`), proxyToMemoryService(`/api/v1/thinking?${thinkQs}`), ]); const memories = []; const thinkingLogs = []; // 解析记忆列表 if (memResult.body && !memResult.body.error) { const memData = Array.isArray(memResult.body) ? memResult.body : (memResult.body.memories || memResult.body.results || []); for (const m of memData) { const createdAt = m.created_at || m.CreatedAt || m.timestamp; memories.push({ id: m.id || m.ID || '', type: 'memory', title: m.title || (m.content || '').substring(0, 60), content: m.content || '', summary: m.summary || '', importance: m.importance || 1, category: m.category || 'other', source: m.source || 'unknown', session_id: m.session_id || '', keywords: m.keywords || [], access_count: m.access_count || 0, timestamp: createdAt ? new Date(createdAt).toISOString() : null, user_id: m.user_id || user_id, }); } } // 解析思考日志列表 if (thinkResult.body && !thinkResult.body.error) { const thinkData = thinkResult.body.logs || (Array.isArray(thinkResult.body) ? thinkResult.body : []); for (const t of thinkData) { const createdAt = t.created_at || t.CreatedAt || t.timestamp; // 解析思考主题:提取第一行或前80个字符 const content = t.content || ''; const firstLine = content.split('\n')[0] || ''; const topic = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine; // 尝试从内容推断触发方式 let trigger = '定时'; if (content.includes('用户') || content.includes('手动') || content.includes('manual')) { trigger = '手动'; } else if (content.includes('scheduled') || content.includes('定时') || content.includes('interval')) { trigger = '定时'; } thinkingLogs.push({ id: t.id || t.ID || '', type: 'thinking', title: topic || '自主思考', content: content, summary: content.length > 200 ? content.substring(0, 197) + '...' : content, tool_call_count: t.tool_call_count || 0, content_length: t.content_length || content.length, trigger: trigger, tool_calls: t.tool_calls || null, timestamp: createdAt ? new Date(createdAt).toISOString() : null, user_id: t.user_id || user_id, // 思考没有重要性,设为0用于排序 importance: 0, source: 'thinking', }); } } // 合并并按时间排序(降序:最新的在前) const timeline = [...memories, ...thinkingLogs].sort((a, b) => { const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; return tb - ta; }); // 截取限制条数 const result = timeline.slice(0, maxItems); // 是否有更多数据 (两边都还有数据表示可能还有更多) const hasMore = memories.length >= maxItems || thinkingLogs.length >= maxItems; // 统计摘要 const stats = { total_memories: memories.length, total_thinking: thinkingLogs.length, latest_memory_time: memories.length > 0 ? memories.reduce((max, m) => { const t = m.timestamp ? new Date(m.timestamp).getTime() : 0; return t > max ? t : max; }, 0) : null, latest_thinking_time: thinkingLogs.length > 0 ? thinkingLogs.reduce((max, t) => { const ts = t.timestamp ? new Date(t.timestamp).getTime() : 0; return ts > max ? ts : max; }, 0) : null, }; res.json({ timeline: result, stats, total: timeline.length, hasMore, user_id, }); } catch (err) { console.error('[记忆时间线] 错误:', err.message); res.status(500).json({ error: `获取时间线失败: ${err.message}` }); } }); // ---- 健康检查代理 ---- app.get('/api/proxy/:id/health', async (req, res) => { const svc = SERVICES[req.params.id]; if (!svc || !svc.healthUrl) { return res.status(404).json({ error: '未知服务或无健康检查端点' }); } try { const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(5000) }); const data = await resp.json(); res.json({ proxy: true, service: req.params.id, status: resp.status, data }); } catch { res.json({ proxy: true, service: req.params.id, status: 'unreachable', data: null }); } }); // ---- 数据库状态检查 ---- import net from 'net'; const IS_DOCKER = process.env.RUNNING_IN_DOCKER === 'true'; function isPortOpen(port, host) { 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, host || '127.0.0.1'); }); } app.get('/api/database/status', async (_req, res) => { const DB_PORTS = IS_DOCKER ? [ { port: 5432, name: 'PostgreSQL', host: 'postgres' }, { port: 6379, name: 'Redis', host: 'redis' }, { port: 6334, name: 'Qdrant gRPC', host: 'qdrant' }, { port: 9000, name: 'MinIO API', host: 'minio' }, ] : [ { port: 5432, name: 'PostgreSQL' }, { port: 6379, name: 'Redis' }, { port: 6334, name: 'Qdrant gRPC' }, { port: 9000, name: 'MinIO API' }, { port: 4222, name: 'NATS' }, ]; const results = await Promise.all(DB_PORTS.map(async ({ port, name, host }) => { const alive = await isPortOpen(port, host); return { port, name, alive }; })); const allAlive = results.every(p => p.alive); const aliveCount = results.filter(p => p.alive).length; let pgDetails = null; const pgPort = results.find(p => p.port === 5432); if (pgPort?.alive) { try { const envPath = path.join(ROOT, 'backend', '.env'); 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); const mPass = envContent.match(/^POSTGRES_PASSWORD=(.+)$/m); const mDb = envContent.match(/^POSTGRES_DB=(.+)$/m); if (mUser) pgUser = mUser[1]; if (mPass) pgPass = mPass[1]; if (mDb) pgDb = mDb[1]; } const out = execSync( `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 }; } catch { /* pg query failed */ } } res.json({ timestamp: Date.now(), ports: results, allAlive, aliveCount, totalPorts: DB_PORTS.length, pgDetails, }); }); // ---- 数据库控制 (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', async (_req, res) => { try { const online = await isPortOpen(DB_PORT, IS_DOCKER ? 'postgres' : undefined); res.json({ online, port: DB_PORT, checked_at: new Date().toISOString() }); } catch { res.json({ online: false, port: DB_PORT, checked_at: new Date().toISOString() }); } }); // POST /api/db/start app.post('/api/db/start', (_req, res) => { try { const out = execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, { encoding: 'utf-8', timeout: 60000, stdio: 'pipe', }); res.json({ success: true, action: 'start', output: out.trim() }); } catch (err) { const stderr = err.stderr?.toString() || err.message; res.status(500).json({ success: false, action: 'start', error: stderr }); } }); // POST /api/db/stop app.post('/api/db/stop', (_req, res) => { try { const out = execSync(`docker compose -f "${DB_COMPOSE_FILE}" down`, { encoding: 'utf-8', timeout: 30000, stdio: 'pipe', }); res.json({ success: true, action: 'stop', output: out.trim() }); } catch (err) { const stderr = err.stderr?.toString() || err.message; res.status(500).json({ success: false, action: 'stop', error: stderr }); } }); // POST /api/db/restart app.post('/api/db/restart', (_req, res) => { try { const downOut = execSync(`docker compose -f "${DB_COMPOSE_FILE}" down`, { encoding: 'utf-8', timeout: 30000, stdio: 'pipe', }); const upOut = execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, { encoding: 'utf-8', timeout: 60000, stdio: 'pipe', }); res.json({ success: true, action: 'restart', output: `down: ${downOut.trim()}\nup: ${upOut.trim()}`, }); } catch (err) { const stderr = err.stderr?.toString() || err.message; res.status(500).json({ success: false, action: 'restart', error: stderr }); } }); // ========== 启动 ========== // 启动性能监控 performanceMonitor.start(); // 确保日志目录存在 fs.mkdirSync(LOGS_DIR, { recursive: true }); server.listen(ETHEND_PORT, () => { console.log(`🛠️ Cyrene ethend 已启动: http://localhost:${ETHEND_PORT}`); console.log(` API: http://localhost:${ETHEND_PORT}/api/health`); console.log(` WebSocket: ws://localhost:${ETHEND_PORT}/ws`); console.log(` Web控制台: http://localhost:${ETHEND_PORT}`); console.log(''); console.log(' 可用服务:'); for (const [id, svc] of Object.entries(SERVICES)) { console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`); } }); // 时间格式化:使用系统本地时区 function formatLocalTime(ts) { var d = new Date(ts); var pad = function(n) { return n < 10 ? '0' + n : '' + n; }; return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); } // 优雅退出 process.on('SIGINT', async () => { console.log('\n关闭所有服务...'); await processManager.stopAll(); performanceMonitor.stop(); process.exit(0); }); process.on('SIGTERM', async () => { await processManager.stopAll(); performanceMonitor.stop(); process.exit(0); });