/** * Cyrene DevTools - 主入口 * * 提供: * - 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, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js'; 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 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()); // 静态文件 - 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()); } }); // ========== 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 服务无响应,请检查网络连接和服务状态', }, }; } } // ========== REST API 路由 ========== // ---- 健康检查 ---- app.get('/api/health', (_req, res) => { res.json({ status: 'ok', service: 'cyrene-devtools', uptime: process.uptime(), wsClients: wsClients.size, }); }); // ---- 仪表盘数据 (必须在 /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); 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 } = req.query; if (!user_id) return res.status(400).json({ error: '缺少 user_id 参数' }); const qs = new URLSearchParams({ user_id }).toString(); const result = await proxyToGateway(`/api/v1/memory?${qs}`); 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); 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); 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 { // 异步重启,因为可能耗时较长 res.json({ success: true, message: '重启中...' }); const result = await processManager.restart(req.params.id); 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); 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); }); // ---- 工具调用记录代理 (转发到 tool-engine) ---- /** * 代理请求到 Tool-Engine * @param {string} path - Tool-Engine API 路径 * @param {object} opts - fetch 选项 */ async function proxyToToolEngine(path, opts = {}) { const url = `${TOOL_ENGINE_URL}${path}`; const logPrefix = `[ToolEngine代理]`; 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: `Tool-Engine 不可达: ${err.message}`, errorType: isConnRefused ? 'tool_engine_not_running' : 'tool_engine_unreachable', hint: isConnRefused ? 'Tool-Engine 服务未启动,请先在「服务管理」面板中启动 Tool-Engine' : 'Tool-Engine 服务无响应,请检查网络连接和服务状态', }, }; } } // 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 proxyToToolEngine(`/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 proxyToToolEngine('/api/v1/tools/calls/stats'); res.status(result.status).json(result.body); }); // ---- STT 处理日志存储 (内存环形缓冲区) ---- const sttLogEntries = []; const MAX_STT_LOGS = 200; /** * 记录 STT 请求日志(devtools 自身维护,因为 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 = '----DevToolsFormBoundary' + 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, devtools_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, devtools_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 服务无响应,请检查网络连接和服务状态', devtools_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); }); /** * 代理请求到 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 } = req.query; if (!user_id) { return res.status(400).json({ error: '缺少 user_id 参数' }); } const maxItems = parseInt(limit) || 100; try { // 并行调用记忆和思考 API const memQs = new URLSearchParams({ user_id, limit: String(maxItems) }).toString(); const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: '0' }).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 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, 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'; 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', 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 results = await Promise.all(DB_PORTS.map(async ({ port, name }) => { const alive = await isPortOpen(port); 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); 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(DEVTOOLS_PORT, () => { console.log(`🛠️ Cyrene DevTools 已启动: http://localhost:${DEVTOOLS_PORT}`); console.log(` API: http://localhost:${DEVTOOLS_PORT}/api/health`); console.log(` WebSocket: ws://localhost:${DEVTOOLS_PORT}/ws`); console.log(` Web控制台: http://localhost:${DEVTOOLS_PORT}`); console.log(''); console.log(' 可用服务:'); for (const [id, svc] of Object.entries(SERVICES)) { console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`); } }); // 优雅退出 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); });