Files
ai-team-dashboard/dashboard/server.js
fang 5f14174bb9 feat: 添加 Dashboard 完整日志监控系统 v1.1.0
 新增功能
- 完整的日志记录系统(6 种日志级别)
- 日志配置功能(可通过 config.json 控制)
- 性能监控装饰器和请求日志中间件
- 7 个管理工具脚本
- 完整的文档和使用指南

🛠️ 管理工具
- start-with-log.sh: 启动脚本(带日志)
- stop-dashboard.sh: 停止脚本
- view-logs.sh: 日志查看器
- monitor-logs.sh: 实时监控工具(支持多种过滤器)
- analyze-logs.sh: 日志分析工具(自动生成报告)
- demo-logging.sh: 功能演示脚本
- test-logging-config.sh: 配置测试工具

📊 日志特性
- 支持 INFO/SUCCESS/WARN/ERROR/DEBUG/PERF 6 种级别
- 自动记录启动过程、API 请求、性能统计
- 缓存命中情况追踪
- 分步性能监控
- 智能过滤器

⚙️ 配置功能
- 可控制是否启用日志(默认:true)
- 可设置日志级别(默认:INFO)
- 可控制文件/控制台输出
- 支持动态配置(重启生效)

📚 文档
- LOGGING_GUIDE.md: 完整使用指南
- LOGGING_CONFIG.md: 配置说明文档
- LOGGING_CONFIG_QUICK.md: 快速配置指南
- 多个中文说明文档

🔒 安全
- 添加 .gitignore 排除敏感信息
- config.json(含 Token)不提交
- 日志文件不提交
- 示例配置使用占位符

 测试
- 语法检查通过
- 功能完整性验证
- 配置控制测试通过
- 文档完整性检查

详见 CHANGELOG_v1.1.0.md

Made-with: Cursor
2026-03-11 11:37:35 +08:00

1678 lines
62 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) });
});