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/__pycache__/__init__.cpython-313.pyc +0 -0
- llms/__pycache__/__init__.cpython-314.pyc +0 -0
- llms/__pycache__/__main__.cpython-314.pyc +0 -0
- llms/__pycache__/main.cpython-312.pyc +0 -0
- llms/__pycache__/main.cpython-313.pyc +0 -0
- llms/__pycache__/main.cpython-314.pyc +0 -0
- llms/index.html +5 -1
- llms/llms.json +722 -67
- llms/main.py +203 -11
- llms/ui/Analytics.mjs +1517 -0
- llms/ui/Brand.mjs +19 -8
- llms/ui/ChatPrompt.mjs +58 -36
- llms/ui/Main.mjs +205 -5
- llms/ui/ModelSelector.mjs +35 -4
- llms/ui/ProviderIcon.mjs +29 -0
- llms/ui/Sidebar.mjs +20 -4
- llms/ui/ai.mjs +1 -1
- llms/ui/app.css +211 -64
- llms/ui/lib/chart.js +14 -0
- llms/ui/lib/charts.mjs +20 -0
- llms/ui/lib/color.js +14 -0
- llms/ui/tailwind.input.css +1 -1
- llms/ui/threadStore.mjs +270 -19
- llms/ui/utils.mjs +36 -0
- {llms_py-2.0.15.dist-info → llms_py-2.0.17.dist-info}/METADATA +8 -35
- llms_py-2.0.17.dist-info/RECORD +56 -0
- llms_py-2.0.15.dist-info/RECORD +0 -46
- {llms_py-2.0.15.dist-info → llms_py-2.0.17.dist-info}/WHEEL +0 -0
- {llms_py-2.0.15.dist-info → llms_py-2.0.17.dist-info}/entry_points.txt +0 -0
- {llms_py-2.0.15.dist-info → llms_py-2.0.17.dist-info}/licenses/LICENSE +0 -0
- {llms_py-2.0.15.dist-info → llms_py-2.0.17.dist-info}/top_level.txt +0 -0
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
|
+
·
|
|
226
|
+
{{ allDailyData[selectedDay]?.requests || 0 }} Requests
|
|
227
|
+
·
|
|
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
|
+
}
|