This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/ethend/src/index.js
T

1938 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<WebSocket>} */
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'}`);
}
});
// 时间格式化:始终显示 UTC+8 (Asia/Shanghai)
function formatLocalTime(ts) {
var d = new Date(ts);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
// Force UTC+8 regardless of system timezone
var utcH = d.getUTCHours();
var utcM = d.getUTCMinutes();
var utcS = d.getUTCSeconds();
var localH = (utcH + 8) % 24;
var dayOffset = Math.floor((utcH + 8) / 24);
var localD = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + dayOffset));
return localD.getUTCFullYear() + '-' + pad(localD.getUTCMonth()+1) + '-' + pad(localD.getUTCDate()) + ' ' +
pad(localH) + ':' + pad(utcM) + ':' + pad(utcS);
}
// 优雅退出
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);
});