@oh-my-pi/omp-stats 14.9.3 → 14.9.5
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.
- package/package.json +6 -6
- package/src/aggregator.ts +138 -11
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +367 -0
- package/src/client/components/BehaviorModelsTable.tsx +422 -0
- package/src/client/components/BehaviorSummary.tsx +75 -0
- package/src/client/components/CostChart.tsx +5 -38
- package/src/client/components/CostSummary.tsx +8 -47
- package/src/client/components/Header.tsx +28 -4
- package/src/client/components/StatsGrid.tsx +10 -1
- package/src/client/types.ts +54 -0
- package/src/db.ts +307 -26
- package/src/parser.ts +75 -4
- package/src/server.ts +30 -6
- package/src/types.ts +81 -0
- package/src/user-metrics.ts +486 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CategoryScale,
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
Legend,
|
|
5
|
+
LinearScale,
|
|
6
|
+
LineElement,
|
|
7
|
+
PointElement,
|
|
8
|
+
Title,
|
|
9
|
+
Tooltip,
|
|
10
|
+
} from "chart.js";
|
|
11
|
+
import { format } from "date-fns";
|
|
12
|
+
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
13
|
+
import { useMemo, useState } from "react";
|
|
14
|
+
import { Line } from "react-chartjs-2";
|
|
15
|
+
import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types";
|
|
16
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
17
|
+
|
|
18
|
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
|
19
|
+
|
|
20
|
+
const MODEL_COLORS = [
|
|
21
|
+
"#a78bfa", // violet
|
|
22
|
+
"#22d3ee", // cyan
|
|
23
|
+
"#ec4899", // pink
|
|
24
|
+
"#4ade80", // green
|
|
25
|
+
"#fbbf24", // amber
|
|
26
|
+
"#f87171", // red
|
|
27
|
+
"#60a5fa", // blue
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const SERIES_COLORS = {
|
|
31
|
+
yelling: "#fbbf24", // amber
|
|
32
|
+
profanity: "#f87171", // red
|
|
33
|
+
drama: "#a78bfa", // violet
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const CHART_THEMES = {
|
|
37
|
+
dark: {
|
|
38
|
+
legendLabel: "#cbd5e1",
|
|
39
|
+
tooltipBackground: "#16161e",
|
|
40
|
+
tooltipTitle: "#f8fafc",
|
|
41
|
+
tooltipBody: "#94a3b8",
|
|
42
|
+
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
43
|
+
grid: "rgba(255, 255, 255, 0.06)",
|
|
44
|
+
tick: "#94a3b8",
|
|
45
|
+
},
|
|
46
|
+
light: {
|
|
47
|
+
legendLabel: "#334155",
|
|
48
|
+
tooltipBackground: "#ffffff",
|
|
49
|
+
tooltipTitle: "#0f172a",
|
|
50
|
+
tooltipBody: "#334155",
|
|
51
|
+
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
52
|
+
grid: "rgba(15, 23, 42, 0.08)",
|
|
53
|
+
tick: "#475569",
|
|
54
|
+
},
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES];
|
|
58
|
+
|
|
59
|
+
interface BehaviorModelsTableProps {
|
|
60
|
+
models: BehaviorModelStats[];
|
|
61
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface DailyPoint {
|
|
65
|
+
timestamp: number;
|
|
66
|
+
yelling: number;
|
|
67
|
+
profanity: number;
|
|
68
|
+
drama: number;
|
|
69
|
+
total: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ModelTrendSeries {
|
|
73
|
+
data: DailyPoint[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 0.9fr 0.9fr 0.9fr 140px 40px";
|
|
77
|
+
|
|
78
|
+
function formatInt(value: number): string {
|
|
79
|
+
return value.toLocaleString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function totalHitRate(model: BehaviorModelStats): number {
|
|
83
|
+
if (model.totalMessages === 0) return 0;
|
|
84
|
+
const hits = model.totalYellingSentences + model.totalProfanity + model.totalDramaRuns;
|
|
85
|
+
return hits / model.totalMessages;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rate-as-percent. < 1% shows one decimal so a 0.4% model doesn't read as 0%.
|
|
90
|
+
*/
|
|
91
|
+
function formatRate(total: number, messages: number): string {
|
|
92
|
+
if (messages === 0) return "-";
|
|
93
|
+
const pct = (total / messages) * 100;
|
|
94
|
+
if (pct === 0) return "0%";
|
|
95
|
+
if (pct < 1) return `${pct.toFixed(1)}%`;
|
|
96
|
+
return `${pct.toFixed(0)}%`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) {
|
|
100
|
+
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
101
|
+
const theme = useSystemTheme();
|
|
102
|
+
const chartTheme = CHART_THEMES[theme];
|
|
103
|
+
|
|
104
|
+
const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]);
|
|
105
|
+
|
|
106
|
+
// Sort by usage so the models you actually rely on surface first; rates
|
|
107
|
+
// stay visible per row so a low-volume freak doesn't dominate.
|
|
108
|
+
const sortedModels = [...models].sort((a, b) => {
|
|
109
|
+
if (b.totalMessages !== a.totalMessages) return b.totalMessages - a.totalMessages;
|
|
110
|
+
return totalHitRate(b) - totalHitRate(a);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="surface overflow-hidden">
|
|
115
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
116
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Behavior by Model</h3>
|
|
117
|
+
<p className="text-xs text-[var(--text-muted)] mt-1">
|
|
118
|
+
How often each model elicited a tantrum — rates are per user message
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="overflow-x-auto">
|
|
123
|
+
<div
|
|
124
|
+
className="grid gap-3 px-5 py-3 text-[var(--text-muted)] text-xs uppercase tracking-wider font-semibold"
|
|
125
|
+
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
|
126
|
+
>
|
|
127
|
+
<div>Model</div>
|
|
128
|
+
<div className="text-right">Messages</div>
|
|
129
|
+
<div className="text-right">CAPS %</div>
|
|
130
|
+
<div className="text-right">Profanity %</div>
|
|
131
|
+
<div className="text-right">Drama %</div>
|
|
132
|
+
<div className="text-right">Hits %</div>
|
|
133
|
+
<div className="text-center">Trend</div>
|
|
134
|
+
<div />
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="max-h-[calc(100vh-300px)] overflow-y-auto">
|
|
138
|
+
{sortedModels.map((model, index) => {
|
|
139
|
+
const key = `${model.model}::${model.provider}`;
|
|
140
|
+
const trend = trendByKey.get(key)?.data ?? [];
|
|
141
|
+
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
|
|
142
|
+
const isExpanded = expandedKey === key;
|
|
143
|
+
const totalHits = model.totalYellingSentences + model.totalProfanity + model.totalDramaRuns;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div key={key} className="border-t border-[var(--border-subtle)]">
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => setExpandedKey(isExpanded ? null : key)}
|
|
150
|
+
className="w-full bg-transparent border-none text-left px-5 py-3 cursor-pointer hover:bg-[var(--bg-hover)] transition-colors"
|
|
151
|
+
>
|
|
152
|
+
<div className="grid gap-3 items-center" style={{ gridTemplateColumns: GRID_TEMPLATE }}>
|
|
153
|
+
<div>
|
|
154
|
+
<div className="font-medium text-[var(--text-primary)]">{model.model}</div>
|
|
155
|
+
<div className="text-xs text-[var(--text-muted)]">{model.provider}</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
158
|
+
{formatInt(model.totalMessages)}
|
|
159
|
+
</div>
|
|
160
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
161
|
+
{formatRate(model.totalYellingSentences, model.totalMessages)}
|
|
162
|
+
</div>
|
|
163
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
164
|
+
{formatRate(model.totalProfanity, model.totalMessages)}
|
|
165
|
+
</div>
|
|
166
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
167
|
+
{formatRate(model.totalDramaRuns, model.totalMessages)}
|
|
168
|
+
</div>
|
|
169
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
170
|
+
{formatRate(totalHits, model.totalMessages)}
|
|
171
|
+
</div>
|
|
172
|
+
<div className="h-10">
|
|
173
|
+
{trend.length === 0 ? (
|
|
174
|
+
<div className="text-[var(--text-muted)] text-center text-sm">-</div>
|
|
175
|
+
) : (
|
|
176
|
+
<TrendSparkline data={trend} color={trendColor} />
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex justify-center text-[var(--text-muted)]">
|
|
180
|
+
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</button>
|
|
184
|
+
|
|
185
|
+
{isExpanded && (
|
|
186
|
+
<div className="px-5 py-4 bg-[var(--bg-elevated)] border-t border-[var(--border-subtle)]">
|
|
187
|
+
<div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
|
|
188
|
+
<div className="space-y-4 text-sm">
|
|
189
|
+
<DetailRow
|
|
190
|
+
label="Yelling (CAPS)"
|
|
191
|
+
total={model.totalYellingSentences}
|
|
192
|
+
messages={model.totalMessages}
|
|
193
|
+
valueClass="text-[var(--accent-amber,#fbbf24)]"
|
|
194
|
+
/>
|
|
195
|
+
<DetailRow
|
|
196
|
+
label="Profanity"
|
|
197
|
+
total={model.totalProfanity}
|
|
198
|
+
messages={model.totalMessages}
|
|
199
|
+
valueClass="text-[var(--accent-red,#f87171)]"
|
|
200
|
+
/>
|
|
201
|
+
<DetailRow
|
|
202
|
+
label="Drama (!!! / ???)"
|
|
203
|
+
total={model.totalDramaRuns}
|
|
204
|
+
messages={model.totalMessages}
|
|
205
|
+
valueClass="text-[var(--accent-violet,#a78bfa)]"
|
|
206
|
+
/>
|
|
207
|
+
<DetailRow
|
|
208
|
+
label="Avg chars / msg"
|
|
209
|
+
total={model.totalChars}
|
|
210
|
+
messages={model.totalMessages}
|
|
211
|
+
valueClass="text-[var(--text-secondary)]"
|
|
212
|
+
mode="average"
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="h-[200px]">
|
|
216
|
+
{trend.length === 0 ? (
|
|
217
|
+
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
218
|
+
No data available
|
|
219
|
+
</div>
|
|
220
|
+
) : (
|
|
221
|
+
<BreakdownChart data={trend} chartTheme={chartTheme} />
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
{sortedModels.length === 0 && (
|
|
231
|
+
<div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
|
|
232
|
+
No user behavior recorded for this range yet.
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function DetailRow({
|
|
242
|
+
label,
|
|
243
|
+
total,
|
|
244
|
+
messages,
|
|
245
|
+
valueClass,
|
|
246
|
+
mode = "rate",
|
|
247
|
+
}: {
|
|
248
|
+
label: string;
|
|
249
|
+
total: number;
|
|
250
|
+
messages: number;
|
|
251
|
+
valueClass: string;
|
|
252
|
+
mode?: "rate" | "average";
|
|
253
|
+
}) {
|
|
254
|
+
const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg";
|
|
255
|
+
const perMsgValue =
|
|
256
|
+
messages > 0 ? (mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0)) : "-";
|
|
257
|
+
return (
|
|
258
|
+
<div>
|
|
259
|
+
<div className="text-[var(--text-primary)] font-medium mb-2">{label}</div>
|
|
260
|
+
<div className="space-y-1 text-[var(--text-secondary)]">
|
|
261
|
+
<div className="flex items-center justify-between">
|
|
262
|
+
<span>Total</span>
|
|
263
|
+
<span className={`font-mono ${valueClass}`}>{formatInt(total)}</span>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="flex items-center justify-between">
|
|
266
|
+
<span>{perMsgLabel}</span>
|
|
267
|
+
<span className="font-mono">{perMsgValue}</span>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function TrendSparkline({ data, color }: { data: DailyPoint[]; color: string }) {
|
|
275
|
+
const chartData = {
|
|
276
|
+
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
277
|
+
datasets: [
|
|
278
|
+
{
|
|
279
|
+
data: data.map(d => d.total),
|
|
280
|
+
borderColor: color,
|
|
281
|
+
backgroundColor: "transparent",
|
|
282
|
+
tension: 0.4,
|
|
283
|
+
pointRadius: 0,
|
|
284
|
+
borderWidth: 2,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const options = {
|
|
290
|
+
responsive: true,
|
|
291
|
+
maintainAspectRatio: false,
|
|
292
|
+
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
293
|
+
scales: {
|
|
294
|
+
x: { display: false },
|
|
295
|
+
y: { display: false, min: 0 },
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return <Line data={chartData} options={options} />;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: ChartTheme }) {
|
|
303
|
+
const chartData = {
|
|
304
|
+
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
305
|
+
datasets: [
|
|
306
|
+
{
|
|
307
|
+
label: "CAPS",
|
|
308
|
+
data: data.map(d => d.yelling),
|
|
309
|
+
borderColor: SERIES_COLORS.yelling,
|
|
310
|
+
backgroundColor: "transparent",
|
|
311
|
+
tension: 0.4,
|
|
312
|
+
pointRadius: 0,
|
|
313
|
+
borderWidth: 2,
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
label: "Profanity",
|
|
317
|
+
data: data.map(d => d.profanity),
|
|
318
|
+
borderColor: SERIES_COLORS.profanity,
|
|
319
|
+
backgroundColor: "transparent",
|
|
320
|
+
tension: 0.4,
|
|
321
|
+
pointRadius: 0,
|
|
322
|
+
borderWidth: 2,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
label: "Drama",
|
|
326
|
+
data: data.map(d => d.drama),
|
|
327
|
+
borderColor: SERIES_COLORS.drama,
|
|
328
|
+
backgroundColor: "transparent",
|
|
329
|
+
tension: 0.4,
|
|
330
|
+
pointRadius: 0,
|
|
331
|
+
borderWidth: 2,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const options = {
|
|
337
|
+
responsive: true,
|
|
338
|
+
maintainAspectRatio: false,
|
|
339
|
+
plugins: {
|
|
340
|
+
legend: {
|
|
341
|
+
display: true,
|
|
342
|
+
position: "top" as const,
|
|
343
|
+
labels: {
|
|
344
|
+
color: chartTheme.legendLabel,
|
|
345
|
+
usePointStyle: true,
|
|
346
|
+
padding: 16,
|
|
347
|
+
font: { size: 12 },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
tooltip: {
|
|
351
|
+
backgroundColor: chartTheme.tooltipBackground,
|
|
352
|
+
titleColor: chartTheme.tooltipTitle,
|
|
353
|
+
bodyColor: chartTheme.tooltipBody,
|
|
354
|
+
borderColor: chartTheme.tooltipBorder,
|
|
355
|
+
borderWidth: 1,
|
|
356
|
+
cornerRadius: 8,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
scales: {
|
|
360
|
+
x: {
|
|
361
|
+
grid: { color: chartTheme.grid },
|
|
362
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
363
|
+
},
|
|
364
|
+
y: {
|
|
365
|
+
grid: { color: chartTheme.grid },
|
|
366
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
367
|
+
min: 0,
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return <Line data={chartData} options={options} />;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Group the daily time-series by model+provider, producing one continuous
|
|
377
|
+
* day-bucket array per model so the sparkline / breakdown chart can render
|
|
378
|
+
* without missing-day artifacts.
|
|
379
|
+
*/
|
|
380
|
+
function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
|
|
381
|
+
if (points.length === 0) return new Map();
|
|
382
|
+
|
|
383
|
+
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
384
|
+
const byKey = new Map<string, Map<number, DailyPoint>>();
|
|
385
|
+
|
|
386
|
+
for (const point of points) {
|
|
387
|
+
const key = `${point.model}::${point.provider}`;
|
|
388
|
+
let dayMap = byKey.get(key);
|
|
389
|
+
if (!dayMap) {
|
|
390
|
+
dayMap = new Map();
|
|
391
|
+
byKey.set(key, dayMap);
|
|
392
|
+
}
|
|
393
|
+
const existing = dayMap.get(point.timestamp) ?? {
|
|
394
|
+
timestamp: point.timestamp,
|
|
395
|
+
yelling: 0,
|
|
396
|
+
profanity: 0,
|
|
397
|
+
drama: 0,
|
|
398
|
+
total: 0,
|
|
399
|
+
};
|
|
400
|
+
existing.yelling += point.yellingSentences;
|
|
401
|
+
existing.profanity += point.profanity;
|
|
402
|
+
existing.drama += point.dramaRuns;
|
|
403
|
+
existing.total = existing.yelling + existing.profanity + existing.drama;
|
|
404
|
+
dayMap.set(point.timestamp, existing);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const out = new Map<string, ModelTrendSeries>();
|
|
408
|
+
for (const [key, dayMap] of byKey) {
|
|
409
|
+
const data = allDays.map(
|
|
410
|
+
ts =>
|
|
411
|
+
dayMap.get(ts) ?? {
|
|
412
|
+
timestamp: ts,
|
|
413
|
+
yelling: 0,
|
|
414
|
+
profanity: 0,
|
|
415
|
+
drama: 0,
|
|
416
|
+
total: 0,
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
out.set(key, { data });
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { BehaviorOverallStats, BehaviorTimeSeriesPoint } from "../types";
|
|
3
|
+
|
|
4
|
+
interface BehaviorSummaryProps {
|
|
5
|
+
overall: BehaviorOverallStats;
|
|
6
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatInt(value: number): string {
|
|
10
|
+
return value.toLocaleString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function BehaviorSummary({ overall, behaviorSeries }: BehaviorSummaryProps) {
|
|
14
|
+
// Top "ranted-at" model: model that absorbed the most caps + profanity + drama.
|
|
15
|
+
const topModel = useMemo(() => {
|
|
16
|
+
const totals = new Map<string, { model: string; provider: string; score: number }>();
|
|
17
|
+
for (const point of behaviorSeries) {
|
|
18
|
+
const key = `${point.model}::${point.provider}`;
|
|
19
|
+
const existing = totals.get(key);
|
|
20
|
+
const score = point.yellingSentences + point.profanity + point.dramaRuns;
|
|
21
|
+
if (existing) {
|
|
22
|
+
existing.score += score;
|
|
23
|
+
} else {
|
|
24
|
+
totals.set(key, { model: point.model, provider: point.provider, score });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
let best: { model: string; provider: string; score: number } | null = null;
|
|
28
|
+
for (const entry of totals.values()) {
|
|
29
|
+
if (!best || entry.score > best.score) best = entry;
|
|
30
|
+
}
|
|
31
|
+
return best;
|
|
32
|
+
}, [behaviorSeries]);
|
|
33
|
+
|
|
34
|
+
const capsPerMsg = overall.totalMessages > 0 ? overall.totalYellingSentences / overall.totalMessages : 0;
|
|
35
|
+
|
|
36
|
+
const cards: Array<{ label: string; value: string; sub?: string }> = [
|
|
37
|
+
{
|
|
38
|
+
label: "Messages",
|
|
39
|
+
value: formatInt(overall.totalMessages),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
label: "Yelling",
|
|
43
|
+
value: formatInt(overall.totalYellingSentences),
|
|
44
|
+
sub: overall.totalMessages > 0 ? `${capsPerMsg.toFixed(2)} / msg` : undefined,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: "Profanity hits",
|
|
48
|
+
value: formatInt(overall.totalProfanity),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
label: "Drama runs",
|
|
52
|
+
value: formatInt(overall.totalDramaRuns),
|
|
53
|
+
sub: "!!! / ???",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: "Most yelled-at",
|
|
57
|
+
value: topModel?.model ?? "—",
|
|
58
|
+
sub: topModel ? `${formatInt(topModel.score)} hits` : undefined,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
|
64
|
+
{cards.map(card => (
|
|
65
|
+
<div key={card.label} className="surface px-4 py-3">
|
|
66
|
+
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
|
|
67
|
+
<p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
|
|
68
|
+
{card.value}
|
|
69
|
+
</p>
|
|
70
|
+
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -53,9 +53,6 @@ const CHART_THEMES = {
|
|
|
53
53
|
},
|
|
54
54
|
} as const;
|
|
55
55
|
|
|
56
|
-
const RANGE_OPTIONS = [14, 30, 90] as const;
|
|
57
|
-
type RangeDays = (typeof RANGE_OPTIONS)[number];
|
|
58
|
-
|
|
59
56
|
interface CostChartProps {
|
|
60
57
|
costSeries: CostTimeSeriesPoint[];
|
|
61
58
|
}
|
|
@@ -88,16 +85,12 @@ function makeBarLabelPlugin(color: string): Plugin<"bar"> {
|
|
|
88
85
|
|
|
89
86
|
export function CostChart({ costSeries }: CostChartProps) {
|
|
90
87
|
const [byModel, setByModel] = useState(false);
|
|
91
|
-
const [days, setDays] = useState<RangeDays>(30);
|
|
92
88
|
const theme = useSystemTheme();
|
|
93
89
|
const chartTheme = CHART_THEMES[theme];
|
|
94
90
|
|
|
95
|
-
const cutoff = Date.now() - days * 86400000;
|
|
96
|
-
const filtered = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
|
|
97
|
-
|
|
98
91
|
const chartData = useMemo(
|
|
99
|
-
() => (byModel ? buildByModelSeries(
|
|
100
|
-
[
|
|
92
|
+
() => (byModel ? buildByModelSeries(costSeries) : buildAggregateSeries(costSeries)),
|
|
93
|
+
[costSeries, byModel],
|
|
101
94
|
);
|
|
102
95
|
|
|
103
96
|
const sharedPlugins = {
|
|
@@ -175,13 +168,7 @@ export function CostChart({ costSeries }: CostChartProps) {
|
|
|
175
168
|
};
|
|
176
169
|
|
|
177
170
|
return (
|
|
178
|
-
<ChartWrapper
|
|
179
|
-
byModel={byModel}
|
|
180
|
-
days={days}
|
|
181
|
-
onByModelChange={setByModel}
|
|
182
|
-
onDaysChange={setDays}
|
|
183
|
-
empty={chartData.labels.length === 0}
|
|
184
|
-
>
|
|
171
|
+
<ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
|
|
185
172
|
<Line data={lineData} options={lineOptions} />
|
|
186
173
|
</ChartWrapper>
|
|
187
174
|
);
|
|
@@ -214,13 +201,7 @@ export function CostChart({ costSeries }: CostChartProps) {
|
|
|
214
201
|
};
|
|
215
202
|
|
|
216
203
|
return (
|
|
217
|
-
<ChartWrapper
|
|
218
|
-
byModel={byModel}
|
|
219
|
-
days={days}
|
|
220
|
-
onByModelChange={setByModel}
|
|
221
|
-
onDaysChange={setDays}
|
|
222
|
-
empty={chartData.labels.length === 0}
|
|
223
|
-
>
|
|
204
|
+
<ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
|
|
224
205
|
<Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
|
|
225
206
|
</ChartWrapper>
|
|
226
207
|
);
|
|
@@ -228,14 +209,12 @@ export function CostChart({ costSeries }: CostChartProps) {
|
|
|
228
209
|
|
|
229
210
|
interface ChartWrapperProps {
|
|
230
211
|
byModel: boolean;
|
|
231
|
-
days: RangeDays;
|
|
232
212
|
onByModelChange: (v: boolean) => void;
|
|
233
|
-
onDaysChange: (v: RangeDays) => void;
|
|
234
213
|
empty: boolean;
|
|
235
214
|
children: React.ReactNode;
|
|
236
215
|
}
|
|
237
216
|
|
|
238
|
-
function ChartWrapper({ byModel,
|
|
217
|
+
function ChartWrapper({ byModel, onByModelChange, empty, children }: ChartWrapperProps) {
|
|
239
218
|
return (
|
|
240
219
|
<div className="surface overflow-hidden">
|
|
241
220
|
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
|
|
@@ -260,18 +239,6 @@ function ChartWrapper({ byModel, days, onByModelChange, onDaysChange, empty, chi
|
|
|
260
239
|
By Model
|
|
261
240
|
</button>
|
|
262
241
|
</div>
|
|
263
|
-
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--radius-sm)] border-[var(--border-subtle)]">
|
|
264
|
-
{RANGE_OPTIONS.map(d => (
|
|
265
|
-
<button
|
|
266
|
-
key={d}
|
|
267
|
-
type="button"
|
|
268
|
-
onClick={() => onDaysChange(d)}
|
|
269
|
-
className={`tab-btn text-xs ${days === d ? "active" : ""}`}
|
|
270
|
-
>
|
|
271
|
-
{d}d
|
|
272
|
-
</button>
|
|
273
|
-
))}
|
|
274
|
-
</div>
|
|
275
242
|
</div>
|
|
276
243
|
</div>
|
|
277
244
|
<div className="p-5 min-h-[320px]">
|
|
@@ -1,35 +1,21 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
1
|
import type { CostTimeSeriesPoint } from "../types";
|
|
3
2
|
|
|
4
3
|
interface CostSummaryProps {
|
|
5
4
|
costSeries: CostTimeSeriesPoint[];
|
|
6
5
|
}
|
|
7
6
|
|
|
8
|
-
const SUMMARY_DAYS = 30;
|
|
9
|
-
|
|
10
7
|
function formatCost(value: number): string {
|
|
11
8
|
return `$${Math.round(value)}`;
|
|
12
9
|
}
|
|
13
10
|
|
|
14
11
|
export function CostSummary({ costSeries }: CostSummaryProps) {
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const current = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
|
|
19
|
-
const previous = useMemo(
|
|
20
|
-
() => costSeries.filter(p => p.timestamp >= prevCutoff && p.timestamp < cutoff),
|
|
21
|
-
[costSeries, prevCutoff, cutoff],
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
const totalCost = current.reduce((sum, p) => sum + p.cost, 0);
|
|
25
|
-
const prevTotalCost = previous.reduce((sum, p) => sum + p.cost, 0);
|
|
26
|
-
|
|
27
|
-
const dayBuckets = new Set(current.map(p => p.timestamp)).size;
|
|
12
|
+
const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0);
|
|
13
|
+
const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size;
|
|
28
14
|
const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0;
|
|
29
15
|
|
|
30
|
-
// Most expensive model over
|
|
16
|
+
// Most expensive model over the visible window
|
|
31
17
|
const modelTotals = new Map<string, number>();
|
|
32
|
-
for (const point of
|
|
18
|
+
for (const point of costSeries) {
|
|
33
19
|
modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
|
|
34
20
|
}
|
|
35
21
|
let topModel = "";
|
|
@@ -41,47 +27,22 @@ export function CostSummary({ costSeries }: CostSummaryProps) {
|
|
|
41
27
|
}
|
|
42
28
|
}
|
|
43
29
|
|
|
44
|
-
const trend = prevTotalCost > 0 ? ((totalCost - prevTotalCost) / prevTotalCost) * 100 : null;
|
|
45
|
-
|
|
46
30
|
const cards = [
|
|
47
|
-
{
|
|
48
|
-
|
|
49
|
-
value: formatCost(totalCost),
|
|
50
|
-
positive: null as boolean | null,
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
label: "Avg / day",
|
|
54
|
-
value: formatCost(avgDaily),
|
|
55
|
-
positive: null as boolean | null,
|
|
56
|
-
},
|
|
31
|
+
{ label: "Total", value: formatCost(totalCost) },
|
|
32
|
+
{ label: "Avg / day", value: formatCost(avgDaily) },
|
|
57
33
|
{
|
|
58
34
|
label: "Top model",
|
|
59
35
|
value: topModel || "—",
|
|
60
36
|
sub: topModel ? formatCost(topModelCost) : undefined,
|
|
61
|
-
positive: null as boolean | null,
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
label: "vs prev 30d",
|
|
65
|
-
value: trend !== null ? `${trend >= 0 ? "+" : ""}${Math.round(trend)}%` : "—",
|
|
66
|
-
sub: undefined as string | undefined,
|
|
67
|
-
positive: trend !== null ? trend <= 0 : null,
|
|
68
37
|
},
|
|
69
38
|
];
|
|
70
39
|
|
|
71
40
|
return (
|
|
72
|
-
<div className="grid grid-cols-
|
|
41
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
73
42
|
{cards.map(card => (
|
|
74
43
|
<div key={card.label} className="surface px-4 py-3">
|
|
75
44
|
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
|
|
76
|
-
<p
|
|
77
|
-
className={`text-lg font-semibold ${
|
|
78
|
-
card.positive === true
|
|
79
|
-
? "text-[var(--accent-green,#4ade80)]"
|
|
80
|
-
: card.positive === false
|
|
81
|
-
? "text-[var(--accent-pink)]"
|
|
82
|
-
: "text-[var(--text-primary)]"
|
|
83
|
-
}`}
|
|
84
|
-
>
|
|
45
|
+
<p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
|
|
85
46
|
{card.value}
|
|
86
47
|
</p>
|
|
87
48
|
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
|