1678 lines
62 KiB
JavaScript
1678 lines
62 KiB
JavaScript
|
|
const express = require('express');
|
|||
|
|
const { execSync } = require('child_process');
|
|||
|
|
const path = require('path');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const os = require('os');
|
|||
|
|
|
|||
|
|
const app = express();
|
|||
|
|
|
|||
|
|
const IS_WIN = process.platform === 'win32';
|
|||
|
|
const DEVNULL = IS_WIN ? '2>nul' : '2>/dev/null';
|
|||
|
|
const HOME = os.homedir();
|
|||
|
|
const LOG_FILE = path.join(HOME, '.openclaw/logs/gateway.log');
|
|||
|
|
|
|||
|
|
// ════════════════ 日志工具 ════════════════
|
|||
|
|
const DASHBOARD_LOG_DIR = path.join(__dirname, 'logs');
|
|||
|
|
const DASHBOARD_LOG_FILE = path.join(DASHBOARD_LOG_DIR, 'dashboard.log');
|
|||
|
|
|
|||
|
|
// 日志配置(先设置默认值,后面从配置文件读取)
|
|||
|
|
let LOGGING_CONFIG = {
|
|||
|
|
enabled: true, // 默认开启日志
|
|||
|
|
level: 'INFO', // 日志级别: DEBUG/INFO/WARN/ERROR
|
|||
|
|
file: true, // 是否写入文件
|
|||
|
|
console: true // 是否输出到控制台
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 日志级别优先级
|
|||
|
|
const LOG_LEVEL_PRIORITY = {
|
|||
|
|
DEBUG: 0,
|
|||
|
|
INFO: 1,
|
|||
|
|
SUCCESS: 1,
|
|||
|
|
PERF: 1,
|
|||
|
|
WARN: 2,
|
|||
|
|
ERROR: 3
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 确保日志目录存在
|
|||
|
|
if (!fs.existsSync(DASHBOARD_LOG_DIR)) {
|
|||
|
|
fs.mkdirSync(DASHBOARD_LOG_DIR, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 日志级别颜色
|
|||
|
|
const LOG_COLORS = {
|
|||
|
|
INFO: '\x1b[36m', // 青色
|
|||
|
|
SUCCESS: '\x1b[32m', // 绿色
|
|||
|
|
WARN: '\x1b[33m', // 黄色
|
|||
|
|
ERROR: '\x1b[31m', // 红色
|
|||
|
|
DEBUG: '\x1b[90m', // 灰色
|
|||
|
|
PERF: '\x1b[35m', // 紫色
|
|||
|
|
RESET: '\x1b[0m'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 格式化时间戳
|
|||
|
|
function timestamp() {
|
|||
|
|
return new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查日志级别是否应该输出
|
|||
|
|
function shouldLog(level) {
|
|||
|
|
if (!LOGGING_CONFIG.enabled) return false;
|
|||
|
|
const currentPriority = LOG_LEVEL_PRIORITY[LOGGING_CONFIG.level] || 1;
|
|||
|
|
const messagePriority = LOG_LEVEL_PRIORITY[level] || 1;
|
|||
|
|
return messagePriority >= currentPriority;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 通用日志函数
|
|||
|
|
function log(level, category, message, data = null) {
|
|||
|
|
// 检查是否应该记录此级别的日志
|
|||
|
|
if (!shouldLog(level)) return;
|
|||
|
|
|
|||
|
|
const color = LOG_COLORS[level] || '';
|
|||
|
|
const reset = LOG_COLORS.RESET;
|
|||
|
|
const ts = timestamp();
|
|||
|
|
const icon = {
|
|||
|
|
INFO: 'ℹ️',
|
|||
|
|
SUCCESS: '✅',
|
|||
|
|
WARN: '⚠️',
|
|||
|
|
ERROR: '❌',
|
|||
|
|
DEBUG: '🔍',
|
|||
|
|
PERF: '⏱️'
|
|||
|
|
}[level] || '📝';
|
|||
|
|
|
|||
|
|
let logLine = `[${ts}] ${icon} [${level}] [${category}] ${message}`;
|
|||
|
|
if (data) {
|
|||
|
|
logLine += ` | ${typeof data === 'object' ? JSON.stringify(data) : data}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 控制台输出(带颜色)
|
|||
|
|
if (LOGGING_CONFIG.console) {
|
|||
|
|
console.log(`${color}${logLine}${reset}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 文件输出(无颜色)
|
|||
|
|
if (LOGGING_CONFIG.file) {
|
|||
|
|
try {
|
|||
|
|
fs.appendFileSync(DASHBOARD_LOG_FILE, logLine + '\n');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('写入日志文件失败:', err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 便捷日志函数
|
|||
|
|
const logger = {
|
|||
|
|
info: (category, message, data) => log('INFO', category, message, data),
|
|||
|
|
success: (category, message, data) => log('SUCCESS', category, message, data),
|
|||
|
|
warn: (category, message, data) => log('WARN', category, message, data),
|
|||
|
|
error: (category, message, data) => log('ERROR', category, message, data),
|
|||
|
|
debug: (category, message, data) => log('DEBUG', category, message, data),
|
|||
|
|
perf: (category, message, duration) => log('PERF', category, `${message} (${duration}ms)`, null)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 性能监控装饰器
|
|||
|
|
function perf(category, operation) {
|
|||
|
|
const start = Date.now();
|
|||
|
|
return {
|
|||
|
|
end: (details = '') => {
|
|||
|
|
const duration = Date.now() - start;
|
|||
|
|
logger.perf(category, `${operation}${details ? ' - ' + details : ''}`, duration);
|
|||
|
|
return duration;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.info('系统', '🦞 AI Team Dashboard 正在初始化...');
|
|||
|
|
logger.info('系统', `操作系统: ${os.platform()} ${os.release()}`);
|
|||
|
|
logger.info('系统', `Node.js: ${process.version}`);
|
|||
|
|
logger.info('系统', `工作目录: ${__dirname}`);
|
|||
|
|
|
|||
|
|
// 缓存配置
|
|||
|
|
const CACHE = {
|
|||
|
|
commits: new Map(),
|
|||
|
|
skills: new Map(),
|
|||
|
|
issues: null,
|
|||
|
|
issuesTime: 0,
|
|||
|
|
};
|
|||
|
|
const CACHE_TTL = 30000; // 30秒缓存
|
|||
|
|
|
|||
|
|
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
|||
|
|
let userConfig = {};
|
|||
|
|
try {
|
|||
|
|
logger.info('配置', '正在加载配置文件...', { path: CONFIG_PATH });
|
|||
|
|
const configTimer = perf('配置', '加载配置文件');
|
|||
|
|
userConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|||
|
|
configTimer.end();
|
|||
|
|
|
|||
|
|
// 更新日志配置
|
|||
|
|
if (userConfig.dashboard?.logging) {
|
|||
|
|
const loggingConfig = userConfig.dashboard.logging;
|
|||
|
|
LOGGING_CONFIG.enabled = loggingConfig.enabled !== undefined ? loggingConfig.enabled : true;
|
|||
|
|
LOGGING_CONFIG.level = loggingConfig.level || 'INFO';
|
|||
|
|
LOGGING_CONFIG.file = loggingConfig.file !== undefined ? loggingConfig.file : true;
|
|||
|
|
LOGGING_CONFIG.console = loggingConfig.console !== undefined ? loggingConfig.console : true;
|
|||
|
|
|
|||
|
|
logger.info('配置', '日志配置已更新', LOGGING_CONFIG);
|
|||
|
|
} else {
|
|||
|
|
logger.info('配置', '使用默认日志配置', LOGGING_CONFIG);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.success('配置', '配置文件加载成功', {
|
|||
|
|
owner: userConfig.github?.owner,
|
|||
|
|
taskRepo: userConfig.github?.taskRepo,
|
|||
|
|
port: userConfig.dashboard?.port || 3800
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
logger.error('配置', '配置文件加载失败', { error: err.message });
|
|||
|
|
console.error('⚠️ 未找到 config.json,请复制 config.json.example 为 config.json 并填入你的配置');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const GITHUB_OWNER = userConfig.github?.owner || 'your-github-username';
|
|||
|
|
const TASK_REPO = userConfig.github?.taskRepo || `${GITHUB_OWNER}/ai-team-tasks`;
|
|||
|
|
const PORT = userConfig.dashboard?.port || 3800;
|
|||
|
|
const POLL_INTERVAL = userConfig.pollInterval || 5;
|
|||
|
|
|
|||
|
|
logger.info('配置', 'Bot 配置初始化完成', {
|
|||
|
|
githubOwner: GITHUB_OWNER,
|
|||
|
|
taskRepo: TASK_REPO,
|
|||
|
|
port: PORT,
|
|||
|
|
pollInterval: POLL_INTERVAL
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const BOTS = [
|
|||
|
|
{
|
|||
|
|
id: 'leader', name: '大龙虾', container: null, role: '项目经理 + 总指挥', type: 'host',
|
|||
|
|
avatar: '🦞', color: '#FF6B35',
|
|||
|
|
capabilities: ['理解用户需求', '自动拆分任务', '调度其他 AI', '汇总结果交付', '管理进度判断完成', '解答 Bot 提问'],
|
|||
|
|
label: null, pollInterval: null,
|
|||
|
|
codeRepo: userConfig.bots?.leader?.codeRepo || `${GITHUB_OWNER}/ai-team-leader-code`,
|
|||
|
|
skillsRepo: userConfig.bots?.leader?.skillsRepo || `${GITHUB_OWNER}/ai-team-leader-skills`,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'qianwen', name: '全栈高手', container: 'ai-team-qianwen', role: '全能开发主力', type: 'docker',
|
|||
|
|
avatar: '⚡', color: '#4ECDC4',
|
|||
|
|
capabilities: ['产品需求分析', 'UI/前端开发', '后端/数据库开发', '测试用例编写', '文案文档撰写', '部署和运维'],
|
|||
|
|
label: 'role:qianwen-worker', pollInterval: POLL_INTERVAL,
|
|||
|
|
codeRepo: userConfig.bots?.qianwen?.codeRepo || `${GITHUB_OWNER}/ai-team-fullstack-code`,
|
|||
|
|
skillsRepo: userConfig.bots?.qianwen?.skillsRepo || `${GITHUB_OWNER}/ai-team-fullstack-skills`,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'kimi', name: '智囊团', container: 'ai-team-kimi', role: '深度分析专家', type: 'docker',
|
|||
|
|
avatar: '🔬', color: '#A78BFA',
|
|||
|
|
capabilities: ['大型代码库分析', '复杂架构设计', '深度代码审查', '技术报告撰写', '跨模块依赖分析', '长文档生成'],
|
|||
|
|
label: 'role:kimi-worker', pollInterval: POLL_INTERVAL,
|
|||
|
|
codeRepo: userConfig.bots?.kimi?.codeRepo || `${GITHUB_OWNER}/ai-team-thinktank-code`,
|
|||
|
|
skillsRepo: userConfig.bots?.kimi?.skillsRepo || `${GITHUB_OWNER}/ai-team-thinktank-skills`,
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function exec(cmd) {
|
|||
|
|
try {
|
|||
|
|
return execSync(cmd, { encoding: 'utf-8', timeout: 15000, shell: IS_WIN ? 'cmd.exe' : '/bin/sh', windowsHide: true }).trim();
|
|||
|
|
} catch { return ''; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function execDetailed(cmd, timeout = 30000) {
|
|||
|
|
try {
|
|||
|
|
const stdout = execSync(cmd, {
|
|||
|
|
encoding: 'utf-8',
|
|||
|
|
timeout,
|
|||
|
|
shell: IS_WIN ? 'cmd.exe' : '/bin/sh',
|
|||
|
|
windowsHide: true,
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
}).trim();
|
|||
|
|
return { ok: true, code: 0, stdout, stderr: '' };
|
|||
|
|
} catch (error) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
code: typeof error.status === 'number' ? error.status : 1,
|
|||
|
|
stdout: String(error.stdout || '').trim(),
|
|||
|
|
stderr: String(error.stderr || '').trim(),
|
|||
|
|
message: error.message || 'Command failed',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function restartBot(bot) {
|
|||
|
|
if (bot.type === 'docker' && bot.container) {
|
|||
|
|
return execDetailed(`docker restart ${bot.container}`, 60000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const restart = execDetailed('openclaw gateway restart', 60000);
|
|||
|
|
if (restart.ok) return restart;
|
|||
|
|
|
|||
|
|
const start = execDetailed('openclaw gateway start', 60000);
|
|||
|
|
if (start.ok) {
|
|||
|
|
return {
|
|||
|
|
ok: true,
|
|||
|
|
code: 0,
|
|||
|
|
stdout: [restart.stdout, restart.stderr, start.stdout].filter(Boolean).join('\n'),
|
|||
|
|
stderr: start.stderr || '',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
code: start.code || restart.code || 1,
|
|||
|
|
stdout: [restart.stdout, start.stdout].filter(Boolean).join('\n'),
|
|||
|
|
stderr: [restart.stderr, start.stderr].filter(Boolean).join('\n'),
|
|||
|
|
message: start.message || restart.message || 'Restart failed',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDockerStatus() {
|
|||
|
|
const timer = perf('Docker', 'getDockerStatus');
|
|||
|
|
const composeDir = path.join(__dirname, '..');
|
|||
|
|
const raw = exec(`docker compose -f "${composeDir}/docker-compose.yml" ps --format json ${DEVNULL}`);
|
|||
|
|
if (!raw) {
|
|||
|
|
logger.warn('Docker', 'Docker 状态获取失败或无容器运行');
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
const statuses = {};
|
|||
|
|
let containerCount = 0;
|
|||
|
|
for (const line of raw.split('\n')) {
|
|||
|
|
try {
|
|||
|
|
const c = JSON.parse(line);
|
|||
|
|
statuses[c.Name] = { state: c.State, status: c.Status, health: c.Health || 'N/A', running: c.State === 'running' };
|
|||
|
|
containerCount++;
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
timer.end(`检查了 ${containerCount} 个容器`);
|
|||
|
|
return statuses;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getPollMeta(containerName) {
|
|||
|
|
if (!containerName) return null;
|
|||
|
|
try {
|
|||
|
|
const meta = exec(`docker exec ${containerName} cat /tmp/poll-meta.json ${DEVNULL}`);
|
|||
|
|
const lastPoll = exec(`docker exec ${containerName} cat /tmp/last-poll.txt ${DEVNULL}`);
|
|||
|
|
if (!meta) return null;
|
|||
|
|
const parsed = JSON.parse(meta);
|
|||
|
|
logger.debug('Docker', `轮询元数据 [${containerName}]`, { interval: `${parsed.interval}s` });
|
|||
|
|
return { startedAt: parsed.startedAt, interval: parsed.interval, lastPollAt: lastPoll ? parseInt(lastPoll, 10) : null };
|
|||
|
|
} catch { return null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getHostGatewayStatus() {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const nullDev = IS_WIN ? 'nul' : '/dev/null';
|
|||
|
|
const health = exec(`curl -s -o ${nullDev} -w "%{http_code}" http://127.0.0.1:18789/healthz ${DEVNULL}`);
|
|||
|
|
const latency = Date.now() - start;
|
|||
|
|
const isHealthy = health === '200';
|
|||
|
|
if (isHealthy) {
|
|||
|
|
logger.debug('Gateway', `健康检查通过`, { latency: `${latency}ms` });
|
|||
|
|
} else {
|
|||
|
|
logger.warn('Gateway', `健康检查失败`, { code: health, latency: `${latency}ms` });
|
|||
|
|
}
|
|||
|
|
return { state: isHealthy ? 'running' : 'stopped', status: isHealthy ? 'Up (主机)' : 'Down', health: isHealthy ? 'healthy' : 'unhealthy', running: isHealthy, latencyMs: latency };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getGitHubIssues(limit = 100) {
|
|||
|
|
// 使用缓存
|
|||
|
|
const now = Date.now();
|
|||
|
|
if (CACHE.issues && (now - CACHE.issuesTime < CACHE_TTL)) {
|
|||
|
|
logger.debug('GitHub', `Issues 使用缓存`, { age: `${now - CACHE.issuesTime}ms` });
|
|||
|
|
return CACHE.issues;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timer = perf('GitHub', 'getGitHubIssues');
|
|||
|
|
const q = IS_WIN ? '"."' : "'.'";
|
|||
|
|
const raw = exec(`gh issue list -R ${TASK_REPO} --state all --limit ${limit} --json number,title,body,labels,state,createdAt,updatedAt,comments -q ${q} ${DEVNULL}`);
|
|||
|
|
if (!raw) {
|
|||
|
|
logger.warn('GitHub', 'Issues 获取失败,返回缓存', { repo: TASK_REPO });
|
|||
|
|
return CACHE.issues || [];
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(raw);
|
|||
|
|
CACHE.issues = data;
|
|||
|
|
CACHE.issuesTime = now;
|
|||
|
|
timer.end(`成功获取 ${data.length} 个 Issues`);
|
|||
|
|
return data;
|
|||
|
|
} catch (err) {
|
|||
|
|
logger.error('GitHub', 'Issues 解析失败', { error: err.message });
|
|||
|
|
return CACHE.issues || [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCronJobs() {
|
|||
|
|
try {
|
|||
|
|
const cronPath = path.join(HOME, '.openclaw/cron/jobs.json');
|
|||
|
|
const data = fs.readFileSync(cronPath, 'utf-8');
|
|||
|
|
const parsed = JSON.parse(data);
|
|||
|
|
return (parsed.jobs || []).map((j) => ({
|
|||
|
|
id: j.id, name: j.name, description: j.description, enabled: j.enabled, schedule: j.schedule,
|
|||
|
|
lastRunAt: j.state?.lastRunAtMs, nextRunAt: j.state?.nextRunAtMs, lastStatus: j.state?.lastStatus,
|
|||
|
|
lastDuration: j.state?.lastDurationMs, errors: j.state?.consecutiveErrors || 0,
|
|||
|
|
}));
|
|||
|
|
} catch { return []; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getRepoCommits(repoFullName, limit = 15) {
|
|||
|
|
// 使用缓存
|
|||
|
|
const cacheKey = `${repoFullName}:${limit}`;
|
|||
|
|
const cached = CACHE.commits.get(cacheKey);
|
|||
|
|
if (cached && (Date.now() - cached.time < CACHE_TTL)) {
|
|||
|
|
logger.debug('GitHub', `Commits 使用缓存 [${repoFullName}]`, { age: `${Date.now() - cached.time}ms` });
|
|||
|
|
return cached.data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timer = perf('GitHub', `getRepoCommits [${repoFullName}]`);
|
|||
|
|
const raw = exec(`gh api repos/${repoFullName}/commits?per_page=${limit} ${DEVNULL}`);
|
|||
|
|
if (!raw) {
|
|||
|
|
logger.warn('GitHub', `Commits 获取失败 [${repoFullName}]`);
|
|||
|
|
return cached ? cached.data : [];
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(raw);
|
|||
|
|
const result = data.map(c => ({ sha: c.sha?.substring(0, 7), fullSha: c.sha, message: c.commit?.message, author: c.commit?.author?.name, date: c.commit?.author?.date, url: c.html_url }));
|
|||
|
|
CACHE.commits.set(cacheKey, { data: result, time: Date.now() });
|
|||
|
|
timer.end(`成功获取 ${result.length} 个 Commits`);
|
|||
|
|
return result;
|
|||
|
|
} catch (err) {
|
|||
|
|
logger.error('GitHub', `Commits 解析失败 [${repoFullName}]`, { error: err.message });
|
|||
|
|
return cached ? cached.data : [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getRepoSkills(repoFullName) {
|
|||
|
|
// 使用缓存
|
|||
|
|
const cached = CACHE.skills.get(repoFullName);
|
|||
|
|
if (cached && (Date.now() - cached.time < CACHE_TTL)) {
|
|||
|
|
return cached.data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const raw = exec(`gh api repos/${repoFullName}/contents ${DEVNULL}`);
|
|||
|
|
if (!raw) return cached ? cached.data : [];
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(raw);
|
|||
|
|
const result = data.filter(f => f.name?.endsWith('.md') && f.name !== 'README.md').map(f => ({ name: f.name, path: f.path, url: f.html_url, sha: f.sha?.substring(0, 7) }));
|
|||
|
|
CACHE.skills.set(repoFullName, { data: result, time: Date.now() });
|
|||
|
|
return result;
|
|||
|
|
} catch {
|
|||
|
|
return cached ? cached.data : [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getInstalledSkills(bot) {
|
|||
|
|
const mcps = [];
|
|||
|
|
const skills = [];
|
|||
|
|
if (bot.type === 'host') {
|
|||
|
|
const skillsDir = path.join(HOME, '.openclaw/workspace/skills');
|
|||
|
|
try {
|
|||
|
|
const entries = fs.readdirSync(skillsDir);
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
const fullPath = path.join(skillsDir, entry);
|
|||
|
|
const stat = fs.statSync(fullPath);
|
|||
|
|
let content = '';
|
|||
|
|
if (stat.isDirectory()) {
|
|||
|
|
const skillFile = path.join(fullPath, 'SKILL.md');
|
|||
|
|
if (fs.existsSync(skillFile)) content = fs.readFileSync(skillFile, 'utf-8');
|
|||
|
|
} else if (entry.endsWith('.md') && entry !== 'README.md') {
|
|||
|
|
content = fs.readFileSync(fullPath, 'utf-8');
|
|||
|
|
} else continue;
|
|||
|
|
const meta = parseSkillMeta(content);
|
|||
|
|
const item = { name: meta.name || entry.replace('.md', ''), version: meta.version || '', description: meta.description || '', path: entry };
|
|||
|
|
if (meta.isMcp) mcps.push(item); else skills.push(item);
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
const ocJson = path.join(HOME, '.openclaw/openclaw.json');
|
|||
|
|
try {
|
|||
|
|
const cfg = JSON.parse(fs.readFileSync(ocJson, 'utf-8'));
|
|||
|
|
const plugins = cfg.plugins?.entries || {};
|
|||
|
|
for (const [id, pl] of Object.entries(plugins)) {
|
|||
|
|
mcps.push({ name: id, version: '', description: typeof pl === 'string' ? 'Plugin' : (pl.description || 'Plugin'), path: '', type: 'plugin' });
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
} else if (bot.container) {
|
|||
|
|
// Worker Bot: 从 .openclaw-config/skills 读取 Skills
|
|||
|
|
const skillFiles = [];
|
|||
|
|
|
|||
|
|
// 1. 读取 .md 文件
|
|||
|
|
const mdFiles = (exec(`docker exec ${bot.container} find /home/node/.openclaw-config/skills -maxdepth 1 -name "*.md" -type f ${DEVNULL}`) || '').trim().split('\n').filter(Boolean);
|
|||
|
|
skillFiles.push(...mdFiles);
|
|||
|
|
|
|||
|
|
// 2. 读取子目录的 SKILL.md
|
|||
|
|
const dirs = (exec(`docker exec ${bot.container} find /home/node/.openclaw-config/skills -maxdepth 1 -type d ${DEVNULL}`) || '').trim().split('\n').filter(Boolean);
|
|||
|
|
for (const dir of dirs) {
|
|||
|
|
if (dir === '/home/node/.openclaw-config/skills') continue;
|
|||
|
|
const skillMd = `${dir}/SKILL.md`;
|
|||
|
|
const exists = (exec(`docker exec ${bot.container} test -f "${skillMd}" && echo "1" ${DEVNULL}`) || '').trim();
|
|||
|
|
if (exists === '1') {
|
|||
|
|
skillFiles.push(skillMd);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 读取每个文件内容
|
|||
|
|
for (const filepath of skillFiles) {
|
|||
|
|
if (!filepath) continue;
|
|||
|
|
const content = exec(`docker exec ${bot.container} head -30 "${filepath}" ${DEVNULL}`) || '';
|
|||
|
|
if (!content) continue;
|
|||
|
|
|
|||
|
|
const meta = parseSkillMeta(content);
|
|||
|
|
|
|||
|
|
// 优先使用 frontmatter 的 name,否则从路径提取
|
|||
|
|
let realName = meta.name;
|
|||
|
|
if (!realName) {
|
|||
|
|
const parts = filepath.split('/');
|
|||
|
|
const filename = parts[parts.length - 1];
|
|||
|
|
const parentDir = parts[parts.length - 2];
|
|||
|
|
if (filename === 'SKILL.md' && parentDir && parentDir !== 'skills') {
|
|||
|
|
realName = parentDir;
|
|||
|
|
} else {
|
|||
|
|
realName = filename.replace('.md', '');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (realName === 'README' || realName === '') continue;
|
|||
|
|
|
|||
|
|
const item = { name: realName, version: meta.version || '', description: meta.description || '', path: filepath };
|
|||
|
|
if (meta.isMcp) mcps.push(item); else skills.push(item);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Worker Bot: 从 openclaw.json 读取 MCP 插件
|
|||
|
|
const jsonRaw = exec(`docker exec ${bot.container} cat /home/node/.openclaw/openclaw.json ${DEVNULL}`);
|
|||
|
|
if (jsonRaw) {
|
|||
|
|
try {
|
|||
|
|
const cfg = JSON.parse(jsonRaw);
|
|||
|
|
const plugins = cfg.plugins?.entries || {};
|
|||
|
|
for (const [id, pl] of Object.entries(plugins)) {
|
|||
|
|
mcps.push({ name: id, version: '', description: typeof pl === 'string' ? 'MCP Plugin' : (pl.description || 'MCP Plugin'), path: '', type: 'plugin' });
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { mcps, skills };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseSkillMeta(content) {
|
|||
|
|
const meta = { isMcp: false };
|
|||
|
|
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|||
|
|
if (fmMatch) {
|
|||
|
|
const fm = fmMatch[1];
|
|||
|
|
const nameMatch = fm.match(/name:\s*(.+)/);
|
|||
|
|
const verMatch = fm.match(/version:\s*(.+)/);
|
|||
|
|
const descMatch = fm.match(/description:\s*"?([^"\n]+)"?/);
|
|||
|
|
if (nameMatch) meta.name = nameMatch[1].trim();
|
|||
|
|
if (verMatch) meta.version = verMatch[1].trim();
|
|||
|
|
if (descMatch) meta.description = descMatch[1].trim();
|
|||
|
|
// 只有明确标记为 MCP 的才算 MCP
|
|||
|
|
if (/primaryEnv|user-invocable:\s*true/i.test(fm)) meta.isMcp = true;
|
|||
|
|
}
|
|||
|
|
if (!meta.name) {
|
|||
|
|
const h1 = content.match(/^#\s+(.+)/m);
|
|||
|
|
if (h1) meta.name = h1[1].trim();
|
|||
|
|
}
|
|||
|
|
return meta;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatIssue(issue) {
|
|||
|
|
const labels = (issue.labels || []).map((l) => l.name);
|
|||
|
|
let status = 'unknown';
|
|||
|
|
if (labels.includes('status:done')) status = 'done';
|
|||
|
|
else if (labels.includes('status:in-progress')) status = 'in-progress';
|
|||
|
|
else if (labels.includes('status:blocked')) status = 'blocked';
|
|||
|
|
else if (labels.includes('status:pending')) status = 'pending';
|
|||
|
|
let assignedTo = 'unassigned';
|
|||
|
|
if (labels.includes('role:qianwen-worker')) assignedTo = 'qianwen';
|
|||
|
|
else if (labels.includes('role:kimi-worker')) assignedTo = 'kimi';
|
|||
|
|
return { number: issue.number, title: issue.title, status, assignedTo, labels, state: issue.state, createdAt: issue.createdAt, updatedAt: issue.updatedAt, commentCount: issue.comments?.length || 0, body: issue.body || '' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 实时监控 API ──
|
|||
|
|
function getGatewayProcess() {
|
|||
|
|
if (IS_WIN) {
|
|||
|
|
const raw = exec('tasklist /FI "IMAGENAME eq node.exe" /FO CSV /NH');
|
|||
|
|
if (!raw) return null;
|
|||
|
|
const lines = raw.split('\n').filter(l => l.includes('node'));
|
|||
|
|
if (!lines.length) return null;
|
|||
|
|
const cols = lines[0].replace(/"/g, '').split(',');
|
|||
|
|
return { pid: cols[1]?.trim(), cpu: 0, mem: 0, vsz: 0, rss: parseInt(cols[4]?.replace(/[^\d]/g, '') || '0', 10) * 1024, started: '', time: '' };
|
|||
|
|
}
|
|||
|
|
const raw = exec("ps aux | grep 'openclaw-gateway\\|openclaw gateway' | grep -v grep | head -1");
|
|||
|
|
if (!raw) return null;
|
|||
|
|
const parts = raw.split(/\s+/);
|
|||
|
|
return { pid: parts[1], cpu: parseFloat(parts[2]), mem: parseFloat(parts[3]), vsz: parseInt(parts[4], 10), rss: parseInt(parts[5], 10), started: parts[8], time: parts[9] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getRecentLogs(lines = 80) {
|
|||
|
|
try {
|
|||
|
|
const data = fs.readFileSync(LOG_FILE, 'utf-8');
|
|||
|
|
const allLines = data.split('\n').filter(Boolean);
|
|||
|
|
const recent = allLines.slice(-lines);
|
|||
|
|
const result = [];
|
|||
|
|
let pendingMsg = null;
|
|||
|
|
for (const line of recent) {
|
|||
|
|
const entry = { raw: line, type: 'system', time: null, content: line };
|
|||
|
|
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+[Z+-\d:]*)/);
|
|||
|
|
if (tsMatch) entry.time = tsMatch[1];
|
|||
|
|
|
|||
|
|
if (line.includes('DM from')) {
|
|||
|
|
const dmMatch = line.match(/DM from [^:]+:\s*(.+)$/);
|
|||
|
|
if (dmMatch) {
|
|||
|
|
entry.type = 'incoming';
|
|||
|
|
entry.content = dmMatch[1].substring(0, 150);
|
|||
|
|
pendingMsg = entry.content;
|
|||
|
|
}
|
|||
|
|
} else if (line.includes('received message') && !line.includes('DM from')) {
|
|||
|
|
continue;
|
|||
|
|
} else if (line.includes('dispatching to agent')) {
|
|||
|
|
entry.type = 'processing';
|
|||
|
|
entry.content = '正在思考...';
|
|||
|
|
} else if (line.includes('dispatch complete')) {
|
|||
|
|
entry.type = 'complete';
|
|||
|
|
const repliesMatch = line.match(/replies=(\d+)/);
|
|||
|
|
entry.content = `回复完成 (${repliesMatch ? repliesMatch[1] : '?'} 条)`;
|
|||
|
|
} else if (line.includes('Started streaming')) {
|
|||
|
|
entry.type = 'streaming';
|
|||
|
|
entry.content = '正在流式输出...';
|
|||
|
|
} else if (line.includes('Closed streaming')) {
|
|||
|
|
entry.type = 'stream_done';
|
|||
|
|
entry.content = '流式输出结束';
|
|||
|
|
} else if (line.includes('WebSocket client started') || line.includes('ws client ready')) {
|
|||
|
|
entry.type = 'warn';
|
|||
|
|
entry.content = 'WebSocket 连接/重连';
|
|||
|
|
} else if (line.includes('reconnect')) {
|
|||
|
|
entry.type = 'warn';
|
|||
|
|
entry.content = 'WebSocket 重连中...';
|
|||
|
|
} else if (line.includes('error') || line.includes('Error')) {
|
|||
|
|
entry.type = 'error';
|
|||
|
|
} else {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
result.push(entry);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
} catch { return []; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getConversationTimeline(logs) {
|
|||
|
|
const timeline = [];
|
|||
|
|
let currentMsg = null;
|
|||
|
|
for (const log of logs) {
|
|||
|
|
if (log.type === 'incoming') {
|
|||
|
|
if (currentMsg && currentMsg.status !== 'done') {
|
|||
|
|
timeline.push({ ...currentMsg });
|
|||
|
|
}
|
|||
|
|
currentMsg = { receivedAt: log.time, message: log.content, status: 'received', respondedAt: null, durationSec: null };
|
|||
|
|
} else if (log.type === 'complete' && currentMsg) {
|
|||
|
|
currentMsg.respondedAt = log.time;
|
|||
|
|
currentMsg.status = 'done';
|
|||
|
|
if (currentMsg.receivedAt && currentMsg.respondedAt) {
|
|||
|
|
const d = new Date(currentMsg.respondedAt) - new Date(currentMsg.receivedAt);
|
|||
|
|
currentMsg.durationSec = Math.round(d / 1000);
|
|||
|
|
}
|
|||
|
|
timeline.push({ ...currentMsg });
|
|||
|
|
currentMsg = null;
|
|||
|
|
} else if (log.type === 'processing' && currentMsg) {
|
|||
|
|
currentMsg.status = 'thinking';
|
|||
|
|
} else if (log.type === 'streaming' && currentMsg) {
|
|||
|
|
currentMsg.status = 'streaming';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (currentMsg) {
|
|||
|
|
if (currentMsg.status === 'received') currentMsg.status = 'queued';
|
|||
|
|
const nowMs = Date.now();
|
|||
|
|
if (currentMsg.receivedAt) {
|
|||
|
|
const elapsed = Math.round((nowMs - new Date(currentMsg.receivedAt).getTime()) / 1000);
|
|||
|
|
currentMsg.elapsedSec = elapsed;
|
|||
|
|
}
|
|||
|
|
timeline.push(currentMsg);
|
|||
|
|
}
|
|||
|
|
return timeline.slice(-10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getSystemLoad() {
|
|||
|
|
const loadAvg = os.loadavg();
|
|||
|
|
const totalMB = Math.round(os.totalmem() / 1048576);
|
|||
|
|
const freeMB = Math.round(os.freemem() / 1048576);
|
|||
|
|
const usedMB = totalMB - freeMB;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
load: loadAvg.map(v => Math.round(v * 100) / 100),
|
|||
|
|
cpuCores: os.cpus().length,
|
|||
|
|
memory: {
|
|||
|
|
totalMB,
|
|||
|
|
usedMB,
|
|||
|
|
free: freeMB,
|
|||
|
|
active: 0,
|
|||
|
|
inactive: 0,
|
|||
|
|
wired: 0,
|
|||
|
|
speculative: 0,
|
|||
|
|
usedPct: Math.round(usedMB / totalMB * 100),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════ 请求日志中间件 ════════════════
|
|||
|
|
logger.info('中间件', '初始化请求日志中间件');
|
|||
|
|
app.use((req, res, next) => {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const originalSend = res.send;
|
|||
|
|
|
|||
|
|
// 拦截 res.send 来记录响应时间
|
|||
|
|
res.send = function(data) {
|
|||
|
|
const duration = Date.now() - start;
|
|||
|
|
const statusColor = res.statusCode >= 500 ? 'ERROR' :
|
|||
|
|
res.statusCode >= 400 ? 'WARN' :
|
|||
|
|
'INFO';
|
|||
|
|
|
|||
|
|
// 只记录 API 请求,跳过静态资源
|
|||
|
|
if (req.path.startsWith('/api/')) {
|
|||
|
|
log(statusColor, 'API', `${req.method} ${req.path}`, {
|
|||
|
|
status: res.statusCode,
|
|||
|
|
duration: `${duration}ms`,
|
|||
|
|
ip: req.ip || req.connection.remoteAddress
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return originalSend.call(this, data);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|||
|
|
|
|||
|
|
function getLastTurnStatus() {
|
|||
|
|
const sessionId = getActiveSessionId();
|
|||
|
|
if (!sessionId) return null;
|
|||
|
|
const filePath = path.join(SESSIONS_DIR, `${sessionId}.jsonl`);
|
|||
|
|
try {
|
|||
|
|
const data = fs.readFileSync(filePath, 'utf-8');
|
|||
|
|
const lines = data.split('\n').filter(Boolean);
|
|||
|
|
const entries = [];
|
|||
|
|
for (const line of lines) {
|
|||
|
|
try { entries.push(JSON.parse(line)); } catch {}
|
|||
|
|
}
|
|||
|
|
let lastAssistant = null;
|
|||
|
|
let hadToolResultBefore = false;
|
|||
|
|
let totalToolCalls = 0;
|
|||
|
|
let lastUserTs = null;
|
|||
|
|
|
|||
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|||
|
|
const e = entries[i];
|
|||
|
|
if (e.type !== 'message') continue;
|
|||
|
|
const role = e.message?.role;
|
|||
|
|
if (role === 'user') {
|
|||
|
|
lastUserTs = e.timestamp;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (role === 'toolResult') hadToolResultBefore = true;
|
|||
|
|
if (role === 'assistant' && !lastAssistant) {
|
|||
|
|
const content = e.message?.content;
|
|||
|
|
let tc = 0;
|
|||
|
|
if (Array.isArray(content)) {
|
|||
|
|
tc = content.filter(c => c.type === 'toolCall' || c.type === 'tool_use').length;
|
|||
|
|
}
|
|||
|
|
totalToolCalls += tc;
|
|||
|
|
lastAssistant = {
|
|||
|
|
stopReason: e.message?.stopReason || '',
|
|||
|
|
hasToolCalls: tc > 0,
|
|||
|
|
toolCallCount: tc,
|
|||
|
|
timestamp: e.timestamp,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
if (role === 'assistant') {
|
|||
|
|
const content = e.message?.content;
|
|||
|
|
if (Array.isArray(content)) {
|
|||
|
|
totalToolCalls += content.filter(c => c.type === 'toolCall' || c.type === 'tool_use').length;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!lastAssistant) return { status: 'idle', detail: '空闲' };
|
|||
|
|
|
|||
|
|
const sr = lastAssistant.stopReason;
|
|||
|
|
if (lastAssistant.hasToolCalls || sr === 'toolUse') {
|
|||
|
|
return { status: 'working', detail: `正在执行工具 (${totalToolCalls} 次调用)`, toolCalls: totalToolCalls };
|
|||
|
|
}
|
|||
|
|
if (hadToolResultBefore) {
|
|||
|
|
return { status: 'final', detail: `已完成 (执行了 ${totalToolCalls} 次工具调用)`, toolCalls: totalToolCalls };
|
|||
|
|
}
|
|||
|
|
return { status: 'text_only', detail: '仅文字回复,未执行任何操作', toolCalls: 0 };
|
|||
|
|
} catch { return null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.get('/api/monitor', (req, res) => {
|
|||
|
|
const timer = perf('API', 'GET /api/monitor');
|
|||
|
|
logger.debug('监控', '开始获取监控数据');
|
|||
|
|
|
|||
|
|
const t1 = Date.now();
|
|||
|
|
const gateway = getHostGatewayStatus();
|
|||
|
|
logger.debug('监控', `Gateway 状态获取完成`, { duration: `${Date.now() - t1}ms` });
|
|||
|
|
|
|||
|
|
const t2 = Date.now();
|
|||
|
|
const gwProcess = getGatewayProcess();
|
|||
|
|
logger.debug('监控', `Gateway 进程信息获取完成`, { duration: `${Date.now() - t2}ms` });
|
|||
|
|
|
|||
|
|
const t3 = Date.now();
|
|||
|
|
const logs = getRecentLogs(60);
|
|||
|
|
logger.debug('监控', `日志获取完成`, { count: logs.length, duration: `${Date.now() - t3}ms` });
|
|||
|
|
|
|||
|
|
const timeline = getConversationTimeline(logs);
|
|||
|
|
const system = getSystemLoad();
|
|||
|
|
const cronJobs = getCronJobs();
|
|||
|
|
const turnStatus = getLastTurnStatus();
|
|||
|
|
|
|||
|
|
const t4 = Date.now();
|
|||
|
|
const dockerStatuses = getDockerStatus();
|
|||
|
|
logger.debug('监控', `Docker 状态获取完成`, { duration: `${Date.now() - t4}ms` });
|
|||
|
|
|
|||
|
|
const workers = BOTS.filter(b => b.type === 'docker').map(b => {
|
|||
|
|
const st = dockerStatuses[b.container] || { state: 'unknown', running: false };
|
|||
|
|
const pollMeta = getPollMeta(b.container);
|
|||
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|||
|
|
let nextPoll = null;
|
|||
|
|
if (pollMeta) {
|
|||
|
|
if (pollMeta.lastPollAt) nextPoll = (pollMeta.lastPollAt + pollMeta.interval) * 1000;
|
|||
|
|
else {
|
|||
|
|
const fp = pollMeta.startedAt + 30;
|
|||
|
|
if (nowSec < fp) nextPoll = fp * 1000;
|
|||
|
|
else { const cyc = Math.floor((nowSec - fp) / pollMeta.interval) + 1; nextPoll = (fp + cyc * pollMeta.interval) * 1000; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { id: b.id, name: b.name, avatar: b.avatar, container: b.container, ...st, nextPoll, pollInterval: b.pollInterval };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const displayLogs = logs.filter(l => ['incoming', 'processing', 'complete', 'streaming', 'stream_done', 'warn', 'error'].includes(l.type)).slice(-20);
|
|||
|
|
|
|||
|
|
let currentStatus = 'idle';
|
|||
|
|
if (timeline.length) {
|
|||
|
|
const last = timeline[timeline.length - 1];
|
|||
|
|
if (last.status === 'thinking' || last.status === 'streaming') currentStatus = last.status;
|
|||
|
|
else if (last.status === 'queued') currentStatus = 'queued';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const duration = timer.end();
|
|||
|
|
logger.info('监控', '监控数据返回成功', {
|
|||
|
|
workers: workers.length,
|
|||
|
|
logs: displayLogs.length,
|
|||
|
|
timeline: timeline.length,
|
|||
|
|
gatewayStatus: gateway.running ? 'online' : 'offline'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
leader: {
|
|||
|
|
gateway, process: gwProcess, currentStatus,
|
|||
|
|
lastActivity: displayLogs.length ? displayLogs[displayLogs.length - 1].time : null,
|
|||
|
|
turnStatus,
|
|||
|
|
},
|
|||
|
|
timeline,
|
|||
|
|
logs: displayLogs,
|
|||
|
|
cronJobs,
|
|||
|
|
workers,
|
|||
|
|
system,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/status', (req, res) => {
|
|||
|
|
const timer = perf('API', 'GET /api/status');
|
|||
|
|
logger.debug('状态', '开始获取总览数据');
|
|||
|
|
|
|||
|
|
const t1 = Date.now();
|
|||
|
|
const dockerStatuses = getDockerStatus();
|
|||
|
|
const hostStatus = getHostGatewayStatus();
|
|||
|
|
logger.debug('状态', `容器状态获取完成`, { duration: `${Date.now() - t1}ms` });
|
|||
|
|
|
|||
|
|
const t2 = Date.now();
|
|||
|
|
const issues = getGitHubIssues(50);
|
|||
|
|
logger.debug('状态', `GitHub Issues 获取完成`, { count: issues.length, duration: `${Date.now() - t2}ms` });
|
|||
|
|
|
|||
|
|
const cronJobs = getCronJobs();
|
|||
|
|
const allFormatted = issues.map(formatIssue);
|
|||
|
|
|
|||
|
|
const t3 = Date.now();
|
|||
|
|
const bots = BOTS.map((bot) => {
|
|||
|
|
const status = bot.type === 'host' ? hostStatus : (dockerStatuses[bot.container] || { state: 'unknown', status: 'Unknown', health: 'unknown', running: false });
|
|||
|
|
let tasks;
|
|||
|
|
if (bot.id === 'leader') {
|
|||
|
|
const dispatched = allFormatted.filter((t) => t.assignedTo !== 'unassigned');
|
|||
|
|
tasks = { dispatched, pending: dispatched.filter((t) => t.status === 'pending'), accepted: dispatched.filter((t) => t.status === 'in-progress'), done: dispatched.filter((t) => t.status === 'done'), blocked: dispatched.filter((t) => t.status === 'blocked'), total: dispatched.length };
|
|||
|
|
} else {
|
|||
|
|
const botIssues = allFormatted.filter((t) => t.labels.includes(bot.label));
|
|||
|
|
tasks = { pending: botIssues.filter((t) => t.status === 'pending'), inProgress: botIssues.filter((t) => t.status === 'in-progress'), done: botIssues.filter((t) => t.status === 'done'), blocked: botIssues.filter((t) => t.status === 'blocked'), total: botIssues.length };
|
|||
|
|
}
|
|||
|
|
let botCron = [];
|
|||
|
|
if (bot.id === 'leader') { botCron = cronJobs; }
|
|||
|
|
else if (bot.pollInterval) {
|
|||
|
|
const pollMeta = getPollMeta(bot.container);
|
|||
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|||
|
|
let nextRunAt = null, lastRunAt = null;
|
|||
|
|
if (pollMeta) {
|
|||
|
|
lastRunAt = pollMeta.lastPollAt ? pollMeta.lastPollAt * 1000 : null;
|
|||
|
|
if (pollMeta.lastPollAt) nextRunAt = (pollMeta.lastPollAt + pollMeta.interval) * 1000;
|
|||
|
|
else { const fp = pollMeta.startedAt + 30; if (nowSec < fp) nextRunAt = fp * 1000; else { const e = nowSec - fp; nextRunAt = (fp + (Math.floor(e / pollMeta.interval) + 1) * pollMeta.interval) * 1000; } }
|
|||
|
|
}
|
|||
|
|
botCron = [{ id: `poll-${bot.id}`, name: '任务轮询', description: `每 ${bot.pollInterval} 分钟检查 GitHub 新任务`, enabled: status.running, schedule: { kind: 'every', everyMs: bot.pollInterval * 60000 }, lastRunAt, nextRunAt, lastStatus: status.running ? 'ok' : 'stopped', lastDuration: null, errors: 0 }];
|
|||
|
|
}
|
|||
|
|
const commits = getRepoCommits(bot.codeRepo, 5);
|
|||
|
|
const skills = getRepoSkills(bot.skillsRepo);
|
|||
|
|
const { mcps, skills: localSkills } = getInstalledSkills(bot);
|
|||
|
|
return { ...bot, status, tasks, cron: botCron, commits, skills, mcps, installedSkills: localSkills };
|
|||
|
|
});
|
|||
|
|
logger.debug('状态', `Bot 数据组装完成`, { duration: `${Date.now() - t3}ms` });
|
|||
|
|
|
|||
|
|
timer.end();
|
|||
|
|
logger.info('状态', '总览数据返回成功', {
|
|||
|
|
bots: bots.length,
|
|||
|
|
totalTasks: allFormatted.length,
|
|||
|
|
openTasks: allFormatted.filter((t) => t.state === 'OPEN').length
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({ timestamp: new Date().toISOString(), bots, stats: { totalTasks: allFormatted.length, openTasks: allFormatted.filter((t) => t.state === 'OPEN').length, doneTasks: allFormatted.filter((t) => t.status === 'done').length } });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/bot/:id', (req, res) => {
|
|||
|
|
const bot = BOTS.find((b) => b.id === req.params.id);
|
|||
|
|
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
|||
|
|
const dockerStatuses = getDockerStatus();
|
|||
|
|
const hostStatus = getHostGatewayStatus();
|
|||
|
|
const status = bot.type === 'host' ? hostStatus : (dockerStatuses[bot.container] || { state: 'unknown', status: 'Unknown', health: 'unknown', running: false });
|
|||
|
|
const issues = getGitHubIssues(200);
|
|||
|
|
const allFormatted = issues.map(formatIssue);
|
|||
|
|
let tasks = bot.id === 'leader' ? allFormatted.filter((t) => t.assignedTo !== 'unassigned') : allFormatted.filter((t) => t.labels.includes(bot.label));
|
|||
|
|
const commits = getRepoCommits(bot.codeRepo, 50);
|
|||
|
|
const skills = getRepoSkills(bot.skillsRepo);
|
|||
|
|
const { mcps, skills: localSkills } = getInstalledSkills(bot);
|
|||
|
|
res.json({ ...bot, status, tasks, commits, skills, mcps, installedSkills: localSkills, timestamp: new Date().toISOString() });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ── 对话内容 API(读取 OpenClaw 会话 JSONL)──
|
|||
|
|
const SESSIONS_DIR = path.join(HOME, '.openclaw/agents/main/sessions');
|
|||
|
|
const STATS_DIR = path.join(HOME, '.openclaw/stats');
|
|||
|
|
const MEMORY_DIR = path.join(HOME, '.openclaw/workspace/memory'); // 使用 OpenClaw 原生记忆目录
|
|||
|
|
|
|||
|
|
// 确保统计目录存在
|
|||
|
|
if (!fs.existsSync(STATS_DIR)) {
|
|||
|
|
fs.mkdirSync(STATS_DIR, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// API 统计辅助函数
|
|||
|
|
function getStatsFile(botId, date = null) {
|
|||
|
|
const d = date || new Date().toISOString().split('T')[0];
|
|||
|
|
return path.join(STATS_DIR, `${botId}-${d}.json`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadBotStats(botId, date = null) {
|
|||
|
|
try {
|
|||
|
|
const file = getStatsFile(botId, date);
|
|||
|
|
if (!fs.existsSync(file)) {
|
|||
|
|
return { date: date || new Date().toISOString().split('T')[0], botId, apiCalls: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, breakdown: {} };
|
|||
|
|
}
|
|||
|
|
const cached = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|||
|
|
// 如果缓存时间在 5 分钟内,直接返回
|
|||
|
|
if (cached.lastUpdated && (Date.now() - new Date(cached.lastUpdated).getTime() < 300000)) {
|
|||
|
|
return cached;
|
|||
|
|
}
|
|||
|
|
return cached;
|
|||
|
|
} catch {
|
|||
|
|
return { date: date || new Date().toISOString().split('T')[0], botId, apiCalls: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, breakdown: {} };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveBotStats(botId, stats) {
|
|||
|
|
try {
|
|||
|
|
const file = getStatsFile(botId, stats.date);
|
|||
|
|
fs.writeFileSync(file, JSON.stringify(stats, null, 2), 'utf-8');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('保存统计失败:', err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从会话文件中分析统计数据
|
|||
|
|
function analyzeSessionStats(botId) {
|
|||
|
|
const stats = loadBotStats(botId);
|
|||
|
|
|
|||
|
|
// 如果缓存在 5 分钟内,直接返回
|
|||
|
|
if (stats.lastUpdated && (Date.now() - new Date(stats.lastUpdated).getTime() < 300000)) {
|
|||
|
|
return stats;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Leader Bot - 从主机会话文件
|
|||
|
|
if (botId === 'leader') {
|
|||
|
|
const sessionId = getActiveSessionId();
|
|||
|
|
if (!sessionId) return stats;
|
|||
|
|
|
|||
|
|
const filePath = path.join(SESSIONS_DIR, `${sessionId}.jsonl`);
|
|||
|
|
try {
|
|||
|
|
const data = fs.readFileSync(filePath, 'utf-8');
|
|||
|
|
const lines = data.split('\n').filter(Boolean);
|
|||
|
|
|
|||
|
|
let apiCalls = 0;
|
|||
|
|
let totalInputTokens = 0;
|
|||
|
|
let totalOutputTokens = 0;
|
|||
|
|
|
|||
|
|
for (const line of lines) {
|
|||
|
|
try {
|
|||
|
|
const entry = JSON.parse(line);
|
|||
|
|
if (entry.type === 'message' && entry.message?.role === 'assistant') {
|
|||
|
|
const usage = entry.message?.usage;
|
|||
|
|
if (usage) {
|
|||
|
|
apiCalls++;
|
|||
|
|
// 支持多种字段名
|
|||
|
|
const inputTokens = usage.input || usage.inputTokens || usage.input_tokens || 0;
|
|||
|
|
const outputTokens = usage.output || usage.outputTokens || usage.output_tokens || 0;
|
|||
|
|
totalInputTokens += inputTokens;
|
|||
|
|
totalOutputTokens += outputTokens;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 成本计算 (基于通义千问定价)
|
|||
|
|
const inputCost = (totalInputTokens / 1000) * 0.004; // $0.004/1K tokens
|
|||
|
|
const outputCost = (totalOutputTokens / 1000) * 0.016; // $0.016/1K tokens
|
|||
|
|
|
|||
|
|
stats.apiCalls = apiCalls;
|
|||
|
|
stats.inputTokens = totalInputTokens;
|
|||
|
|
stats.outputTokens = totalOutputTokens;
|
|||
|
|
stats.totalTokens = totalInputTokens + totalOutputTokens;
|
|||
|
|
stats.totalCost = inputCost + outputCost;
|
|||
|
|
stats.lastUpdated = new Date().toISOString();
|
|||
|
|
|
|||
|
|
saveBotStats(botId, stats);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('分析会话统计失败:', err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Worker Bot - 从 Docker 容器内的会话文件
|
|||
|
|
else {
|
|||
|
|
const bot = BOTS.find(b => b.id === botId);
|
|||
|
|
if (!bot || !bot.container) return stats;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 获取容器内所有会话文件
|
|||
|
|
const filesCmd = `docker exec ${bot.container} find /home/node/.openclaw/agents/main/sessions -name "*.jsonl" -type f ${DEVNULL}`;
|
|||
|
|
const filesRaw = exec(filesCmd);
|
|||
|
|
if (!filesRaw) return stats;
|
|||
|
|
|
|||
|
|
const files = filesRaw.split('\n').filter(Boolean);
|
|||
|
|
|
|||
|
|
let apiCalls = 0;
|
|||
|
|
let totalInputTokens = 0;
|
|||
|
|
let totalOutputTokens = 0;
|
|||
|
|
|
|||
|
|
// 分析每个会话文件
|
|||
|
|
for (const file of files) {
|
|||
|
|
try {
|
|||
|
|
// 读取文件内容
|
|||
|
|
const catCmd = `docker exec ${bot.container} cat "${file}" ${DEVNULL}`;
|
|||
|
|
const content = exec(catCmd);
|
|||
|
|
if (!content) continue;
|
|||
|
|
|
|||
|
|
const lines = content.split('\n').filter(Boolean);
|
|||
|
|
|
|||
|
|
for (const line of lines) {
|
|||
|
|
try {
|
|||
|
|
const entry = JSON.parse(line);
|
|||
|
|
if (entry.type === 'message' && entry.message?.role === 'assistant') {
|
|||
|
|
const usage = entry.message?.usage;
|
|||
|
|
if (usage) {
|
|||
|
|
apiCalls++;
|
|||
|
|
const inputTokens = usage.input || usage.inputTokens || usage.input_tokens || 0;
|
|||
|
|
const outputTokens = usage.output || usage.outputTokens || usage.output_tokens || 0;
|
|||
|
|
totalInputTokens += inputTokens;
|
|||
|
|
totalOutputTokens += outputTokens;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`分析文件失败 ${file}:`, err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 成本计算
|
|||
|
|
const inputCost = (totalInputTokens / 1000) * 0.004;
|
|||
|
|
const outputCost = (totalOutputTokens / 1000) * 0.016;
|
|||
|
|
|
|||
|
|
stats.apiCalls = apiCalls;
|
|||
|
|
stats.inputTokens = totalInputTokens;
|
|||
|
|
stats.outputTokens = totalOutputTokens;
|
|||
|
|
stats.totalTokens = totalInputTokens + totalOutputTokens;
|
|||
|
|
stats.totalCost = inputCost + outputCost;
|
|||
|
|
stats.lastUpdated = new Date().toISOString();
|
|||
|
|
|
|||
|
|
saveBotStats(botId, stats);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`分析 ${botId} 统计失败:`, err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return stats;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取所有 Bot 的统计汇总
|
|||
|
|
function getAllStats() {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
const stats = {};
|
|||
|
|
|
|||
|
|
for (const bot of BOTS) {
|
|||
|
|
stats[bot.id] = analyzeSessionStats(bot.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const total = {
|
|||
|
|
apiCalls: 0,
|
|||
|
|
totalTokens: 0,
|
|||
|
|
inputTokens: 0,
|
|||
|
|
outputTokens: 0,
|
|||
|
|
totalCost: 0,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
for (const botId in stats) {
|
|||
|
|
const s = stats[botId];
|
|||
|
|
total.apiCalls += s.apiCalls || 0;
|
|||
|
|
total.totalTokens += s.totalTokens || 0;
|
|||
|
|
total.inputTokens += s.inputTokens || 0;
|
|||
|
|
total.outputTokens += s.outputTokens || 0;
|
|||
|
|
total.totalCost += s.totalCost || 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { date: today, bots: stats, total, timestamp: new Date().toISOString() };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 每日记忆模块 ──
|
|||
|
|
function getMemoryFile(date = null) {
|
|||
|
|
const d = date || new Date().toISOString().split('T')[0];
|
|||
|
|
return path.join(MEMORY_DIR, `${d}.md`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析 Markdown 格式的记忆文件
|
|||
|
|
function parseMarkdownMemory(content, date) {
|
|||
|
|
const memory = {
|
|||
|
|
date,
|
|||
|
|
content: content,
|
|||
|
|
summary: '',
|
|||
|
|
sections: {},
|
|||
|
|
tasksCompleted: 0,
|
|||
|
|
events: [],
|
|||
|
|
learnings: [],
|
|||
|
|
notes: [],
|
|||
|
|
dailySummary: '',
|
|||
|
|
reflections: []
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 提取摘要(第一个---之前的内容)
|
|||
|
|
const summaryMatch = content.match(/^#[^\n]+\n\n([\s\S]*?)\n---/);
|
|||
|
|
if (summaryMatch) {
|
|||
|
|
memory.summary = summaryMatch[1].trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 按章节分割
|
|||
|
|
const sections = content.split(/\n## /);
|
|||
|
|
for (const section of sections) {
|
|||
|
|
if (!section.trim()) continue;
|
|||
|
|
|
|||
|
|
const lines = section.split('\n');
|
|||
|
|
const title = lines[0].replace(/^## /, '').trim();
|
|||
|
|
const sectionContent = lines.slice(1).join('\n').trim();
|
|||
|
|
|
|||
|
|
memory.sections[title] = sectionContent;
|
|||
|
|
|
|||
|
|
// 提取特定内容
|
|||
|
|
if (title.includes('今日事件') || title.includes('📝')) {
|
|||
|
|
const eventMatches = sectionContent.match(/###[^\n]+/g);
|
|||
|
|
if (eventMatches) {
|
|||
|
|
memory.events = eventMatches.map(e => e.replace(/^### /, '').trim());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (title.includes('学习笔记') || title.includes('🧠')) {
|
|||
|
|
const learningItems = sectionContent.split(/\n### /).filter(s => s.trim() && s !== '无');
|
|||
|
|
memory.learnings = learningItems.map(s => {
|
|||
|
|
const lines = s.split('\n');
|
|||
|
|
return {
|
|||
|
|
title: lines[0].trim(),
|
|||
|
|
content: lines.slice(1).join('\n').trim()
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 新增:每日总结
|
|||
|
|
if (title.includes('每日总结') || title.includes('今日总结') || title.includes('📌') || title.includes('💡')) {
|
|||
|
|
memory.dailySummary = sectionContent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 新增:教训与反思
|
|||
|
|
if (title.includes('教训') || title.includes('反思') || title.includes('经验') || title.includes('⚠️') || title.includes('🎯')) {
|
|||
|
|
// 提取子项(### 开头的)
|
|||
|
|
const reflectionItems = sectionContent.split(/\n### /).filter(s => s.trim() && s !== '无');
|
|||
|
|
if (reflectionItems.length > 0) {
|
|||
|
|
memory.reflections = reflectionItems.map(s => {
|
|||
|
|
const lines = s.split('\n');
|
|||
|
|
return {
|
|||
|
|
title: lines[0].trim(),
|
|||
|
|
content: lines.slice(1).join('\n').trim()
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
// 如果没有子项,整段作为一个反思
|
|||
|
|
if (sectionContent.trim() && sectionContent.trim() !== '无') {
|
|||
|
|
memory.reflections = [{
|
|||
|
|
title: '教训与反思',
|
|||
|
|
content: sectionContent
|
|||
|
|
}];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (title.includes('待跟进') || title.includes('🔄')) {
|
|||
|
|
const todoItems = sectionContent.match(/- \[[ x]\][^\n]+/g);
|
|||
|
|
if (todoItems) {
|
|||
|
|
memory.notes = todoItems.map(t => t.replace(/^- \[[ x]\] /, '').trim());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 统计任务数(从内容中查找复选框)
|
|||
|
|
const checkboxes = content.match(/- \[x\]/g);
|
|||
|
|
memory.tasksCompleted = checkboxes ? checkboxes.length : 0;
|
|||
|
|
|
|||
|
|
return memory;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadBotMemory(botId, date = null) {
|
|||
|
|
// OpenClaw 的记忆是全局的,不区分 Bot
|
|||
|
|
// 但为了保持 API 兼容,我们返回相同的内容
|
|||
|
|
const d = date || new Date().toISOString().split('T')[0];
|
|||
|
|
const file = getMemoryFile(d);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(file)) {
|
|||
|
|
return {
|
|||
|
|
date: d,
|
|||
|
|
botId,
|
|||
|
|
content: '',
|
|||
|
|
summary: `${d} 暂无记录`,
|
|||
|
|
sections: {},
|
|||
|
|
tasksCompleted: 0,
|
|||
|
|
events: [],
|
|||
|
|
learnings: [],
|
|||
|
|
notes: [],
|
|||
|
|
isEmpty: true
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|||
|
|
const memory = parseMarkdownMemory(content, d);
|
|||
|
|
memory.botId = botId;
|
|||
|
|
memory.isEmpty = false;
|
|||
|
|
|
|||
|
|
return memory;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('读取记忆失败:', err);
|
|||
|
|
return {
|
|||
|
|
date: d,
|
|||
|
|
botId,
|
|||
|
|
content: '',
|
|||
|
|
summary: '读取失败',
|
|||
|
|
sections: {},
|
|||
|
|
tasksCompleted: 0,
|
|||
|
|
events: [],
|
|||
|
|
learnings: [],
|
|||
|
|
notes: [],
|
|||
|
|
isEmpty: true,
|
|||
|
|
error: err.message
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveBotMemory(botId, memory) {
|
|||
|
|
// OpenClaw 的记忆系统不支持从 Dashboard 直接写入
|
|||
|
|
// 这个功能保留但不实现
|
|||
|
|
return { ok: false, error: 'Memory is read-only from OpenClaw workspace' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取所有 Bot 的记忆(实际上是同一份)
|
|||
|
|
function getAllMemories(date = null) {
|
|||
|
|
const d = date || new Date().toISOString().split('T')[0];
|
|||
|
|
const memory = loadBotMemory('shared', d);
|
|||
|
|
|
|||
|
|
// 为了保持 API 兼容性,每个 Bot 返回相同的内容
|
|||
|
|
const memories = {};
|
|||
|
|
for (const bot of BOTS) {
|
|||
|
|
memories[bot.id] = { ...memory, botId: bot.id };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { date: d, bots: memories, timestamp: new Date().toISOString() };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取历史记忆列表
|
|||
|
|
function getMemoryHistory(botId, days = 7) {
|
|||
|
|
const history = [];
|
|||
|
|
const today = new Date();
|
|||
|
|
|
|||
|
|
for (let i = 0; i < days; i++) {
|
|||
|
|
const date = new Date(today);
|
|||
|
|
date.setDate(date.getDate() - i);
|
|||
|
|
const dateStr = date.toISOString().split('T')[0];
|
|||
|
|
const memory = loadBotMemory(botId, dateStr);
|
|||
|
|
if (!memory.isEmpty) {
|
|||
|
|
history.push(memory);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return history;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getActiveSessionId() {
|
|||
|
|
try {
|
|||
|
|
const sessData = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, 'sessions.json'), 'utf-8'));
|
|||
|
|
for (const [key, val] of Object.entries(sessData)) {
|
|||
|
|
if (key.includes('feishu:direct:ou_')) return val.sessionId;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
try {
|
|||
|
|
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.jsonl.lock'));
|
|||
|
|
if (files.length) return files[0].replace('.jsonl.lock', '');
|
|||
|
|
} catch {}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getSessionMessages(sessionId, limit = 20) {
|
|||
|
|
if (!sessionId) return [];
|
|||
|
|
const filePath = path.join(SESSIONS_DIR, `${sessionId}.jsonl`);
|
|||
|
|
try {
|
|||
|
|
const data = fs.readFileSync(filePath, 'utf-8');
|
|||
|
|
const lines = data.split('\n').filter(Boolean);
|
|||
|
|
const msgs = [];
|
|||
|
|
const allEntries = [];
|
|||
|
|
|
|||
|
|
for (const line of lines) {
|
|||
|
|
try { allEntries.push(JSON.parse(line)); } catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (let i = 0; i < allEntries.length; i++) {
|
|||
|
|
const entry = allEntries[i];
|
|||
|
|
if (entry.type !== 'message') continue;
|
|||
|
|
const role = entry.message?.role;
|
|||
|
|
if (role !== 'user' && role !== 'assistant') continue;
|
|||
|
|
let rawText = '';
|
|||
|
|
const content = entry.message?.content;
|
|||
|
|
let hasToolCalls = false;
|
|||
|
|
let toolCallCount = 0;
|
|||
|
|
let thinkingText = '';
|
|||
|
|
if (Array.isArray(content)) {
|
|||
|
|
rawText = content.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|||
|
|
toolCallCount = content.filter(c => c.type === 'toolCall' || c.type === 'tool_use').length;
|
|||
|
|
hasToolCalls = toolCallCount > 0;
|
|||
|
|
for (const c of content) {
|
|||
|
|
if (c.type === 'thinking' && c.thinking) thinkingText += c.thinking + '\n';
|
|||
|
|
}
|
|||
|
|
} else if (typeof content === 'string') {
|
|||
|
|
rawText = content;
|
|||
|
|
}
|
|||
|
|
if (!rawText && !hasToolCalls && !thinkingText) continue;
|
|||
|
|
|
|||
|
|
const stopReason = entry.message?.stopReason || '';
|
|||
|
|
|
|||
|
|
let thinking = thinkingText.trim() || null, reply = rawText;
|
|||
|
|
if (role === 'assistant' && !thinking) {
|
|||
|
|
const lastThinkIdx = rawText.lastIndexOf('</think>');
|
|||
|
|
if (lastThinkIdx !== -1) {
|
|||
|
|
let thinkContent = rawText.substring(0, lastThinkIdx);
|
|||
|
|
thinkContent = thinkContent.replace(/<think>/g, '').trim();
|
|||
|
|
reply = rawText.substring(lastThinkIdx + 8).trim();
|
|||
|
|
if (thinkContent) thinking = thinkContent;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (role === 'user') {
|
|||
|
|
const msgIdMatch = rawText.match(/\[message_id:[^\]]+\]\n([^:]+):\s*([\s\S]+)$/);
|
|||
|
|
if (msgIdMatch) {
|
|||
|
|
reply = msgIdMatch[2].trim();
|
|||
|
|
} else {
|
|||
|
|
const dmMatch = rawText.match(/ou_[a-f0-9]+:\s*([\s\S]+)$/);
|
|||
|
|
if (dmMatch) reply = dmMatch[1].trim();
|
|||
|
|
else {
|
|||
|
|
const textLines = rawText.split('\n').filter(Boolean);
|
|||
|
|
const lastNonJson = [...textLines].reverse().find(l => !l.trim().startsWith('{') && !l.trim().startsWith('```') && !l.trim().startsWith('"'));
|
|||
|
|
reply = lastNonJson || textLines[textLines.length - 1] || rawText;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let turnStatus = null;
|
|||
|
|
if (role === 'assistant') {
|
|||
|
|
if (hasToolCalls) {
|
|||
|
|
turnStatus = 'working';
|
|||
|
|
} else if (stopReason === 'stop' || stopReason === 'endTurn' || stopReason === '') {
|
|||
|
|
let hasToolResultBefore = false;
|
|||
|
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|||
|
|
const prev = allEntries[j];
|
|||
|
|
if (prev?.type === 'message') {
|
|||
|
|
const prevRole = prev.message?.role;
|
|||
|
|
if (prevRole === 'user') break;
|
|||
|
|
if (prevRole === 'toolResult') { hasToolResultBefore = true; break; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
turnStatus = hasToolResultBefore ? 'final' : 'text_only';
|
|||
|
|
} else if (stopReason === 'toolUse') {
|
|||
|
|
turnStatus = 'working';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
msgs.push({
|
|||
|
|
id: entry.id,
|
|||
|
|
role,
|
|||
|
|
thinking: thinking ? thinking.substring(0, 2000) : null,
|
|||
|
|
content: reply.substring(0, 3000),
|
|||
|
|
timestamp: entry.timestamp || (entry.message?.timestamp ? new Date(entry.message.timestamp).toISOString() : null),
|
|||
|
|
turnStatus,
|
|||
|
|
hasToolCalls,
|
|||
|
|
toolCallCount,
|
|||
|
|
stopReason: role === 'assistant' ? stopReason : undefined,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return msgs.slice(-limit);
|
|||
|
|
} catch { return []; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取 Worker Bot 对话数据
|
|||
|
|
function getWorkerBotConversation(container, limit = 20) {
|
|||
|
|
try {
|
|||
|
|
const cmd = `docker exec ${container} find /home/node/.openclaw/agents/main/sessions -name "*.jsonl" ! -name "*.lock" -type f -printf "%T@ %p\\n" 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-`;
|
|||
|
|
const latestSession = exec(cmd);
|
|||
|
|
if (!latestSession) return { sessionId: null, messages: [] };
|
|||
|
|
|
|||
|
|
const sessionFile = latestSession.trim();
|
|||
|
|
const sessionId = sessionFile.split('/').pop().replace('.jsonl', '');
|
|||
|
|
|
|||
|
|
const readCmd = `docker exec ${container} cat "${sessionFile}" 2>/dev/null`;
|
|||
|
|
const data = exec(readCmd);
|
|||
|
|
if (!data) return { sessionId, messages: [] };
|
|||
|
|
|
|||
|
|
const lines = data.split('\n').filter(Boolean);
|
|||
|
|
const msgs = [];
|
|||
|
|
const allEntries = [];
|
|||
|
|
|
|||
|
|
for (const line of lines) {
|
|||
|
|
try { allEntries.push(JSON.parse(line)); } catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (let i = 0; i < allEntries.length; i++) {
|
|||
|
|
const entry = allEntries[i];
|
|||
|
|
if (entry.type !== 'message') continue;
|
|||
|
|
const role = entry.message?.role;
|
|||
|
|
if (role !== 'user' && role !== 'assistant') continue;
|
|||
|
|
|
|||
|
|
let rawText = '';
|
|||
|
|
const content = entry.message?.content;
|
|||
|
|
let hasToolCalls = false;
|
|||
|
|
let toolCallCount = 0;
|
|||
|
|
let thinkingText = '';
|
|||
|
|
|
|||
|
|
if (Array.isArray(content)) {
|
|||
|
|
rawText = content.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|||
|
|
toolCallCount = content.filter(c => c.type === 'toolCall' || c.type === 'tool_use').length;
|
|||
|
|
hasToolCalls = toolCallCount > 0;
|
|||
|
|
for (const c of content) {
|
|||
|
|
if (c.type === 'thinking' && c.thinking) thinkingText += c.thinking + '\n';
|
|||
|
|
}
|
|||
|
|
} else if (typeof content === 'string') {
|
|||
|
|
rawText = content;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!rawText && !hasToolCalls && !thinkingText) continue;
|
|||
|
|
|
|||
|
|
const stopReason = entry.message?.stopReason || '';
|
|||
|
|
let thinking = thinkingText.trim() || null;
|
|||
|
|
let reply = rawText;
|
|||
|
|
|
|||
|
|
if (role === 'assistant' && !thinking) {
|
|||
|
|
const lastThinkIdx = rawText.lastIndexOf('</think>');
|
|||
|
|
if (lastThinkIdx !== -1) {
|
|||
|
|
let thinkContent = rawText.substring(0, lastThinkIdx);
|
|||
|
|
thinkContent = thinkContent.replace(/<think>/g, '').trim();
|
|||
|
|
reply = rawText.substring(lastThinkIdx + 8).trim();
|
|||
|
|
if (thinkContent) thinking = thinkContent;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (role === 'user') {
|
|||
|
|
const msgIdMatch = rawText.match(/\[message_id:[^\]]+\]\n([^:]+):\s*([\s\S]+)$/);
|
|||
|
|
if (msgIdMatch) {
|
|||
|
|
reply = msgIdMatch[2].trim();
|
|||
|
|
} else {
|
|||
|
|
const dmMatch = rawText.match(/ou_[a-f0-9]+:\s*([\s\S]+)$/);
|
|||
|
|
if (dmMatch) reply = dmMatch[1].trim();
|
|||
|
|
else {
|
|||
|
|
const textLines = rawText.split('\n').filter(Boolean);
|
|||
|
|
const lastNonJson = [...textLines].reverse().find(l => !l.trim().startsWith('{') && !l.trim().startsWith('```') && !l.trim().startsWith('"'));
|
|||
|
|
reply = lastNonJson || textLines[textLines.length - 1] || rawText;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let turnStatus = null;
|
|||
|
|
if (role === 'assistant') {
|
|||
|
|
if (hasToolCalls) {
|
|||
|
|
turnStatus = 'working';
|
|||
|
|
} else if (stopReason === 'stop' || stopReason === 'endTurn' || stopReason === '') {
|
|||
|
|
let hasToolResultBefore = false;
|
|||
|
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|||
|
|
const prev = allEntries[j];
|
|||
|
|
if (prev?.type === 'message') {
|
|||
|
|
const prevRole = prev.message?.role;
|
|||
|
|
if (prevRole === 'user') break;
|
|||
|
|
if (prevRole === 'toolResult') { hasToolResultBefore = true; break; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
turnStatus = hasToolResultBefore ? 'final' : 'text_only';
|
|||
|
|
} else if (stopReason === 'toolUse') {
|
|||
|
|
turnStatus = 'working';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
msgs.push({
|
|||
|
|
id: entry.id,
|
|||
|
|
role,
|
|||
|
|
thinking: thinking ? thinking.substring(0, 2000) : null,
|
|||
|
|
content: reply.substring(0, 3000),
|
|||
|
|
timestamp: entry.timestamp || (entry.message?.timestamp ? new Date(entry.message.timestamp).toISOString() : null),
|
|||
|
|
turnStatus,
|
|||
|
|
hasToolCalls,
|
|||
|
|
toolCallCount,
|
|||
|
|
stopReason: role === 'assistant' ? stopReason : undefined,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { sessionId, messages: msgs.slice(-limit) };
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`Error getting worker bot conversation for ${container}:`, err.message);
|
|||
|
|
return { sessionId: null, messages: [] };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.get('/api/monitor/conversation', (req, res) => {
|
|||
|
|
const limit = parseInt(req.query.limit || '20', 10);
|
|||
|
|
const botId = req.query.botId || 'leader';
|
|||
|
|
|
|||
|
|
if (botId === 'leader') {
|
|||
|
|
const sessionId = getActiveSessionId();
|
|||
|
|
if (!sessionId) return res.json({ messages: [], sessionId: null, botId });
|
|||
|
|
const messages = getSessionMessages(sessionId, Math.min(limit, 50));
|
|||
|
|
const lockExists = fs.existsSync(path.join(SESSIONS_DIR, `${sessionId}.jsonl.lock`));
|
|||
|
|
return res.json({ sessionId, active: lockExists, messages, timestamp: new Date().toISOString(), botId });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Worker Bot 对话数据
|
|||
|
|
const bot = BOTS.find(b => b.id === botId);
|
|||
|
|
if (!bot || !bot.container) {
|
|||
|
|
return res.json({ messages: [], sessionId: null, botId, timestamp: new Date().toISOString() });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { sessionId, messages } = getWorkerBotConversation(bot.container, Math.min(limit, 50));
|
|||
|
|
res.json({ sessionId, active: false, messages, timestamp: new Date().toISOString(), botId });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/bot/:id/logs', (req, res) => {
|
|||
|
|
const bot = BOTS.find(b => b.id === req.params.id);
|
|||
|
|
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
|||
|
|
const lines = parseInt(req.query.lines || '60', 10);
|
|||
|
|
if (bot.type === 'host') {
|
|||
|
|
const logs = getRecentLogs(lines);
|
|||
|
|
const filtered = logs.filter(l => ['incoming', 'processing', 'complete', 'streaming', 'stream_done', 'warn', 'error'].includes(l.type)).slice(-30);
|
|||
|
|
return res.json({ id: bot.id, logs: filtered, timestamp: new Date().toISOString() });
|
|||
|
|
}
|
|||
|
|
if (!bot.container) return res.json({ id: bot.id, logs: [], timestamp: new Date().toISOString() });
|
|||
|
|
const stderrRedir = IS_WIN ? '2>&1' : '2>&1';
|
|||
|
|
const raw = exec(`docker logs ${bot.container} --tail ${lines} ${stderrRedir}`);
|
|||
|
|
const entries = [];
|
|||
|
|
if (raw) {
|
|||
|
|
for (const line of raw.split('\n')) {
|
|||
|
|
if (!line.trim()) continue;
|
|||
|
|
let type = 'system', content = line;
|
|||
|
|
if (/开始轮询/.test(line)) { type = 'poll'; content = '开始轮询任务'; }
|
|||
|
|
else if (/没有待处理/.test(line)) { type = 'idle'; content = '📭 无待处理任务'; }
|
|||
|
|
else if (/继续处理|领取新/.test(line)) { type = 'processing'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else if (/开始处理第/.test(line)) { type = 'working'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else if (/已勾选|项处理完成/.test(line)) { type = 'complete'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else if (/阶段.*完成|交接/.test(line)) { type = 'handoff'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else if (/⚠️|失败|error/i.test(line)) { type = 'error'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else if (/Gateway|跳过/.test(line)) { type = 'warn'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else if (/━━━/.test(line)) continue;
|
|||
|
|
else if (/📦|🧠|📋|🎯|🧠/.test(line)) { type = 'info'; content = line.replace(/^.*?\]\s*/, ''); }
|
|||
|
|
else continue;
|
|||
|
|
const tsMatch = line.match(/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
|
|||
|
|
entries.push({ type, content, time: tsMatch ? tsMatch[1] : null });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const pollMeta = getPollMeta(bot.container);
|
|||
|
|
const dockerStatuses = getDockerStatus();
|
|||
|
|
const st = dockerStatuses[bot.container] || { state: 'unknown', running: false };
|
|||
|
|
res.json({ id: bot.id, logs: entries.slice(-30), status: st, pollMeta, timestamp: new Date().toISOString() });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/bot/:id/restart', (req, res) => {
|
|||
|
|
const bot = BOTS.find((b) => b.id === req.params.id);
|
|||
|
|
if (!bot) return res.status(404).json({ ok: false, error: 'Bot not found' });
|
|||
|
|
|
|||
|
|
const result = restartBot(bot);
|
|||
|
|
const status = bot.type === 'host'
|
|||
|
|
? getHostGatewayStatus()
|
|||
|
|
: (getDockerStatus()[bot.container] || { state: 'unknown', status: 'Unknown', health: 'unknown', running: false });
|
|||
|
|
|
|||
|
|
res.status(result.ok ? 200 : 500).json({
|
|||
|
|
ok: result.ok,
|
|||
|
|
botId: bot.id,
|
|||
|
|
name: bot.name,
|
|||
|
|
message: result.ok ? `${bot.name} 重启命令已发送,请等待几秒刷新状态` : `${bot.name} 重启失败`,
|
|||
|
|
result,
|
|||
|
|
status,
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/task/:number', (req, res) => {
|
|||
|
|
const num = req.params.number;
|
|||
|
|
const q = IS_WIN ? '"."' : "'.'";
|
|||
|
|
const raw = exec(`gh issue view ${num} -R ${TASK_REPO} --json number,title,body,labels,state,createdAt,updatedAt,comments -q ${q} ${DEVNULL}`);
|
|||
|
|
if (!raw) return res.status(404).json({ error: 'Task not found' });
|
|||
|
|
try {
|
|||
|
|
const issue = JSON.parse(raw);
|
|||
|
|
const formatted = formatIssue(issue);
|
|||
|
|
const comments = (issue.comments || []).map((c) => ({ author: c.author?.login || 'unknown', body: c.body, createdAt: c.createdAt }));
|
|||
|
|
res.json({ ...formatted, body: issue.body, comments, timestamp: new Date().toISOString() });
|
|||
|
|
} catch { res.status(500).json({ error: 'Parse error' }); }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ── API 统计数据端点 ──
|
|||
|
|
app.get('/api/stats', (req, res) => {
|
|||
|
|
const stats = getAllStats();
|
|||
|
|
res.json(stats);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/stats/:botId', (req, res) => {
|
|||
|
|
const botId = req.params.botId;
|
|||
|
|
const bot = BOTS.find(b => b.id === botId);
|
|||
|
|
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
|||
|
|
|
|||
|
|
const stats = analyzeSessionStats(botId);
|
|||
|
|
res.json(stats);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ── 每日记忆 API ──
|
|||
|
|
app.get('/api/memory', (req, res) => {
|
|||
|
|
const date = req.query.date || null;
|
|||
|
|
const memories = getAllMemories(date);
|
|||
|
|
res.json(memories);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/memory/:botId', (req, res) => {
|
|||
|
|
const botId = req.params.botId;
|
|||
|
|
const bot = BOTS.find(b => b.id === botId);
|
|||
|
|
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
|||
|
|
|
|||
|
|
const date = req.query.date || null;
|
|||
|
|
const memory = date ? loadBotMemory(botId, date) : generateDailyMemory(botId);
|
|||
|
|
res.json(memory);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/memory/:botId/history', (req, res) => {
|
|||
|
|
const botId = req.params.botId;
|
|||
|
|
const bot = BOTS.find(b => b.id === botId);
|
|||
|
|
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
|||
|
|
|
|||
|
|
const days = parseInt(req.query.days || '7', 10);
|
|||
|
|
const history = getMemoryHistory(botId, days);
|
|||
|
|
res.json({ botId, history, timestamp: new Date().toISOString() });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/memory/:botId', express.json(), (req, res) => {
|
|||
|
|
const botId = req.params.botId;
|
|||
|
|
const bot = BOTS.find(b => b.id === botId);
|
|||
|
|
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
|||
|
|
|
|||
|
|
const memory = req.body;
|
|||
|
|
if (!memory.date) memory.date = new Date().toISOString().split('T')[0];
|
|||
|
|
memory.botId = botId;
|
|||
|
|
memory.lastUpdated = new Date().toISOString();
|
|||
|
|
|
|||
|
|
const result = saveBotMemory(botId, memory);
|
|||
|
|
if (result.ok) {
|
|||
|
|
res.json({ ok: true, memory, timestamp: new Date().toISOString() });
|
|||
|
|
} else {
|
|||
|
|
res.status(500).json({ ok: false, error: result.error });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ════════════════ 服务器启动 ════════════════
|
|||
|
|
logger.info('系统', '正在启动 Express 服务器...');
|
|||
|
|
app.listen(PORT, () => {
|
|||
|
|
logger.success('系统', `🦞 AI Team Dashboard 启动成功!`);
|
|||
|
|
logger.success('系统', `访问地址: http://localhost:${PORT}`);
|
|||
|
|
logger.info('系统', `日志文件: ${DASHBOARD_LOG_FILE}`);
|
|||
|
|
logger.info('系统', `配置文件: ${CONFIG_PATH}`);
|
|||
|
|
logger.info('系统', `PID: ${process.pid}`);
|
|||
|
|
logger.info('系统', '─'.repeat(60));
|
|||
|
|
logger.info('系统', '📊 Dashboard 已就绪,等待请求...');
|
|||
|
|
console.log(`🦞 AI Team Dashboard running at http://localhost:${PORT}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 优雅退出
|
|||
|
|
process.on('SIGINT', () => {
|
|||
|
|
logger.warn('系统', '收到 SIGINT 信号,准备退出...');
|
|||
|
|
process.exit(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
process.on('SIGTERM', () => {
|
|||
|
|
logger.warn('系统', '收到 SIGTERM 信号,准备退出...');
|
|||
|
|
process.exit(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
process.on('exit', (code) => {
|
|||
|
|
logger.info('系统', `Dashboard 已退出`, { code });
|
|||
|
|
logger.info('系统', '═'.repeat(60));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 捕获未处理的错误
|
|||
|
|
process.on('uncaughtException', (err) => {
|
|||
|
|
logger.error('系统', '未捕获的异常', { error: err.message, stack: err.stack });
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|||
|
|
logger.error('系统', '未处理的 Promise 拒绝', { reason: String(reason) });
|
|||
|
|
});
|