- 新增 /api/agents/:agentName/conversations API,按 sessionKey 分组汇总 - 新增 /api/agents/:agentName/conversations/:sessionKey/timeline API - 前端新增「对话汇总」Tab,展示对话级别的统计信息 - 支持展开查看对话下的所有 session 阶段 - 修复 session 文件不存在时返回 404 的问题 功能: - 显示每个对话的总消息数、Token 数、压缩次数、时长 - 标注原始 session 和压缩后的 session - 显示对话时间跨度
474 lines
19 KiB
JavaScript
474 lines
19 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const readline = require('readline');
|
|
|
|
const app = express();
|
|
const PORT = 16039;
|
|
const AGENTS_DIR = '/home/openclaw/.openclaw/agents';
|
|
|
|
// 静态文件
|
|
app.use(express.static('public'));
|
|
|
|
// API: 获取所有智能体
|
|
app.get('/api/agents', async (req, res) => {
|
|
try {
|
|
const agents = [];
|
|
const dirs = fs.readdirSync(AGENTS_DIR, { withFileTypes: true });
|
|
|
|
for (const dir of dirs) {
|
|
if (dir.isDirectory()) {
|
|
const agentPath = path.join(AGENTS_DIR, dir.name);
|
|
const sessionsPath = path.join(agentPath, 'sessions');
|
|
|
|
if (fs.existsSync(sessionsPath)) {
|
|
const sessionsFile = path.join(sessionsPath, 'sessions.json');
|
|
let sessionCount = 0;
|
|
let lastActive = null;
|
|
|
|
if (fs.existsSync(sessionsFile)) {
|
|
const sessionsData = JSON.parse(fs.readFileSync(sessionsFile, 'utf8'));
|
|
const sessionKeys = Object.keys(sessionsData);
|
|
sessionCount = sessionKeys.length;
|
|
|
|
// 找到最近的活跃时间
|
|
for (const key of sessionKeys) {
|
|
const session = sessionsData[key];
|
|
if (session.updatedAt) {
|
|
if (!lastActive || session.updatedAt > lastActive) {
|
|
lastActive = session.updatedAt;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
agents.push({
|
|
name: dir.name,
|
|
path: agentPath,
|
|
sessionCount,
|
|
lastActive: lastActive ? new Date(lastActive).toISOString() : null
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 按最后活跃时间排序
|
|
agents.sort((a, b) => {
|
|
if (!a.lastActive) return 1;
|
|
if (!b.lastActive) return -1;
|
|
return new Date(b.lastActive) - new Date(a.lastActive);
|
|
});
|
|
|
|
res.json(agents);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// API: 获取智能体的会话列表
|
|
app.get('/api/agents/:agentName/sessions', async (req, res) => {
|
|
try {
|
|
const { agentName } = req.params;
|
|
const sessionsPath = path.join(AGENTS_DIR, agentName, 'sessions');
|
|
const sessionsFile = path.join(sessionsPath, 'sessions.json');
|
|
|
|
if (!fs.existsSync(sessionsFile)) {
|
|
return res.json([]);
|
|
}
|
|
|
|
const sessionsData = JSON.parse(fs.readFileSync(sessionsFile, 'utf8'));
|
|
const sessions = [];
|
|
|
|
for (const [key, session] of Object.entries(sessionsData)) {
|
|
// 解析 sessionKey 格式
|
|
const parts = key.split(':');
|
|
let channel = 'unknown';
|
|
let chatType = 'unknown';
|
|
let target = 'unknown';
|
|
|
|
if (parts.length >= 3) {
|
|
channel = parts[2] || 'unknown';
|
|
if (parts.length >= 4) {
|
|
chatType = parts[3] || 'unknown';
|
|
if (parts.length >= 5) {
|
|
target = parts.slice(4).join(':');
|
|
}
|
|
}
|
|
}
|
|
|
|
sessions.push({
|
|
sessionKey: key,
|
|
sessionId: session.sessionId,
|
|
channel,
|
|
chatType,
|
|
target,
|
|
status: session.status || 'unknown',
|
|
model: session.model || session.authProfileOverride || 'unknown',
|
|
modelProvider: session.modelProvider || 'unknown',
|
|
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : null,
|
|
startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : null,
|
|
endedAt: session.endedAt ? new Date(session.endedAt).toISOString() : null,
|
|
lastInteractionAt: session.lastInteractionAt ? new Date(session.lastInteractionAt).toISOString() : null,
|
|
inputTokens: session.inputTokens || 0,
|
|
outputTokens: session.outputTokens || 0,
|
|
totalTokens: session.totalTokens || 0,
|
|
sessionFile: session.sessionFile
|
|
});
|
|
}
|
|
|
|
// 按更新时间排序
|
|
sessions.sort((a, b) => {
|
|
if (!a.updatedAt) return 1;
|
|
if (!b.updatedAt) return -1;
|
|
return new Date(b.updatedAt) - new Date(a.updatedAt);
|
|
});
|
|
|
|
res.json(sessions);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// API: 获取会话的消息概览列表(仅摘要,不包含完整内容)
|
|
app.get('/api/agents/:agentName/sessions/:sessionId/messages', async (req, res) => {
|
|
try {
|
|
const { agentName, sessionId } = req.params;
|
|
const sessionFile = path.join(AGENTS_DIR, agentName, 'sessions', `${sessionId}.jsonl`);
|
|
|
|
// 如果文件不存在,返回空数组而不是 404
|
|
if (!fs.existsSync(sessionFile)) {
|
|
return res.json([]);
|
|
}
|
|
|
|
const messages = [];
|
|
const fileStream = fs.createReadStream(sessionFile);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (line.trim()) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
|
|
// 只提取消息类型
|
|
if (data.type === 'message' && data.message) {
|
|
const content = data.message.content;
|
|
let text = '';
|
|
|
|
if (typeof content === 'string') {
|
|
text = content;
|
|
} else if (Array.isArray(content)) {
|
|
text = content.map(c => {
|
|
if (typeof c === 'string') return c;
|
|
if (c.type === 'text') return c.text;
|
|
if (c.type === 'image_url') return '[Image]';
|
|
return JSON.stringify(c);
|
|
}).join('\n');
|
|
}
|
|
|
|
// 生成摘要(前150字符)
|
|
const summary = text.substring(0, 150);
|
|
const hasMoreContent = text.length > 150;
|
|
|
|
messages.push({
|
|
id: data.id,
|
|
timestamp: data.timestamp,
|
|
role: data.message.role,
|
|
summary: summary,
|
|
hasMoreContent: hasMoreContent,
|
|
contentLength: text.length
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// 跳过解析错误的行
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json(messages);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// API: 获取单条消息的完整内容
|
|
app.get('/api/agents/:agentName/sessions/:sessionId/messages/:messageId', async (req, res) => {
|
|
try {
|
|
const { agentName, sessionId, messageId } = req.params;
|
|
const sessionFile = path.join(AGENTS_DIR, agentName, 'sessions', `${sessionId}.jsonl`);
|
|
|
|
// 如果文件不存在,返回 404
|
|
if (!fs.existsSync(sessionFile)) {
|
|
return res.status(404).json({ error: 'Session file not found (may have been reset or deleted)' });
|
|
}
|
|
|
|
const fileStream = fs.createReadStream(sessionFile);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (line.trim()) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
if (data.id === messageId && data.message) {
|
|
const content = data.message.content;
|
|
let text = '';
|
|
|
|
if (typeof content === 'string') {
|
|
text = content;
|
|
} else if (Array.isArray(content)) {
|
|
text = content.map(c => {
|
|
if (typeof c === 'string') return c;
|
|
if (c.type === 'text') return c.text;
|
|
return JSON.stringify(c, null, 2);
|
|
}).join('\n');
|
|
}
|
|
|
|
return res.json({
|
|
id: data.id,
|
|
timestamp: data.timestamp,
|
|
role: data.message.role,
|
|
content: text
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// 跳过
|
|
}
|
|
}
|
|
}
|
|
|
|
res.status(404).json({ error: 'Message not found' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// API: 获取智能体的"对话"列表(按 sessionKey 分组,包含连续性信息)
|
|
app.get('/api/agents/:agentName/conversations', async (req, res) => {
|
|
try {
|
|
const { agentName } = req.params;
|
|
const sessionsPath = path.join(AGENTS_DIR, agentName, 'sessions');
|
|
const sessionsFile = path.join(sessionsPath, 'sessions.json');
|
|
|
|
if (!fs.existsSync(sessionsFile)) {
|
|
return res.json([]);
|
|
}
|
|
|
|
const sessionsData = JSON.parse(fs.readFileSync(sessionsFile, 'utf8'));
|
|
const conversations = {};
|
|
|
|
// 按 sessionKey 分组
|
|
for (const [sessionKey, session] of Object.entries(sessionsData)) {
|
|
if (!conversations[sessionKey]) {
|
|
// 解析 sessionKey 获取元信息
|
|
const parts = sessionKey.split(':');
|
|
let channel = 'unknown';
|
|
let chatType = 'unknown';
|
|
let target = 'unknown';
|
|
|
|
if (parts.length >= 3) {
|
|
channel = parts[2] || 'unknown';
|
|
if (parts.length >= 4) {
|
|
chatType = parts[3] || 'unknown';
|
|
if (parts.length >= 5) {
|
|
target = parts.slice(4).join(':');
|
|
}
|
|
}
|
|
}
|
|
|
|
conversations[sessionKey] = {
|
|
sessionKey,
|
|
channel,
|
|
chatType,
|
|
target,
|
|
sessions: [],
|
|
totalTokens: 0,
|
|
totalInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalMessages: 0,
|
|
firstStartedAt: null,
|
|
lastUpdatedAt: null,
|
|
compactionCount: 0
|
|
};
|
|
}
|
|
|
|
const conv = conversations[sessionKey];
|
|
|
|
// 获取 session 文件中的消息数
|
|
let messageCount = 0;
|
|
const sessionFile = session.sessionFile;
|
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
try {
|
|
const content = fs.readFileSync(sessionFile, 'utf8');
|
|
const lines = content.trim().split('\n');
|
|
messageCount = lines.filter(line => {
|
|
try {
|
|
const d = JSON.parse(line);
|
|
return d.type === 'message';
|
|
} catch { return false; }
|
|
}).length;
|
|
} catch { messageCount = 0; }
|
|
}
|
|
|
|
conv.sessions.push({
|
|
sessionId: session.sessionId,
|
|
status: session.status || 'unknown',
|
|
model: session.model || session.authProfileOverride || 'unknown',
|
|
startedAt: session.sessionStartedAt ? new Date(session.sessionStartedAt).toISOString() : null,
|
|
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : null,
|
|
lastInteractionAt: session.lastInteractionAt ? new Date(session.lastInteractionAt).toISOString() : null,
|
|
inputTokens: session.inputTokens || 0,
|
|
outputTokens: session.outputTokens || 0,
|
|
totalTokens: session.totalTokens || 0,
|
|
compactionCount: session.compactionCount || 0,
|
|
messageCount,
|
|
sessionFile: session.sessionFile
|
|
});
|
|
|
|
conv.totalTokens += session.totalTokens || 0;
|
|
conv.totalInputTokens += session.inputTokens || 0;
|
|
conv.totalOutputTokens += session.outputTokens || 0;
|
|
conv.totalMessages += messageCount;
|
|
conv.compactionCount = Math.max(conv.compactionCount, session.compactionCount || 0);
|
|
|
|
// 更新最早开始时间和最新更新时间
|
|
const startedAt = session.sessionStartedAt ? new Date(session.sessionStartedAt) : null;
|
|
const updatedAt = session.updatedAt ? new Date(session.updatedAt) : null;
|
|
|
|
if (startedAt && (!conv.firstStartedAt || startedAt < new Date(conv.firstStartedAt))) {
|
|
conv.firstStartedAt = startedAt.toISOString();
|
|
}
|
|
if (updatedAt && (!conv.lastUpdatedAt || updatedAt > new Date(conv.lastUpdatedAt))) {
|
|
conv.lastUpdatedAt = updatedAt.toISOString();
|
|
}
|
|
}
|
|
|
|
// 转换为数组并排序
|
|
const result = Object.values(conversations).map(conv => {
|
|
// 按时间排序 sessions
|
|
conv.sessions.sort((a, b) => {
|
|
if (!a.startedAt) return 1;
|
|
if (!b.startedAt) return -1;
|
|
return new Date(a.startedAt) - new Date(b.startedAt);
|
|
});
|
|
|
|
return {
|
|
...conv,
|
|
sessionCount: conv.sessions.length,
|
|
durationHours: conv.firstStartedAt && conv.lastUpdatedAt
|
|
? ((new Date(conv.lastUpdatedAt) - new Date(conv.firstStartedAt)) / 3600000).toFixed(1)
|
|
: null
|
|
};
|
|
});
|
|
|
|
// 按最后活跃时间排序
|
|
result.sort((a, b) => {
|
|
if (!a.lastUpdatedAt) return 1;
|
|
if (!b.lastUpdatedAt) return -1;
|
|
return new Date(b.lastUpdatedAt) - new Date(a.lastUpdatedAt);
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// API: 获取对话的详细信息(包含所有 session 的消息时间线)
|
|
app.get('/api/agents/:agentName/conversations/:sessionKey/timeline', async (req, res) => {
|
|
try {
|
|
const { agentName, sessionKey } = req.params;
|
|
const sessionsPath = path.join(AGENTS_DIR, agentName, 'sessions');
|
|
const sessionsFile = path.join(sessionsPath, 'sessions.json');
|
|
|
|
if (!fs.existsSync(sessionsFile)) {
|
|
return res.status(404).json({ error: 'Sessions file not found' });
|
|
}
|
|
|
|
const sessionsData = JSON.parse(fs.readFileSync(sessionsFile, 'utf8'));
|
|
const session = sessionsData[sessionKey];
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
}
|
|
|
|
// 获取 session 文件中的消息时间线
|
|
const timeline = [];
|
|
const sessionFile = session.sessionFile;
|
|
|
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
const content = fs.readFileSync(sessionFile, 'utf8');
|
|
const lines = content.trim().split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
|
|
if (data.type === 'message' && data.message) {
|
|
const content = data.message.content;
|
|
let text = '';
|
|
|
|
if (typeof content === 'string') {
|
|
text = content;
|
|
} else if (Array.isArray(content)) {
|
|
text = content.map(c => {
|
|
if (typeof c === 'string') return c;
|
|
if (c.type === 'text') return c.text;
|
|
if (c.type === 'image_url') return '[Image]';
|
|
return '';
|
|
}).filter(Boolean).join('\n');
|
|
}
|
|
|
|
timeline.push({
|
|
id: data.id,
|
|
timestamp: data.timestamp,
|
|
role: data.message.role,
|
|
type: 'message',
|
|
summary: text.substring(0, 100),
|
|
contentLength: text.length
|
|
});
|
|
} else if (data.type === 'session') {
|
|
timeline.push({
|
|
id: data.id,
|
|
timestamp: data.timestamp,
|
|
type: 'session_start',
|
|
summary: 'Session started'
|
|
});
|
|
} else if (data.type === 'custom' && data.customType === 'compaction') {
|
|
timeline.push({
|
|
id: data.id,
|
|
timestamp: data.timestamp,
|
|
type: 'compaction',
|
|
summary: 'Context compacted'
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
// 按时间排序
|
|
timeline.sort((a, b) => {
|
|
if (!a.timestamp) return 1;
|
|
if (!b.timestamp) return -1;
|
|
return new Date(a.timestamp) - new Date(b.timestamp);
|
|
});
|
|
|
|
res.json({
|
|
sessionKey,
|
|
sessionId: session.sessionId,
|
|
timeline
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Agent Sessions Viewer running at http://localhost:${PORT}`);
|
|
}); |