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
+60
View File
@@ -0,0 +1,60 @@
# OpenClaw Agent Sessions Viewer
智能体会话历史查看器 - 一个用于展示 OpenClaw 智能体历史会话信息的网页工具。
## 功能
- 📋 **智能体列表** - 显示所有智能体及其会话数量
- 💬 **会话列表** - 查看每个智能体的会话,包含渠道、状态、模型等信息
- 📝 **消息详情** - 浏览会话中的完整消息历史
- 🔍 **完整内容查看** - 对于长消息,可点击查看完整内容
## 技术栈
- **后端**: Node.js + Express
- **前端**: 纯 HTML/CSS/JavaScript(无框架)
- **数据源**: `/home/openclaw/.openclaw/agents/` 目录下的 JSONL 会话文件
## 部署
### 快速启动
```bash
cd /home/openclaw/.openclaw/workspace-hz4th_coder/works/agent-sessions-viewer
npm install
npm start
```
服务将在 **端口 16039** 启动。
访问地址: http://localhost:16039
### systemd 服务(持久运行)
```bash
sudo cp agent-sessions-viewer.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable agent-sessions-viewer
sudo systemctl start agent-sessions-viewer
```
## API 接口
| 接口 | 描述 |
|------|------|
| `GET /api/agents` | 获取所有智能体列表 |
| `GET /api/agents/:name/sessions` | 获取指定智能体的会话列表 |
| `GET /api/agents/:name/sessions/:id/messages` | 获取指定会话的消息列表 |
| `GET /api/agents/:name/sessions/:id/messages/:msgId` | 获取单条消息的完整内容 |
## 界面预览
![界面截图](三栏布局设计)
- 左栏:智能体列表(按最近活跃时间排序)
- 中栏:会话列表(显示渠道、模型、token统计)
- 右栏:消息详情(用户/助手消息区分显示)
## 作者
hz4th_coder - OpenClaw 黄庄4号程序员
+157 -38
View File
@@ -1,6 +1,9 @@
// 状态
let currentAgent = null;
let currentSession = null;
let currentMessage = null;
let currentView = 'conversations'; // 'conversations' or 'sessions'
let conversationsData = []; // 缓存对话汇总数据
// API 调用
async function fetchAPI(endpoint) {
@@ -45,8 +48,9 @@ function formatNumber(num) {
}
// 截断文本
function truncate(text, maxLen = 100) {
function truncate(text, maxLen = 50) {
if (!text) return '';
text = text.replace(/\n/g, ' ').trim();
if (text.length <= maxLen) return text;
return text.substring(0, maxLen) + '...';
}
@@ -79,21 +83,38 @@ async function loadAgents() {
}
}
// 切换视图
function switchView(view) {
currentView = view;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
document.querySelectorAll('.view-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${view}-view`).classList.add('active');
}
// 选择智能体
async function selectAgent(agentName) {
currentAgent = agentName;
currentSession = null;
currentMessage = null;
// 更新UI
document.querySelectorAll('.agent-item').forEach(el => el.classList.remove('active'));
event.target.closest('.agent-item')?.classList.add('active');
document.getElementById('agent-name-display').textContent = `- ${agentName}`;
document.getElementById('agent-name-display').textContent = `🤖 ${agentName}`;
document.getElementById('session-id-display').textContent = '';
document.getElementById('messages-list').innerHTML = '<div class="placeholder">请选择一个会话</div>';
document.getElementById('message-detail').innerHTML = '<div class="placeholder">请选择一条消息</div>';
// 加载会话列表
await loadSessions(agentName);
// 同时加载对话汇总和会话列表
await Promise.all([
loadConversations(agentName),
loadSessions(agentName)
]);
}
// 加载会话列表
@@ -112,13 +133,15 @@ async function loadSessions(agentName) {
container.innerHTML = sessions.map(session => `
<div class="session-item ${currentSession === session.sessionId ? 'active' : ''}"
onclick="selectSession('${agentName}', '${session.sessionId}')">
<div class="channel ${session.channel}">${session.channel}</div>
<span class="status ${session.status}">${session.status}</span>
<div class="session-header">
<span class="channel ${session.channel}">${session.channel}</span>
<span class="status ${session.status}">${session.status}</span>
</div>
<div class="meta">
<span class="model">${session.model}</span>
<span>${formatTime(session.updatedAt)}</span>
</div>
<div class="target">${truncate(session.target, 50)}</div>
<div class="target">${truncate(session.target, 40)}</div>
<div class="tokens">
📊 ${formatNumber(session.totalTokens)} tokens
(${formatNumber(session.inputTokens)} in / ${formatNumber(session.outputTokens)} out)
@@ -130,22 +153,24 @@ async function loadSessions(agentName) {
}
}
// 选择会话
// 选择会话 - 显示消息概览列表
async function selectSession(agentName, sessionId) {
currentSession = sessionId;
currentMessage = null;
// 更新UI
document.querySelectorAll('.session-item').forEach(el => el.classList.remove('active'));
event.target.closest('.session-item')?.classList.add('active');
document.getElementById('session-id-display').textContent = `- ${sessionId.substring(0, 8)}...`;
document.getElementById('message-detail').innerHTML = '<div class="placeholder">请选择一条消息查看详情</div>';
// 加载消息
await loadMessages(agentName, sessionId);
// 加载消息概览列表
await loadMessageSummaries(agentName, sessionId);
}
// 加载消息
async function loadMessages(agentName, sessionId) {
// 加载消息概览列表(不加载完整内容)
async function loadMessageSummaries(agentName, sessionId) {
const container = document.getElementById('messages-list');
container.innerHTML = '<div class="loading">加载中...</div>';
@@ -158,13 +183,16 @@ async function loadMessages(agentName, sessionId) {
}
container.innerHTML = messages.map((msg, index) => `
<div class="message-item ${msg.role}" data-agent="${agentName}" data-session="${sessionId}" data-msg-id="${msg.id}">
<div class="header">
<span class="role ${msg.role}">${msg.role}</span>
<span class="time">${formatFullTime(msg.timestamp)}</span>
<div class="message-summary ${msg.role} ${currentMessage === msg.id ? 'active' : ''}"
onclick="selectMessage('${agentName}', '${sessionId}', '${msg.id}')">
<div class="summary-header">
<span class="role-badge ${msg.role}">${msg.role === 'user' ? '👤' : '🤖'} ${msg.role}</span>
<span class="time">${formatTime(msg.timestamp)}</span>
</div>
<div class="summary-text">${escapeHtml(msg.summary)}${msg.hasMoreContent ? '...' : ''}</div>
<div class="summary-meta">
${msg.hasMoreContent ? `<span class="more-indicator">📝 ${formatNumber(msg.contentLength)} 字符</span>` : ''}
</div>
<div class="content ${msg.fullContent ? 'truncated' : ''}">${escapeHtml(msg.content)}</div>
${msg.fullContent ? `<button class="expand-btn" onclick="showFullMessage('${agentName}', '${sessionId}', '${msg.id}')">查看完整内容</button>` : ''}
</div>
`).join('');
} catch (error) {
@@ -172,28 +200,42 @@ async function loadMessages(agentName, sessionId) {
}
}
// 显示完整消息
async function showFullMessage(agentName, sessionId, messageId) {
const modal = document.getElementById('message-modal');
const content = document.getElementById('modal-content');
// 选择消息 - 显示完整内容
async function selectMessage(agentName, sessionId, messageId) {
currentMessage = messageId;
modal.classList.remove('hidden');
content.textContent = '加载中...';
// 更新UI
document.querySelectorAll('.message-summary').forEach(el => el.classList.remove('active'));
event.target.closest('.message-summary')?.classList.add('active');
// 加载完整消息内容
await loadMessageDetail(agentName, sessionId, messageId);
}
// 加载消息详情(完整内容)
async function loadMessageDetail(agentName, sessionId, messageId) {
const container = document.getElementById('message-detail');
container.innerHTML = '<div class="loading">加载中...</div>';
try {
const msg = await fetchAPI(`/agents/${agentName}/sessions/${sessionId}/messages/${messageId}`);
document.getElementById('modal-title').textContent = `${msg.role.toUpperCase()} - ${formatFullTime(msg.timestamp)}`;
content.textContent = msg.content;
container.innerHTML = `
<div class="message-detail-content ${msg.role}">
<div class="detail-header">
<span class="role-badge large ${msg.role}">${msg.role === 'user' ? '👤' : '🤖'} ${msg.role}</span>
<span class="time">${formatFullTime(msg.timestamp)}</span>
</div>
<div class="detail-body">
<pre class="content-text">${escapeHtml(msg.content)}</pre>
</div>
</div>
`;
} catch (error) {
content.textContent = `加载失败: ${error.message}`;
container.innerHTML = `<div class="placeholder">加载失败: ${error.message}</div>`;
}
}
// 关闭模态框
function closeModal() {
document.getElementById('message-modal').classList.add('hidden');
}
// HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
@@ -204,16 +246,93 @@ function escapeHtml(text) {
// 键盘事件
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
// 可以添加关闭操作
}
});
// 点击模态框外部关闭
document.getElementById('message-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) {
closeModal();
// 加载对话汇总
async function loadConversations(agentName) {
const container = document.getElementById('conversations-list');
container.innerHTML = '<div class="loading">加载中...</div>';
try {
const conversations = await fetchAPI(`/agents/${agentName}/conversations`);
conversationsData = conversations;
if (conversations.length === 0) {
container.innerHTML = '<div class="placeholder">没有找到对话</div>';
return;
}
container.innerHTML = conversations.map((conv, index) => `
<div class="conversation-item" onclick="showConversationDetail(${index})">
<div class="conv-header">
<span class="conv-title">
💬 对话 ${index + 1}
<span class="conv-badge">${conv.sessionCount} 个会话</span>
</span>
</div>
<div class="conv-stats">
<span class="stat">📨 消息: <span class="stat-value">${conv.totalMessages}</span></span>
<span class="stat">📊 Token: <span class="stat-value">${formatNumber(conv.totalTokens)}</span></span>
<span class="stat">🔄 压缩: <span class="stat-value">${conv.compactionCount}</span></span>
<span class="stat">⏱️ 时长: <span class="stat-value">${conv.durationHours || '?'}h</span></span>
</div>
<div class="conv-target">${conv.channel} · ${conv.chatType} · ${truncate(conv.target, 30)}</div>
<div class="conv-time">${formatTime(conv.firstStartedAt)}${formatTime(conv.lastUpdatedAt)}</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<div class="placeholder">加载失败: ${error.message}</div>`;
}
});
}
// 显示对话详情(包含该对话下的所有 session)
function showConversationDetail(index) {
const conv = conversationsData[index];
if (!conv) return;
// 高亮选中的对话
document.querySelectorAll('.conversation-item').forEach((el, i) => {
el.classList.toggle('active', i === index);
});
// 切换到会话视图并显示该对话下的 session
switchView('sessions');
const container = document.getElementById('sessions-list');
container.innerHTML = `
<div class="conv-sessions-header">
<span class="back-btn" onclick="backToConversations()">← 返回</span>
💬 ${conv.channel} · ${conv.chatType}
</div>
${conv.sessions.map((session, sIndex) => `
<div class="session-item" onclick="selectSession('${currentAgent}', '${session.sessionId}')">
<div class="session-header">
<span class="channel ${conv.channel}">${conv.channel}</span>
<span class="status ${session.status}">${session.status}</span>
${sIndex === 0 ? '<span class="conv-badge">原始</span>' : `<span class="conv-badge">压缩 #${sIndex}</span>`}
</div>
<div class="meta">
<span class="model">${session.model}</span>
<span>${formatTime(session.startedAt)}</span>
</div>
<div class="tokens">
📊 ${formatNumber(session.totalTokens)} tokens · 📨 ${session.messageCount} 条消息
</div>
<div class="conv-time">
${formatTime(session.startedAt)}${formatTime(session.updatedAt)}
</div>
</div>
`).join('')}
`;
}
// 返回对话汇总
function backToConversations() {
switchView('conversations');
document.querySelectorAll('.conversation-item').forEach(el => el.classList.remove('active'));
}
// 初始化
loadAgents();
+28 -22
View File
@@ -14,45 +14,51 @@
</header>
<main>
<!-- 左侧:智能体列表 -->
<!-- 第1栏:智能体列表 -->
<aside class="agents-panel">
<h2>智能体列表</h2>
<h2>智能体</h2>
<div id="agents-list" class="agents-list">
<div class="loading">加载中...</div>
</div>
</aside>
<!-- 中间:会话列表 -->
<!-- 第2栏:对话汇总 + 会话列表 -->
<section class="sessions-panel">
<h2>会话列表 <span id="agent-name-display"></span></h2>
<div id="sessions-list" class="sessions-list">
<div class="placeholder">请选择一个智能体</div>
<h2><span id="agent-name-display">会话</span></h2>
<div class="view-tabs">
<button class="tab-btn active" data-view="conversations" onclick="switchView('conversations')">📊 对话汇总</button>
<button class="tab-btn" data-view="sessions" onclick="switchView('sessions')">📋 会话列表</button>
</div>
<div id="conversations-view" class="view-content active">
<div id="conversations-list" class="conversations-list">
<div class="placeholder">请选择智能体</div>
</div>
</div>
<div id="sessions-view" class="view-content">
<div id="sessions-list" class="sessions-list">
<div class="placeholder">请选择智能体</div>
</div>
</div>
</section>
<!-- 右侧:消息详情 -->
<!-- 第3栏:消息概览列表 -->
<section class="messages-panel">
<h2>消息详情 <span id="session-id-display"></span></h2>
<h2>消息 <span id="session-id-display"></span></h2>
<div id="messages-list" class="messages-list">
<div class="placeholder">请选择一个会话</div>
<div class="placeholder">请选择会话</div>
</div>
</section>
<!-- 第4栏:消息详情 -->
<section class="detail-panel">
<h2>内容</h2>
<div id="message-detail" class="detail-list">
<div class="placeholder">请选择消息</div>
</div>
</section>
</main>
</div>
<!-- 消息详情模态框 -->
<div id="message-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">消息详情</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="modal-content"></pre>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+364 -188
View File
@@ -14,18 +14,18 @@ body {
.container {
max-width: 1800px;
margin: 0 auto;
padding: 20px;
padding: 15px;
}
header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
header h1 {
font-size: 2.5rem;
font-size: 2rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@@ -34,61 +34,64 @@ header h1 {
header .subtitle {
color: #888;
margin-top: 8px;
margin-top: 5px;
font-size: 0.9rem;
}
main {
display: grid;
grid-template-columns: 250px 350px 1fr;
gap: 20px;
height: calc(100vh - 150px);
grid-template-columns: 180px 280px 280px 1fr;
gap: 15px;
height: calc(100vh - 120px);
}
/* 面板通用样式 */
.agents-panel, .sessions-panel, .messages-panel {
.agents-panel, .sessions-panel, .messages-panel, .detail-panel {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
border-radius: 10px;
padding: 15px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.agents-panel h2, .sessions-panel h2, .messages-panel h2 {
font-size: 1.1rem;
margin-bottom: 15px;
padding-bottom: 10px;
.agents-panel h2, .sessions-panel h2, .messages-panel h2, .detail-panel h2 {
font-size: 1rem;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.1);
color: #fff;
}
.agents-panel h2 span, .sessions-panel h2 span, .messages-panel h2 span {
font-size: 0.85rem;
font-size: 0.8rem;
color: #888;
margin-left: 10px;
margin-left: 8px;
}
/* 智能体列表 */
.agents-list, .sessions-list, .messages-list {
/* 列表容器 */
.agents-list, .sessions-list, .messages-list, .detail-list {
flex: 1;
overflow-y: auto;
}
.agent-item, .session-item {
padding: 12px 15px;
border-radius: 8px;
/* 智能体项 */
.agent-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 8px;
margin-bottom: 6px;
background: rgba(255,255,255,0.03);
border: 1px solid transparent;
}
.agent-item:hover, .session-item:hover {
.agent-item:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(0,212,255,0.3);
}
.agent-item.active, .session-item.active {
.agent-item.active {
background: rgba(0,212,255,0.15);
border-color: rgba(0,212,255,0.5);
}
@@ -96,22 +99,49 @@ main {
.agent-item .name {
font-weight: 600;
color: #fff;
margin-bottom: 5px;
font-size: 0.9rem;
}
.agent-item .meta, .session-item .meta {
font-size: 0.8rem;
.agent-item .meta {
font-size: 0.75rem;
color: #888;
display: flex;
gap: 10px;
gap: 8px;
margin-top: 4px;
}
/* 会话项 */
.session-item {
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid transparent;
}
.session-item:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(0,212,255,0.3);
}
.session-item.active {
background: rgba(0,212,255,0.15);
border-color: rgba(0,212,255,0.5);
}
.session-item .session-header {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.session-item .channel {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
margin-bottom: 5px;
font-size: 0.7rem;
}
.session-item .channel.matrix { background: #0dbd8b; color: #fff; }
@@ -120,113 +150,197 @@ main {
.session-item .channel.cli { background: #4a4a4a; color: #fff; }
.session-item .channel.main { background: #7b2cbf; color: #fff; }
.session-item .status {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.65rem;
text-transform: uppercase;
}
.session-item .status.done { background: #2e7d32; color: #fff; }
.session-item .status.running { background: #ed6c02; color: #fff; }
.session-item .status.error { background: #c62828; color: #fff; }
.session-item .meta {
font-size: 0.75rem;
color: #888;
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.session-item .model {
color: #00d4ff;
}
.session-item .target {
font-size: 0.75rem;
font-size: 0.7rem;
color: #aaa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 消息列表 */
.message-item {
padding: 15px;
border-radius: 8px;
margin-bottom: 12px;
.session-item .tokens {
font-size: 0.7rem;
color: #666;
margin-top: 4px;
}
/* 消息概览项 */
.message-summary {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 6px;
background: rgba(255,255,255,0.03);
border: 1px solid transparent;
border-left: 3px solid #666;
}
.message-item.user {
.message-summary.user {
border-left-color: #00d4ff;
}
.message-item.assistant {
.message-summary.assistant {
border-left-color: #7b2cbf;
}
.message-item .header {
.message-summary.toolResult {
border-left-color: #ed6c02;
}
.message-summary:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(0,212,255,0.2);
}
.message-summary.active {
background: rgba(0,212,255,0.15);
border-color: rgba(0,212,255,0.5);
}
.message-summary .summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
margin-bottom: 6px;
}
.message-item .role {
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
}
.message-item .role.user { color: #00d4ff; }
.message-item .role.assistant { color: #7b2cbf; }
.message-item .time {
.message-summary .role-badge {
font-size: 0.75rem;
font-weight: 600;
}
.message-summary .role-badge.user { color: #00d4ff; }
.message-summary .role-badge.assistant { color: #7b2cbf; }
.message-summary .role-badge.toolResult { color: #ed6c02; }
.message-summary .time {
font-size: 0.7rem;
color: #666;
}
.message-item .content {
font-size: 0.9rem;
line-height: 1.5;
.message-summary .summary-text {
font-size: 0.85rem;
color: #ccc;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-summary .summary-meta {
font-size: 0.7rem;
color: #888;
margin-top: 4px;
}
.message-summary .more-indicator {
color: #00d4ff;
}
/* 消息详情 */
.message-detail-content {
padding: 15px;
border-radius: 8px;
background: rgba(255,255,255,0.05);
border-left: 4px solid #666;
}
.message-detail-content.user {
border-left-color: #00d4ff;
}
.message-detail-content.assistant {
border-left-color: #7b2cbf;
}
.message-detail-content.toolResult {
border-left-color: #ed6c02;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.role-badge.large {
font-size: 0.9rem;
font-weight: 600;
}
.role-badge.large.user { color: #00d4ff; }
.role-badge.large.assistant { color: #7b2cbf; }
.role-badge.large.toolResult { color: #ed6c02; }
.detail-header .time {
font-size: 0.8rem;
color: #888;
}
.detail-body {
overflow-x: auto;
}
.content-text {
white-space: pre-wrap;
word-break: break-word;
}
.message-item .content.truncated {
max-height: 200px;
overflow: hidden;
position: relative;
}
.message-item .content.truncated::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background: linear-gradient(transparent, rgba(26,26,46,0.9));
}
.message-item .expand-btn {
margin-top: 10px;
padding: 5px 15px;
font-size: 0.8rem;
background: rgba(0,212,255,0.2);
border: none;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
line-height: 1.5;
color: #ddd;
background: rgba(0,0,0,0.2);
padding: 10px;
border-radius: 4px;
color: #00d4ff;
cursor: pointer;
transition: background 0.2s;
}
.message-item .expand-btn:hover {
background: rgba(0,212,255,0.3);
max-height: calc(100vh - 250px);
overflow-y: auto;
}
/* 加载和占位 */
.loading, .placeholder {
text-align: center;
padding: 40px 20px;
padding: 30px 15px;
color: #666;
font-size: 0.85rem;
}
.loading::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
border: 2px solid #333;
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
margin-left: 8px;
vertical-align: middle;
}
@@ -234,114 +348,28 @@ main {
to { transform: rotate(360deg); }
}
/* 模态框 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.hidden {
display: none;
}
.modal-content {
background: #1a1a2e;
border-radius: 12px;
width: 90%;
max-width: 900px;
max-height: 80vh;
display: flex;
flex-direction: column;
border: 1px solid rgba(255,255,255,0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.modal-header h3 {
color: #fff;
}
.close-btn {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
}
.close-btn:hover {
color: #fff;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-body pre {
white-space: pre-wrap;
word-break: break-word;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.9rem;
line-height: 1.6;
color: #ccc;
}
/* 状态标签 */
.status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
text-transform: uppercase;
}
.status.done { background: #2e7d32; color: #fff; }
.status.running { background: #ed6c02; color: #fff; }
.status.error { background: #c62828; color: #fff; }
/* Token 统计 */
.tokens {
font-size: 0.75rem;
color: #666;
margin-top: 5px;
}
/* 滚动条样式 */
.agents-list::-webkit-scrollbar,
.sessions-list::-webkit-scrollbar,
.messages-list::-webkit-scrollbar,
.modal-body::-webkit-scrollbar {
width: 6px;
.detail-list::-webkit-scrollbar,
.content-text::-webkit-scrollbar {
width: 5px;
}
.agents-list::-webkit-scrollbar-track,
.sessions-list::-webkit-scrollbar-track,
.messages-list::-webkit-scrollbar-track,
.modal-body::-webkit-scrollbar-track {
.detail-list::-webkit-scrollbar-track,
.content-text::-webkit-scrollbar-track {
background: transparent;
}
.agents-list::-webkit-scrollbar-thumb,
.sessions-list::-webkit-scrollbar-thumb,
.messages-list::-webkit-scrollbar-thumb,
.modal-body::-webkit-scrollbar-thumb {
.detail-list::-webkit-scrollbar-thumb,
.content-text::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 3px;
}
@@ -349,24 +377,172 @@ main {
.agents-list::-webkit-scrollbar-thumb:hover,
.sessions-list::-webkit-scrollbar-thumb:hover,
.messages-list::-webkit-scrollbar-thumb:hover,
.modal-body::-webkit-scrollbar-thumb:hover {
.detail-list::-webkit-scrollbar-thumb:hover,
.content-text::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.3);
}
/* Tab 切换样式 */
.view-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.tab-btn {
flex: 1;
padding: 6px 10px;
border: none;
border-radius: 4px;
background: rgba(255,255,255,0.05);
color: #888;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.tab-btn.active {
background: rgba(0,212,255,0.2);
color: #00d4ff;
}
.view-content {
display: none;
flex: 1;
overflow-y: auto;
}
.view-content.active {
display: block;
}
/* 对话汇总项 */
.conversation-item {
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid transparent;
}
.conversation-item:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(123,44,191,0.3);
}
.conversation-item.active {
background: rgba(123,44,191,0.15);
border-color: rgba(123,44,191,0.5);
}
.conversation-item .conv-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.conversation-item .conv-title {
font-weight: 600;
color: #fff;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
}
.conversation-item .conv-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.65rem;
background: rgba(123,44,191,0.3);
color: #c77dff;
}
.conversation-item .conv-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
font-size: 0.7rem;
color: #888;
margin-top: 6px;
}
.conversation-item .conv-stats .stat {
display: flex;
align-items: center;
gap: 4px;
}
.conversation-item .conv-stats .stat-value {
color: #00d4ff;
font-weight: 500;
}
.conversation-item .conv-target {
font-size: 0.7rem;
color: #aaa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 4px;
}
.conversation-item .conv-time {
font-size: 0.65rem;
color: #666;
margin-top: 4px;
}
/* 对话详情 - session 列表 */
.conv-sessions-header {
padding: 8px 12px;
background: rgba(123,44,191,0.1);
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8rem;
color: #c77dff;
}
.conv-sessions-header .back-btn {
cursor: pointer;
margin-right: 8px;
}
.conv-sessions-header .back-btn:hover {
color: #fff;
}
/* 响应式 */
@media (max-width: 1200px) {
@media (max-width: 1400px) {
main {
grid-template-columns: 200px 300px 1fr;
grid-template-columns: 160px 250px 250px 1fr;
}
}
@media (max-width: 1100px) {
main {
grid-template-columns: 150px 220px 220px 1fr;
}
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
grid-template-rows: 200px 200px 200px 1fr;
}
.agents-panel, .sessions-panel {
max-height: 300px;
.agents-panel, .sessions-panel, .messages-panel {
max-height: 200px;
}
}
+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}`);
});