llms-py 2.0.15__py3-none-any.whl → 2.0.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
llms/ui/Analytics.mjs ADDED
@@ -0,0 +1,1483 @@
1
+ import { ref, onMounted, watch, nextTick, computed } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+ import { useFormatters } from "@servicestack/vue"
4
+ import { leftPart } from '@servicestack/client'
5
+ import { Chart, registerables } from "chart.js"
6
+ import { useThreadStore } from './threadStore.mjs'
7
+ import { formatCost } from './utils.mjs'
8
+ Chart.register(...registerables)
9
+
10
+ const { humanifyNumber, humanifyMs } = useFormatters()
11
+
12
+ export const colors = [
13
+ { background: 'rgba(54, 162, 235, 0.2)', border: 'rgb(54, 162, 235)' }, //blue
14
+ { background: 'rgba(255, 99, 132, 0.2)', border: 'rgb(255, 99, 132)' },
15
+ { background: 'rgba(153, 102, 255, 0.2)', border: 'rgb(153, 102, 255)' },
16
+ { background: 'rgba(54, 162, 235, 0.2)', border: 'rgb(54, 162, 235)' },
17
+ { background: 'rgba(255, 159, 64, 0.2)', border: 'rgb(255, 159, 64)' },
18
+ { background: 'rgba(67, 56, 202, 0.2)', border: 'rgb(67, 56, 202)' },
19
+ { background: 'rgba(255, 99, 132, 0.2)', border: 'rgb(255, 99, 132)' },
20
+ { background: 'rgba(14, 116, 144, 0.2)', border: 'rgb(14, 116, 144)' },
21
+ { background: 'rgba(162, 28, 175, 0.2)', border: 'rgb(162, 28, 175)' },
22
+ { background: 'rgba(201, 203, 207, 0.2)', border: 'rgb(201, 203, 207)' },
23
+ ]
24
+
25
+ const MonthSelector = {
26
+ template:`
27
+ <div class="flex gap-4 items-center">
28
+ <!-- Months Row -->
29
+ <div class="flex gap-2 flex-wrap justify-center">
30
+ <template v-for="month in availableMonthsForYear" :key="month">
31
+ <span v-if="selectedMonth === month"
32
+ class="text-xs leading-5 font-semibold bg-indigo-600 text-white rounded-full py-1 px-3 flex items-center space-x-2">
33
+ {{ new Date(selectedYear + '-' + month.toString().padStart(2,'0') + '-01').toLocaleString('default', { month: 'long' }) }}
34
+ </span>
35
+ <button v-else type="button"
36
+ class="text-xs leading-5 font-semibold bg-slate-400/10 rounded-full py-1 px-3 flex items-center space-x-2 hover:bg-slate-400/20 dark:highlight-white/5"
37
+ @click="updateSelection(selectedYear, month)">
38
+ {{ new Date(selectedYear + '-' + month.toString().padStart(2,'0') + '-01').toLocaleString('default', { month: 'short' }) }}
39
+ </button>
40
+ </template>
41
+ </div>
42
+
43
+ <!-- Year Dropdown -->
44
+ <select :value="selectedYear" @change="(e) => updateSelection(parseInt(e.target.value), selectedMonth)"
45
+ class="border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500">
46
+ <option v-for="year in availableYears" :key="year" :value="year">
47
+ {{ year }}
48
+ </option>
49
+ </select>
50
+ </div>
51
+ `,
52
+ props: {
53
+ dailyData: Array,
54
+ },
55
+ setup(props) {
56
+ const router = useRouter()
57
+ const route = useRoute()
58
+
59
+ const selectedMonth = computed(() => {
60
+ const now = new Date()
61
+ return route.query.month !== undefined ? parseInt(route.query.month) : now.getMonth() + 1
62
+ })
63
+
64
+ const selectedYear = computed(() => {
65
+ const now = new Date()
66
+ return route.query.year !== undefined ? parseInt(route.query.year) : now.getFullYear()
67
+ })
68
+
69
+ const updateSelection = (year, month) => {
70
+ router.push({
71
+ query: {
72
+ ...route.query,
73
+ month,
74
+ year
75
+ }
76
+ })
77
+ }
78
+
79
+ const availableYears = computed(() => {
80
+ // Get all years that have data
81
+ const yearsSet = new Set()
82
+ Object.keys(props.dailyData || {}).forEach(dateKey => {
83
+ const year = parseInt(leftPart(dateKey, '-'))
84
+ yearsSet.add(year)
85
+ })
86
+ return Array.from(yearsSet).sort((a, b) => a - b)
87
+ })
88
+
89
+ const availableMonthsForYear = computed(() => {
90
+ // Get all months that have data for the selected year
91
+ const monthsSet = new Set()
92
+ Object.keys(props.dailyData || {}).forEach(dateKey => {
93
+ const date = new Date(dateKey + 'T00:00:00Z')
94
+ if (date.getFullYear() === selectedYear.value) {
95
+ monthsSet.add(date.getMonth() + 1)
96
+ }
97
+ })
98
+ return Array.from(monthsSet).sort((a, b) => a - b)
99
+ })
100
+
101
+ return {
102
+ selectedMonth,
103
+ selectedYear,
104
+ updateSelection,
105
+ availableYears,
106
+ availableMonthsForYear,
107
+ }
108
+ }
109
+ }
110
+
111
+ export default {
112
+ components: {
113
+ MonthSelector,
114
+ },
115
+ template: `
116
+ <div class="flex flex-col h-full w-full">
117
+ <!-- Header -->
118
+ <div class="border-b border-gray-200 bg-white px-4 py-3 min-h-16">
119
+ <div class="max-w-6xl mx-auto flex items-center justify-between gap-3">
120
+ <h2 class="text-lg font-semibold text-gray-900">
121
+ <RouterLink to="/analytics">Analytics</RouterLink>
122
+ </h2>
123
+ <MonthSelector :dailyData="allDailyData" />
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Tabs -->
128
+ <div class="border-b border-gray-200 bg-white px-4">
129
+ <div class="max-w-6xl mx-auto flex gap-8">
130
+ <button type="button"
131
+ @click="activeTab = 'cost'"
132
+ :class="['py-3 px-1 border-b-2 font-medium text-sm transition-colors',
133
+ activeTab === 'cost'
134
+ ? 'border-blue-500 text-blue-600'
135
+ : 'border-transparent text-gray-600 hover:text-gray-900']">
136
+ Cost Analysis
137
+ </button>
138
+ <button type="button"
139
+ @click="activeTab = 'tokens'"
140
+ :class="['py-3 px-1 border-b-2 font-medium text-sm transition-colors',
141
+ activeTab === 'tokens'
142
+ ? 'border-blue-500 text-blue-600'
143
+ : 'border-transparent text-gray-600 hover:text-gray-900']">
144
+ Token Usage
145
+ </button>
146
+ <button type="button"
147
+ @click="activeTab = 'activity'"
148
+ :class="['py-3 px-1 border-b-2 font-medium text-sm transition-colors',
149
+ activeTab === 'activity'
150
+ ? 'border-blue-500 text-blue-600'
151
+ : 'border-transparent text-gray-600 hover:text-gray-900']">
152
+ Activity
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Content -->
158
+ <div class="flex-1 overflow-auto bg-gray-50" :class="activeTab === 'activity' ? 'p-0' : 'p-4'">
159
+
160
+ <div :class="activeTab === 'activity' ? 'h-full' : 'max-w-6xl mx-auto'">
161
+ <!-- Stats Summary (hidden for Activity tab) -->
162
+ <div v-if="activeTab !== 'activity'" class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
163
+ <div class="bg-white rounded-lg shadow p-4">
164
+ <div class="text-sm font-medium text-gray-600">Total Cost</div>
165
+ <div class="text-2xl font-bold text-gray-900 mt-1">{{ formatCost(totalCost) }}</div>
166
+ </div>
167
+ <div class="bg-white rounded-lg shadow p-4">
168
+ <div class="text-sm font-medium text-gray-600">Total Requests</div>
169
+ <div class="text-2xl font-bold text-gray-900 mt-1">{{ totalRequests }}</div>
170
+ </div>
171
+ <div class="bg-white rounded-lg shadow p-4">
172
+ <div class="text-sm font-medium text-gray-600">Total Input Tokens</div>
173
+ <div class="text-2xl font-bold text-gray-900 mt-1">{{ humanifyNumber(totalInputTokens) }}</div>
174
+ </div>
175
+ <div class="bg-white rounded-lg shadow p-4">
176
+ <div class="text-sm font-medium text-gray-600">Total Output Tokens</div>
177
+ <div class="text-2xl font-bold text-gray-900 mt-1">{{ humanifyNumber(totalOutputTokens) }}</div>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Cost Analysis Tab -->
182
+ <div v-if="activeTab === 'cost'" class="bg-white rounded-lg shadow p-6">
183
+ <div class="flex items-center justify-between mb-6">
184
+ <h3 class="text-lg font-semibold text-gray-900">Daily Costs</h3>
185
+ <h3 class="text-lg font-semibold text-gray-900">
186
+ {{ new Date(selectedDay).toLocaleDateString(undefined, { year: 'numeric', month: 'long' }) }}
187
+ </h3>
188
+ <select v-model="costChartType" class="px-3 pr-6 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50">
189
+ <option value="bar">Bar Chart</option>
190
+ <option value="line">Line Chart</option>
191
+ </select>
192
+ </div>
193
+
194
+ <div v-if="chartData.labels.length > 0" class="relative h-96">
195
+ <canvas ref="costChartCanvas"></canvas>
196
+ </div>
197
+ <div v-else class="flex items-center justify-center h-96 text-gray-500">
198
+ <p>No request data available</p>
199
+ </div>
200
+ </div>
201
+
202
+ <!-- Token Usage Tab -->
203
+ <div v-if="activeTab === 'tokens'" class="bg-white rounded-lg shadow p-6">
204
+ <h3 class="text-lg font-semibold text-gray-900 mb-6 flex justify-between items-center">
205
+ <span>Daily Token Usage</span>
206
+ <span>
207
+ {{ new Date(selectedDay).toLocaleDateString(undefined, { year: 'numeric', month: 'long' }) }}
208
+ </span>
209
+ </h3>
210
+
211
+ <div v-if="tokenChartData.labels.length > 0" class="relative h-96">
212
+ <canvas ref="tokenChartCanvas"></canvas>
213
+ </div>
214
+ <div v-else class="flex items-center justify-center h-96 text-gray-500">
215
+ <p>No request data available</p>
216
+ </div>
217
+ </div>
218
+
219
+ <div v-if="allDailyData[selectedDay]?.requests && ['cost', 'tokens'].includes(activeTab)" class="mt-8 px-2 text-gray-700 font-medium flex items-center justify-between">
220
+ <div>
221
+ {{ new Date(selectedDay).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) }}
222
+ </div>
223
+ <div>
224
+ {{ formatCost(allDailyData[selectedDay]?.cost || 0) }}
225
+ &#183;
226
+ {{ allDailyData[selectedDay]?.requests || 0 }} Requests
227
+ &#183;
228
+ {{ humanifyNumber(allDailyData[selectedDay]?.inputTokens || 0) }} -> {{ humanifyNumber(allDailyData[selectedDay]?.outputTokens || 0) }} Tokens
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Pie Charts for Selected Day -->
233
+ <div v-if="allDailyData[selectedDay]?.requests && activeTab === 'cost' && selectedDay" class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
234
+ <!-- Model Pie Chart -->
235
+ <div class="bg-white rounded-lg shadow p-6">
236
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">
237
+ Cost by Model
238
+ </h3>
239
+ <div v-if="modelPieData.labels.length > 0" class="relative h-80">
240
+ <canvas ref="modelPieCanvas"></canvas>
241
+ </div>
242
+ <div v-else class="flex items-center justify-center h-80 text-gray-500">
243
+ <p>No data for selected day</p>
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Provider Pie Chart -->
248
+ <div class="bg-white rounded-lg shadow p-6">
249
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">
250
+ Cost by Provider
251
+ </h3>
252
+ <div v-if="providerPieData.labels.length > 0" class="relative h-80">
253
+ <canvas ref="providerPieCanvas"></canvas>
254
+ </div>
255
+ <div v-else class="flex items-center justify-center h-80 text-gray-500">
256
+ <p>No data for selected day</p>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <!-- Token Pie Charts for Selected Day -->
262
+ <div v-if="allDailyData[selectedDay]?.requests && activeTab === 'tokens' && selectedDay" class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
263
+ <!-- Token Model Pie Chart -->
264
+ <div class="bg-white rounded-lg shadow p-6">
265
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">
266
+ Tokens by Model
267
+ </h3>
268
+ <div v-if="tokenModelPieData.labels.length > 0" class="relative h-80">
269
+ <canvas ref="tokenModelPieCanvas"></canvas>
270
+ </div>
271
+ <div v-else class="flex items-center justify-center h-80 text-gray-500">
272
+ <p>No data for selected day</p>
273
+ </div>
274
+ </div>
275
+
276
+ <!-- Token Provider Pie Chart -->
277
+ <div class="bg-white rounded-lg shadow p-6">
278
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">
279
+ Tokens by Provider
280
+ </h3>
281
+ <div v-if="tokenProviderPieData.labels.length > 0" class="relative h-80">
282
+ <canvas ref="tokenProviderPieCanvas"></canvas>
283
+ </div>
284
+ <div v-else class="flex items-center justify-center h-80 text-gray-500">
285
+ <p>No data for selected day</p>
286
+ </div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Activity Tab - Full Page Layout -->
291
+ <div v-if="activeTab === 'activity'" class="h-full flex flex-col bg-white">
292
+ <!-- Filters Bar -->
293
+ <div class="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4">
294
+ <div class="flex flex-wrap gap-4 items-end">
295
+ <div class="flex flex-col">
296
+ <select v-model="selectedModel" class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
297
+ <option value="">All Models</option>
298
+ <option v-for="model in filterOptions.models" :key="model" :value="model">
299
+ {{ model }}
300
+ </option>
301
+ </select>
302
+ </div>
303
+
304
+ <div class="flex flex-col">
305
+ <select v-model="selectedProvider" class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
306
+ <option value="">All Providers</option>
307
+ <option v-for="provider in filterOptions.providers" :key="provider" :value="provider">
308
+ {{ provider }}
309
+ </option>
310
+ </select>
311
+ </div>
312
+
313
+ <div class="flex flex-col">
314
+ <select v-model="sortBy" class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
315
+ <option value="created">Date (Newest)</option>
316
+ <option value="cost">Cost (Highest)</option>
317
+ <option value="duration">Duration (Longest)</option>
318
+ <option value="totalTokens">Tokens (Most)</option>
319
+ </select>
320
+ </div>
321
+
322
+ <button v-if="hasActiveFilters" @click="clearActivityFilters" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors">
323
+ Clear Filters
324
+ </button>
325
+ </div>
326
+ </div>
327
+
328
+ <!-- Requests List with Infinite Scroll -->
329
+ <div class="flex-1 overflow-y-auto" @scroll="onActivityScroll" ref="activityScrollContainer">
330
+ <div v-if="isActivityLoading && activityRequests.length === 0" class="flex items-center justify-center h-full">
331
+ <div class="text-center">
332
+ <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
333
+ <p class="mt-4 text-gray-600">Loading requests...</p>
334
+ </div>
335
+ </div>
336
+
337
+ <div v-else-if="activityRequests.length === 0" class="flex items-center justify-center h-full">
338
+ <p class="text-gray-500">No requests found</p>
339
+ </div>
340
+
341
+ <div v-else class="divide-y divide-gray-200">
342
+ <div v-for="request in activityRequests" :key="request.id" class="px-6 py-4 hover:bg-gray-50 transition-colors">
343
+ <div class="flex items-start justify-between gap-4">
344
+ <div class="flex-1 min-w-0">
345
+ <div class="flex justify-between">
346
+ <div class="flex items-center gap-2 mb-2 flex-wrap">
347
+ <span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded font-medium">{{ request.model }}</span>
348
+ <span class="text-xs px-2 py-1 bg-purple-100 text-purple-800 rounded font-medium">{{ request.provider }}</span>
349
+ <span v-if="request.providerRef" class="text-xs px-2 py-1 bg-green-100 text-green-800 rounded font-medium">{{ request.providerRef }}</span>
350
+ <span v-if="request.finishReason" class="text-xs px-2 py-1 bg-gray-100 text-gray-800 rounded font-medium">{{ request.finishReason }}</span>
351
+ </div>
352
+ <div class="text-xs text-gray-500">
353
+ {{ formatActivityDate(request.created) }}
354
+ </div>
355
+ </div>
356
+ <div class="text-sm font-semibold text-gray-900 truncate">
357
+ {{ request.title }}
358
+ </div>
359
+
360
+ <div class="grid grid-cols-2 md:grid-cols-5 gap-4 mt-3">
361
+ <div :title="request.cost">
362
+ <div class="text-xs text-gray-500 font-medium">Cost</div>
363
+ <div class="text-sm font-semibold text-gray-900">{{ formatCost(request.cost) }}</div>
364
+ </div>
365
+ <div>
366
+ <div class="text-xs text-gray-500 font-medium">Tokens</div>
367
+ <div class="text-sm font-semibold text-gray-900">
368
+ {{ humanifyNumber(request.inputTokens) }} -> {{ humanifyNumber(request.outputTokens) }}
369
+ <span v-if="request.inputCachedTokens" class="ml-1 text-xs text-gray-500">({{ humanifyNumber(request.inputCachedTokens) }} cached)</span>
370
+ </div>
371
+ </div>
372
+ <div>
373
+ <div class="text-xs text-gray-500 font-medium">Duration</div>
374
+ <div class="text-sm font-semibold text-gray-900">{{ request.duration ? humanifyMs(request.duration) : '—' }}</div>
375
+ </div>
376
+ <div>
377
+ <div class="text-xs text-gray-500 font-medium">Speed</div>
378
+ <div class="text-sm font-semibold text-gray-900">{{ request.duration && request.outputTokens ? (request.outputTokens / (request.duration / 1000)).toFixed(1) + ' tok/s' : '—' }}</div>
379
+ </div>
380
+ </div>
381
+ </div>
382
+ <div class="flex flex-col gap-2">
383
+ <button type="button" v-if="request.threadId" @click="openThread(request.threadId)" class="flex-shrink-0 px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border border-blue-300 rounded hover:bg-blue-50 transition-colors whitespace-nowrap">
384
+ View<span class="hidden lg:inline"> Thread</span>
385
+ </button>
386
+ <button type="button" @click="deleteRequestLog(request.id)" class="flex-shrink-0 px-4 py-2 text-sm font-medium text-red-600 hover:text-red-800 border border-red-300 rounded hover:bg-red-50 transition-colors whitespace-nowrap">
387
+ Delete<span class="hidden lg:inline"> Request</span>
388
+ </button>
389
+ </div>
390
+ </div>
391
+ </div>
392
+
393
+ <div v-if="isActivityLoadingMore" class="px-6 py-8 text-center">
394
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
395
+ </div>
396
+
397
+ <div v-if="!activityHasMore && activityRequests.length > 0" class="px-6 py-8 text-center text-gray-500 text-sm">
398
+ No more requests to load
399
+ </div>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </div>
404
+ </div>
405
+ </div>
406
+ `,
407
+ setup() {
408
+ const router = useRouter()
409
+ const route = useRoute()
410
+ const threads = useThreadStore()
411
+ const { initDB } = threads
412
+
413
+ // Initialize activeTab from URL query parameter, default to 'cost'
414
+ const activeTab = ref(route.query.tab || 'cost')
415
+ const costChartType = ref('bar')
416
+ const costChartCanvas = ref(null)
417
+ const tokenChartCanvas = ref(null)
418
+ const modelPieCanvas = ref(null)
419
+ const providerPieCanvas = ref(null)
420
+ const tokenModelPieCanvas = ref(null)
421
+ const tokenProviderPieCanvas = ref(null)
422
+ let costChartInstance = null
423
+ let tokenChartInstance = null
424
+ let modelPieChartInstance = null
425
+ let providerPieChartInstance = null
426
+ let tokenModelPieChartInstance = null
427
+ let tokenProviderPieChartInstance = null
428
+
429
+ // Month/Year selection - read from URL as source of truth
430
+ const currentDate = new Date()
431
+ const selectedMonth = computed(() => {
432
+ return route.query.month !== undefined ? parseInt(route.query.month) : currentDate.getMonth() + 1
433
+ })
434
+ const selectedYear = computed(() => {
435
+ return route.query.year !== undefined ? parseInt(route.query.year) : currentDate.getFullYear()
436
+ })
437
+ const allDailyData = ref({}) // Store all data for filtering
438
+
439
+ // Selected day - read from URL, default to today
440
+ const selectedDay = computed(() => {
441
+ if (route.query.day !== undefined) {
442
+ return route.query.day
443
+ }
444
+ // Default to today
445
+ const today = new Date()
446
+ return today.toISOString().split('T')[0]
447
+ })
448
+
449
+ const chartData = ref({
450
+ labels: [],
451
+ datasets: [],
452
+ dateKeys: [] // Store full date keys for click handling
453
+ })
454
+
455
+ const tokenChartData = ref({
456
+ labels: [],
457
+ datasets: [],
458
+ dateKeys: [] // Store full date keys for click handling
459
+ })
460
+
461
+ const modelPieData = ref({
462
+ labels: [],
463
+ datasets: []
464
+ })
465
+
466
+ const providerPieData = ref({
467
+ labels: [],
468
+ datasets: []
469
+ })
470
+
471
+ const tokenModelPieData = ref({
472
+ labels: [],
473
+ datasets: []
474
+ })
475
+
476
+ const tokenProviderPieData = ref({
477
+ labels: [],
478
+ datasets: []
479
+ })
480
+
481
+ const totalCost = computed(() => {
482
+ // Calculate totals for selected month/year only
483
+ const filteredDates = Object.keys(allDailyData.value)
484
+ .filter(dateKey => {
485
+ const date = new Date(dateKey + 'T00:00:00Z')
486
+ return date.getFullYear() === selectedYear.value && (date.getMonth() + 1) === selectedMonth.value
487
+ })
488
+ return filteredDates.reduce((sum, date) => sum + (allDailyData.value[date].cost || 0), 0)
489
+ })
490
+
491
+ const totalRequests = computed(() => {
492
+ // Calculate totals for selected month/year only
493
+ const filteredDates = Object.keys(allDailyData.value)
494
+ .filter(dateKey => {
495
+ const date = new Date(dateKey + 'T00:00:00Z')
496
+ return date.getFullYear() === selectedYear.value && (date.getMonth() + 1) === selectedMonth.value
497
+ })
498
+ return filteredDates.reduce((sum, date) => sum + (allDailyData.value[date].requests || 0), 0)
499
+ })
500
+
501
+ const totalInputTokens = computed(() => {
502
+ // Calculate totals for selected month/year only
503
+ const filteredDates = Object.keys(allDailyData.value)
504
+ .filter(dateKey => {
505
+ const date = new Date(dateKey + 'T00:00:00Z')
506
+ return date.getFullYear() === selectedYear.value && (date.getMonth() + 1) === selectedMonth.value
507
+ })
508
+ return filteredDates.reduce((sum, date) => sum + (allDailyData.value[date].inputTokens || 0), 0)
509
+ })
510
+
511
+ const totalOutputTokens = computed(() => {
512
+ // Calculate totals for selected month/year only
513
+ const filteredDates = Object.keys(allDailyData.value)
514
+ .filter(dateKey => {
515
+ const date = new Date(dateKey + 'T00:00:00Z')
516
+ return date.getFullYear() === selectedYear.value && (date.getMonth() + 1) === selectedMonth.value
517
+ })
518
+ return filteredDates.reduce((sum, date) => sum + (allDailyData.value[date].outputTokens || 0), 0)
519
+ })
520
+
521
+ // Activity tab state
522
+ const activityRequests = ref([])
523
+ const isActivityLoading = ref(false)
524
+ const isActivityLoadingMore = ref(false)
525
+ const activityHasMore = ref(true)
526
+ const activityOffset = ref(0)
527
+ const activityPageSize = 20
528
+
529
+ const selectedModel = ref('')
530
+ const selectedProvider = ref('')
531
+ const sortBy = ref('created')
532
+ const filterOptions = ref({ models: [], providers: [] })
533
+ const activityScrollContainer = ref(null)
534
+
535
+ const hasActiveFilters = computed(() => selectedModel.value || selectedProvider.value)
536
+
537
+ async function loadAnalyticsData() {
538
+ try {
539
+ const db = await initDB()
540
+ const tx = db.transaction(['requests'], 'readonly')
541
+ const store = tx.objectStore('requests')
542
+ const allRequests = await store.getAll()
543
+
544
+ // Group requests by date
545
+ const dailyData = {}
546
+ let totalCostSum = 0
547
+ let totalInputSum = 0
548
+ let totalOutputSum = 0
549
+ const yearsSet = new Set()
550
+
551
+ allRequests.forEach(req => {
552
+ const date = new Date(req.created * 1000)
553
+ const dateKey = date.toISOString().split('T')[0] // YYYY-MM-DD
554
+ yearsSet.add(date.getFullYear())
555
+
556
+ if (!dailyData[dateKey]) {
557
+ dailyData[dateKey] = {
558
+ cost: 0,
559
+ requests: 0,
560
+ inputTokens: 0,
561
+ outputTokens: 0
562
+ }
563
+ }
564
+
565
+ dailyData[dateKey].cost += req.cost || 0
566
+ dailyData[dateKey].requests += 1
567
+ dailyData[dateKey].inputTokens += req.inputTokens || 0
568
+ dailyData[dateKey].outputTokens += req.outputTokens || 0
569
+
570
+ totalCostSum += req.cost || 0
571
+ totalInputSum += req.inputTokens || 0
572
+ totalOutputSum += req.outputTokens || 0
573
+ })
574
+
575
+ // Store all daily data for filtering
576
+ allDailyData.value = dailyData
577
+
578
+ // Update chart data based on selected month/year
579
+ updateChartData()
580
+
581
+ await nextTick()
582
+ renderCostChart()
583
+ renderTokenChart()
584
+ } catch (error) {
585
+ console.error('Error loading analytics data:', error)
586
+ }
587
+ }
588
+
589
+ function updateChartData() {
590
+ // Filter data for selected month and year
591
+ const filteredDates = Object.keys(allDailyData.value)
592
+ .filter(dateKey => {
593
+ const date = new Date(dateKey + 'T00:00:00Z')
594
+ return date.getFullYear() === selectedYear.value && (date.getMonth() + 1) === selectedMonth.value
595
+ })
596
+ .sort()
597
+
598
+ const costs = filteredDates.map(date => allDailyData.value[date].cost)
599
+ const inputTokens = filteredDates.map(date => allDailyData.value[date].inputTokens)
600
+ const outputTokens = filteredDates.map(date => allDailyData.value[date].outputTokens)
601
+
602
+ // Extract day numbers from dates for labels
603
+ const dayLabels = filteredDates.map(dateKey => {
604
+ const date = new Date(dateKey + 'T00:00:00Z')
605
+ return date.getDate().toString()
606
+ })
607
+
608
+ // Cost chart data
609
+ chartData.value = {
610
+ labels: dayLabels,
611
+ dateKeys: filteredDates, // Store full date keys for click handling
612
+ datasets: [{
613
+ label: 'Daily Cost ($)',
614
+ data: costs,
615
+ backgroundColor: colors[0].background,
616
+ borderColor: colors[0].border,
617
+ borderWidth: 2,
618
+ tension: 0.1
619
+ }]
620
+ }
621
+
622
+ // Token chart data (stacked)
623
+ tokenChartData.value = {
624
+ labels: dayLabels,
625
+ dateKeys: filteredDates, // Store full date keys for click handling
626
+ datasets: [
627
+ {
628
+ label: 'Input Tokens',
629
+ data: inputTokens,
630
+ backgroundColor: 'rgba(168, 85, 247, 0.2)',
631
+ borderColor: 'rgb(126, 34, 206)',
632
+ borderWidth: 1
633
+ },
634
+ {
635
+ label: 'Output Tokens',
636
+ data: outputTokens,
637
+ backgroundColor: 'rgba(251, 146, 60, 0.2)',
638
+ borderColor: 'rgb(234, 88, 12)',
639
+ borderWidth: 1
640
+ }
641
+ ]
642
+ }
643
+ }
644
+
645
+ async function updatePieChartData(dateKey) {
646
+ if (!dateKey) {
647
+ modelPieData.value = { labels: [], datasets: [] }
648
+ providerPieData.value = { labels: [], datasets: [] }
649
+ return
650
+ }
651
+
652
+ try {
653
+ const db = await initDB()
654
+ const tx = db.transaction(['requests'], 'readonly')
655
+ const store = tx.objectStore('requests')
656
+ const allRequests = await store.getAll()
657
+
658
+ // Filter requests for the selected day
659
+ const dayStart = Math.floor(new Date(dateKey + 'T00:00:00Z').getTime() / 1000)
660
+ const dayEnd = Math.floor(new Date(dateKey + 'T23:59:59Z').getTime() / 1000)
661
+
662
+ const dayRequests = allRequests.filter(req => req.created >= dayStart && req.created <= dayEnd)
663
+
664
+ // Aggregate by model
665
+ const modelData = {}
666
+ const providerData = {}
667
+
668
+ dayRequests.forEach(req => {
669
+ // Model aggregation
670
+ if (!modelData[req.model]) {
671
+ modelData[req.model] = { cost: 0, count: 0 }
672
+ }
673
+ modelData[req.model].cost += req.cost || 0
674
+ modelData[req.model].count += 1
675
+
676
+ // Provider aggregation
677
+ if (!providerData[req.provider]) {
678
+ providerData[req.provider] = { cost: 0, count: 0 }
679
+ }
680
+ providerData[req.provider].cost += req.cost || 0
681
+ providerData[req.provider].count += 1
682
+ })
683
+
684
+ // Prepare model pie chart data
685
+ const modelLabels = Object.keys(modelData).sort()
686
+ const modelCosts = modelLabels.map(model => modelData[model].cost)
687
+
688
+ modelPieData.value = {
689
+ labels: modelLabels,
690
+ datasets: [{
691
+ label: 'Cost by Model',
692
+ data: modelCosts,
693
+ backgroundColor: colors.map(c => c.background),
694
+ borderColor: colors.map(c => c.border),
695
+ borderWidth: 2
696
+ }]
697
+ }
698
+
699
+ // Prepare provider pie chart data
700
+ const providerLabels = Object.keys(providerData).sort()
701
+ const providerCosts = providerLabels.map(provider => providerData[provider].cost)
702
+
703
+ providerPieData.value = {
704
+ labels: providerLabels,
705
+ datasets: [{
706
+ label: 'Cost by Provider',
707
+ data: providerCosts,
708
+ backgroundColor: colors.map(c => c.background),
709
+ borderColor: colors.map(c => c.border),
710
+ borderWidth: 2
711
+ }]
712
+ }
713
+ } catch (error) {
714
+ console.error('Error updating pie chart data:', error)
715
+ }
716
+ }
717
+
718
+ async function updateTokenPieChartData(dateKey) {
719
+ if (!dateKey) {
720
+ tokenModelPieData.value = { labels: [], datasets: [] }
721
+ tokenProviderPieData.value = { labels: [], datasets: [] }
722
+ return
723
+ }
724
+
725
+ try {
726
+ const db = await initDB()
727
+ const tx = db.transaction(['requests'], 'readonly')
728
+ const store = tx.objectStore('requests')
729
+ const allRequests = await store.getAll()
730
+
731
+ // Filter requests for the selected day
732
+ const dayStart = Math.floor(new Date(dateKey + 'T00:00:00Z').getTime() / 1000)
733
+ const dayEnd = Math.floor(new Date(dateKey + 'T23:59:59Z').getTime() / 1000)
734
+
735
+ const dayRequests = allRequests.filter(req => req.created >= dayStart && req.created <= dayEnd)
736
+
737
+ // Aggregate by model and provider (using tokens)
738
+ const modelData = {}
739
+ const providerData = {}
740
+
741
+ dayRequests.forEach(req => {
742
+ const totalTokens = (req.inputTokens || 0) + (req.outputTokens || 0)
743
+
744
+ // Model aggregation
745
+ if (!modelData[req.model]) {
746
+ modelData[req.model] = { tokens: 0, count: 0 }
747
+ }
748
+ modelData[req.model].tokens += totalTokens
749
+ modelData[req.model].count += 1
750
+
751
+ // Provider aggregation
752
+ if (!providerData[req.provider]) {
753
+ providerData[req.provider] = { tokens: 0, count: 0 }
754
+ }
755
+ providerData[req.provider].tokens += totalTokens
756
+ providerData[req.provider].count += 1
757
+ })
758
+
759
+ // Prepare model pie chart data
760
+ const modelLabels = Object.keys(modelData).sort()
761
+ const modelTokens = modelLabels.map(model => modelData[model].tokens)
762
+
763
+ tokenModelPieData.value = {
764
+ labels: modelLabels,
765
+ datasets: [{
766
+ label: 'Tokens by Model',
767
+ data: modelTokens,
768
+ backgroundColor: colors.map(c => c.background),
769
+ borderColor: colors.map(c => c.border),
770
+ borderWidth: 2
771
+ }]
772
+ }
773
+
774
+ // Prepare provider pie chart data
775
+ const providerLabels = Object.keys(providerData).sort()
776
+ const providerTokens = providerLabels.map(provider => providerData[provider].tokens)
777
+
778
+ tokenProviderPieData.value = {
779
+ labels: providerLabels,
780
+ datasets: [{
781
+ label: 'Tokens by Provider',
782
+ data: providerTokens,
783
+ backgroundColor: colors.map(c => c.background),
784
+ borderColor: colors.map(c => c.border),
785
+ borderWidth: 2
786
+ }]
787
+ }
788
+ } catch (error) {
789
+ console.error('Error updating token pie chart data:', error)
790
+ }
791
+ }
792
+
793
+ function renderCostChart() {
794
+ if (!costChartCanvas.value || chartData.value.labels.length === 0) return
795
+
796
+ // Destroy existing chart
797
+ if (costChartInstance) {
798
+ costChartInstance.destroy()
799
+ }
800
+
801
+ const ctx = costChartCanvas.value.getContext('2d')
802
+ const chartTypeValue = costChartType.value
803
+
804
+ // Find the index of the selected day
805
+ const selectedDayIndex = chartData.value.dateKeys.indexOf(selectedDay.value)
806
+
807
+ // Create color arrays with highlight for selected day
808
+ const backgroundColor = chartData.value.dateKeys.map((_, index) => {
809
+ if (index === selectedDayIndex) {
810
+ return 'rgba(34, 197, 94, 0.8)' // Green for selected day
811
+ }
812
+ return colors[0].background
813
+ })
814
+
815
+ const borderColor = chartData.value.dateKeys.map((_, index) => {
816
+ if (index === selectedDayIndex) {
817
+ return 'rgb(22, 163, 74)' // Darker green for selected day
818
+ }
819
+ return colors[0].border
820
+ })
821
+
822
+ // Update dataset with dynamic colors
823
+ const chartDataWithColors = {
824
+ ...chartData.value,
825
+ datasets: [{
826
+ ...chartData.value.datasets[0],
827
+ backgroundColor: backgroundColor,
828
+ borderColor: borderColor
829
+ }]
830
+ }
831
+
832
+ costChartInstance = new Chart(ctx, {
833
+ type: chartTypeValue,
834
+ data: chartDataWithColors,
835
+ options: {
836
+ responsive: true,
837
+ maintainAspectRatio: false,
838
+ onClick: async (_, elements) => {
839
+ if (chartTypeValue === 'bar' && elements.length > 0) {
840
+ const index = elements[0].index
841
+ const dateKey = chartData.value.dateKeys[index]
842
+ // Update URL with selected day
843
+ router.push({
844
+ query: {
845
+ ...route.query,
846
+ day: dateKey
847
+ }
848
+ })
849
+ }
850
+ },
851
+ plugins: {
852
+ legend: {
853
+ display: true,
854
+ position: 'top'
855
+ },
856
+ tooltip: {
857
+ callbacks: {
858
+ title: function(context) {
859
+ const index = context[0].dataIndex
860
+ const dateKey = chartData.value.dateKeys[index]
861
+ const date = new Date(dateKey + 'T00:00:00Z')
862
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
863
+ },
864
+ label: function(context) {
865
+ return `Cost: ${formatCost(context.parsed.y)}`
866
+ }
867
+ }
868
+ }
869
+ },
870
+ scales: {
871
+ y: {
872
+ beginAtZero: true,
873
+ ticks: {
874
+ callback: function(value) {
875
+ return '$' + value.toFixed(4)
876
+ }
877
+ }
878
+ }
879
+ }
880
+ }
881
+ })
882
+ }
883
+
884
+ function renderTokenChart() {
885
+ if (!tokenChartCanvas.value || tokenChartData.value.labels.length === 0) return
886
+
887
+ // Destroy existing chart
888
+ if (tokenChartInstance) {
889
+ tokenChartInstance.destroy()
890
+ }
891
+
892
+ const ctx = tokenChartCanvas.value.getContext('2d')
893
+
894
+ // Find the index of the selected day
895
+ const selectedDayIndex = tokenChartData.value.dateKeys.indexOf(selectedDay.value)
896
+
897
+ // Create color arrays with highlight for selected day
898
+ const inputBackgroundColor = tokenChartData.value.dateKeys.map((_, index) => {
899
+ if (index === selectedDayIndex) {
900
+ return 'rgba(34, 197, 94, 0.2)' // Green for selected day (light/transparent)
901
+ }
902
+ return 'rgba(168, 85, 247, 0.2)' // Purple for input tokens (light/transparent)
903
+ })
904
+
905
+ const inputBorderColor = tokenChartData.value.dateKeys.map((_, index) => {
906
+ if (index === selectedDayIndex) {
907
+ return 'rgb(22, 163, 74)' // Darker green for selected day
908
+ }
909
+ return 'rgb(126, 34, 206)' // Darker purple for input tokens
910
+ })
911
+
912
+ const outputBackgroundColor = tokenChartData.value.dateKeys.map((_, index) => {
913
+ if (index === selectedDayIndex) {
914
+ return 'rgba(34, 197, 94, 0.2)' // Green for selected day (light/transparent)
915
+ }
916
+ return 'rgba(251, 146, 60, 0.2)' // Orange for output tokens (light/transparent)
917
+ })
918
+
919
+ const outputBorderColor = tokenChartData.value.dateKeys.map((_, index) => {
920
+ if (index === selectedDayIndex) {
921
+ return 'rgb(22, 163, 74)' // Darker green for selected day
922
+ }
923
+ return 'rgb(234, 88, 12)' // Darker orange for output tokens
924
+ })
925
+
926
+ // Update datasets with dynamic colors
927
+ const chartDataWithColors = {
928
+ ...tokenChartData.value,
929
+ datasets: [
930
+ {
931
+ ...tokenChartData.value.datasets[0],
932
+ backgroundColor: inputBackgroundColor,
933
+ borderColor: inputBorderColor
934
+ },
935
+ {
936
+ ...tokenChartData.value.datasets[1],
937
+ backgroundColor: outputBackgroundColor,
938
+ borderColor: outputBorderColor
939
+ }
940
+ ]
941
+ }
942
+
943
+ tokenChartInstance = new Chart(ctx, {
944
+ type: 'bar',
945
+ data: chartDataWithColors,
946
+ options: {
947
+ responsive: true,
948
+ maintainAspectRatio: false,
949
+ indexAxis: 'x',
950
+ onClick: async (_, elements) => {
951
+ if (elements.length > 0) {
952
+ const index = elements[0].index
953
+ const dateKey = tokenChartData.value.dateKeys[index]
954
+ // Update URL with selected day
955
+ router.push({
956
+ query: {
957
+ ...route.query,
958
+ day: dateKey
959
+ }
960
+ })
961
+ }
962
+ },
963
+ scales: {
964
+ x: {
965
+ stacked: true
966
+ },
967
+ y: {
968
+ stacked: true,
969
+ beginAtZero: true,
970
+ ticks: {
971
+ callback: function(value) {
972
+ return humanifyNumber(value)
973
+ }
974
+ }
975
+ }
976
+ },
977
+ plugins: {
978
+ legend: {
979
+ display: true,
980
+ position: 'top'
981
+ },
982
+ tooltip: {
983
+ callbacks: {
984
+ title: function(context) {
985
+ const index = context[0].dataIndex
986
+ const dateKey = tokenChartData.value.dateKeys[index]
987
+ const date = new Date(dateKey + 'T00:00:00Z')
988
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
989
+ },
990
+ label: function(context) {
991
+ return `${context.dataset.label}: ${humanifyNumber(context.parsed.y)}`
992
+ }
993
+ }
994
+ }
995
+ }
996
+ }
997
+ })
998
+ }
999
+
1000
+ function renderModelPieChart() {
1001
+ if (!modelPieCanvas.value || modelPieData.value.labels.length === 0) return
1002
+
1003
+ // Destroy existing chart
1004
+ if (modelPieChartInstance) {
1005
+ modelPieChartInstance.destroy()
1006
+ }
1007
+
1008
+ const ctx = modelPieCanvas.value.getContext('2d')
1009
+
1010
+ // Custom plugin to draw percentage labels on pie slices
1011
+ const percentagePlugin = {
1012
+ id: 'percentageLabel',
1013
+ afterDatasetsDraw(chart) {
1014
+ const { ctx: chartCtx, data } = chart
1015
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1016
+ const { x, y } = datapoint.tooltipPosition()
1017
+ const value = data.datasets[0].data[index]
1018
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1019
+ const percentage = ((value * 100) / sum).toFixed(1)
1020
+
1021
+ // Only display label if percentage > 1%
1022
+ if (parseFloat(percentage) > 1) {
1023
+ chartCtx.fillStyle = '#000'
1024
+ chartCtx.font = 'bold 12px Arial'
1025
+ chartCtx.textAlign = 'center'
1026
+ chartCtx.textBaseline = 'middle'
1027
+ chartCtx.fillText(percentage + '%', x, y)
1028
+ }
1029
+ })
1030
+ }
1031
+ }
1032
+
1033
+ modelPieChartInstance = new Chart(ctx, {
1034
+ type: 'pie',
1035
+ data: modelPieData.value,
1036
+ options: {
1037
+ responsive: true,
1038
+ maintainAspectRatio: false,
1039
+ plugins: {
1040
+ legend: {
1041
+ display: true,
1042
+ position: 'right'
1043
+ },
1044
+ tooltip: {
1045
+ callbacks: {
1046
+ label: function(context) {
1047
+ return `${context.label}: ${formatCost(context.parsed)}`
1048
+ }
1049
+ }
1050
+ }
1051
+ }
1052
+ },
1053
+ plugins: [percentagePlugin]
1054
+ })
1055
+ }
1056
+
1057
+ function renderProviderPieChart() {
1058
+ if (!providerPieCanvas.value || providerPieData.value.labels.length === 0) return
1059
+
1060
+ // Destroy existing chart
1061
+ if (providerPieChartInstance) {
1062
+ providerPieChartInstance.destroy()
1063
+ }
1064
+
1065
+ const ctx = providerPieCanvas.value.getContext('2d')
1066
+
1067
+ // Custom plugin to draw percentage labels on pie slices
1068
+ const percentagePlugin = {
1069
+ id: 'percentageLabel',
1070
+ afterDatasetsDraw(chart) {
1071
+ const { ctx: chartCtx, data } = chart
1072
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1073
+ const { x, y } = datapoint.tooltipPosition()
1074
+ const value = data.datasets[0].data[index]
1075
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1076
+ const percentage = ((value * 100) / sum).toFixed(1)
1077
+
1078
+ // Only display label if percentage > 1%
1079
+ if (parseFloat(percentage) > 1) {
1080
+ chartCtx.fillStyle = '#000'
1081
+ chartCtx.font = 'bold 12px Arial'
1082
+ chartCtx.textAlign = 'center'
1083
+ chartCtx.textBaseline = 'middle'
1084
+ chartCtx.fillText(percentage + '%', x, y)
1085
+ }
1086
+ })
1087
+ }
1088
+ }
1089
+
1090
+ providerPieChartInstance = new Chart(ctx, {
1091
+ type: 'pie',
1092
+ data: providerPieData.value,
1093
+ options: {
1094
+ responsive: true,
1095
+ maintainAspectRatio: false,
1096
+ plugins: {
1097
+ legend: {
1098
+ display: true,
1099
+ position: 'right'
1100
+ },
1101
+ tooltip: {
1102
+ callbacks: {
1103
+ label: function(context) {
1104
+ return `${context.label}: ${formatCost(context.parsed)}`
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+ },
1110
+ plugins: [percentagePlugin]
1111
+ })
1112
+ }
1113
+
1114
+ function renderTokenModelPieChart() {
1115
+ if (!tokenModelPieCanvas.value || tokenModelPieData.value.labels.length === 0) return
1116
+
1117
+ // Destroy existing chart
1118
+ if (tokenModelPieChartInstance) {
1119
+ tokenModelPieChartInstance.destroy()
1120
+ }
1121
+
1122
+ const ctx = tokenModelPieCanvas.value.getContext('2d')
1123
+
1124
+ // Custom plugin to draw percentage labels on pie slices
1125
+ const percentagePlugin = {
1126
+ id: 'percentageLabel',
1127
+ afterDatasetsDraw(chart) {
1128
+ const { ctx: chartCtx, data } = chart
1129
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1130
+ const { x, y } = datapoint.tooltipPosition()
1131
+ const value = data.datasets[0].data[index]
1132
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1133
+ const percentage = ((value * 100) / sum).toFixed(1)
1134
+
1135
+ // Only display label if percentage > 1%
1136
+ if (parseFloat(percentage) > 1) {
1137
+ chartCtx.fillStyle = '#000'
1138
+ chartCtx.font = 'bold 12px Arial'
1139
+ chartCtx.textAlign = 'center'
1140
+ chartCtx.textBaseline = 'middle'
1141
+ chartCtx.fillText(percentage + '%', x, y)
1142
+ }
1143
+ })
1144
+ }
1145
+ }
1146
+
1147
+ tokenModelPieChartInstance = new Chart(ctx, {
1148
+ type: 'pie',
1149
+ data: tokenModelPieData.value,
1150
+ options: {
1151
+ responsive: true,
1152
+ maintainAspectRatio: false,
1153
+ plugins: {
1154
+ legend: {
1155
+ display: true,
1156
+ position: 'right'
1157
+ },
1158
+ tooltip: {
1159
+ callbacks: {
1160
+ label: function(context) {
1161
+ return `${context.label}: ${humanifyNumber(context.parsed)}`
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ },
1167
+ plugins: [percentagePlugin]
1168
+ })
1169
+ }
1170
+
1171
+ function renderTokenProviderPieChart() {
1172
+ if (!tokenProviderPieCanvas.value || tokenProviderPieData.value.labels.length === 0) return
1173
+
1174
+ // Destroy existing chart
1175
+ if (tokenProviderPieChartInstance) {
1176
+ tokenProviderPieChartInstance.destroy()
1177
+ }
1178
+
1179
+ const ctx = tokenProviderPieCanvas.value.getContext('2d')
1180
+
1181
+ // Custom plugin to draw percentage labels on pie slices
1182
+ const percentagePlugin = {
1183
+ id: 'percentageLabel',
1184
+ afterDatasetsDraw(chart) {
1185
+ const { ctx: chartCtx, data } = chart
1186
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1187
+ const { x, y } = datapoint.tooltipPosition()
1188
+ const value = data.datasets[0].data[index]
1189
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1190
+ const percentage = ((value * 100) / sum).toFixed(1)
1191
+
1192
+ // Only display label if percentage > 1%
1193
+ if (parseFloat(percentage) > 1) {
1194
+ chartCtx.fillStyle = '#000'
1195
+ chartCtx.font = 'bold 12px Arial'
1196
+ chartCtx.textAlign = 'center'
1197
+ chartCtx.textBaseline = 'middle'
1198
+ chartCtx.fillText(percentage + '%', x, y)
1199
+ }
1200
+ })
1201
+ }
1202
+ }
1203
+
1204
+ tokenProviderPieChartInstance = new Chart(ctx, {
1205
+ type: 'pie',
1206
+ data: tokenProviderPieData.value,
1207
+ options: {
1208
+ responsive: true,
1209
+ maintainAspectRatio: false,
1210
+ plugins: {
1211
+ legend: {
1212
+ display: true,
1213
+ position: 'right'
1214
+ },
1215
+ tooltip: {
1216
+ callbacks: {
1217
+ label: function(context) {
1218
+ return `${context.label}: ${humanifyNumber(context.parsed)}`
1219
+ }
1220
+ }
1221
+ }
1222
+ }
1223
+ },
1224
+ plugins: [percentagePlugin]
1225
+ })
1226
+ }
1227
+
1228
+ // Activity tab functions
1229
+ const loadActivityFilterOptions = async () => {
1230
+ try {
1231
+ filterOptions.value = await threads.getFilterOptions()
1232
+ } catch (error) {
1233
+ console.error('Failed to load filter options:', error)
1234
+ }
1235
+ }
1236
+
1237
+ const loadActivityRequests = async (reset = false) => {
1238
+ if (reset) {
1239
+ activityOffset.value = 0
1240
+ activityRequests.value = []
1241
+ isActivityLoading.value = true
1242
+ } else {
1243
+ isActivityLoadingMore.value = true
1244
+ }
1245
+
1246
+ try {
1247
+ // Calculate date range for selected month/year
1248
+ const startDate = new Date(selectedYear.value, selectedMonth.value - 1, 1)
1249
+ const endDate = new Date(selectedYear.value, selectedMonth.value, 0, 23, 59, 59)
1250
+
1251
+ const filters = {
1252
+ model: selectedModel.value || null,
1253
+ provider: selectedProvider.value || null,
1254
+ sortBy: sortBy.value,
1255
+ sortOrder: 'desc',
1256
+ startDate: Math.floor(startDate.getTime() / 1000),
1257
+ endDate: Math.floor(endDate.getTime() / 1000)
1258
+ }
1259
+
1260
+ const result = await threads.getRequests(filters, activityPageSize, activityOffset.value)
1261
+
1262
+ if (reset) {
1263
+ activityRequests.value = result.requests
1264
+ } else {
1265
+ activityRequests.value.push(...result.requests)
1266
+ }
1267
+
1268
+ activityHasMore.value = result.hasMore
1269
+ activityOffset.value += activityPageSize
1270
+ } catch (error) {
1271
+ console.error('Failed to load requests:', error)
1272
+ } finally {
1273
+ isActivityLoading.value = false
1274
+ isActivityLoadingMore.value = false
1275
+ }
1276
+ }
1277
+
1278
+ const onActivityScroll = async () => {
1279
+ if (!activityScrollContainer.value) return
1280
+
1281
+ const { scrollTop, scrollHeight, clientHeight } = activityScrollContainer.value
1282
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 200
1283
+
1284
+ if (isNearBottom && activityHasMore.value && !isActivityLoadingMore.value && !isActivityLoading.value) {
1285
+ await loadActivityRequests(false)
1286
+ }
1287
+ }
1288
+
1289
+ const clearActivityFilters = async () => {
1290
+ selectedModel.value = ''
1291
+ selectedProvider.value = ''
1292
+ sortBy.value = 'created'
1293
+ await loadActivityRequests(true)
1294
+ }
1295
+
1296
+ const formatActivityDate = (timestamp) => {
1297
+ const date = new Date(timestamp * 1000)
1298
+ return date.toLocaleTimeString(undefined, { hour12: false }) + ' '
1299
+ + date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
1300
+
1301
+ }
1302
+
1303
+ const openThread = (threadId) => {
1304
+ router.push(`${router.currentRoute.value.path.split('/').slice(0, -1).join('/')}/c/${threadId}`)
1305
+ }
1306
+
1307
+ const deleteRequestLog = async (requestId) => {
1308
+ if (confirm('Are you sure you want to delete this request log?')) {
1309
+ try {
1310
+ await threads.deleteRequest(requestId)
1311
+ // Remove from the list
1312
+ activityRequests.value = activityRequests.value.filter(r => r.id !== requestId)
1313
+ // Reload analytics data
1314
+ await loadAnalyticsData()
1315
+ } catch (error) {
1316
+ console.error('Failed to delete request:', error)
1317
+ alert('Failed to delete request')
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ watch(costChartType, () => {
1323
+ renderCostChart()
1324
+ })
1325
+
1326
+ watch(() => route.query, async () => {
1327
+ updateChartData()
1328
+ await nextTick()
1329
+ renderCostChart()
1330
+ renderTokenChart()
1331
+
1332
+ // Also update pie charts if a day is selected
1333
+ if (selectedDay.value) {
1334
+ if (activeTab.value === 'cost') {
1335
+ await updatePieChartData(selectedDay.value)
1336
+ await nextTick()
1337
+ renderModelPieChart()
1338
+ renderProviderPieChart()
1339
+ } else if (activeTab.value === 'tokens') {
1340
+ await updateTokenPieChartData(selectedDay.value)
1341
+ await nextTick()
1342
+ renderTokenModelPieChart()
1343
+ renderTokenProviderPieChart()
1344
+ }
1345
+ }
1346
+ })
1347
+
1348
+ watch(selectedDay, async (newDay) => {
1349
+ if (newDay) {
1350
+ if (activeTab.value === 'cost') {
1351
+ await updatePieChartData(newDay)
1352
+ await nextTick()
1353
+ renderModelPieChart()
1354
+ renderProviderPieChart()
1355
+ } else if (activeTab.value === 'tokens') {
1356
+ await updateTokenPieChartData(newDay)
1357
+ await nextTick()
1358
+ renderTokenModelPieChart()
1359
+ renderTokenProviderPieChart()
1360
+ }
1361
+ }
1362
+ })
1363
+
1364
+ watch(modelPieData, () => {
1365
+ renderModelPieChart()
1366
+ }, { deep: true })
1367
+
1368
+ watch(providerPieData, () => {
1369
+ renderProviderPieChart()
1370
+ }, { deep: true })
1371
+
1372
+ watch(tokenModelPieData, () => {
1373
+ renderTokenModelPieChart()
1374
+ }, { deep: true })
1375
+
1376
+ watch(tokenProviderPieData, () => {
1377
+ renderTokenProviderPieChart()
1378
+ }, { deep: true })
1379
+
1380
+ watch(activeTab, async (newTab) => {
1381
+ // Update URL when tab changes, preserving other query parameters
1382
+ router.push({ query: { ...route.query, tab: newTab } })
1383
+
1384
+ await nextTick()
1385
+ if (newTab === 'cost') {
1386
+ renderCostChart()
1387
+ renderModelPieChart()
1388
+ renderProviderPieChart()
1389
+ } else if (newTab === 'tokens') {
1390
+ renderTokenChart()
1391
+ // Load token pie data if not already loaded
1392
+ if (tokenModelPieData.value.labels.length === 0 && selectedDay.value) {
1393
+ await updateTokenPieChartData(selectedDay.value)
1394
+ await nextTick()
1395
+ }
1396
+ renderTokenModelPieChart()
1397
+ renderTokenProviderPieChart()
1398
+ } else if (newTab === 'activity') {
1399
+ await loadActivityFilterOptions()
1400
+ await loadActivityRequests(true)
1401
+ }
1402
+ })
1403
+
1404
+ // Watch for activity filter changes and reload requests
1405
+ watch([selectedModel, selectedProvider, sortBy, selectedMonth, selectedYear], async () => {
1406
+ if (activeTab.value === 'activity') {
1407
+ await loadActivityRequests(true)
1408
+ }
1409
+ })
1410
+
1411
+ onMounted(async () => {
1412
+ await loadAnalyticsData()
1413
+
1414
+ // Load pie chart data for the selected day (default to today)
1415
+ await nextTick()
1416
+
1417
+ if (activeTab.value === 'cost') {
1418
+ await updatePieChartData(selectedDay.value)
1419
+ await nextTick()
1420
+ renderModelPieChart()
1421
+ renderProviderPieChart()
1422
+ } else if (activeTab.value === 'tokens') {
1423
+ await updateTokenPieChartData(selectedDay.value)
1424
+ await nextTick()
1425
+ renderTokenModelPieChart()
1426
+ renderTokenProviderPieChart()
1427
+ }
1428
+
1429
+ // If Activity tab is active on page load, load activity data
1430
+ if (activeTab.value === 'activity') {
1431
+ await loadActivityFilterOptions()
1432
+ await loadActivityRequests(true)
1433
+ }
1434
+ })
1435
+
1436
+ return {
1437
+ activeTab,
1438
+ costChartType,
1439
+ costChartCanvas,
1440
+ tokenChartCanvas,
1441
+ modelPieCanvas,
1442
+ providerPieCanvas,
1443
+ tokenModelPieCanvas,
1444
+ tokenProviderPieCanvas,
1445
+ chartData,
1446
+ tokenChartData,
1447
+ modelPieData,
1448
+ providerPieData,
1449
+ tokenModelPieData,
1450
+ tokenProviderPieData,
1451
+ selectedDay,
1452
+ totalCost,
1453
+ totalRequests,
1454
+ totalInputTokens,
1455
+ totalOutputTokens,
1456
+ formatCost,
1457
+ humanifyNumber,
1458
+ humanifyMs,
1459
+ // Month/Year selection
1460
+ selectedMonth,
1461
+ selectedYear,
1462
+ allDailyData,
1463
+ // Activity tab
1464
+ activityRequests,
1465
+ isActivityLoading,
1466
+ isActivityLoadingMore,
1467
+ activityHasMore,
1468
+ selectedModel,
1469
+ selectedProvider,
1470
+ sortBy,
1471
+ filterOptions,
1472
+ hasActiveFilters,
1473
+ activityScrollContainer,
1474
+ onActivityScroll,
1475
+ clearActivityFilters,
1476
+ formatActivityDate,
1477
+ openThread,
1478
+ deleteRequestLog,
1479
+ loadActivityFilterOptions,
1480
+ loadActivityRequests,
1481
+ }
1482
+ }
1483
+ }