diff --git a/public/js/app.js b/public/js/app.js
index 4813b9d..45a2285 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -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) => `
| ${i + 1} |
- ${escHtml(u.name)} |
+ ${escHtml(u.name)} |
${escHtml(u.email)} |
${fmtTokens(u.totalTokens)} |
${fmtCost(u.totalCost)} |
@@ -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 = '
| No conversations found in this period |
';
+ return;
+ }
+ tbody.innerHTML = d.map((c, i) => `
+
+ | ${i + 1} |
+ ${escHtml(c.title)} |
+ ${escHtml(c.userName)} |
+ ${c.models.map(m => `${escHtml(m)}`).join(' ')} |
+ ${fmtTokens(c.promptTokens)} |
+ ${fmtTokens(c.completionTokens)} |
+ ${fmtCost(c.totalCost)} |
+
+ `).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(),
]);
}
diff --git a/routes/api.js b/routes/api.js
index 52f12db..ce3c316 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -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;
diff --git a/services/analytics.js b/services/analytics.js
index b297cb4..8d50f4f 100644
--- a/services/analytics.js
+++ b/services/analytics.js
@@ -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,
};