@oh-my-pi/omp-stats 16.0.3 → 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
|
@@ -1,342 +0,0 @@
|
|
|
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 { useMemo, useState } from "react";
|
|
13
|
-
import { Line } from "react-chartjs-2";
|
|
14
|
-
import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types";
|
|
15
|
-
import { useSystemTheme } from "../useSystemTheme";
|
|
16
|
-
import {
|
|
17
|
-
DetailChartEmpty,
|
|
18
|
-
detailChartPlugins,
|
|
19
|
-
detailChartScalesSingleAxis,
|
|
20
|
-
ExpandableModelRow,
|
|
21
|
-
lineSeriesStyle,
|
|
22
|
-
MiniSparkline,
|
|
23
|
-
MODEL_COLORS,
|
|
24
|
-
ModelNameCell,
|
|
25
|
-
ModelTableBody,
|
|
26
|
-
ModelTableHeader,
|
|
27
|
-
ModelTableShell,
|
|
28
|
-
TABLE_CHART_THEMES,
|
|
29
|
-
type TableChartTheme,
|
|
30
|
-
TrendEmpty,
|
|
31
|
-
} from "./models-table-shared";
|
|
32
|
-
|
|
33
|
-
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
|
34
|
-
|
|
35
|
-
const SERIES_COLORS = {
|
|
36
|
-
yelling: "#fbbf24", // amber
|
|
37
|
-
profanity: "#f87171", // red
|
|
38
|
-
anguish: "#a78bfa", // violet
|
|
39
|
-
frustration: "#22d3ee", // cyan - new semantic signals
|
|
40
|
-
} as const;
|
|
41
|
-
|
|
42
|
-
interface BehaviorModelsTableProps {
|
|
43
|
-
models: BehaviorModelStats[];
|
|
44
|
-
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface DailyPoint {
|
|
48
|
-
timestamp: number;
|
|
49
|
-
yelling: number;
|
|
50
|
-
profanity: number;
|
|
51
|
-
anguish: number;
|
|
52
|
-
frustration: number;
|
|
53
|
-
total: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface ModelTrendSeries {
|
|
57
|
-
data: DailyPoint[];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px";
|
|
61
|
-
|
|
62
|
-
function formatInt(value: number): string {
|
|
63
|
-
return value.toLocaleString();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function totalHitRate(model: BehaviorModelStats): number {
|
|
67
|
-
if (model.totalMessages === 0) return 0;
|
|
68
|
-
const hits =
|
|
69
|
-
model.totalYelling +
|
|
70
|
-
model.totalProfanity +
|
|
71
|
-
model.totalAnguish +
|
|
72
|
-
model.totalNegation +
|
|
73
|
-
model.totalRepetition +
|
|
74
|
-
model.totalBlame;
|
|
75
|
-
return hits / model.totalMessages;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Rate-as-percent. < 1% shows one decimal so a 0.4% model doesn't read as 0%.
|
|
80
|
-
*/
|
|
81
|
-
function formatRate(total: number, messages: number): string {
|
|
82
|
-
if (messages === 0) return "-";
|
|
83
|
-
const pct = (total / messages) * 100;
|
|
84
|
-
if (pct === 0) return "0%";
|
|
85
|
-
if (pct < 1) return `${pct.toFixed(1)}%`;
|
|
86
|
-
return `${pct.toFixed(0)}%`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) {
|
|
90
|
-
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
91
|
-
const theme = useSystemTheme();
|
|
92
|
-
const chartTheme = TABLE_CHART_THEMES[theme];
|
|
93
|
-
|
|
94
|
-
const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]);
|
|
95
|
-
|
|
96
|
-
// Sort by usage so the models you actually rely on surface first; rates
|
|
97
|
-
// stay visible per row so a low-volume freak doesn't dominate.
|
|
98
|
-
const sortedModels = [...models].sort((a, b) => {
|
|
99
|
-
if (b.totalMessages !== a.totalMessages) return b.totalMessages - a.totalMessages;
|
|
100
|
-
return totalHitRate(b) - totalHitRate(a);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
return (
|
|
104
|
-
<ModelTableShell
|
|
105
|
-
title="Behavior by Model"
|
|
106
|
-
subtitle="How often each model elicited a tantrum — rates are per user message"
|
|
107
|
-
>
|
|
108
|
-
<ModelTableHeader
|
|
109
|
-
gridTemplate={GRID_TEMPLATE}
|
|
110
|
-
columns={[
|
|
111
|
-
{ label: "Model" },
|
|
112
|
-
{ label: "Messages", align: "right" },
|
|
113
|
-
{ label: "CAPS %", align: "right" },
|
|
114
|
-
{ label: "Profanity %", align: "right" },
|
|
115
|
-
{ label: "Anguish %", align: "right" },
|
|
116
|
-
{ label: "Frustration %", align: "right" },
|
|
117
|
-
{ label: "Hits %", align: "right" },
|
|
118
|
-
{ label: "Trend", align: "center" },
|
|
119
|
-
]}
|
|
120
|
-
/>
|
|
121
|
-
|
|
122
|
-
<ModelTableBody>
|
|
123
|
-
{sortedModels.map((model, index) => {
|
|
124
|
-
const key = `${model.model}::${model.provider}`;
|
|
125
|
-
const trend = trendByKey.get(key)?.data ?? [];
|
|
126
|
-
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
|
|
127
|
-
const isExpanded = expandedKey === key;
|
|
128
|
-
const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame;
|
|
129
|
-
const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration;
|
|
130
|
-
|
|
131
|
-
return (
|
|
132
|
-
<ExpandableModelRow
|
|
133
|
-
key={key}
|
|
134
|
-
gridTemplate={GRID_TEMPLATE}
|
|
135
|
-
isExpanded={isExpanded}
|
|
136
|
-
onToggle={() => setExpandedKey(isExpanded ? null : key)}
|
|
137
|
-
cells={[
|
|
138
|
-
<ModelNameCell key="name" model={model.model} provider={model.provider} />,
|
|
139
|
-
<div key="messages" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
140
|
-
{formatInt(model.totalMessages)}
|
|
141
|
-
</div>,
|
|
142
|
-
<div key="caps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
143
|
-
{formatRate(model.totalYelling, model.totalMessages)}
|
|
144
|
-
</div>,
|
|
145
|
-
<div key="profanity" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
146
|
-
{formatRate(model.totalProfanity, model.totalMessages)}
|
|
147
|
-
</div>,
|
|
148
|
-
<div key="anguish" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
149
|
-
{formatRate(model.totalAnguish, model.totalMessages)}
|
|
150
|
-
</div>,
|
|
151
|
-
<div key="frustration" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
152
|
-
{formatRate(totalFrustration, model.totalMessages)}
|
|
153
|
-
</div>,
|
|
154
|
-
<div key="hits" className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
155
|
-
{formatRate(totalHits, model.totalMessages)}
|
|
156
|
-
</div>,
|
|
157
|
-
]}
|
|
158
|
-
trendCell={
|
|
159
|
-
trend.length === 0 ? (
|
|
160
|
-
<TrendEmpty />
|
|
161
|
-
) : (
|
|
162
|
-
<MiniSparkline
|
|
163
|
-
timestamps={trend.map(d => d.timestamp)}
|
|
164
|
-
values={trend.map(d => d.total)}
|
|
165
|
-
color={trendColor}
|
|
166
|
-
/>
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
expandedContent={
|
|
170
|
-
<div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
|
|
171
|
-
<div className="space-y-4 text-sm">
|
|
172
|
-
<DetailRow
|
|
173
|
-
label="Yelling (CAPS)"
|
|
174
|
-
total={model.totalYelling}
|
|
175
|
-
messages={model.totalMessages}
|
|
176
|
-
valueClass="text-[var(--accent-amber,#fbbf24)]"
|
|
177
|
-
/>
|
|
178
|
-
<DetailRow
|
|
179
|
-
label="Profanity"
|
|
180
|
-
total={model.totalProfanity}
|
|
181
|
-
messages={model.totalMessages}
|
|
182
|
-
valueClass="text-[var(--accent-red,#f87171)]"
|
|
183
|
-
/>
|
|
184
|
-
<DetailRow
|
|
185
|
-
label="Anguish (!!!, nooo, dude, ..)"
|
|
186
|
-
total={model.totalAnguish}
|
|
187
|
-
messages={model.totalMessages}
|
|
188
|
-
valueClass="text-[var(--accent-violet,#a78bfa)]"
|
|
189
|
-
/>
|
|
190
|
-
<DetailRow
|
|
191
|
-
label="Negation (no/nope/wrong)"
|
|
192
|
-
total={model.totalNegation}
|
|
193
|
-
messages={model.totalMessages}
|
|
194
|
-
valueClass="text-[var(--accent-cyan,#22d3ee)]"
|
|
195
|
-
/>
|
|
196
|
-
<DetailRow
|
|
197
|
-
label="Repetition (i meant, still doesnt)"
|
|
198
|
-
total={model.totalRepetition}
|
|
199
|
-
messages={model.totalMessages}
|
|
200
|
-
valueClass="text-[var(--accent-cyan,#22d3ee)]"
|
|
201
|
-
/>
|
|
202
|
-
<DetailRow
|
|
203
|
-
label="Blame (you didnt, stop X-ing)"
|
|
204
|
-
total={model.totalBlame}
|
|
205
|
-
messages={model.totalMessages}
|
|
206
|
-
valueClass="text-[var(--accent-cyan,#22d3ee)]"
|
|
207
|
-
/>
|
|
208
|
-
<DetailRow
|
|
209
|
-
label="Avg chars / msg"
|
|
210
|
-
total={model.totalChars}
|
|
211
|
-
messages={model.totalMessages}
|
|
212
|
-
valueClass="text-[var(--text-secondary)]"
|
|
213
|
-
mode="average"
|
|
214
|
-
/>
|
|
215
|
-
</div>
|
|
216
|
-
<div className="h-[200px]">
|
|
217
|
-
{trend.length === 0 ? (
|
|
218
|
-
<DetailChartEmpty />
|
|
219
|
-
) : (
|
|
220
|
-
<BreakdownChart data={trend} chartTheme={chartTheme} />
|
|
221
|
-
)}
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
}
|
|
225
|
-
/>
|
|
226
|
-
);
|
|
227
|
-
})}
|
|
228
|
-
{sortedModels.length === 0 ? (
|
|
229
|
-
<div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
|
|
230
|
-
No user behavior recorded for this range yet.
|
|
231
|
-
</div>
|
|
232
|
-
) : null}
|
|
233
|
-
</ModelTableBody>
|
|
234
|
-
</ModelTableShell>
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function DetailRow({
|
|
239
|
-
label,
|
|
240
|
-
total,
|
|
241
|
-
messages,
|
|
242
|
-
valueClass,
|
|
243
|
-
mode = "rate",
|
|
244
|
-
}: {
|
|
245
|
-
label: string;
|
|
246
|
-
total: number;
|
|
247
|
-
messages: number;
|
|
248
|
-
valueClass: string;
|
|
249
|
-
mode?: "rate" | "average";
|
|
250
|
-
}) {
|
|
251
|
-
const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg";
|
|
252
|
-
const perMsgValue =
|
|
253
|
-
messages > 0 ? (mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0)) : "-";
|
|
254
|
-
return (
|
|
255
|
-
<div>
|
|
256
|
-
<div className="text-[var(--text-primary)] font-medium mb-2">{label}</div>
|
|
257
|
-
<div className="space-y-1 text-[var(--text-secondary)]">
|
|
258
|
-
<div className="flex items-center justify-between">
|
|
259
|
-
<span>Total</span>
|
|
260
|
-
<span className={`font-mono ${valueClass}`}>{formatInt(total)}</span>
|
|
261
|
-
</div>
|
|
262
|
-
<div className="flex items-center justify-between">
|
|
263
|
-
<span>{perMsgLabel}</span>
|
|
264
|
-
<span className="font-mono">{perMsgValue}</span>
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
267
|
-
</div>
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) {
|
|
272
|
-
const chartData = {
|
|
273
|
-
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
274
|
-
datasets: [
|
|
275
|
-
{ label: "CAPS", data: data.map(d => d.yelling), ...lineSeriesStyle(SERIES_COLORS.yelling) },
|
|
276
|
-
{ label: "Profanity", data: data.map(d => d.profanity), ...lineSeriesStyle(SERIES_COLORS.profanity) },
|
|
277
|
-
{ label: "Anguish", data: data.map(d => d.anguish), ...lineSeriesStyle(SERIES_COLORS.anguish) },
|
|
278
|
-
{ label: "Frustration", data: data.map(d => d.frustration), ...lineSeriesStyle(SERIES_COLORS.frustration) },
|
|
279
|
-
],
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const options = {
|
|
283
|
-
responsive: true,
|
|
284
|
-
maintainAspectRatio: false,
|
|
285
|
-
plugins: detailChartPlugins(chartTheme),
|
|
286
|
-
scales: detailChartScalesSingleAxis(chartTheme),
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
return <Line data={chartData} options={options} />;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Group the daily time-series by model+provider, producing one continuous
|
|
294
|
-
* day-bucket array per model so the sparkline / breakdown chart can render
|
|
295
|
-
* without missing-day artifacts.
|
|
296
|
-
*/
|
|
297
|
-
function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
|
|
298
|
-
if (points.length === 0) return new Map();
|
|
299
|
-
|
|
300
|
-
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
301
|
-
const byKey = new Map<string, Map<number, DailyPoint>>();
|
|
302
|
-
|
|
303
|
-
for (const point of points) {
|
|
304
|
-
const key = `${point.model}::${point.provider}`;
|
|
305
|
-
let dayMap = byKey.get(key);
|
|
306
|
-
if (!dayMap) {
|
|
307
|
-
dayMap = new Map();
|
|
308
|
-
byKey.set(key, dayMap);
|
|
309
|
-
}
|
|
310
|
-
const existing = dayMap.get(point.timestamp) ?? {
|
|
311
|
-
timestamp: point.timestamp,
|
|
312
|
-
yelling: 0,
|
|
313
|
-
profanity: 0,
|
|
314
|
-
anguish: 0,
|
|
315
|
-
frustration: 0,
|
|
316
|
-
total: 0,
|
|
317
|
-
};
|
|
318
|
-
existing.yelling += point.yelling;
|
|
319
|
-
existing.profanity += point.profanity;
|
|
320
|
-
existing.anguish += point.anguish;
|
|
321
|
-
existing.frustration += point.negation + point.repetition + point.blame;
|
|
322
|
-
existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration;
|
|
323
|
-
dayMap.set(point.timestamp, existing);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const out = new Map<string, ModelTrendSeries>();
|
|
327
|
-
for (const [key, dayMap] of byKey) {
|
|
328
|
-
const data = allDays.map(
|
|
329
|
-
ts =>
|
|
330
|
-
dayMap.get(ts) ?? {
|
|
331
|
-
timestamp: ts,
|
|
332
|
-
yelling: 0,
|
|
333
|
-
profanity: 0,
|
|
334
|
-
anguish: 0,
|
|
335
|
-
frustration: 0,
|
|
336
|
-
total: 0,
|
|
337
|
-
},
|
|
338
|
-
);
|
|
339
|
-
out.set(key, { data });
|
|
340
|
-
}
|
|
341
|
-
return out;
|
|
342
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
/**
|
|
14
|
-
* Per-message rate for a signal. Uses 2 decimals so a 0.01-hits-per-msg model
|
|
15
|
-
* still distinguishes from a true zero, and never shows `NaN` or `Infinity`
|
|
16
|
-
* when there are no messages.
|
|
17
|
-
*/
|
|
18
|
-
function perMsg(total: number, messages: number): string | undefined {
|
|
19
|
-
if (messages <= 0) return undefined;
|
|
20
|
-
return `${(total / messages).toFixed(2)} / msg`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function BehaviorSummary({ overall, behaviorSeries }: BehaviorSummaryProps) {
|
|
24
|
-
// Top "ranted-at" model: model that absorbed the most caps + profanity +
|
|
25
|
-
// anguish + frustration (negation/repetition/blame).
|
|
26
|
-
const topModel = useMemo(() => {
|
|
27
|
-
const totals = new Map<string, { model: string; provider: string; score: number }>();
|
|
28
|
-
for (const point of behaviorSeries) {
|
|
29
|
-
const key = `${point.model}::${point.provider}`;
|
|
30
|
-
const existing = totals.get(key);
|
|
31
|
-
const score =
|
|
32
|
-
point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
|
|
33
|
-
if (existing) {
|
|
34
|
-
existing.score += score;
|
|
35
|
-
} else {
|
|
36
|
-
totals.set(key, { model: point.model, provider: point.provider, score });
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
let best: { model: string; provider: string; score: number } | null = null;
|
|
40
|
-
for (const entry of totals.values()) {
|
|
41
|
-
if (!best || entry.score > best.score) best = entry;
|
|
42
|
-
}
|
|
43
|
-
return best;
|
|
44
|
-
}, [behaviorSeries]);
|
|
45
|
-
|
|
46
|
-
const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame;
|
|
47
|
-
const messages = overall.totalMessages;
|
|
48
|
-
|
|
49
|
-
const cards: Array<{ label: string; value: string; sub?: string }> = [
|
|
50
|
-
{
|
|
51
|
-
label: "Messages",
|
|
52
|
-
value: formatInt(overall.totalMessages),
|
|
53
|
-
sub: messages > 0 ? "in selected range" : undefined,
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
label: "Yelling",
|
|
57
|
-
value: formatInt(overall.totalYelling),
|
|
58
|
-
sub: perMsg(overall.totalYelling, messages),
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
label: "Profanity hits",
|
|
62
|
-
value: formatInt(overall.totalProfanity),
|
|
63
|
-
sub: perMsg(overall.totalProfanity, messages),
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
label: "Anguish",
|
|
67
|
-
value: formatInt(overall.totalAnguish),
|
|
68
|
-
sub: perMsg(overall.totalAnguish, messages),
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
label: "Frustration",
|
|
72
|
-
value: formatInt(totalFrustration),
|
|
73
|
-
sub: perMsg(totalFrustration, messages),
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
label: "Most yelled-at",
|
|
77
|
-
value: topModel?.model ?? "\u2014",
|
|
78
|
-
sub: topModel ? `${formatInt(topModel.score)} hits` : undefined,
|
|
79
|
-
},
|
|
80
|
-
];
|
|
81
|
-
|
|
82
|
-
return (
|
|
83
|
-
<div className="grid grid-cols-2 sm:grid-cols-6 gap-4">
|
|
84
|
-
{cards.map(card => (
|
|
85
|
-
<div key={card.label} className="surface px-4 py-3">
|
|
86
|
-
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
|
|
87
|
-
<p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
|
|
88
|
-
{card.value}
|
|
89
|
-
</p>
|
|
90
|
-
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
|
|
91
|
-
</div>
|
|
92
|
-
))}
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CategoryScale,
|
|
3
|
-
Chart as ChartJS,
|
|
4
|
-
Filler,
|
|
5
|
-
Legend,
|
|
6
|
-
LinearScale,
|
|
7
|
-
LineElement,
|
|
8
|
-
PointElement,
|
|
9
|
-
Title,
|
|
10
|
-
Tooltip,
|
|
11
|
-
} from "chart.js";
|
|
12
|
-
import { useMemo } from "react";
|
|
13
|
-
import { Line } from "react-chartjs-2";
|
|
14
|
-
import type { ModelTimeSeriesPoint, TimeRange } from "../types";
|
|
15
|
-
import { useSystemTheme } from "../useSystemTheme";
|
|
16
|
-
import { formatRangeTick, rangeMeta } from "./range-meta";
|
|
17
|
-
|
|
18
|
-
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
|
|
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 CHART_THEMES = {
|
|
31
|
-
dark: {
|
|
32
|
-
legendLabel: "#94a3b8",
|
|
33
|
-
tooltipBackground: "#16161e",
|
|
34
|
-
tooltipTitle: "#f8fafc",
|
|
35
|
-
tooltipBody: "#94a3b8",
|
|
36
|
-
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
37
|
-
grid: "rgba(255, 255, 255, 0.06)",
|
|
38
|
-
tick: "#64748b",
|
|
39
|
-
},
|
|
40
|
-
light: {
|
|
41
|
-
legendLabel: "#475569",
|
|
42
|
-
tooltipBackground: "#ffffff",
|
|
43
|
-
tooltipTitle: "#0f172a",
|
|
44
|
-
tooltipBody: "#334155",
|
|
45
|
-
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
46
|
-
grid: "rgba(15, 23, 42, 0.08)",
|
|
47
|
-
tick: "#64748b",
|
|
48
|
-
},
|
|
49
|
-
} as const;
|
|
50
|
-
interface ChartsContainerProps {
|
|
51
|
-
modelSeries: ModelTimeSeriesPoint[];
|
|
52
|
-
timeRange: TimeRange;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function ChartsContainer({ modelSeries, timeRange }: ChartsContainerProps) {
|
|
56
|
-
const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]);
|
|
57
|
-
const theme = useSystemTheme();
|
|
58
|
-
const chartTheme = CHART_THEMES[theme];
|
|
59
|
-
const meta = rangeMeta(timeRange);
|
|
60
|
-
const data = {
|
|
61
|
-
labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)),
|
|
62
|
-
datasets: chartData.series.map((seriesName, index) => ({
|
|
63
|
-
label: seriesName,
|
|
64
|
-
data: chartData.data.map(d => d[seriesName] ?? 0),
|
|
65
|
-
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
66
|
-
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
|
|
67
|
-
fill: true,
|
|
68
|
-
tension: 0.4,
|
|
69
|
-
pointRadius: 0,
|
|
70
|
-
pointHoverRadius: 4,
|
|
71
|
-
borderWidth: 2,
|
|
72
|
-
})),
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const options = {
|
|
76
|
-
responsive: true,
|
|
77
|
-
maintainAspectRatio: false,
|
|
78
|
-
interaction: {
|
|
79
|
-
mode: "index" as const,
|
|
80
|
-
intersect: false,
|
|
81
|
-
},
|
|
82
|
-
plugins: {
|
|
83
|
-
legend: {
|
|
84
|
-
position: "top" as const,
|
|
85
|
-
align: "start" as const,
|
|
86
|
-
labels: {
|
|
87
|
-
color: chartTheme.legendLabel,
|
|
88
|
-
usePointStyle: true,
|
|
89
|
-
padding: 16,
|
|
90
|
-
font: { size: 12 },
|
|
91
|
-
boxWidth: 8,
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
tooltip: {
|
|
95
|
-
backgroundColor: chartTheme.tooltipBackground,
|
|
96
|
-
titleColor: chartTheme.tooltipTitle,
|
|
97
|
-
bodyColor: chartTheme.tooltipBody,
|
|
98
|
-
borderColor: chartTheme.tooltipBorder,
|
|
99
|
-
borderWidth: 1,
|
|
100
|
-
padding: 12,
|
|
101
|
-
cornerRadius: 8,
|
|
102
|
-
callbacks: {
|
|
103
|
-
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
104
|
-
const label = context.dataset.label ?? "";
|
|
105
|
-
const value = context.parsed.y;
|
|
106
|
-
return `${label}: ${(value ?? 0).toFixed(1)}%`;
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
scales: {
|
|
112
|
-
x: {
|
|
113
|
-
grid: {
|
|
114
|
-
color: chartTheme.grid,
|
|
115
|
-
drawBorder: false,
|
|
116
|
-
},
|
|
117
|
-
ticks: {
|
|
118
|
-
color: chartTheme.tick,
|
|
119
|
-
font: { size: 11 },
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
y: {
|
|
123
|
-
grid: {
|
|
124
|
-
color: chartTheme.grid,
|
|
125
|
-
drawBorder: false,
|
|
126
|
-
},
|
|
127
|
-
ticks: {
|
|
128
|
-
color: chartTheme.tick,
|
|
129
|
-
font: { size: 11 },
|
|
130
|
-
callback: (value: number | string) => `${value}%`,
|
|
131
|
-
},
|
|
132
|
-
min: 0,
|
|
133
|
-
max: 100,
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
return (
|
|
139
|
-
<div className="surface overflow-hidden">
|
|
140
|
-
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
141
|
-
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Preference</h3>
|
|
142
|
-
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over {meta.windowLabel}</p>
|
|
143
|
-
</div>
|
|
144
|
-
<div className="p-5 min-h-[320px]">
|
|
145
|
-
{chartData.data.length === 0 ? (
|
|
146
|
-
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
147
|
-
No data available
|
|
148
|
-
</div>
|
|
149
|
-
) : (
|
|
150
|
-
<div className="h-[280px]">
|
|
151
|
-
<Line data={data} options={options} />
|
|
152
|
-
</div>
|
|
153
|
-
)}
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function buildModelPreferenceSeries(
|
|
160
|
-
points: ModelTimeSeriesPoint[],
|
|
161
|
-
topN = 5,
|
|
162
|
-
): {
|
|
163
|
-
data: Array<Record<string, number>>;
|
|
164
|
-
series: string[];
|
|
165
|
-
} {
|
|
166
|
-
if (points.length === 0) return { data: [], series: [] };
|
|
167
|
-
|
|
168
|
-
const totals = new Map<string, { model: string; provider: string; total: number }>();
|
|
169
|
-
for (const point of points) {
|
|
170
|
-
const key = `${point.model}::${point.provider}`;
|
|
171
|
-
const existing = totals.get(key);
|
|
172
|
-
if (existing) {
|
|
173
|
-
existing.total += point.requests;
|
|
174
|
-
} else {
|
|
175
|
-
totals.set(key, { model: point.model, provider: point.provider, total: point.requests });
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const sorted = [...totals.entries()].map(([key, value]) => ({ key, ...value })).sort((a, b) => b.total - a.total);
|
|
180
|
-
const topEntries = sorted.slice(0, topN);
|
|
181
|
-
const topKeys = new Set(topEntries.map(entry => entry.key));
|
|
182
|
-
|
|
183
|
-
const topModelCounts = new Map<string, number>();
|
|
184
|
-
for (const entry of topEntries) {
|
|
185
|
-
topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const labelByKey = new Map<string, string>();
|
|
189
|
-
for (const entry of topEntries) {
|
|
190
|
-
const showProvider = (topModelCounts.get(entry.model) ?? 0) > 1;
|
|
191
|
-
labelByKey.set(entry.key, showProvider ? `${entry.model} (${entry.provider})` : entry.model);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const dataMap = new Map<number, Record<string, number>>();
|
|
195
|
-
|
|
196
|
-
for (const point of points) {
|
|
197
|
-
const key = `${point.model}::${point.provider}`;
|
|
198
|
-
const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 };
|
|
199
|
-
bucket.total += point.requests;
|
|
200
|
-
const seriesLabel = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
|
|
201
|
-
bucket[seriesLabel] = (bucket[seriesLabel] ?? 0) + point.requests;
|
|
202
|
-
dataMap.set(point.timestamp, bucket);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const series = topEntries.map(entry => labelByKey.get(entry.key) ?? entry.model);
|
|
206
|
-
if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
|
|
207
|
-
series.push("Other");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const data = [...dataMap.values()]
|
|
211
|
-
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
212
|
-
.map(row => {
|
|
213
|
-
const total = row.total ?? 0;
|
|
214
|
-
for (const key of series) {
|
|
215
|
-
row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
|
|
216
|
-
}
|
|
217
|
-
return row;
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
return { data, series };
|
|
221
|
-
}
|