1931 lines
69 KiB
JavaScript
1931 lines
69 KiB
JavaScript
/**
|
||
* 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'}`);
|
||
}
|
||
});
|
||
|
||
// 时间格式化:使用系统本地时区
|
||
function formatLocalTime(ts) {
|
||
var d = new Date(ts);
|
||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
||
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' +
|
||
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||
}
|
||
|
||
// 优雅退出
|
||
process.on('SIGINT', async () => {
|
||
console.log('\n关闭所有服务...');
|
||
await processManager.stopAll();
|
||
performanceMonitor.stop();
|
||
process.exit(0);
|
||
});
|
||
|
||
process.on('SIGTERM', async () => {
|
||
await processManager.stopAll();
|
||
performanceMonitor.stop();
|
||
process.exit(0);
|
||
});
|