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:
@@ -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
@@ -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
@@ -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()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="modal-content"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+364
-188
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user