@oh-my-pi/omp-stats 14.9.9 → 15.0.1
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 +5 -5
- package/src/client/components/BehaviorChart.tsx +85 -270
- package/src/client/components/BehaviorModelsTable.tsx +151 -274
- package/src/client/components/CostChart.tsx +85 -246
- package/src/client/components/ModelsTable.tsx +130 -246
- package/src/client/components/RequestDetail.tsx +0 -2
- package/src/client/components/chart-shared.tsx +320 -0
- package/src/client/components/models-table-shared.tsx +275 -0
- package/src/client/types.ts +21 -121
- package/src/db.ts +41 -1
- package/src/parser.ts +39 -5
- package/src/shared-types.ts +204 -0
- package/src/types.ts +16 -201
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "15.0.1",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-ai": "
|
|
41
|
-
"@oh-my-pi/pi-utils": "
|
|
40
|
+
"@oh-my-pi/pi-ai": "15.0.1",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.0.1",
|
|
42
42
|
"@tailwindcss/node": "^4.2.4",
|
|
43
43
|
"chart.js": "^4.5.1",
|
|
44
44
|
"date-fns": "^4.1.0",
|
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
"tailwindcss": "^4.2.4"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@types/bun": "^1.3.
|
|
52
|
+
"@types/bun": "^1.3.14",
|
|
53
53
|
"@types/react": "^19.2.14",
|
|
54
54
|
"@types/react-dom": "^19.2.3",
|
|
55
55
|
"postcss": "^8.5.14"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
|
-
"bun": ">=1.3.
|
|
58
|
+
"bun": ">=1.3.14"
|
|
59
59
|
},
|
|
60
60
|
"files": [
|
|
61
61
|
"src",
|
|
@@ -11,45 +11,26 @@ import {
|
|
|
11
11
|
Title,
|
|
12
12
|
Tooltip,
|
|
13
13
|
} from "chart.js";
|
|
14
|
-
import { format } from "date-fns";
|
|
15
14
|
import { useMemo, useState } from "react";
|
|
16
15
|
import { Bar, Line } from "react-chartjs-2";
|
|
17
16
|
import type { BehaviorTimeSeriesPoint } from "../types";
|
|
18
17
|
import { useSystemTheme } from "../useSystemTheme";
|
|
18
|
+
import {
|
|
19
|
+
barDatasetStyle,
|
|
20
|
+
buildAggregateTimeSeries,
|
|
21
|
+
buildSharedPlugins,
|
|
22
|
+
buildSharedScales,
|
|
23
|
+
buildTopNByModelSeries,
|
|
24
|
+
CHART_THEMES,
|
|
25
|
+
ChartFrame,
|
|
26
|
+
type ChartSeries,
|
|
27
|
+
lineDatasetStyle,
|
|
28
|
+
MODEL_COLORS,
|
|
29
|
+
styleDatasets,
|
|
30
|
+
} from "./chart-shared";
|
|
19
31
|
|
|
20
32
|
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
|
|
21
33
|
|
|
22
|
-
const MODEL_COLORS = [
|
|
23
|
-
"#a78bfa", // violet
|
|
24
|
-
"#22d3ee", // cyan
|
|
25
|
-
"#ec4899", // pink
|
|
26
|
-
"#4ade80", // green
|
|
27
|
-
"#fbbf24", // amber
|
|
28
|
-
"#f87171", // red
|
|
29
|
-
"#60a5fa", // blue
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
const CHART_THEMES = {
|
|
33
|
-
dark: {
|
|
34
|
-
legendLabel: "#94a3b8",
|
|
35
|
-
tooltipBackground: "#16161e",
|
|
36
|
-
tooltipTitle: "#f8fafc",
|
|
37
|
-
tooltipBody: "#94a3b8",
|
|
38
|
-
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
39
|
-
grid: "rgba(255, 255, 255, 0.06)",
|
|
40
|
-
tick: "#64748b",
|
|
41
|
-
},
|
|
42
|
-
light: {
|
|
43
|
-
legendLabel: "#475569",
|
|
44
|
-
tooltipBackground: "#ffffff",
|
|
45
|
-
tooltipTitle: "#0f172a",
|
|
46
|
-
tooltipBody: "#334155",
|
|
47
|
-
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
48
|
-
grid: "rgba(15, 23, 42, 0.08)",
|
|
49
|
-
tick: "#64748b",
|
|
50
|
-
},
|
|
51
|
-
} as const;
|
|
52
|
-
|
|
53
34
|
const METRIC_OPTIONS = [
|
|
54
35
|
{ value: "yelling", label: "Yelling" },
|
|
55
36
|
{ value: "profanity", label: "Profanity" },
|
|
@@ -87,98 +68,36 @@ function ratePercent(hits: number, messages: number): number {
|
|
|
87
68
|
return (hits / messages) * 100;
|
|
88
69
|
}
|
|
89
70
|
|
|
90
|
-
interface ChartSeries {
|
|
91
|
-
labels: string[];
|
|
92
|
-
datasets: Array<{ label: string; data: number[] }>;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
71
|
interface DailyBucket {
|
|
96
72
|
hits: number;
|
|
97
73
|
messages: number;
|
|
98
74
|
}
|
|
99
75
|
|
|
100
76
|
function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
|
|
112
|
-
return {
|
|
113
|
-
labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
|
|
114
|
-
datasets: [
|
|
115
|
-
{
|
|
116
|
-
label: METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits",
|
|
117
|
-
data: sorted.map(([, b]) => ratePercent(b.hits, b.messages)),
|
|
118
|
-
},
|
|
119
|
-
],
|
|
120
|
-
};
|
|
77
|
+
const label = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits";
|
|
78
|
+
return buildAggregateTimeSeries<BehaviorTimeSeriesPoint, DailyBucket>(points, label, {
|
|
79
|
+
initBucket: () => ({ hits: 0, messages: 0 }),
|
|
80
|
+
accumulate: (bucket, point) => {
|
|
81
|
+
bucket.hits += pointHits(point, metric);
|
|
82
|
+
bucket.messages += point.messages;
|
|
83
|
+
},
|
|
84
|
+
bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
|
|
85
|
+
});
|
|
121
86
|
}
|
|
122
87
|
|
|
123
|
-
function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric
|
|
124
|
-
if (points.length === 0) return { labels: [], datasets: [] };
|
|
125
|
-
|
|
88
|
+
function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
|
|
126
89
|
// Rank by message volume so the models you actually use surface first,
|
|
127
|
-
// matching the Behavior-by-Model table.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const sorted = [...totals.entries()].sort((a, b) => b[1].messages - a[1].messages);
|
|
140
|
-
const topEntries = sorted.slice(0, topN);
|
|
141
|
-
const topKeys = new Set(topEntries.map(([key]) => key));
|
|
142
|
-
|
|
143
|
-
const modelCount = new Map<string, number>();
|
|
144
|
-
for (const [, { model }] of topEntries) {
|
|
145
|
-
modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
|
|
146
|
-
}
|
|
147
|
-
const labelByKey = new Map<string, string>();
|
|
148
|
-
for (const [key, { model, provider }] of topEntries) {
|
|
149
|
-
labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
153
|
-
const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
|
|
154
|
-
const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
|
|
155
|
-
if (hasOther) seriesNames.push("Other");
|
|
156
|
-
|
|
157
|
-
// Track hits and messages separately per (day, series), then convert to a
|
|
158
|
-
// rate at the end. Summing rates would weight low-volume days unfairly.
|
|
159
|
-
const dayMap = new Map<number, Record<string, DailyBucket>>();
|
|
160
|
-
for (const day of allDays) dayMap.set(day, {});
|
|
161
|
-
for (const point of points) {
|
|
162
|
-
const key = `${point.model}::${point.provider}`;
|
|
163
|
-
const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
|
|
164
|
-
const row = dayMap.get(point.timestamp);
|
|
165
|
-
if (!row) continue;
|
|
166
|
-
const bucket = row[label] ?? { hits: 0, messages: 0 };
|
|
167
|
-
bucket.hits += pointHits(point, metric);
|
|
168
|
-
bucket.messages += point.messages;
|
|
169
|
-
row[label] = bucket;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
labels: allDays.map(ts => format(new Date(ts), "MMM d")),
|
|
174
|
-
datasets: seriesNames.map(name => ({
|
|
175
|
-
label: name,
|
|
176
|
-
data: allDays.map(day => {
|
|
177
|
-
const bucket = dayMap.get(day)?.[name];
|
|
178
|
-
return bucket ? ratePercent(bucket.hits, bucket.messages) : 0;
|
|
179
|
-
}),
|
|
180
|
-
})),
|
|
181
|
-
};
|
|
90
|
+
// matching the Behavior-by-Model table. Per-bucket math tracks hits +
|
|
91
|
+
// messages separately so the final rate isn't skewed by low-volume days.
|
|
92
|
+
return buildTopNByModelSeries<BehaviorTimeSeriesPoint, DailyBucket>(points, {
|
|
93
|
+
rankWeight: point => point.messages,
|
|
94
|
+
initBucket: () => ({ hits: 0, messages: 0 }),
|
|
95
|
+
accumulate: (bucket, point) => {
|
|
96
|
+
bucket.hits += pointHits(point, metric);
|
|
97
|
+
bucket.messages += point.messages;
|
|
98
|
+
},
|
|
99
|
+
bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
|
|
100
|
+
});
|
|
182
101
|
}
|
|
183
102
|
|
|
184
103
|
export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
|
|
@@ -192,65 +111,36 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
|
|
|
192
111
|
[behaviorSeries, byModel, metric],
|
|
193
112
|
);
|
|
194
113
|
|
|
195
|
-
const sharedPlugins = {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
color: chartTheme.legendLabel,
|
|
202
|
-
usePointStyle: true,
|
|
203
|
-
padding: 16,
|
|
204
|
-
font: { size: 12 },
|
|
205
|
-
boxWidth: 8,
|
|
206
|
-
},
|
|
207
|
-
},
|
|
208
|
-
tooltip: {
|
|
209
|
-
backgroundColor: chartTheme.tooltipBackground,
|
|
210
|
-
titleColor: chartTheme.tooltipTitle,
|
|
211
|
-
bodyColor: chartTheme.tooltipBody,
|
|
212
|
-
borderColor: chartTheme.tooltipBorder,
|
|
213
|
-
borderWidth: 1,
|
|
214
|
-
padding: 12,
|
|
215
|
-
cornerRadius: 8,
|
|
216
|
-
callbacks: {
|
|
217
|
-
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
218
|
-
const label = context.dataset.label ?? "Hits";
|
|
219
|
-
const value = context.parsed.y ?? 0;
|
|
220
|
-
return `${label}: ${formatRateAxis(value)}`;
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
};
|
|
114
|
+
const sharedPlugins = buildSharedPlugins({
|
|
115
|
+
chartTheme,
|
|
116
|
+
showLegend: byModel,
|
|
117
|
+
defaultLabel: "Hits",
|
|
118
|
+
formatValue: formatRateAxis,
|
|
119
|
+
});
|
|
225
120
|
|
|
226
|
-
const sharedScaleBase = {
|
|
227
|
-
grid: { color: chartTheme.grid, drawBorder: false },
|
|
228
|
-
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
229
|
-
};
|
|
121
|
+
const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatRateAxis });
|
|
230
122
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
123
|
+
const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "";
|
|
124
|
+
const metricTabs = (
|
|
125
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
|
|
126
|
+
{METRIC_OPTIONS.map(opt => (
|
|
127
|
+
<button
|
|
128
|
+
key={opt.value}
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => setMetric(opt.value)}
|
|
131
|
+
className={`tab-btn text-xs ${metric === opt.value ? "active" : ""}`}
|
|
132
|
+
>
|
|
133
|
+
{opt.label}
|
|
134
|
+
</button>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
239
138
|
|
|
139
|
+
let chartNode: React.ReactNode;
|
|
240
140
|
if (byModel) {
|
|
241
141
|
const lineData = {
|
|
242
142
|
labels: chartData.labels,
|
|
243
|
-
datasets: chartData
|
|
244
|
-
label: ds.label,
|
|
245
|
-
data: ds.data,
|
|
246
|
-
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
247
|
-
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
|
|
248
|
-
fill: true,
|
|
249
|
-
tension: 0,
|
|
250
|
-
pointRadius: 3,
|
|
251
|
-
pointHoverRadius: 4,
|
|
252
|
-
borderWidth: 2,
|
|
253
|
-
})),
|
|
143
|
+
datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
254
144
|
};
|
|
255
145
|
|
|
256
146
|
const lineOptions: ChartOptions<"line"> = {
|
|
@@ -261,114 +151,39 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
|
|
|
261
151
|
scales: { x: sharedScaleBase, y: yScale },
|
|
262
152
|
};
|
|
263
153
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
empty={chartData.labels.length === 0}
|
|
271
|
-
>
|
|
272
|
-
<Line data={lineData} options={lineOptions} />
|
|
273
|
-
</ChartWrapper>
|
|
274
|
-
);
|
|
275
|
-
}
|
|
154
|
+
chartNode = <Line data={lineData} options={lineOptions} />;
|
|
155
|
+
} else {
|
|
156
|
+
const barData = {
|
|
157
|
+
labels: chartData.labels,
|
|
158
|
+
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
159
|
+
};
|
|
276
160
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
161
|
+
const barOptions: ChartOptions<"bar"> = {
|
|
162
|
+
responsive: true,
|
|
163
|
+
maintainAspectRatio: false,
|
|
164
|
+
interaction: { mode: "index", intersect: false },
|
|
165
|
+
plugins: sharedPlugins,
|
|
166
|
+
scales: {
|
|
167
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
168
|
+
y: { ...yScale, stacked: true },
|
|
169
|
+
},
|
|
170
|
+
layout: { padding: { top: 8 } },
|
|
171
|
+
};
|
|
288
172
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
maintainAspectRatio: false,
|
|
292
|
-
interaction: { mode: "index", intersect: false },
|
|
293
|
-
plugins: sharedPlugins,
|
|
294
|
-
scales: {
|
|
295
|
-
x: { ...sharedScaleBase, stacked: true },
|
|
296
|
-
y: { ...yScale, stacked: true },
|
|
297
|
-
},
|
|
298
|
-
layout: { padding: { top: 8 } },
|
|
299
|
-
};
|
|
173
|
+
chartNode = <Bar data={barData} options={barOptions} />;
|
|
174
|
+
}
|
|
300
175
|
|
|
301
176
|
return (
|
|
302
|
-
<
|
|
177
|
+
<ChartFrame
|
|
178
|
+
title="User Tantrums"
|
|
179
|
+
subtitle={`${metricLabel} as % of user messages per day`}
|
|
180
|
+
empty={chartData.labels.length === 0}
|
|
181
|
+
emptyMessage="No behavioral data yet. Sync to scan your sessions."
|
|
182
|
+
controls={metricTabs}
|
|
303
183
|
byModel={byModel}
|
|
304
|
-
metric={metric}
|
|
305
184
|
onByModelChange={setByModel}
|
|
306
|
-
onMetricChange={setMetric}
|
|
307
|
-
empty={chartData.labels.length === 0}
|
|
308
185
|
>
|
|
309
|
-
|
|
310
|
-
</
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
interface ChartWrapperProps {
|
|
315
|
-
byModel: boolean;
|
|
316
|
-
metric: Metric;
|
|
317
|
-
onByModelChange: (v: boolean) => void;
|
|
318
|
-
onMetricChange: (v: Metric) => void;
|
|
319
|
-
empty: boolean;
|
|
320
|
-
children: React.ReactNode;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function ChartWrapper({ byModel, metric, onByModelChange, onMetricChange, empty, children }: ChartWrapperProps) {
|
|
324
|
-
const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "";
|
|
325
|
-
return (
|
|
326
|
-
<div className="surface overflow-hidden">
|
|
327
|
-
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
|
|
328
|
-
<div>
|
|
329
|
-
<h3 className="text-sm font-semibold text-[var(--text-primary)]">User Tantrums</h3>
|
|
330
|
-
<p className="text-xs text-[var(--text-muted)] mt-1">{metricLabel} as % of user messages per day</p>
|
|
331
|
-
</div>
|
|
332
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
333
|
-
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
|
|
334
|
-
{METRIC_OPTIONS.map(opt => (
|
|
335
|
-
<button
|
|
336
|
-
key={opt.value}
|
|
337
|
-
type="button"
|
|
338
|
-
onClick={() => onMetricChange(opt.value)}
|
|
339
|
-
className={`tab-btn text-xs ${metric === opt.value ? "active" : ""}`}
|
|
340
|
-
>
|
|
341
|
-
{opt.label}
|
|
342
|
-
</button>
|
|
343
|
-
))}
|
|
344
|
-
</div>
|
|
345
|
-
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
|
|
346
|
-
<button
|
|
347
|
-
type="button"
|
|
348
|
-
onClick={() => onByModelChange(false)}
|
|
349
|
-
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
|
|
350
|
-
>
|
|
351
|
-
All Models
|
|
352
|
-
</button>
|
|
353
|
-
<button
|
|
354
|
-
type="button"
|
|
355
|
-
onClick={() => onByModelChange(true)}
|
|
356
|
-
className={`tab-btn text-xs ${byModel ? "active" : ""}`}
|
|
357
|
-
>
|
|
358
|
-
By Model
|
|
359
|
-
</button>
|
|
360
|
-
</div>
|
|
361
|
-
</div>
|
|
362
|
-
</div>
|
|
363
|
-
<div className="p-5 min-h-[320px]">
|
|
364
|
-
{empty ? (
|
|
365
|
-
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
366
|
-
No behavioral data yet. Sync to scan your sessions.
|
|
367
|
-
</div>
|
|
368
|
-
) : (
|
|
369
|
-
<div className="h-[280px]">{children}</div>
|
|
370
|
-
)}
|
|
371
|
-
</div>
|
|
372
|
-
</div>
|
|
186
|
+
{chartNode}
|
|
187
|
+
</ChartFrame>
|
|
373
188
|
);
|
|
374
189
|
}
|