feat: Add Conversations tab with user drill-down
New tab showing most expensive conversations with title, user, models, and token breakdown. Clicking a user name in the Users tab filters conversations to that user. Includes CSV export. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d25c21d781
commit
e74f21d57e
5 changed files with 217 additions and 1 deletions
|
|
@ -408,6 +408,48 @@ tr:hover td {
|
|||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* User Links */
|
||||
.user-link {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Model Tags */
|
||||
.model-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: rgba(255, 196, 7, 0.08);
|
||||
border: 1px solid rgba(255, 196, 7, 0.15);
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-filter-user {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: var(--accent-red);
|
||||
border-color: rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.btn-filter-user:hover {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@
|
|||
<i data-lucide="cpu" style="width:20px;height:20px"></i>
|
||||
Models
|
||||
</li>
|
||||
<li data-tab="conversations">
|
||||
<i data-lucide="message-circle" style="width:20px;height:20px"></i>
|
||||
Conversations
|
||||
</li>
|
||||
<li data-tab="agents">
|
||||
<i data-lucide="bot" style="width:20px;height:20px"></i>
|
||||
Agents
|
||||
|
|
@ -154,6 +158,33 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations Tab -->
|
||||
<div class="tab-content" id="tab-conversations">
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<h3 id="conversationsTitle">Most Expensive Conversations</h3>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center">
|
||||
<button class="btn-export btn-filter-user" id="clearUserFilter" onclick="clearUserFilter()" style="display:none"><i data-lucide="x" style="width:14px;height:14px"></i> Clear filter</button>
|
||||
<button class="btn-export" onclick="exportConversations()"><i data-lucide="download" style="width:16px;height:16px"></i> Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Title</th>
|
||||
<th>User</th>
|
||||
<th>Models</th>
|
||||
<th class="text-right">Input Tokens</th>
|
||||
<th class="text-right">Output Tokens</th>
|
||||
<th class="text-right">Total Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="conversationsTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents Tab -->
|
||||
<div class="tab-content" id="tab-agents">
|
||||
<div class="table-container">
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ let cachedData = {
|
|||
topUsers: null,
|
||||
topModels: null,
|
||||
topAgents: null,
|
||||
topConversations: null,
|
||||
};
|
||||
|
||||
let currentUserFilter = null;
|
||||
|
||||
// --- API Helpers ---
|
||||
function apiHeaders() {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
|
|
@ -129,6 +132,16 @@ function exportAgents() {
|
|||
);
|
||||
}
|
||||
|
||||
function exportConversations() {
|
||||
const d = cachedData.topConversations;
|
||||
if (!d || !d.length) return alert('No data to export');
|
||||
downloadCSV(
|
||||
['Rank', 'Title', 'User', 'Email', 'Models', 'Input Tokens', 'Output Tokens', 'Total Cost (USD)'],
|
||||
d.map((c, i) => [i + 1, c.title, c.userName, c.userEmail, c.models.join(', '), c.promptTokens, c.completionTokens, c.totalCost.toFixed(4)]),
|
||||
`top-conversations-${currentPeriod}.csv`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Tab Navigation ---
|
||||
document.querySelectorAll('.nav-links li').forEach(li => {
|
||||
li.addEventListener('click', () => {
|
||||
|
|
@ -286,7 +299,7 @@ async function loadTopUsers() {
|
|||
tbody.innerHTML = d.map((u, i) => `
|
||||
<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${escHtml(u.name)}</td>
|
||||
<td><a href="#" class="user-link" onclick="filterByUser('${u._id}', '${escHtml(u.name)}'); return false;">${escHtml(u.name)}</a></td>
|
||||
<td>${escHtml(u.email)}</td>
|
||||
<td class="text-right token-value">${fmtTokens(u.totalTokens)}</td>
|
||||
<td class="text-right cost-value">${fmtCost(u.totalCost)}</td>
|
||||
|
|
@ -340,6 +353,50 @@ async function loadTopModels() {
|
|||
} catch (e) { console.error('Top models:', e); }
|
||||
}
|
||||
|
||||
async function loadTopConversations() {
|
||||
try {
|
||||
let endpoint = 'top-conversations?limit=50';
|
||||
if (currentUserFilter) endpoint += `&userId=${currentUserFilter.id}`;
|
||||
const d = await fetchAPI(endpoint);
|
||||
cachedData.topConversations = d;
|
||||
const tbody = document.getElementById('conversationsTableBody');
|
||||
if (d.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted)">No conversations found in this period</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.map((c, i) => `
|
||||
<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${escHtml(c.title)}</td>
|
||||
<td>${escHtml(c.userName)}</td>
|
||||
<td><span class="model-tags">${c.models.map(m => `<span class="model-tag">${escHtml(m)}</span>`).join(' ')}</span></td>
|
||||
<td class="text-right token-value">${fmtTokens(c.promptTokens)}</td>
|
||||
<td class="text-right token-value">${fmtTokens(c.completionTokens)}</td>
|
||||
<td class="text-right cost-value">${fmtCost(c.totalCost)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (e) { console.error('Top conversations:', e); }
|
||||
}
|
||||
|
||||
function filterByUser(userId, userName) {
|
||||
currentUserFilter = { id: userId, name: userName };
|
||||
document.getElementById('conversationsTitle').textContent = `Conversations — ${userName}`;
|
||||
document.getElementById('clearUserFilter').style.display = 'inline-flex';
|
||||
// Switch to conversations tab
|
||||
document.querySelectorAll('.nav-links li').forEach(l => l.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
document.querySelector('[data-tab="conversations"]').classList.add('active');
|
||||
document.getElementById('tab-conversations').classList.add('active');
|
||||
loadTopConversations();
|
||||
}
|
||||
|
||||
function clearUserFilter() {
|
||||
currentUserFilter = null;
|
||||
document.getElementById('conversationsTitle').textContent = 'Most Expensive Conversations';
|
||||
document.getElementById('clearUserFilter').style.display = 'none';
|
||||
loadTopConversations();
|
||||
}
|
||||
|
||||
async function loadTopAgents() {
|
||||
try {
|
||||
const d = await fetchAPI('top-agents?limit=20');
|
||||
|
|
@ -383,6 +440,7 @@ async function refreshAll() {
|
|||
loadTopUsers(),
|
||||
loadTopModels(),
|
||||
loadTopAgents(),
|
||||
loadTopConversations(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,4 +65,16 @@ router.get('/usage-over-time', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.get('/top-conversations', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const userId = req.query.userId || null;
|
||||
const data = await analytics.getTopConversations(req.db, req.query, limit, userId);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('Top conversations error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -275,6 +275,78 @@ async function getUsageOverTime(db, query) {
|
|||
};
|
||||
}
|
||||
|
||||
async function getTopConversations(db, query, limit = 20, userId = null) {
|
||||
await refreshAgentsCache(db);
|
||||
const { startDate, endDate } = getDateRange(query);
|
||||
|
||||
const match = { createdAt: { $gte: startDate, $lte: endDate } };
|
||||
if (userId) match.user = new (require('mongodb').ObjectId)(userId);
|
||||
|
||||
const results = await db.collection('transactions').aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: '$conversationId',
|
||||
totalTokens: { $sum: { $abs: '$rawAmount' } },
|
||||
totalCost: { $sum: { $abs: '$tokenValue' } },
|
||||
models: { $addToSet: '$model' },
|
||||
user: { $first: '$user' },
|
||||
promptTokens: {
|
||||
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
|
||||
},
|
||||
completionTokens: {
|
||||
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
|
||||
},
|
||||
}
|
||||
},
|
||||
{ $sort: { totalCost: -1 } },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'user',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'conversations',
|
||||
localField: '_id',
|
||||
foreignField: 'conversationId',
|
||||
as: 'convInfo'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$convInfo', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
conversationId: '$_id',
|
||||
title: { $ifNull: ['$convInfo.title', 'Unknown'] },
|
||||
userName: { $ifNull: ['$userInfo.name', 'Unknown'] },
|
||||
userEmail: { $ifNull: ['$userInfo.email', ''] },
|
||||
models: 1,
|
||||
totalTokens: 1,
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalCost: { $divide: ['$totalCost', 1_000_000] },
|
||||
}
|
||||
}
|
||||
]).toArray();
|
||||
|
||||
// Resolve agent model names
|
||||
return results.map(r => ({
|
||||
...r,
|
||||
models: r.models.map(m => {
|
||||
if (m && m.startsWith('agent_')) {
|
||||
const agent = agentsCache.get(m);
|
||||
return agent ? `${agent.name} (${agent.model})` : m;
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSummary,
|
||||
getTopUsers,
|
||||
|
|
@ -282,4 +354,5 @@ module.exports = {
|
|||
getTopAgents,
|
||||
getCostBreakdown,
|
||||
getUsageOverTime,
|
||||
getTopConversations,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue