@oh-my-pi/omp-stats 14.9.3 → 14.9.7
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 +279 -42
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +374 -0
- package/src/client/components/BehaviorModelsTable.tsx +465 -0
- package/src/client/components/BehaviorSummary.tsx +95 -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 +63 -0
- package/src/db.ts +420 -26
- package/src/index.ts +29 -3
- package/src/parser.ts +95 -7
- package/src/server.ts +30 -6
- package/src/sync-worker.ts +31 -0
- package/src/types.ts +113 -0
- package/src/user-metrics.ts +686 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarElement,
|
|
3
|
+
CategoryScale,
|
|
4
|
+
Chart as ChartJS,
|
|
5
|
+
type ChartOptions,
|
|
6
|
+
Filler,
|
|
7
|
+
Legend,
|
|
8
|
+
LinearScale,
|
|
9
|
+
LineElement,
|
|
10
|
+
PointElement,
|
|
11
|
+
Title,
|
|
12
|
+
Tooltip,
|
|
13
|
+
} from "chart.js";
|
|
14
|
+
import { format } from "date-fns";
|
|
15
|
+
import { useMemo, useState } from "react";
|
|
16
|
+
import { Bar, Line } from "react-chartjs-2";
|
|
17
|
+
import type { BehaviorTimeSeriesPoint } from "../types";
|
|
18
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
19
|
+
|
|
20
|
+
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
|
|
21
|
+
|
|
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
|
+
const METRIC_OPTIONS = [
|
|
54
|
+
{ value: "yelling", label: "Yelling" },
|
|
55
|
+
{ value: "profanity", label: "Profanity" },
|
|
56
|
+
{ value: "anguish", label: "Anguish (!!!, nooo, dude, ..)" },
|
|
57
|
+
{ value: "negation", label: "Negation (no/nope/wrong)" },
|
|
58
|
+
{ value: "repetition", label: "Repetition (i meant, still doesnt)" },
|
|
59
|
+
{ value: "blame", label: "Blame (you didnt, stop X-ing)" },
|
|
60
|
+
{ value: "frustration", label: "Frustration (neg + rep + blame)" },
|
|
61
|
+
{ value: "total", label: "All signals combined" },
|
|
62
|
+
] as const;
|
|
63
|
+
type Metric = (typeof METRIC_OPTIONS)[number]["value"];
|
|
64
|
+
|
|
65
|
+
function formatRateAxis(value: number): string {
|
|
66
|
+
if (!Number.isFinite(value)) return "-";
|
|
67
|
+
if (value === 0) return "0%";
|
|
68
|
+
if (Math.abs(value) < 1) return `${value.toFixed(1)}%`;
|
|
69
|
+
return `${value.toFixed(0)}%`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface BehaviorChartProps {
|
|
73
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number {
|
|
77
|
+
if (metric === "frustration") return point.negation + point.repetition + point.blame;
|
|
78
|
+
if (metric === "total") {
|
|
79
|
+
return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
|
|
80
|
+
}
|
|
81
|
+
return point[metric];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Hits per 100 user messages, 0 when there were no messages. */
|
|
85
|
+
function ratePercent(hits: number, messages: number): number {
|
|
86
|
+
if (messages <= 0) return 0;
|
|
87
|
+
return (hits / messages) * 100;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ChartSeries {
|
|
91
|
+
labels: string[];
|
|
92
|
+
datasets: Array<{ label: string; data: number[] }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface DailyBucket {
|
|
96
|
+
hits: number;
|
|
97
|
+
messages: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
|
|
101
|
+
if (points.length === 0) return { labels: [], datasets: [] };
|
|
102
|
+
|
|
103
|
+
const byDay = new Map<number, DailyBucket>();
|
|
104
|
+
for (const point of points) {
|
|
105
|
+
const bucket = byDay.get(point.timestamp) ?? { hits: 0, messages: 0 };
|
|
106
|
+
bucket.hits += pointHits(point, metric);
|
|
107
|
+
bucket.messages += point.messages;
|
|
108
|
+
byDay.set(point.timestamp, bucket);
|
|
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
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric, topN = 5): ChartSeries {
|
|
124
|
+
if (points.length === 0) return { labels: [], datasets: [] };
|
|
125
|
+
|
|
126
|
+
// Rank by message volume so the models you actually use surface first,
|
|
127
|
+
// matching the Behavior-by-Model table.
|
|
128
|
+
const totals = new Map<string, { model: string; provider: string; messages: number }>();
|
|
129
|
+
for (const point of points) {
|
|
130
|
+
const key = `${point.model}::${point.provider}`;
|
|
131
|
+
const existing = totals.get(key);
|
|
132
|
+
if (existing) {
|
|
133
|
+
existing.messages += point.messages;
|
|
134
|
+
} else {
|
|
135
|
+
totals.set(key, { model: point.model, provider: point.provider, messages: point.messages });
|
|
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
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
|
|
185
|
+
const [byModel, setByModel] = useState(false);
|
|
186
|
+
const [metric, setMetric] = useState<Metric>("total");
|
|
187
|
+
const theme = useSystemTheme();
|
|
188
|
+
const chartTheme = CHART_THEMES[theme];
|
|
189
|
+
|
|
190
|
+
const chartData = useMemo(
|
|
191
|
+
() => (byModel ? buildByModelSeries(behaviorSeries, metric) : buildAggregateSeries(behaviorSeries, metric)),
|
|
192
|
+
[behaviorSeries, byModel, metric],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const sharedPlugins = {
|
|
196
|
+
legend: {
|
|
197
|
+
display: byModel,
|
|
198
|
+
position: "top" as const,
|
|
199
|
+
align: "start" as const,
|
|
200
|
+
labels: {
|
|
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
|
+
};
|
|
225
|
+
|
|
226
|
+
const sharedScaleBase = {
|
|
227
|
+
grid: { color: chartTheme.grid, drawBorder: false },
|
|
228
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const yScale = {
|
|
232
|
+
...sharedScaleBase,
|
|
233
|
+
ticks: {
|
|
234
|
+
...sharedScaleBase.ticks,
|
|
235
|
+
callback: (value: number | string) => formatRateAxis(Number(value)),
|
|
236
|
+
},
|
|
237
|
+
min: 0,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (byModel) {
|
|
241
|
+
const lineData = {
|
|
242
|
+
labels: chartData.labels,
|
|
243
|
+
datasets: chartData.datasets.map((ds, index) => ({
|
|
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
|
+
})),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const lineOptions: ChartOptions<"line"> = {
|
|
257
|
+
responsive: true,
|
|
258
|
+
maintainAspectRatio: false,
|
|
259
|
+
interaction: { mode: "index", intersect: false },
|
|
260
|
+
plugins: sharedPlugins,
|
|
261
|
+
scales: { x: sharedScaleBase, y: yScale },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<ChartWrapper
|
|
266
|
+
byModel={byModel}
|
|
267
|
+
metric={metric}
|
|
268
|
+
onByModelChange={setByModel}
|
|
269
|
+
onMetricChange={setMetric}
|
|
270
|
+
empty={chartData.labels.length === 0}
|
|
271
|
+
>
|
|
272
|
+
<Line data={lineData} options={lineOptions} />
|
|
273
|
+
</ChartWrapper>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const barData = {
|
|
278
|
+
labels: chartData.labels,
|
|
279
|
+
datasets: chartData.datasets.map((ds, index) => ({
|
|
280
|
+
label: ds.label,
|
|
281
|
+
data: ds.data,
|
|
282
|
+
backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
283
|
+
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
284
|
+
borderWidth: 0,
|
|
285
|
+
borderRadius: 3,
|
|
286
|
+
})),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const barOptions: ChartOptions<"bar"> = {
|
|
290
|
+
responsive: true,
|
|
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
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<ChartWrapper
|
|
303
|
+
byModel={byModel}
|
|
304
|
+
metric={metric}
|
|
305
|
+
onByModelChange={setByModel}
|
|
306
|
+
onMetricChange={setMetric}
|
|
307
|
+
empty={chartData.labels.length === 0}
|
|
308
|
+
>
|
|
309
|
+
<Bar data={barData} options={barOptions} />
|
|
310
|
+
</ChartWrapper>
|
|
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>
|
|
373
|
+
);
|
|
374
|
+
}
|