feat: 新增对话汇总视图,支持跨 session 连续性分析

- 新增 /api/agents/:agentName/conversations API,按 sessionKey 分组汇总
- 新增 /api/agents/:agentName/conversations/:sessionKey/timeline API
- 前端新增「对话汇总」Tab,展示对话级别的统计信息
- 支持展开查看对话下的所有 session 阶段
- 修复 session 文件不存在时返回 404 的问题

功能:
- 显示每个对话的总消息数、Token 数、压缩次数、时长
- 标注原始 session 和压缩后的 session
- 显示对话时间跨度
This commit is contained in:
2026-07-09 17:32:44 +08:00
parent 16dc0b57da
commit 3e835371cb
5 changed files with 845 additions and 257 deletions
+236 -9
View File
@@ -81,8 +81,6 @@ app.get('/api/agents/:agentName/sessions', async (req, res) => {
for (const [key, session] of Object.entries(sessionsData)) {
// 解析 sessionKey 格式
// 例如: agent:hudi:matrix:direct:@huangzhuang_bro:matrix.tphai.com
// 或者: agent:hudi:main
const parts = key.split(':');
let channel = 'unknown';
let chatType = 'unknown';
@@ -105,7 +103,7 @@ app.get('/api/agents/:agentName/sessions', async (req, res) => {
chatType,
target,
status: session.status || 'unknown',
model: session.model || '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,
@@ -131,14 +129,15 @@ app.get('/api/agents/:agentName/sessions', async (req, res) => {
}
});
// API: 获取会话详情(消息列表
// 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.status(404).json({ error: 'Session file not found' });
return res.json([]);
}
const messages = [];
@@ -169,12 +168,17 @@ app.get('/api/agents/:agentName/sessions/:sessionId/messages', async (req, res)
}).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,
content: text.substring(0, 10000), // 限制内容长度
fullContent: text.length > 10000
summary: summary,
hasMoreContent: hasMoreContent,
contentLength: text.length
});
}
} catch (e) {
@@ -189,14 +193,15 @@ app.get('/api/agents/:agentName/sessions/:sessionId/messages', async (req, res)
}
});
// API: 获取会话完整消息
// 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' });
return res.status(404).json({ error: 'Session file not found (may have been reset or deleted)' });
}
const fileStream = fs.createReadStream(sessionFile);
@@ -242,6 +247,228 @@ app.get('/api/agents/:agentName/sessions/:sessionId/messages/:messageId', async
}
});
// 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}`);
});