llms-py 2.0.15__py3-none-any.whl → 2.0.17__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,1517 @@
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="threadExists(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
+ const existingThreadIds = ref(new Set())
529
+
530
+ const selectedModel = ref('')
531
+ const selectedProvider = ref('')
532
+ const sortBy = ref('created')
533
+ const filterOptions = ref({ models: [], providers: [] })
534
+ const activityScrollContainer = ref(null)
535
+
536
+ const hasActiveFilters = computed(() => selectedModel.value || selectedProvider.value)
537
+
538
+ async function loadAnalyticsData() {
539
+ try {
540
+ const db = await initDB()
541
+ const tx = db.transaction(['requests'], 'readonly')
542
+ const store = tx.objectStore('requests')
543
+ const allRequests = await store.getAll()
544
+
545
+ // Group requests by date
546
+ const dailyData = {}
547
+ let totalCostSum = 0
548
+ let totalInputSum = 0
549
+ let totalOutputSum = 0
550
+ const yearsSet = new Set()
551
+
552
+ allRequests.forEach(req => {
553
+ const date = new Date(req.created * 1000)
554
+ const dateKey = date.toISOString().split('T')[0] // YYYY-MM-DD
555
+ yearsSet.add(date.getFullYear())
556
+
557
+ if (!dailyData[dateKey]) {
558
+ dailyData[dateKey] = {
559
+ cost: 0,
560
+ requests: 0,
561
+ inputTokens: 0,
562
+ outputTokens: 0
563
+ }
564
+ }
565
+
566
+ dailyData[dateKey].cost += req.cost || 0
567
+ dailyData[dateKey].requests += 1
568
+ dailyData[dateKey].inputTokens += req.inputTokens || 0
569
+ dailyData[dateKey].outputTokens += req.outputTokens || 0
570
+
571
+ totalCostSum += req.cost || 0
572
+ totalInputSum += req.inputTokens || 0
573
+ totalOutputSum += req.outputTokens || 0
574
+ })
575
+
576
+ // Store all daily data for filtering
577
+ allDailyData.value = dailyData
578
+
579
+ // Update chart data based on selected month/year
580
+ updateChartData()
581
+
582
+ await nextTick()
583
+ renderCostChart()
584
+ renderTokenChart()
585
+ } catch (error) {
586
+ console.error('Error loading analytics data:', error)
587
+ }
588
+ }
589
+
590
+ function updateChartData() {
591
+ // Filter data for selected month and year
592
+ const filteredDates = Object.keys(allDailyData.value)
593
+ .filter(dateKey => {
594
+ const date = new Date(dateKey + 'T00:00:00Z')
595
+ return date.getFullYear() === selectedYear.value && (date.getMonth() + 1) === selectedMonth.value
596
+ })
597
+ .sort()
598
+
599
+ const costs = filteredDates.map(date => allDailyData.value[date].cost)
600
+ const inputTokens = filteredDates.map(date => allDailyData.value[date].inputTokens)
601
+ const outputTokens = filteredDates.map(date => allDailyData.value[date].outputTokens)
602
+
603
+ // Extract day numbers from dates for labels
604
+ const dayLabels = filteredDates.map(dateKey => {
605
+ const date = new Date(dateKey + 'T00:00:00Z')
606
+ return date.getDate().toString()
607
+ })
608
+
609
+ // Cost chart data
610
+ chartData.value = {
611
+ labels: dayLabels,
612
+ dateKeys: filteredDates, // Store full date keys for click handling
613
+ datasets: [{
614
+ label: 'Daily Cost ($)',
615
+ data: costs,
616
+ backgroundColor: colors[0].background,
617
+ borderColor: colors[0].border,
618
+ borderWidth: 2,
619
+ tension: 0.1
620
+ }]
621
+ }
622
+
623
+ // Token chart data (stacked)
624
+ tokenChartData.value = {
625
+ labels: dayLabels,
626
+ dateKeys: filteredDates, // Store full date keys for click handling
627
+ datasets: [
628
+ {
629
+ label: 'Input Tokens',
630
+ data: inputTokens,
631
+ backgroundColor: 'rgba(168, 85, 247, 0.2)',
632
+ borderColor: 'rgb(126, 34, 206)',
633
+ borderWidth: 1
634
+ },
635
+ {
636
+ label: 'Output Tokens',
637
+ data: outputTokens,
638
+ backgroundColor: 'rgba(251, 146, 60, 0.2)',
639
+ borderColor: 'rgb(234, 88, 12)',
640
+ borderWidth: 1
641
+ }
642
+ ]
643
+ }
644
+ }
645
+
646
+ async function updatePieChartData(dateKey) {
647
+ if (!dateKey) {
648
+ modelPieData.value = { labels: [], datasets: [] }
649
+ providerPieData.value = { labels: [], datasets: [] }
650
+ return
651
+ }
652
+
653
+ try {
654
+ const db = await initDB()
655
+ const tx = db.transaction(['requests'], 'readonly')
656
+ const store = tx.objectStore('requests')
657
+ const allRequests = await store.getAll()
658
+
659
+ // Filter requests for the selected day
660
+ const dayStart = Math.floor(new Date(dateKey + 'T00:00:00Z').getTime() / 1000)
661
+ const dayEnd = Math.floor(new Date(dateKey + 'T23:59:59Z').getTime() / 1000)
662
+
663
+ const dayRequests = allRequests.filter(req => req.created >= dayStart && req.created <= dayEnd)
664
+
665
+ // Aggregate by model
666
+ const modelData = {}
667
+ const providerData = {}
668
+
669
+ dayRequests.forEach(req => {
670
+ // Model aggregation
671
+ if (!modelData[req.model]) {
672
+ modelData[req.model] = { cost: 0, count: 0 }
673
+ }
674
+ modelData[req.model].cost += req.cost || 0
675
+ modelData[req.model].count += 1
676
+
677
+ // Provider aggregation
678
+ if (!providerData[req.provider]) {
679
+ providerData[req.provider] = { cost: 0, count: 0 }
680
+ }
681
+ providerData[req.provider].cost += req.cost || 0
682
+ providerData[req.provider].count += 1
683
+ })
684
+
685
+ // Prepare model pie chart data
686
+ const modelLabels = Object.keys(modelData).sort()
687
+ const modelCosts = modelLabels.map(model => modelData[model].cost)
688
+
689
+ modelPieData.value = {
690
+ labels: modelLabels,
691
+ datasets: [{
692
+ label: 'Cost by Model',
693
+ data: modelCosts,
694
+ backgroundColor: colors.map(c => c.background),
695
+ borderColor: colors.map(c => c.border),
696
+ borderWidth: 2
697
+ }]
698
+ }
699
+
700
+ // Prepare provider pie chart data
701
+ const providerLabels = Object.keys(providerData).sort()
702
+ const providerCosts = providerLabels.map(provider => providerData[provider].cost)
703
+
704
+ providerPieData.value = {
705
+ labels: providerLabels,
706
+ datasets: [{
707
+ label: 'Cost by Provider',
708
+ data: providerCosts,
709
+ backgroundColor: colors.map(c => c.background),
710
+ borderColor: colors.map(c => c.border),
711
+ borderWidth: 2
712
+ }]
713
+ }
714
+ } catch (error) {
715
+ console.error('Error updating pie chart data:', error)
716
+ }
717
+ }
718
+
719
+ async function updateTokenPieChartData(dateKey) {
720
+ if (!dateKey) {
721
+ tokenModelPieData.value = { labels: [], datasets: [] }
722
+ tokenProviderPieData.value = { labels: [], datasets: [] }
723
+ return
724
+ }
725
+
726
+ try {
727
+ const db = await initDB()
728
+ const tx = db.transaction(['requests'], 'readonly')
729
+ const store = tx.objectStore('requests')
730
+ const allRequests = await store.getAll()
731
+
732
+ // Filter requests for the selected day
733
+ const dayStart = Math.floor(new Date(dateKey + 'T00:00:00Z').getTime() / 1000)
734
+ const dayEnd = Math.floor(new Date(dateKey + 'T23:59:59Z').getTime() / 1000)
735
+
736
+ const dayRequests = allRequests.filter(req => req.created >= dayStart && req.created <= dayEnd)
737
+
738
+ // Aggregate by model and provider (using tokens)
739
+ const modelData = {}
740
+ const providerData = {}
741
+
742
+ dayRequests.forEach(req => {
743
+ const totalTokens = (req.inputTokens || 0) + (req.outputTokens || 0)
744
+
745
+ // Model aggregation
746
+ if (!modelData[req.model]) {
747
+ modelData[req.model] = { tokens: 0, count: 0 }
748
+ }
749
+ modelData[req.model].tokens += totalTokens
750
+ modelData[req.model].count += 1
751
+
752
+ // Provider aggregation
753
+ if (!providerData[req.provider]) {
754
+ providerData[req.provider] = { tokens: 0, count: 0 }
755
+ }
756
+ providerData[req.provider].tokens += totalTokens
757
+ providerData[req.provider].count += 1
758
+ })
759
+
760
+ // Prepare model pie chart data
761
+ const modelLabels = Object.keys(modelData).sort()
762
+ const modelTokens = modelLabels.map(model => modelData[model].tokens)
763
+
764
+ tokenModelPieData.value = {
765
+ labels: modelLabels,
766
+ datasets: [{
767
+ label: 'Tokens by Model',
768
+ data: modelTokens,
769
+ backgroundColor: colors.map(c => c.background),
770
+ borderColor: colors.map(c => c.border),
771
+ borderWidth: 2
772
+ }]
773
+ }
774
+
775
+ // Prepare provider pie chart data
776
+ const providerLabels = Object.keys(providerData).sort()
777
+ const providerTokens = providerLabels.map(provider => providerData[provider].tokens)
778
+
779
+ tokenProviderPieData.value = {
780
+ labels: providerLabels,
781
+ datasets: [{
782
+ label: 'Tokens by Provider',
783
+ data: providerTokens,
784
+ backgroundColor: colors.map(c => c.background),
785
+ borderColor: colors.map(c => c.border),
786
+ borderWidth: 2
787
+ }]
788
+ }
789
+ } catch (error) {
790
+ console.error('Error updating token pie chart data:', error)
791
+ }
792
+ }
793
+
794
+ function renderCostChart() {
795
+ if (!costChartCanvas.value || chartData.value.labels.length === 0) return
796
+
797
+ // Destroy existing chart
798
+ if (costChartInstance) {
799
+ costChartInstance.destroy()
800
+ }
801
+
802
+ const ctx = costChartCanvas.value.getContext('2d')
803
+ const chartTypeValue = costChartType.value
804
+
805
+ // Find the index of the selected day
806
+ const selectedDayIndex = chartData.value.dateKeys.indexOf(selectedDay.value)
807
+
808
+ // Create color arrays with highlight for selected day
809
+ const backgroundColor = chartData.value.dateKeys.map((_, index) => {
810
+ if (index === selectedDayIndex) {
811
+ return 'rgba(34, 197, 94, 0.8)' // Green for selected day
812
+ }
813
+ return colors[0].background
814
+ })
815
+
816
+ const borderColor = chartData.value.dateKeys.map((_, index) => {
817
+ if (index === selectedDayIndex) {
818
+ return 'rgb(22, 163, 74)' // Darker green for selected day
819
+ }
820
+ return colors[0].border
821
+ })
822
+
823
+ // Update dataset with dynamic colors
824
+ const chartDataWithColors = {
825
+ ...chartData.value,
826
+ datasets: [{
827
+ ...chartData.value.datasets[0],
828
+ backgroundColor: backgroundColor,
829
+ borderColor: borderColor
830
+ }]
831
+ }
832
+
833
+ costChartInstance = new Chart(ctx, {
834
+ type: chartTypeValue,
835
+ data: chartDataWithColors,
836
+ options: {
837
+ responsive: true,
838
+ maintainAspectRatio: false,
839
+ onClick: async (_, elements) => {
840
+ if (chartTypeValue === 'bar' && elements.length > 0) {
841
+ const index = elements[0].index
842
+ const dateKey = chartData.value.dateKeys[index]
843
+ // Update URL with selected day
844
+ router.push({
845
+ query: {
846
+ ...route.query,
847
+ day: dateKey
848
+ }
849
+ })
850
+ }
851
+ },
852
+ plugins: {
853
+ legend: {
854
+ display: true,
855
+ position: 'top'
856
+ },
857
+ tooltip: {
858
+ callbacks: {
859
+ title: function(context) {
860
+ const index = context[0].dataIndex
861
+ const dateKey = chartData.value.dateKeys[index]
862
+ const date = new Date(dateKey + 'T00:00:00Z')
863
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
864
+ },
865
+ label: function(context) {
866
+ return `Cost: ${formatCost(context.parsed.y)}`
867
+ }
868
+ }
869
+ }
870
+ },
871
+ scales: {
872
+ y: {
873
+ beginAtZero: true,
874
+ ticks: {
875
+ callback: function(value) {
876
+ return '$' + value.toFixed(4)
877
+ }
878
+ }
879
+ }
880
+ }
881
+ }
882
+ })
883
+ }
884
+
885
+ function renderTokenChart() {
886
+ if (!tokenChartCanvas.value || tokenChartData.value.labels.length === 0) return
887
+
888
+ // Destroy existing chart
889
+ if (tokenChartInstance) {
890
+ tokenChartInstance.destroy()
891
+ }
892
+
893
+ const ctx = tokenChartCanvas.value.getContext('2d')
894
+
895
+ // Find the index of the selected day
896
+ const selectedDayIndex = tokenChartData.value.dateKeys.indexOf(selectedDay.value)
897
+
898
+ // Create color arrays with highlight for selected day
899
+ const inputBackgroundColor = tokenChartData.value.dateKeys.map((_, index) => {
900
+ if (index === selectedDayIndex) {
901
+ return 'rgba(34, 197, 94, 0.2)' // Green for selected day (light/transparent)
902
+ }
903
+ return 'rgba(168, 85, 247, 0.2)' // Purple for input tokens (light/transparent)
904
+ })
905
+
906
+ const inputBorderColor = tokenChartData.value.dateKeys.map((_, index) => {
907
+ if (index === selectedDayIndex) {
908
+ return 'rgb(22, 163, 74)' // Darker green for selected day
909
+ }
910
+ return 'rgb(126, 34, 206)' // Darker purple for input tokens
911
+ })
912
+
913
+ const outputBackgroundColor = tokenChartData.value.dateKeys.map((_, index) => {
914
+ if (index === selectedDayIndex) {
915
+ return 'rgba(34, 197, 94, 0.2)' // Green for selected day (light/transparent)
916
+ }
917
+ return 'rgba(251, 146, 60, 0.2)' // Orange for output tokens (light/transparent)
918
+ })
919
+
920
+ const outputBorderColor = tokenChartData.value.dateKeys.map((_, index) => {
921
+ if (index === selectedDayIndex) {
922
+ return 'rgb(22, 163, 74)' // Darker green for selected day
923
+ }
924
+ return 'rgb(234, 88, 12)' // Darker orange for output tokens
925
+ })
926
+
927
+ // Update datasets with dynamic colors
928
+ const chartDataWithColors = {
929
+ ...tokenChartData.value,
930
+ datasets: [
931
+ {
932
+ ...tokenChartData.value.datasets[0],
933
+ backgroundColor: inputBackgroundColor,
934
+ borderColor: inputBorderColor
935
+ },
936
+ {
937
+ ...tokenChartData.value.datasets[1],
938
+ backgroundColor: outputBackgroundColor,
939
+ borderColor: outputBorderColor
940
+ }
941
+ ]
942
+ }
943
+
944
+ tokenChartInstance = new Chart(ctx, {
945
+ type: 'bar',
946
+ data: chartDataWithColors,
947
+ options: {
948
+ responsive: true,
949
+ maintainAspectRatio: false,
950
+ indexAxis: 'x',
951
+ onClick: async (_, elements) => {
952
+ if (elements.length > 0) {
953
+ const index = elements[0].index
954
+ const dateKey = tokenChartData.value.dateKeys[index]
955
+ // Update URL with selected day
956
+ router.push({
957
+ query: {
958
+ ...route.query,
959
+ day: dateKey
960
+ }
961
+ })
962
+ }
963
+ },
964
+ scales: {
965
+ x: {
966
+ stacked: true
967
+ },
968
+ y: {
969
+ stacked: true,
970
+ beginAtZero: true,
971
+ ticks: {
972
+ callback: function(value) {
973
+ return humanifyNumber(value)
974
+ }
975
+ }
976
+ }
977
+ },
978
+ plugins: {
979
+ legend: {
980
+ display: true,
981
+ position: 'top'
982
+ },
983
+ tooltip: {
984
+ callbacks: {
985
+ title: function(context) {
986
+ const index = context[0].dataIndex
987
+ const dateKey = tokenChartData.value.dateKeys[index]
988
+ const date = new Date(dateKey + 'T00:00:00Z')
989
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
990
+ },
991
+ label: function(context) {
992
+ return `${context.dataset.label}: ${humanifyNumber(context.parsed.y)}`
993
+ }
994
+ }
995
+ }
996
+ }
997
+ }
998
+ })
999
+ }
1000
+
1001
+ function renderModelPieChart() {
1002
+ if (!modelPieCanvas.value || modelPieData.value.labels.length === 0) return
1003
+
1004
+ // Destroy existing chart
1005
+ if (modelPieChartInstance) {
1006
+ modelPieChartInstance.destroy()
1007
+ }
1008
+
1009
+ const ctx = modelPieCanvas.value.getContext('2d')
1010
+
1011
+ // Custom plugin to draw percentage labels on pie slices
1012
+ const percentagePlugin = {
1013
+ id: 'percentageLabel',
1014
+ afterDatasetsDraw(chart) {
1015
+ const { ctx: chartCtx, data } = chart
1016
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1017
+ const { x, y } = datapoint.tooltipPosition()
1018
+ const value = data.datasets[0].data[index]
1019
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1020
+ const percentage = ((value * 100) / sum).toFixed(1)
1021
+
1022
+ // Only display label if percentage > 1%
1023
+ if (parseFloat(percentage) > 1) {
1024
+ chartCtx.fillStyle = '#000'
1025
+ chartCtx.font = 'bold 12px Arial'
1026
+ chartCtx.textAlign = 'center'
1027
+ chartCtx.textBaseline = 'middle'
1028
+ chartCtx.fillText(percentage + '%', x, y)
1029
+ }
1030
+ })
1031
+ }
1032
+ }
1033
+
1034
+ modelPieChartInstance = new Chart(ctx, {
1035
+ type: 'pie',
1036
+ data: modelPieData.value,
1037
+ options: {
1038
+ responsive: true,
1039
+ maintainAspectRatio: false,
1040
+ plugins: {
1041
+ legend: {
1042
+ display: true,
1043
+ position: 'right'
1044
+ },
1045
+ tooltip: {
1046
+ callbacks: {
1047
+ label: function(context) {
1048
+ return `${context.label}: ${formatCost(context.parsed)}`
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ },
1054
+ plugins: [percentagePlugin]
1055
+ })
1056
+ }
1057
+
1058
+ function renderProviderPieChart() {
1059
+ if (!providerPieCanvas.value || providerPieData.value.labels.length === 0) return
1060
+
1061
+ // Destroy existing chart
1062
+ if (providerPieChartInstance) {
1063
+ providerPieChartInstance.destroy()
1064
+ }
1065
+
1066
+ const ctx = providerPieCanvas.value.getContext('2d')
1067
+
1068
+ // Custom plugin to draw percentage labels on pie slices
1069
+ const percentagePlugin = {
1070
+ id: 'percentageLabel',
1071
+ afterDatasetsDraw(chart) {
1072
+ const { ctx: chartCtx, data } = chart
1073
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1074
+ const { x, y } = datapoint.tooltipPosition()
1075
+ const value = data.datasets[0].data[index]
1076
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1077
+ const percentage = ((value * 100) / sum).toFixed(1)
1078
+
1079
+ // Only display label if percentage > 1%
1080
+ if (parseFloat(percentage) > 1) {
1081
+ chartCtx.fillStyle = '#000'
1082
+ chartCtx.font = 'bold 12px Arial'
1083
+ chartCtx.textAlign = 'center'
1084
+ chartCtx.textBaseline = 'middle'
1085
+ chartCtx.fillText(percentage + '%', x, y)
1086
+ }
1087
+ })
1088
+ }
1089
+ }
1090
+
1091
+ providerPieChartInstance = new Chart(ctx, {
1092
+ type: 'pie',
1093
+ data: providerPieData.value,
1094
+ options: {
1095
+ responsive: true,
1096
+ maintainAspectRatio: false,
1097
+ plugins: {
1098
+ legend: {
1099
+ display: true,
1100
+ position: 'right'
1101
+ },
1102
+ tooltip: {
1103
+ callbacks: {
1104
+ label: function(context) {
1105
+ return `${context.label}: ${formatCost(context.parsed)}`
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+ },
1111
+ plugins: [percentagePlugin]
1112
+ })
1113
+ }
1114
+
1115
+ function renderTokenModelPieChart() {
1116
+ if (!tokenModelPieCanvas.value || tokenModelPieData.value.labels.length === 0) return
1117
+
1118
+ // Destroy existing chart
1119
+ if (tokenModelPieChartInstance) {
1120
+ tokenModelPieChartInstance.destroy()
1121
+ }
1122
+
1123
+ const ctx = tokenModelPieCanvas.value.getContext('2d')
1124
+
1125
+ // Custom plugin to draw percentage labels on pie slices
1126
+ const percentagePlugin = {
1127
+ id: 'percentageLabel',
1128
+ afterDatasetsDraw(chart) {
1129
+ const { ctx: chartCtx, data } = chart
1130
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1131
+ const { x, y } = datapoint.tooltipPosition()
1132
+ const value = data.datasets[0].data[index]
1133
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1134
+ const percentage = ((value * 100) / sum).toFixed(1)
1135
+
1136
+ // Only display label if percentage > 1%
1137
+ if (parseFloat(percentage) > 1) {
1138
+ chartCtx.fillStyle = '#000'
1139
+ chartCtx.font = 'bold 12px Arial'
1140
+ chartCtx.textAlign = 'center'
1141
+ chartCtx.textBaseline = 'middle'
1142
+ chartCtx.fillText(percentage + '%', x, y)
1143
+ }
1144
+ })
1145
+ }
1146
+ }
1147
+
1148
+ tokenModelPieChartInstance = new Chart(ctx, {
1149
+ type: 'pie',
1150
+ data: tokenModelPieData.value,
1151
+ options: {
1152
+ responsive: true,
1153
+ maintainAspectRatio: false,
1154
+ plugins: {
1155
+ legend: {
1156
+ display: true,
1157
+ position: 'right'
1158
+ },
1159
+ tooltip: {
1160
+ callbacks: {
1161
+ label: function(context) {
1162
+ return `${context.label}: ${humanifyNumber(context.parsed)}`
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ },
1168
+ plugins: [percentagePlugin]
1169
+ })
1170
+ }
1171
+
1172
+ function renderTokenProviderPieChart() {
1173
+ if (!tokenProviderPieCanvas.value || tokenProviderPieData.value.labels.length === 0) return
1174
+
1175
+ // Destroy existing chart
1176
+ if (tokenProviderPieChartInstance) {
1177
+ tokenProviderPieChartInstance.destroy()
1178
+ }
1179
+
1180
+ const ctx = tokenProviderPieCanvas.value.getContext('2d')
1181
+
1182
+ // Custom plugin to draw percentage labels on pie slices
1183
+ const percentagePlugin = {
1184
+ id: 'percentageLabel',
1185
+ afterDatasetsDraw(chart) {
1186
+ const { ctx: chartCtx, data } = chart
1187
+ chart.getDatasetMeta(0).data.forEach((datapoint, index) => {
1188
+ const { x, y } = datapoint.tooltipPosition()
1189
+ const value = data.datasets[0].data[index]
1190
+ const sum = data.datasets[0].data.reduce((a, b) => a + b, 0)
1191
+ const percentage = ((value * 100) / sum).toFixed(1)
1192
+
1193
+ // Only display label if percentage > 1%
1194
+ if (parseFloat(percentage) > 1) {
1195
+ chartCtx.fillStyle = '#000'
1196
+ chartCtx.font = 'bold 12px Arial'
1197
+ chartCtx.textAlign = 'center'
1198
+ chartCtx.textBaseline = 'middle'
1199
+ chartCtx.fillText(percentage + '%', x, y)
1200
+ }
1201
+ })
1202
+ }
1203
+ }
1204
+
1205
+ tokenProviderPieChartInstance = new Chart(ctx, {
1206
+ type: 'pie',
1207
+ data: tokenProviderPieData.value,
1208
+ options: {
1209
+ responsive: true,
1210
+ maintainAspectRatio: false,
1211
+ plugins: {
1212
+ legend: {
1213
+ display: true,
1214
+ position: 'right'
1215
+ },
1216
+ tooltip: {
1217
+ callbacks: {
1218
+ label: function(context) {
1219
+ return `${context.label}: ${humanifyNumber(context.parsed)}`
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1224
+ },
1225
+ plugins: [percentagePlugin]
1226
+ })
1227
+ }
1228
+
1229
+ // Activity tab functions
1230
+ const loadActivityFilterOptions = async () => {
1231
+ try {
1232
+ filterOptions.value = await threads.getFilterOptions()
1233
+ } catch (error) {
1234
+ console.error('Failed to load filter options:', error)
1235
+ }
1236
+ }
1237
+
1238
+ const loadExistingThreadIds = async () => {
1239
+ try {
1240
+ // Calculate date range for selected month/year
1241
+ const startDate = new Date(selectedYear.value, selectedMonth.value - 1, 1)
1242
+ const endDate = new Date(selectedYear.value, selectedMonth.value, 0, 23, 59, 59)
1243
+
1244
+ // Convert to timestamp strings (threadId format)
1245
+ const startThreadId = startDate.getTime().toString()
1246
+ const endThreadId = endDate.getTime().toString()
1247
+
1248
+ const db = await initDB()
1249
+ const tx = db.transaction(['threads'], 'readonly')
1250
+ const store = tx.objectStore('threads')
1251
+
1252
+ // Use IDBKeyRange to only load threads within the month's timestamp range
1253
+ const range = IDBKeyRange.bound(startThreadId, endThreadId)
1254
+ const monthThreads = await store.getAll(range)
1255
+
1256
+ // Create a Set of existing thread IDs for the month
1257
+ existingThreadIds.value = new Set(monthThreads.map(thread => thread.id))
1258
+ } catch (error) {
1259
+ console.error('Failed to load existing thread IDs:', error)
1260
+ existingThreadIds.value = new Set()
1261
+ }
1262
+ }
1263
+
1264
+ const threadExists = (threadId) => {
1265
+ return threadId ? existingThreadIds.value.has(threadId) : false
1266
+ }
1267
+
1268
+ const loadActivityRequests = async (reset = false) => {
1269
+ if (reset) {
1270
+ activityOffset.value = 0
1271
+ activityRequests.value = []
1272
+ isActivityLoading.value = true
1273
+ // Load all existing thread IDs once when resetting
1274
+ await loadExistingThreadIds()
1275
+ } else {
1276
+ isActivityLoadingMore.value = true
1277
+ }
1278
+
1279
+ try {
1280
+ // Calculate date range for selected month/year
1281
+ const startDate = new Date(selectedYear.value, selectedMonth.value - 1, 1)
1282
+ const endDate = new Date(selectedYear.value, selectedMonth.value, 0, 23, 59, 59)
1283
+
1284
+ const filters = {
1285
+ model: selectedModel.value || null,
1286
+ provider: selectedProvider.value || null,
1287
+ sortBy: sortBy.value,
1288
+ sortOrder: 'desc',
1289
+ startDate: Math.floor(startDate.getTime() / 1000),
1290
+ endDate: Math.floor(endDate.getTime() / 1000)
1291
+ }
1292
+
1293
+ const result = await threads.getRequests(filters, activityPageSize, activityOffset.value)
1294
+
1295
+ if (reset) {
1296
+ activityRequests.value = result.requests
1297
+ } else {
1298
+ activityRequests.value.push(...result.requests)
1299
+ }
1300
+
1301
+ activityHasMore.value = result.hasMore
1302
+ activityOffset.value += activityPageSize
1303
+ } catch (error) {
1304
+ console.error('Failed to load requests:', error)
1305
+ } finally {
1306
+ isActivityLoading.value = false
1307
+ isActivityLoadingMore.value = false
1308
+ }
1309
+ }
1310
+
1311
+ const onActivityScroll = async () => {
1312
+ if (!activityScrollContainer.value) return
1313
+
1314
+ const { scrollTop, scrollHeight, clientHeight } = activityScrollContainer.value
1315
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 200
1316
+
1317
+ if (isNearBottom && activityHasMore.value && !isActivityLoadingMore.value && !isActivityLoading.value) {
1318
+ await loadActivityRequests(false)
1319
+ }
1320
+ }
1321
+
1322
+ const clearActivityFilters = async () => {
1323
+ selectedModel.value = ''
1324
+ selectedProvider.value = ''
1325
+ sortBy.value = 'created'
1326
+ await loadActivityRequests(true)
1327
+ }
1328
+
1329
+ const formatActivityDate = (timestamp) => {
1330
+ const date = new Date(timestamp * 1000)
1331
+ return date.toLocaleTimeString(undefined, { hour12: false }) + ' '
1332
+ + date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
1333
+
1334
+ }
1335
+
1336
+ const openThread = (threadId) => {
1337
+ router.push(`${router.currentRoute.value.path.split('/').slice(0, -1).join('/')}/c/${threadId}`)
1338
+ }
1339
+
1340
+ const deleteRequestLog = async (requestId) => {
1341
+ if (confirm('Are you sure you want to delete this request log?')) {
1342
+ try {
1343
+ await threads.deleteRequest(requestId)
1344
+ // Remove from the list
1345
+ activityRequests.value = activityRequests.value.filter(r => r.id !== requestId)
1346
+ // Reload analytics data
1347
+ await loadAnalyticsData()
1348
+ } catch (error) {
1349
+ console.error('Failed to delete request:', error)
1350
+ alert('Failed to delete request')
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ watch(costChartType, () => {
1356
+ renderCostChart()
1357
+ })
1358
+
1359
+ watch(() => route.query, async () => {
1360
+ updateChartData()
1361
+ await nextTick()
1362
+ renderCostChart()
1363
+ renderTokenChart()
1364
+
1365
+ // Also update pie charts if a day is selected
1366
+ if (selectedDay.value) {
1367
+ if (activeTab.value === 'cost') {
1368
+ await updatePieChartData(selectedDay.value)
1369
+ await nextTick()
1370
+ renderModelPieChart()
1371
+ renderProviderPieChart()
1372
+ } else if (activeTab.value === 'tokens') {
1373
+ await updateTokenPieChartData(selectedDay.value)
1374
+ await nextTick()
1375
+ renderTokenModelPieChart()
1376
+ renderTokenProviderPieChart()
1377
+ }
1378
+ }
1379
+ })
1380
+
1381
+ watch(selectedDay, async (newDay) => {
1382
+ if (newDay) {
1383
+ if (activeTab.value === 'cost') {
1384
+ await updatePieChartData(newDay)
1385
+ await nextTick()
1386
+ renderModelPieChart()
1387
+ renderProviderPieChart()
1388
+ } else if (activeTab.value === 'tokens') {
1389
+ await updateTokenPieChartData(newDay)
1390
+ await nextTick()
1391
+ renderTokenModelPieChart()
1392
+ renderTokenProviderPieChart()
1393
+ }
1394
+ }
1395
+ })
1396
+
1397
+ watch(modelPieData, () => {
1398
+ renderModelPieChart()
1399
+ }, { deep: true })
1400
+
1401
+ watch(providerPieData, () => {
1402
+ renderProviderPieChart()
1403
+ }, { deep: true })
1404
+
1405
+ watch(tokenModelPieData, () => {
1406
+ renderTokenModelPieChart()
1407
+ }, { deep: true })
1408
+
1409
+ watch(tokenProviderPieData, () => {
1410
+ renderTokenProviderPieChart()
1411
+ }, { deep: true })
1412
+
1413
+ watch(activeTab, async (newTab) => {
1414
+ // Update URL when tab changes, preserving other query parameters
1415
+ router.push({ query: { ...route.query, tab: newTab } })
1416
+
1417
+ await nextTick()
1418
+ if (newTab === 'cost') {
1419
+ renderCostChart()
1420
+ renderModelPieChart()
1421
+ renderProviderPieChart()
1422
+ } else if (newTab === 'tokens') {
1423
+ renderTokenChart()
1424
+ // Load token pie data if not already loaded
1425
+ if (tokenModelPieData.value.labels.length === 0 && selectedDay.value) {
1426
+ await updateTokenPieChartData(selectedDay.value)
1427
+ await nextTick()
1428
+ }
1429
+ renderTokenModelPieChart()
1430
+ renderTokenProviderPieChart()
1431
+ } else if (newTab === 'activity') {
1432
+ await loadActivityFilterOptions()
1433
+ await loadActivityRequests(true)
1434
+ }
1435
+ })
1436
+
1437
+ // Watch for activity filter changes and reload requests
1438
+ watch([selectedModel, selectedProvider, sortBy, selectedMonth, selectedYear], async () => {
1439
+ if (activeTab.value === 'activity') {
1440
+ await loadActivityRequests(true)
1441
+ }
1442
+ })
1443
+
1444
+ onMounted(async () => {
1445
+ await loadAnalyticsData()
1446
+
1447
+ // Load pie chart data for the selected day (default to today)
1448
+ await nextTick()
1449
+
1450
+ if (activeTab.value === 'cost') {
1451
+ await updatePieChartData(selectedDay.value)
1452
+ await nextTick()
1453
+ renderModelPieChart()
1454
+ renderProviderPieChart()
1455
+ } else if (activeTab.value === 'tokens') {
1456
+ await updateTokenPieChartData(selectedDay.value)
1457
+ await nextTick()
1458
+ renderTokenModelPieChart()
1459
+ renderTokenProviderPieChart()
1460
+ }
1461
+
1462
+ // If Activity tab is active on page load, load activity data
1463
+ if (activeTab.value === 'activity') {
1464
+ await loadActivityFilterOptions()
1465
+ await loadActivityRequests(true)
1466
+ }
1467
+ })
1468
+
1469
+ return {
1470
+ activeTab,
1471
+ costChartType,
1472
+ costChartCanvas,
1473
+ tokenChartCanvas,
1474
+ modelPieCanvas,
1475
+ providerPieCanvas,
1476
+ tokenModelPieCanvas,
1477
+ tokenProviderPieCanvas,
1478
+ chartData,
1479
+ tokenChartData,
1480
+ modelPieData,
1481
+ providerPieData,
1482
+ tokenModelPieData,
1483
+ tokenProviderPieData,
1484
+ selectedDay,
1485
+ totalCost,
1486
+ totalRequests,
1487
+ totalInputTokens,
1488
+ totalOutputTokens,
1489
+ formatCost,
1490
+ humanifyNumber,
1491
+ humanifyMs,
1492
+ // Month/Year selection
1493
+ selectedMonth,
1494
+ selectedYear,
1495
+ allDailyData,
1496
+ // Activity tab
1497
+ activityRequests,
1498
+ isActivityLoading,
1499
+ isActivityLoadingMore,
1500
+ activityHasMore,
1501
+ selectedModel,
1502
+ selectedProvider,
1503
+ sortBy,
1504
+ filterOptions,
1505
+ hasActiveFilters,
1506
+ activityScrollContainer,
1507
+ onActivityScroll,
1508
+ clearActivityFilters,
1509
+ formatActivityDate,
1510
+ threadExists,
1511
+ openThread,
1512
+ deleteRequestLog,
1513
+ loadActivityFilterOptions,
1514
+ loadActivityRequests,
1515
+ }
1516
+ }
1517
+ }