@oh-my-pi/omp-stats 16.0.4 → 16.0.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/CHANGELOG.md +15 -0
- package/build.ts +11 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +11 -0
- package/dist/client/index.js +108 -108
- package/dist/client/styles.css +1070 -631
- package/dist/types/client/api.d.ts +19 -10
- package/dist/types/client/app/AppLayout.d.ts +16 -0
- package/dist/types/client/app/NavRail.d.ts +7 -0
- package/dist/types/client/app/RangeControl.d.ts +7 -0
- package/dist/types/client/app/SyncButton.d.ts +14 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/TopBar.d.ts +15 -0
- package/dist/types/client/app/routes.d.ts +12 -0
- package/dist/types/client/components/chart-shared.d.ts +26 -40
- package/dist/types/client/components/models-table-shared.d.ts +20 -40
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +7 -0
- package/dist/types/client/data/useHashRoute.d.ts +8 -0
- package/dist/types/client/data/useResource.d.ts +13 -0
- package/dist/types/client/data/view-models.d.ts +37 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
- package/dist/types/client/routes/CostsRoute.d.ts +7 -0
- package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
- package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
- package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
- package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
- package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
- package/dist/types/client/routes/index.d.ts +7 -0
- package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
- package/dist/types/client/ui/DataTable.d.ts +17 -0
- package/dist/types/client/ui/EmptyState.d.ts +7 -0
- package/dist/types/client/ui/ErrorState.d.ts +6 -0
- package/dist/types/client/ui/JsonBlock.d.ts +7 -0
- package/dist/types/client/ui/MetricCluster.d.ts +5 -0
- package/dist/types/client/ui/Panel.d.ts +7 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
- package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
- package/dist/types/client/ui/Skeleton.d.ts +8 -0
- package/dist/types/client/ui/StatusPill.d.ts +7 -0
- package/dist/types/client/ui/index.d.ts +11 -0
- package/dist/types/client/useSystemTheme.d.ts +9 -0
- package/package.json +4 -4
- package/src/aggregator.ts +4 -3
- package/src/client/App.tsx +89 -207
- package/src/client/api.ts +55 -37
- package/src/client/app/AppLayout.tsx +93 -0
- package/src/client/app/NavRail.tsx +44 -0
- package/src/client/app/RangeControl.tsx +39 -0
- package/src/client/app/SyncButton.tsx +75 -0
- package/src/client/app/ThemeToggle.tsx +37 -0
- package/src/client/app/TopBar.tsx +73 -0
- package/src/client/app/routes.ts +50 -0
- package/src/client/components/chart-shared.tsx +28 -91
- package/src/client/components/models-table-shared.tsx +9 -29
- package/src/client/components/range-meta.ts +3 -2
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +38 -0
- package/src/client/data/useHashRoute.ts +85 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +178 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/BehaviorRoute.tsx +623 -0
- package/src/client/routes/CostsRoute.tsx +234 -0
- package/src/client/routes/ErrorsRoute.tsx +118 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +332 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/index.ts +7 -0
- package/src/client/styles.css +1242 -225
- package/src/client/ui/AsyncBoundary.tsx +54 -0
- package/src/client/ui/DataTable.tsx +122 -0
- package/src/client/ui/EmptyState.tsx +16 -0
- package/src/client/ui/ErrorState.tsx +25 -0
- package/src/client/ui/JsonBlock.tsx +75 -0
- package/src/client/ui/MetricCluster.tsx +67 -0
- package/src/client/ui/Panel.tsx +24 -0
- package/src/client/ui/RequestDrawer.tsx +208 -0
- package/src/client/ui/SegmentedControl.tsx +36 -0
- package/src/client/ui/Skeleton.tsx +17 -0
- package/src/client/ui/StatusPill.tsx +15 -0
- package/src/client/ui/index.ts +11 -0
- package/src/client/useSystemTheme.ts +73 -17
- package/dist/types/client/components/BehaviorChart.d.ts +0 -6
- package/dist/types/client/components/BehaviorModelsTable.d.ts +0 -7
- package/dist/types/client/components/BehaviorSummary.d.ts +0 -7
- package/dist/types/client/components/ChartsContainer.d.ts +0 -7
- package/dist/types/client/components/CostChart.d.ts +0 -6
- package/dist/types/client/components/CostSummary.d.ts +0 -6
- package/dist/types/client/components/Header.d.ts +0 -12
- package/dist/types/client/components/ModelsTable.d.ts +0 -8
- package/dist/types/client/components/RequestDetail.d.ts +0 -6
- package/dist/types/client/components/RequestList.d.ts +0 -8
- package/dist/types/client/components/StatsGrid.d.ts +0 -6
- package/src/client/components/BehaviorChart.tsx +0 -189
- package/src/client/components/BehaviorModelsTable.tsx +0 -342
- package/src/client/components/BehaviorSummary.tsx +0 -95
- package/src/client/components/ChartsContainer.tsx +0 -221
- package/src/client/components/CostChart.tsx +0 -171
- package/src/client/components/CostSummary.tsx +0 -53
- package/src/client/components/Header.tsx +0 -72
- package/src/client/components/ModelsTable.tsx +0 -265
- package/src/client/components/RequestDetail.tsx +0 -172
- package/src/client/components/RequestList.tsx +0 -73
- package/src/client/components/StatsGrid.tsx +0 -135
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { format } from "date-fns";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Bar, Line } from "react-chartjs-2";
|
|
4
|
+
import { getBehaviorDashboardStats } from "../api";
|
|
5
|
+
import {
|
|
6
|
+
barDatasetStyle,
|
|
7
|
+
buildAggregateTimeSeries,
|
|
8
|
+
buildSharedPlugins,
|
|
9
|
+
buildSharedScales,
|
|
10
|
+
buildTopNByModelSeries,
|
|
11
|
+
CHART_THEMES,
|
|
12
|
+
lineDatasetStyle,
|
|
13
|
+
MODEL_COLORS,
|
|
14
|
+
styleDatasets,
|
|
15
|
+
} from "../components/chart-shared";
|
|
16
|
+
import {
|
|
17
|
+
DetailChartEmpty,
|
|
18
|
+
detailChartPlugins,
|
|
19
|
+
detailChartScalesSingleAxis,
|
|
20
|
+
ExpandableModelRow,
|
|
21
|
+
lineSeriesStyle,
|
|
22
|
+
MiniSparkline,
|
|
23
|
+
ModelNameCell,
|
|
24
|
+
ModelTableBody,
|
|
25
|
+
ModelTableHeader,
|
|
26
|
+
ModelTableShell,
|
|
27
|
+
TABLE_CHART_THEMES,
|
|
28
|
+
type TableChartTheme,
|
|
29
|
+
TrendEmpty,
|
|
30
|
+
} from "../components/models-table-shared";
|
|
31
|
+
import { formatInteger } from "../data/formatters";
|
|
32
|
+
import { useResource } from "../data/useResource";
|
|
33
|
+
import { buildBehaviorSummary } from "../data/view-models";
|
|
34
|
+
import type { BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, TimeRange } from "../types";
|
|
35
|
+
import { AsyncBoundary, Panel, SegmentedControl } from "../ui";
|
|
36
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
37
|
+
|
|
38
|
+
export interface BehaviorRouteProps {
|
|
39
|
+
active: boolean;
|
|
40
|
+
range: TimeRange;
|
|
41
|
+
refreshTrigger: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function BehaviorRoute({ active, range, refreshTrigger }: BehaviorRouteProps) {
|
|
45
|
+
const {
|
|
46
|
+
data: stats,
|
|
47
|
+
error,
|
|
48
|
+
loading,
|
|
49
|
+
} = useResource(["behavior", range, refreshTrigger], signal => getBehaviorDashboardStats(range, signal), {
|
|
50
|
+
pollMs: 30000,
|
|
51
|
+
enabled: active,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="stats-route-container space-y-6">
|
|
56
|
+
<AsyncBoundary loading={loading} error={error} data={stats}>
|
|
57
|
+
{stats && (
|
|
58
|
+
<>
|
|
59
|
+
<BehaviorSummaryPanel overall={stats.overall} behaviorSeries={stats.behaviorSeries} />
|
|
60
|
+
<BehaviorChartPanel behaviorSeries={stats.behaviorSeries} />
|
|
61
|
+
<BehaviorModelsTable models={stats.byModel} behaviorSeries={stats.behaviorSeries} />
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
64
|
+
</AsyncBoundary>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function perMsg(total: number, messages: number): string | undefined {
|
|
70
|
+
if (messages <= 0) return undefined;
|
|
71
|
+
return `${(total / messages).toFixed(2)} / msg`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function BehaviorSummaryPanel({
|
|
75
|
+
overall,
|
|
76
|
+
behaviorSeries,
|
|
77
|
+
}: {
|
|
78
|
+
overall: BehaviorOverallStats;
|
|
79
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
80
|
+
}) {
|
|
81
|
+
const summary = useMemo(() => buildBehaviorSummary(overall, behaviorSeries), [overall, behaviorSeries]);
|
|
82
|
+
const messages = overall.totalMessages;
|
|
83
|
+
|
|
84
|
+
const cards = [
|
|
85
|
+
{
|
|
86
|
+
label: "User Messages",
|
|
87
|
+
value: formatInteger(overall.totalMessages),
|
|
88
|
+
sub: messages > 0 ? "in range" : undefined,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: "Yelling (CAPS)",
|
|
92
|
+
value: formatInteger(overall.totalYelling),
|
|
93
|
+
sub: perMsg(overall.totalYelling, messages),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: "Profanity Hits",
|
|
97
|
+
value: formatInteger(overall.totalProfanity),
|
|
98
|
+
sub: perMsg(overall.totalProfanity, messages),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: "Anguish Signals",
|
|
102
|
+
value: formatInteger(overall.totalAnguish),
|
|
103
|
+
sub: perMsg(overall.totalAnguish, messages),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: "Friction Signals",
|
|
107
|
+
value: formatInteger(summary.totalFrustration),
|
|
108
|
+
sub: perMsg(summary.totalFrustration, messages),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
label: "Highest Friction Model",
|
|
112
|
+
value: summary.highestFrictionModel?.model ?? "—",
|
|
113
|
+
sub: summary.highestFrictionModel ? `${formatInteger(summary.highestFrictionModel.score)} hits` : undefined,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
|
119
|
+
{cards.map(card => (
|
|
120
|
+
<Panel key={card.label} className="stats-behavior-summary-card py-3 px-4">
|
|
121
|
+
<p className="text-xs stats-text-muted mb-1 font-medium truncate">{card.label}</p>
|
|
122
|
+
<p className="text-lg font-bold stats-text-primary truncate" title={card.value}>
|
|
123
|
+
{card.value}
|
|
124
|
+
</p>
|
|
125
|
+
{card.sub && <p className="text-xs stats-text-muted mt-0.5">{card.sub}</p>}
|
|
126
|
+
</Panel>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const METRIC_OPTIONS = [
|
|
133
|
+
{ value: "yelling", label: "CAPS", title: "Yelling (CAPS)" },
|
|
134
|
+
{ value: "profanity", label: "Profanity", title: "Profanity" },
|
|
135
|
+
{ value: "anguish", label: "Anguish", title: "Anguish (!!!, nooo, dude, ..)" },
|
|
136
|
+
{ value: "negation", label: "Negation", title: "Negation (no/nope/wrong)" },
|
|
137
|
+
{
|
|
138
|
+
value: "repetition",
|
|
139
|
+
label: "Repetition",
|
|
140
|
+
title: "Repetition (i meant, still doesnt)",
|
|
141
|
+
},
|
|
142
|
+
{ value: "blame", label: "Blame", title: "Blame (you didnt, stop X-ing)" },
|
|
143
|
+
{
|
|
144
|
+
value: "frustration",
|
|
145
|
+
label: "Frustration",
|
|
146
|
+
title: "Frustration (neg + rep + blame)",
|
|
147
|
+
},
|
|
148
|
+
{ value: "total", label: "All", title: "All signals combined" },
|
|
149
|
+
] as const;
|
|
150
|
+
|
|
151
|
+
type Metric = (typeof METRIC_OPTIONS)[number]["value"];
|
|
152
|
+
|
|
153
|
+
function formatRateAxis(value: number): string {
|
|
154
|
+
if (!Number.isFinite(value)) return "-";
|
|
155
|
+
if (value === 0) return "0%";
|
|
156
|
+
if (Math.abs(value) < 1) return `${value.toFixed(1)}%`;
|
|
157
|
+
return `${value.toFixed(0)}%`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number {
|
|
161
|
+
if (metric === "frustration") {
|
|
162
|
+
return point.negation + point.repetition + point.blame;
|
|
163
|
+
}
|
|
164
|
+
if (metric === "total") {
|
|
165
|
+
return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
|
|
166
|
+
}
|
|
167
|
+
return point[metric];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ratePercent(hits: number, messages: number): number {
|
|
171
|
+
if (messages <= 0) return 0;
|
|
172
|
+
return (hits / messages) * 100;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface DailyBucket {
|
|
176
|
+
hits: number;
|
|
177
|
+
messages: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function BehaviorChartPanel({ behaviorSeries }: { behaviorSeries: BehaviorTimeSeriesPoint[] }) {
|
|
181
|
+
const [byModel, setByModel] = useState(false);
|
|
182
|
+
const [metric, setMetric] = useState<Metric>("total");
|
|
183
|
+
const theme = useSystemTheme();
|
|
184
|
+
const chartTheme = CHART_THEMES[theme];
|
|
185
|
+
|
|
186
|
+
const chartData = useMemo(() => {
|
|
187
|
+
if (byModel) {
|
|
188
|
+
return buildTopNByModelSeries<BehaviorTimeSeriesPoint, DailyBucket>(behaviorSeries, {
|
|
189
|
+
rankWeight: point => point.messages,
|
|
190
|
+
initBucket: () => ({ hits: 0, messages: 0 }),
|
|
191
|
+
accumulate: (bucket, point) => {
|
|
192
|
+
bucket.hits += pointHits(point, metric);
|
|
193
|
+
bucket.messages += point.messages;
|
|
194
|
+
},
|
|
195
|
+
bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.title ?? "Hits";
|
|
199
|
+
return buildAggregateTimeSeries<BehaviorTimeSeriesPoint, DailyBucket>(behaviorSeries, metricLabel, {
|
|
200
|
+
initBucket: () => ({ hits: 0, messages: 0 }),
|
|
201
|
+
accumulate: (bucket, point) => {
|
|
202
|
+
bucket.hits += pointHits(point, metric);
|
|
203
|
+
bucket.messages += point.messages;
|
|
204
|
+
},
|
|
205
|
+
bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
|
|
206
|
+
});
|
|
207
|
+
}, [behaviorSeries, byModel, metric]);
|
|
208
|
+
|
|
209
|
+
const sharedPlugins = useMemo(() => {
|
|
210
|
+
return buildSharedPlugins({
|
|
211
|
+
chartTheme,
|
|
212
|
+
showLegend: byModel,
|
|
213
|
+
defaultLabel: "Hits",
|
|
214
|
+
formatValue: formatRateAxis,
|
|
215
|
+
});
|
|
216
|
+
}, [chartTheme, byModel]);
|
|
217
|
+
|
|
218
|
+
const { sharedScaleBase, yScale } = useMemo(() => {
|
|
219
|
+
return buildSharedScales({ chartTheme, formatY: formatRateAxis });
|
|
220
|
+
}, [chartTheme]);
|
|
221
|
+
|
|
222
|
+
const metricLabel = useMemo(() => {
|
|
223
|
+
return METRIC_OPTIONS.find(m => m.value === metric)?.title ?? "";
|
|
224
|
+
}, [metric]);
|
|
225
|
+
|
|
226
|
+
const lineData = useMemo(() => {
|
|
227
|
+
if (!byModel) return null;
|
|
228
|
+
return {
|
|
229
|
+
labels: chartData.labels,
|
|
230
|
+
datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
231
|
+
};
|
|
232
|
+
}, [chartData, byModel]);
|
|
233
|
+
|
|
234
|
+
const lineOptions = useMemo(() => {
|
|
235
|
+
return {
|
|
236
|
+
responsive: true,
|
|
237
|
+
maintainAspectRatio: false,
|
|
238
|
+
interaction: { mode: "index" as const, intersect: false },
|
|
239
|
+
plugins: sharedPlugins,
|
|
240
|
+
scales: { x: sharedScaleBase, y: yScale },
|
|
241
|
+
};
|
|
242
|
+
}, [sharedPlugins, sharedScaleBase, yScale]);
|
|
243
|
+
|
|
244
|
+
const barData = useMemo(() => {
|
|
245
|
+
if (byModel) return null;
|
|
246
|
+
return {
|
|
247
|
+
labels: chartData.labels,
|
|
248
|
+
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
249
|
+
};
|
|
250
|
+
}, [chartData, byModel]);
|
|
251
|
+
|
|
252
|
+
const barOptions = useMemo(() => {
|
|
253
|
+
return {
|
|
254
|
+
responsive: true,
|
|
255
|
+
maintainAspectRatio: false,
|
|
256
|
+
interaction: { mode: "index" as const, intersect: false },
|
|
257
|
+
plugins: sharedPlugins,
|
|
258
|
+
scales: {
|
|
259
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
260
|
+
y: { ...yScale, stacked: true },
|
|
261
|
+
},
|
|
262
|
+
layout: { padding: { top: 8 } },
|
|
263
|
+
};
|
|
264
|
+
}, [sharedPlugins, sharedScaleBase, yScale]);
|
|
265
|
+
|
|
266
|
+
const byModelOptions = [
|
|
267
|
+
{ value: false, label: "All Models" },
|
|
268
|
+
{ value: true, label: "By Model" },
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<Panel
|
|
273
|
+
title="User Friction Signals"
|
|
274
|
+
subtitle={`${metricLabel} as % of user messages per day`}
|
|
275
|
+
actions={
|
|
276
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
277
|
+
<SegmentedControl
|
|
278
|
+
options={METRIC_OPTIONS.map(o => ({
|
|
279
|
+
value: o.value,
|
|
280
|
+
label: o.label,
|
|
281
|
+
title: o.title,
|
|
282
|
+
}))}
|
|
283
|
+
value={metric}
|
|
284
|
+
onChange={setMetric}
|
|
285
|
+
/>
|
|
286
|
+
<SegmentedControl options={byModelOptions} value={byModel} onChange={setByModel} />
|
|
287
|
+
</div>
|
|
288
|
+
}
|
|
289
|
+
>
|
|
290
|
+
<div className="h-[300px]">
|
|
291
|
+
{chartData.labels.length === 0 ? (
|
|
292
|
+
<div className="h-full flex items-center justify-center text-stats-muted text-sm">
|
|
293
|
+
No friction signal data available
|
|
294
|
+
</div>
|
|
295
|
+
) : byModel && lineData ? (
|
|
296
|
+
<Line data={lineData} options={lineOptions} />
|
|
297
|
+
) : barData ? (
|
|
298
|
+
<Bar data={barData} options={barOptions} />
|
|
299
|
+
) : null}
|
|
300
|
+
</div>
|
|
301
|
+
</Panel>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const TABLE_GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px";
|
|
306
|
+
|
|
307
|
+
function totalHitRate(model: BehaviorModelStats): number {
|
|
308
|
+
if (model.totalMessages === 0) return 0;
|
|
309
|
+
const hits =
|
|
310
|
+
model.totalYelling +
|
|
311
|
+
model.totalProfanity +
|
|
312
|
+
model.totalAnguish +
|
|
313
|
+
model.totalNegation +
|
|
314
|
+
model.totalRepetition +
|
|
315
|
+
model.totalBlame;
|
|
316
|
+
return hits / model.totalMessages;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatRate(total: number, messages: number): string {
|
|
320
|
+
if (messages === 0) return "-";
|
|
321
|
+
const pct = (total / messages) * 100;
|
|
322
|
+
if (pct === 0) return "0%";
|
|
323
|
+
if (pct < 1) return `${pct.toFixed(1)}%`;
|
|
324
|
+
return `${pct.toFixed(0)}%`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function BehaviorModelsTable({
|
|
328
|
+
models,
|
|
329
|
+
behaviorSeries,
|
|
330
|
+
}: {
|
|
331
|
+
models: BehaviorModelStats[];
|
|
332
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
333
|
+
}) {
|
|
334
|
+
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
335
|
+
const theme = useSystemTheme();
|
|
336
|
+
const chartTheme = TABLE_CHART_THEMES[theme];
|
|
337
|
+
|
|
338
|
+
const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]);
|
|
339
|
+
|
|
340
|
+
const sortedModels = useMemo(() => {
|
|
341
|
+
return [...models].sort((a, b) => {
|
|
342
|
+
if (b.totalMessages !== a.totalMessages) {
|
|
343
|
+
return b.totalMessages - a.totalMessages;
|
|
344
|
+
}
|
|
345
|
+
return totalHitRate(b) - totalHitRate(a);
|
|
346
|
+
});
|
|
347
|
+
}, [models]);
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<ModelTableShell title="Behavior Signals by Model" subtitle="Rates are per user message">
|
|
351
|
+
<ModelTableHeader
|
|
352
|
+
gridTemplate={TABLE_GRID_TEMPLATE}
|
|
353
|
+
columns={[
|
|
354
|
+
{ label: "Model" },
|
|
355
|
+
{ label: "Messages", align: "right" },
|
|
356
|
+
{ label: "CAPS %", align: "right" },
|
|
357
|
+
{ label: "Profanity %", align: "right" },
|
|
358
|
+
{ label: "Anguish %", align: "right" },
|
|
359
|
+
{ label: "Frustration %", align: "right" },
|
|
360
|
+
{ label: "Hits %", align: "right" },
|
|
361
|
+
{ label: "Trend", align: "center" },
|
|
362
|
+
]}
|
|
363
|
+
/>
|
|
364
|
+
|
|
365
|
+
<ModelTableBody>
|
|
366
|
+
{sortedModels.map((model, index) => {
|
|
367
|
+
const key = `${model.model}::${model.provider}`;
|
|
368
|
+
const trend = trendByKey.get(key)?.data ?? [];
|
|
369
|
+
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
|
|
370
|
+
const isExpanded = expandedKey === key;
|
|
371
|
+
const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame;
|
|
372
|
+
const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration;
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<ExpandableModelRow
|
|
376
|
+
key={key}
|
|
377
|
+
gridTemplate={TABLE_GRID_TEMPLATE}
|
|
378
|
+
isExpanded={isExpanded}
|
|
379
|
+
onToggle={() => setExpandedKey(isExpanded ? null : key)}
|
|
380
|
+
cells={[
|
|
381
|
+
<ModelNameCell key="name" model={model.model} provider={model.provider} />,
|
|
382
|
+
<div key="messages" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
383
|
+
{formatInteger(model.totalMessages)}
|
|
384
|
+
</div>,
|
|
385
|
+
<div key="caps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
386
|
+
{formatRate(model.totalYelling, model.totalMessages)}
|
|
387
|
+
</div>,
|
|
388
|
+
<div key="profanity" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
389
|
+
{formatRate(model.totalProfanity, model.totalMessages)}
|
|
390
|
+
</div>,
|
|
391
|
+
<div key="anguish" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
392
|
+
{formatRate(model.totalAnguish, model.totalMessages)}
|
|
393
|
+
</div>,
|
|
394
|
+
<div key="frustration" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
395
|
+
{formatRate(totalFrustration, model.totalMessages)}
|
|
396
|
+
</div>,
|
|
397
|
+
<div key="hits" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
398
|
+
{formatRate(totalHits, model.totalMessages)}
|
|
399
|
+
</div>,
|
|
400
|
+
]}
|
|
401
|
+
trendCell={
|
|
402
|
+
trend.length === 0 ? (
|
|
403
|
+
<TrendEmpty />
|
|
404
|
+
) : (
|
|
405
|
+
<MiniSparkline
|
|
406
|
+
timestamps={trend.map(d => d.timestamp)}
|
|
407
|
+
values={trend.map(d => d.total)}
|
|
408
|
+
color={trendColor}
|
|
409
|
+
/>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
expandedContent={
|
|
413
|
+
<div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
|
|
414
|
+
<div className="space-y-4 text-sm">
|
|
415
|
+
<DetailRow
|
|
416
|
+
label="Yelling (CAPS)"
|
|
417
|
+
total={model.totalYelling}
|
|
418
|
+
messages={model.totalMessages}
|
|
419
|
+
valueClass="text-[#ed4abf]"
|
|
420
|
+
/>
|
|
421
|
+
<DetailRow
|
|
422
|
+
label="Profanity"
|
|
423
|
+
total={model.totalProfanity}
|
|
424
|
+
messages={model.totalMessages}
|
|
425
|
+
valueClass="text-[#ff6b7d]"
|
|
426
|
+
/>
|
|
427
|
+
<DetailRow
|
|
428
|
+
label="Anguish (!!!, nooo, dude, ..)"
|
|
429
|
+
total={model.totalAnguish}
|
|
430
|
+
messages={model.totalMessages}
|
|
431
|
+
valueClass="text-[#9b4dff]"
|
|
432
|
+
/>
|
|
433
|
+
<DetailRow
|
|
434
|
+
label="Negation (no/nope/wrong)"
|
|
435
|
+
total={model.totalNegation}
|
|
436
|
+
messages={model.totalMessages}
|
|
437
|
+
valueClass="text-[#5ad8e6]"
|
|
438
|
+
/>
|
|
439
|
+
<DetailRow
|
|
440
|
+
label="Repetition (i meant, still doesnt)"
|
|
441
|
+
total={model.totalRepetition}
|
|
442
|
+
messages={model.totalMessages}
|
|
443
|
+
valueClass="text-[#5ad8e6]"
|
|
444
|
+
/>
|
|
445
|
+
<DetailRow
|
|
446
|
+
label="Blame (you didnt, stop X-ing)"
|
|
447
|
+
total={model.totalBlame}
|
|
448
|
+
messages={model.totalMessages}
|
|
449
|
+
valueClass="text-[#5ad8e6]"
|
|
450
|
+
/>
|
|
451
|
+
<DetailRow
|
|
452
|
+
label="Avg chars / msg"
|
|
453
|
+
total={model.totalChars}
|
|
454
|
+
messages={model.totalMessages}
|
|
455
|
+
valueClass="stats-text-secondary"
|
|
456
|
+
mode="average"
|
|
457
|
+
/>
|
|
458
|
+
</div>
|
|
459
|
+
<div className="h-[200px]">
|
|
460
|
+
{trend.length === 0 ? (
|
|
461
|
+
<DetailChartEmpty />
|
|
462
|
+
) : (
|
|
463
|
+
<BreakdownChart data={trend} chartTheme={chartTheme} />
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
}
|
|
468
|
+
/>
|
|
469
|
+
);
|
|
470
|
+
})}
|
|
471
|
+
{sortedModels.length === 0 ? (
|
|
472
|
+
<div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
|
|
473
|
+
No user behavior recorded for this range yet.
|
|
474
|
+
</div>
|
|
475
|
+
) : null}
|
|
476
|
+
</ModelTableBody>
|
|
477
|
+
</ModelTableShell>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function DetailRow({
|
|
482
|
+
label,
|
|
483
|
+
total,
|
|
484
|
+
messages,
|
|
485
|
+
valueClass,
|
|
486
|
+
mode = "rate",
|
|
487
|
+
}: {
|
|
488
|
+
label: string;
|
|
489
|
+
total: number;
|
|
490
|
+
messages: number;
|
|
491
|
+
valueClass: string;
|
|
492
|
+
mode?: "rate" | "average";
|
|
493
|
+
}) {
|
|
494
|
+
const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg";
|
|
495
|
+
const perMsgValue = useMemo(() => {
|
|
496
|
+
if (messages === 0) return "-";
|
|
497
|
+
return mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0);
|
|
498
|
+
}, [total, messages, mode]);
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<div>
|
|
502
|
+
<div className="text-[var(--text-primary)] font-medium mb-1">{label}</div>
|
|
503
|
+
<div className="space-y-0.5 text-[var(--text-secondary)]">
|
|
504
|
+
<div className="flex items-center justify-between">
|
|
505
|
+
<span className="stats-text-muted text-xs">Total</span>
|
|
506
|
+
<span className={`font-mono text-xs ${valueClass}`}>{formatInteger(total)}</span>
|
|
507
|
+
</div>
|
|
508
|
+
<div className="flex items-center justify-between">
|
|
509
|
+
<span className="stats-text-muted text-xs">{perMsgLabel}</span>
|
|
510
|
+
<span className="font-mono text-xs stats-text-primary">{perMsgValue}</span>
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const SERIES_COLORS = {
|
|
518
|
+
yelling: "#ed4abf", // brand pink
|
|
519
|
+
profanity: "#ff6b7d", // rose
|
|
520
|
+
anguish: "#9b4dff", // brand violet
|
|
521
|
+
frustration: "#5ad8e6", // brand cyan
|
|
522
|
+
} as const;
|
|
523
|
+
|
|
524
|
+
function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) {
|
|
525
|
+
const chartData = useMemo(() => {
|
|
526
|
+
return {
|
|
527
|
+
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
528
|
+
datasets: [
|
|
529
|
+
{
|
|
530
|
+
label: "CAPS",
|
|
531
|
+
data: data.map(d => d.yelling),
|
|
532
|
+
...lineSeriesStyle(SERIES_COLORS.yelling),
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
label: "Profanity",
|
|
536
|
+
data: data.map(d => d.profanity),
|
|
537
|
+
...lineSeriesStyle(SERIES_COLORS.profanity),
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
label: "Anguish",
|
|
541
|
+
data: data.map(d => d.anguish),
|
|
542
|
+
...lineSeriesStyle(SERIES_COLORS.anguish),
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
label: "Frustration",
|
|
546
|
+
data: data.map(d => d.frustration),
|
|
547
|
+
...lineSeriesStyle(SERIES_COLORS.frustration),
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
}, [data]);
|
|
552
|
+
|
|
553
|
+
const options = useMemo(() => {
|
|
554
|
+
return {
|
|
555
|
+
responsive: true,
|
|
556
|
+
maintainAspectRatio: false,
|
|
557
|
+
plugins: detailChartPlugins(chartTheme),
|
|
558
|
+
scales: detailChartScalesSingleAxis(chartTheme),
|
|
559
|
+
};
|
|
560
|
+
}, [chartTheme]);
|
|
561
|
+
|
|
562
|
+
return <Line data={chartData} options={options} />;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
interface DailyPoint {
|
|
566
|
+
timestamp: number;
|
|
567
|
+
yelling: number;
|
|
568
|
+
profanity: number;
|
|
569
|
+
anguish: number;
|
|
570
|
+
frustration: number;
|
|
571
|
+
total: number;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
interface ModelTrendSeries {
|
|
575
|
+
data: DailyPoint[];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
|
|
579
|
+
if (points.length === 0) return new Map();
|
|
580
|
+
|
|
581
|
+
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
582
|
+
const byKey = new Map<string, Map<number, DailyPoint>>();
|
|
583
|
+
|
|
584
|
+
for (const point of points) {
|
|
585
|
+
const key = `${point.model}::${point.provider}`;
|
|
586
|
+
let dayMap = byKey.get(key);
|
|
587
|
+
if (!dayMap) {
|
|
588
|
+
dayMap = new Map();
|
|
589
|
+
byKey.set(key, dayMap);
|
|
590
|
+
}
|
|
591
|
+
const existing = dayMap.get(point.timestamp) ?? {
|
|
592
|
+
timestamp: point.timestamp,
|
|
593
|
+
yelling: 0,
|
|
594
|
+
profanity: 0,
|
|
595
|
+
anguish: 0,
|
|
596
|
+
frustration: 0,
|
|
597
|
+
total: 0,
|
|
598
|
+
};
|
|
599
|
+
existing.yelling += point.yelling;
|
|
600
|
+
existing.profanity += point.profanity;
|
|
601
|
+
existing.anguish += point.anguish;
|
|
602
|
+
existing.frustration += point.negation + point.repetition + point.blame;
|
|
603
|
+
existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration;
|
|
604
|
+
dayMap.set(point.timestamp, existing);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const out = new Map<string, ModelTrendSeries>();
|
|
608
|
+
for (const [key, dayMap] of byKey) {
|
|
609
|
+
const data = allDays.map(
|
|
610
|
+
ts =>
|
|
611
|
+
dayMap.get(ts) ?? {
|
|
612
|
+
timestamp: ts,
|
|
613
|
+
yelling: 0,
|
|
614
|
+
profanity: 0,
|
|
615
|
+
anguish: 0,
|
|
616
|
+
frustration: 0,
|
|
617
|
+
total: 0,
|
|
618
|
+
},
|
|
619
|
+
);
|
|
620
|
+
out.set(key, { data });
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|