@arraystar/tokenscope 0.1.0
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/README.md +87 -0
- package/bun.lock +1170 -0
- package/components.json +25 -0
- package/eslint.config.js +23 -0
- package/index.html +13 -0
- package/package.json +46 -0
- package/public/data.json +41 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +184 -0
- package/src/App.tsx +98 -0
- package/src/PricingContext.tsx +131 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/cli.ts +98 -0
- package/src/components/Overview.tsx +468 -0
- package/src/components/PricingSheet.tsx +209 -0
- package/src/components/SessionDetail.tsx +244 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +103 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +721 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/table.tsx +114 -0
- package/src/components/ui/tooltip.tsx +64 -0
- package/src/data.ts +90 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/i18n.tsx +148 -0
- package/src/index.css +138 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/parser/claude.ts +225 -0
- package/src/parser/codex.ts +181 -0
- package/src/parser/index.ts +83 -0
- package/src/parser/types.ts +35 -0
- package/src/pricing.ts +139 -0
- package/src/types.ts +56 -0
- package/tsconfig.app.json +30 -0
- package/tsconfig.json +13 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +13 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { usePricingConfig } from "../PricingContext";
|
|
2
|
+
import { PRICING_DB, findPricingEntry, type CustomPrice } from "../pricing";
|
|
3
|
+
import { useLang, useTranslations } from "../i18n";
|
|
4
|
+
import { X, RotateCcw } from "lucide-react";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
export default function PricingSheet() {
|
|
9
|
+
const { config, detectedModels, isOpen, setOpen, updateModelMapping, updateCustomPricing, resetConfig } =
|
|
10
|
+
usePricingConfig();
|
|
11
|
+
const { lang } = useLang();
|
|
12
|
+
const tr = useTranslations(lang);
|
|
13
|
+
|
|
14
|
+
if (!isOpen) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
{/* Backdrop */}
|
|
19
|
+
<div
|
|
20
|
+
className="fixed inset-0 bg-black/40 z-40 transition-opacity"
|
|
21
|
+
onClick={() => setOpen(false)}
|
|
22
|
+
/>
|
|
23
|
+
{/* Panel */}
|
|
24
|
+
<div className="fixed right-0 top-0 bottom-0 w-[420px] max-w-full bg-background border-l border-border z-50 shadow-xl flex flex-col animate-slide-in">
|
|
25
|
+
{/* Header */}
|
|
26
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
|
27
|
+
<h2 className="text-base font-semibold">{tr.modelPricing}</h2>
|
|
28
|
+
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
|
|
29
|
+
<X className="w-4 h-4" />
|
|
30
|
+
</Button>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
{/* Body */}
|
|
34
|
+
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
|
35
|
+
<p className="text-xs text-muted-foreground">
|
|
36
|
+
{tr.detectedModels}: {detectedModels.length}
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
{detectedModels.map((modelName) => (
|
|
40
|
+
<ModelConfigRow
|
|
41
|
+
key={modelName}
|
|
42
|
+
modelName={modelName}
|
|
43
|
+
mapping={config.modelMappings[modelName]}
|
|
44
|
+
custom={config.customPricing[modelName]}
|
|
45
|
+
onMappingChange={(id) => updateModelMapping(modelName, id)}
|
|
46
|
+
onCustomChange={(cp) => updateCustomPricing(modelName, cp)}
|
|
47
|
+
/>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Footer */}
|
|
52
|
+
<div className="px-5 py-3 border-t border-border">
|
|
53
|
+
<Button variant="outline" size="sm" onClick={resetConfig} className="w-full gap-2">
|
|
54
|
+
<RotateCcw className="w-3 h-3" />
|
|
55
|
+
{tr.resetToDefaults}
|
|
56
|
+
</Button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ModelConfigRow({
|
|
64
|
+
modelName,
|
|
65
|
+
mapping,
|
|
66
|
+
custom,
|
|
67
|
+
onMappingChange,
|
|
68
|
+
onCustomChange,
|
|
69
|
+
}: {
|
|
70
|
+
modelName: string;
|
|
71
|
+
mapping?: string;
|
|
72
|
+
custom?: CustomPrice;
|
|
73
|
+
onMappingChange: (id: string | null) => void;
|
|
74
|
+
onCustomChange: (cp: CustomPrice | null) => void;
|
|
75
|
+
}) {
|
|
76
|
+
const { lang } = useLang();
|
|
77
|
+
const tr = useTranslations(lang);
|
|
78
|
+
const [isCustom, setIsCustom] = useState(!!custom);
|
|
79
|
+
const [customInput, setCustomInput] = useState(custom?.input ?? 0);
|
|
80
|
+
const [customOutput, setCustomOutput] = useState(custom?.output ?? 0);
|
|
81
|
+
const [customCacheRead, setCustomCacheRead] = useState(custom?.cacheRead ?? 0);
|
|
82
|
+
const [customCurrency, setCustomCurrency] = useState<"usd" | "cny">(custom?.currency ?? "usd");
|
|
83
|
+
|
|
84
|
+
const currentEntry = mapping ? findPricingEntry(mapping) : undefined;
|
|
85
|
+
|
|
86
|
+
const handleSelectChange = (value: string) => {
|
|
87
|
+
if (value === "__custom__") {
|
|
88
|
+
setIsCustom(true);
|
|
89
|
+
} else {
|
|
90
|
+
setIsCustom(false);
|
|
91
|
+
onMappingChange(value || null);
|
|
92
|
+
onCustomChange(null);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleCustomApply = () => {
|
|
97
|
+
onCustomChange({
|
|
98
|
+
input: customInput,
|
|
99
|
+
output: customOutput,
|
|
100
|
+
cacheRead: customCacheRead,
|
|
101
|
+
currency: customCurrency,
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Group pricing entries by provider
|
|
106
|
+
const providers = PRICING_DB.reduce<Record<string, typeof PRICING_DB>>((acc, entry) => {
|
|
107
|
+
if (!acc[entry.provider]) acc[entry.provider] = [];
|
|
108
|
+
acc[entry.provider].push(entry);
|
|
109
|
+
return acc;
|
|
110
|
+
}, {});
|
|
111
|
+
|
|
112
|
+
const curSymbol = (c: string) => (c === "cny" ? "¥" : "$");
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="space-y-2.5 rounded-lg border border-border p-3">
|
|
116
|
+
{/* Model name + current pricing preview */}
|
|
117
|
+
<div className="flex items-center justify-between">
|
|
118
|
+
<span className="text-sm font-semibold font-mono">{modelName}</span>
|
|
119
|
+
{currentEntry && (
|
|
120
|
+
<span className="text-[10px] text-muted-foreground">
|
|
121
|
+
{curSymbol(currentEntry.currency)}
|
|
122
|
+
{currentEntry.input}/{curSymbol(currentEntry.currency)}
|
|
123
|
+
{currentEntry.output}/{curSymbol(currentEntry.currency)}
|
|
124
|
+
{currentEntry.cacheRead}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
{custom && (
|
|
128
|
+
<span className="text-[10px] text-muted-foreground">
|
|
129
|
+
{curSymbol(custom.currency)}
|
|
130
|
+
{custom.input}/{curSymbol(custom.currency)}
|
|
131
|
+
{custom.output}/{curSymbol(custom.currency)}
|
|
132
|
+
{custom.cacheRead}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Pricing selector */}
|
|
138
|
+
<select
|
|
139
|
+
value={isCustom ? "__custom__" : (mapping ?? "")}
|
|
140
|
+
onChange={(e) => handleSelectChange(e.target.value)}
|
|
141
|
+
className="w-full bg-background border border-border rounded-md px-2.5 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
142
|
+
>
|
|
143
|
+
<option value="">{tr.noPricing}</option>
|
|
144
|
+
{Object.entries(providers).map(([provider, entries]) => (
|
|
145
|
+
<optgroup key={provider} label={provider}>
|
|
146
|
+
{entries.map((entry) => (
|
|
147
|
+
<option key={entry.id} value={entry.id}>
|
|
148
|
+
{entry.model} ({curSymbol(entry.currency)}{entry.input}/{curSymbol(entry.currency)}{entry.output})
|
|
149
|
+
</option>
|
|
150
|
+
))}
|
|
151
|
+
</optgroup>
|
|
152
|
+
))}
|
|
153
|
+
<option value="__custom__">{tr.customPricing}</option>
|
|
154
|
+
</select>
|
|
155
|
+
|
|
156
|
+
{/* Custom pricing inputs */}
|
|
157
|
+
{isCustom && (
|
|
158
|
+
<div className="space-y-2 pt-1">
|
|
159
|
+
<div className="grid grid-cols-3 gap-2">
|
|
160
|
+
<div>
|
|
161
|
+
<label className="text-[10px] text-muted-foreground">{tr.inputPrice}</label>
|
|
162
|
+
<input
|
|
163
|
+
type="number"
|
|
164
|
+
step="0.01"
|
|
165
|
+
value={customInput}
|
|
166
|
+
onChange={(e) => setCustomInput(Number(e.target.value))}
|
|
167
|
+
className="w-full bg-background border border-border rounded px-2 py-1 text-xs font-mono"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
<div>
|
|
171
|
+
<label className="text-[10px] text-muted-foreground">{tr.outputPrice}</label>
|
|
172
|
+
<input
|
|
173
|
+
type="number"
|
|
174
|
+
step="0.01"
|
|
175
|
+
value={customOutput}
|
|
176
|
+
onChange={(e) => setCustomOutput(Number(e.target.value))}
|
|
177
|
+
className="w-full bg-background border border-border rounded px-2 py-1 text-xs font-mono"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
<div>
|
|
181
|
+
<label className="text-[10px] text-muted-foreground">{tr.cacheReadPrice}</label>
|
|
182
|
+
<input
|
|
183
|
+
type="number"
|
|
184
|
+
step="0.01"
|
|
185
|
+
value={customCacheRead}
|
|
186
|
+
onChange={(e) => setCustomCacheRead(Number(e.target.value))}
|
|
187
|
+
className="w-full bg-background border border-border rounded px-2 py-1 text-xs font-mono"
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="flex items-center gap-2">
|
|
192
|
+
<select
|
|
193
|
+
value={customCurrency}
|
|
194
|
+
onChange={(e) => setCustomCurrency(e.target.value as "usd" | "cny")}
|
|
195
|
+
className="bg-background border border-border rounded px-2 py-1 text-xs"
|
|
196
|
+
>
|
|
197
|
+
<option value="usd">USD ($)</option>
|
|
198
|
+
<option value="cny">CNY (¥)</option>
|
|
199
|
+
</select>
|
|
200
|
+
<span className="text-[10px] text-muted-foreground">{tr.perMillionTokens}</span>
|
|
201
|
+
<Button size="sm" variant="secondary" onClick={handleCustomApply} className="ml-auto text-xs h-7">
|
|
202
|
+
Apply
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useParams, useNavigate } from "react-router-dom";
|
|
2
|
+
import {
|
|
3
|
+
BarChart,
|
|
4
|
+
Bar,
|
|
5
|
+
XAxis,
|
|
6
|
+
YAxis,
|
|
7
|
+
Tooltip,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
Cell,
|
|
10
|
+
} from "recharts";
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
|
12
|
+
import { Badge } from "./ui/badge";
|
|
13
|
+
import { Button } from "./ui/button";
|
|
14
|
+
import { fmt, fmtCost, getAllSessions } from "../data";
|
|
15
|
+
import { useRecalculatedData, usePricingConfig } from "../PricingContext";
|
|
16
|
+
import { useLang, useTranslations } from "../i18n";
|
|
17
|
+
import { ArrowLeft, MessageSquare, Zap, Database, Coins, ArrowUpDown } from "lucide-react";
|
|
18
|
+
import { useState, useMemo } from "react";
|
|
19
|
+
|
|
20
|
+
export default function SessionDetail() {
|
|
21
|
+
const { sessionKey } = useParams();
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
const data = useRecalculatedData();
|
|
24
|
+
const { currencySymbol } = usePricingConfig();
|
|
25
|
+
const allSessions = getAllSessions(data);
|
|
26
|
+
const { lang } = useLang();
|
|
27
|
+
const tr = useTranslations(lang);
|
|
28
|
+
const [sortBy, setSortBy] = useState<"total" | "time" | "input" | "output" | "cache_read">("total");
|
|
29
|
+
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
|
30
|
+
|
|
31
|
+
const session = allSessions.find((s) => s.sessionKey === decodeURIComponent(sessionKey || ""));
|
|
32
|
+
if (!session) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="text-center py-20">
|
|
35
|
+
<p className="text-muted-foreground">{tr.sessionNotFound}</p>
|
|
36
|
+
<Button variant="ghost" onClick={() => navigate("/")} className="mt-4">
|
|
37
|
+
<ArrowLeft className="w-4 h-4 mr-2" /> {tr.backToOverview}
|
|
38
|
+
</Button>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sortedTurns = useMemo(() => {
|
|
44
|
+
const t = [...session.turns];
|
|
45
|
+
const dir = sortDir === "asc" ? 1 : -1;
|
|
46
|
+
t.sort((a, b) => {
|
|
47
|
+
if (sortBy === "time") return a.time.localeCompare(b.time) * dir;
|
|
48
|
+
return (a[sortBy] - b[sortBy]) * dir;
|
|
49
|
+
});
|
|
50
|
+
return t;
|
|
51
|
+
}, [session.turns, sortBy, sortDir]);
|
|
52
|
+
|
|
53
|
+
const toggleSort = (col: typeof sortBy) => {
|
|
54
|
+
if (sortBy === col) {
|
|
55
|
+
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
56
|
+
} else {
|
|
57
|
+
setSortBy(col);
|
|
58
|
+
setSortDir("desc");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const chartData = session.turns.map((t, i) => ({
|
|
63
|
+
idx: i + 1,
|
|
64
|
+
input: t.input,
|
|
65
|
+
output: t.output,
|
|
66
|
+
cache_read: t.cache_read,
|
|
67
|
+
total: t.total,
|
|
68
|
+
user: t.user.slice(0, 30),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const stats = session.stats;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="space-y-6">
|
|
75
|
+
{/* Back button + header */}
|
|
76
|
+
<div className="flex items-center gap-4">
|
|
77
|
+
<Button variant="ghost" size="sm" onClick={() => navigate("/")}>
|
|
78
|
+
<ArrowLeft className="w-4 h-4" />
|
|
79
|
+
</Button>
|
|
80
|
+
<div className="flex-1">
|
|
81
|
+
<h2 className="text-lg font-bold">{session.project}</h2>
|
|
82
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
83
|
+
<span>{tr.session} {session.sid}</span>
|
|
84
|
+
<span>|</span>
|
|
85
|
+
<span>{session.date}</span>
|
|
86
|
+
<Badge variant="secondary">{stats.msgs} {tr.msgs}</Badge>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<Badge variant="outline" className="text-base px-3 py-1">
|
|
90
|
+
{fmt(stats.total)} {tr.tokens}
|
|
91
|
+
</Badge>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Session Stats Cards */}
|
|
95
|
+
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
96
|
+
<MiniStat label={tr.inputCol} value={fmt(stats.input)} icon={<Zap className="w-3 h-3" />} color="text-violet-500" />
|
|
97
|
+
<MiniStat label={tr.outputCol} value={fmt(stats.output)} icon={<MessageSquare className="w-3 h-3" />} color="text-emerald-500" />
|
|
98
|
+
<MiniStat label={tr.cacheRCol} value={fmt(stats.cache_read)} icon={<Database className="w-3 h-3" />} color="text-amber-500" />
|
|
99
|
+
<MiniStat label={tr.cacheWriteLabel} value={fmt(stats.cache_write)} icon={<Database className="w-3 h-3" />} color="text-rose-500" />
|
|
100
|
+
<MiniStat label={tr.costCol} value={fmtCost(stats.cost, currencySymbol)} icon={<Coins className="w-3 h-3" />} color="text-sky-500" />
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Turn Timeline Chart */}
|
|
104
|
+
<Card>
|
|
105
|
+
<CardHeader className="pb-2">
|
|
106
|
+
<CardTitle className="text-sm font-medium">{tr.tokenUsagePerTurn}</CardTitle>
|
|
107
|
+
</CardHeader>
|
|
108
|
+
<CardContent>
|
|
109
|
+
<ResponsiveContainer width="100%" height={200}>
|
|
110
|
+
<BarChart data={chartData}>
|
|
111
|
+
<XAxis dataKey="idx" tick={{ fontSize: 10 }} />
|
|
112
|
+
<YAxis tickFormatter={(v: number) => fmt(v)} tick={{ fontSize: 10 }} />
|
|
113
|
+
<Tooltip
|
|
114
|
+
content={({ active, payload }) => {
|
|
115
|
+
if (!active || !payload?.length) return null;
|
|
116
|
+
const d = payload[0].payload;
|
|
117
|
+
return (
|
|
118
|
+
<div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg text-xs">
|
|
119
|
+
<p className="font-medium mb-1">#{d.idx}</p>
|
|
120
|
+
<p>{tr.inputCol}: {fmt(d.input)}</p>
|
|
121
|
+
<p>{tr.outputCol}: {fmt(d.output)}</p>
|
|
122
|
+
<p>{tr.cacheRCol}: {fmt(d.cache_read)}</p>
|
|
123
|
+
<p className="font-semibold mt-1">{tr.totalLabel}: {fmt(d.total)}</p>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
<Bar dataKey="total" radius={[2, 2, 0, 0]}>
|
|
129
|
+
{chartData.map((_, i) => (
|
|
130
|
+
<Cell
|
|
131
|
+
key={i}
|
|
132
|
+
fill={`hsl(220, ${40 + (i % 3) * 15}%, ${45 + (i % 4) * 8}%)`}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</Bar>
|
|
136
|
+
</BarChart>
|
|
137
|
+
</ResponsiveContainer>
|
|
138
|
+
</CardContent>
|
|
139
|
+
</Card>
|
|
140
|
+
|
|
141
|
+
{/* Turns Table */}
|
|
142
|
+
<Card>
|
|
143
|
+
<CardHeader className="pb-2">
|
|
144
|
+
<CardTitle className="text-sm font-medium">
|
|
145
|
+
{tr.conversationTurns} ({session.turns.length})
|
|
146
|
+
</CardTitle>
|
|
147
|
+
</CardHeader>
|
|
148
|
+
<CardContent>
|
|
149
|
+
<div className="overflow-x-auto">
|
|
150
|
+
<table className="w-full text-sm">
|
|
151
|
+
<thead>
|
|
152
|
+
<tr className="border-b border-border">
|
|
153
|
+
<th className="text-left px-3 py-2 text-xs text-muted-foreground font-medium w-10">{tr.turn}</th>
|
|
154
|
+
<th className="text-left px-3 py-2 text-xs text-muted-foreground font-medium w-16">{tr.time}</th>
|
|
155
|
+
<th className="text-left px-3 py-2 text-xs text-muted-foreground font-medium">{tr.message}</th>
|
|
156
|
+
<SortHeader label={tr.inputCol} col="input" sortBy={sortBy} sortDir={sortDir} onToggle={() => toggleSort("input")} />
|
|
157
|
+
<SortHeader label={tr.outputCol} col="output" sortBy={sortBy} sortDir={sortDir} onToggle={() => toggleSort("output")} />
|
|
158
|
+
<SortHeader label={tr.cacheRCol} col="cache_read" sortBy={sortBy} sortDir={sortDir} onToggle={() => toggleSort("cache_read")} />
|
|
159
|
+
<SortHeader label={tr.totalCol} col="total" sortBy={sortBy} sortDir={sortDir} onToggle={() => toggleSort("total")} />
|
|
160
|
+
<th className="text-right px-3 py-2 text-xs text-muted-foreground font-medium">{tr.costCol}</th>
|
|
161
|
+
</tr>
|
|
162
|
+
</thead>
|
|
163
|
+
<tbody>
|
|
164
|
+
{sortedTurns.map((turn, i) => {
|
|
165
|
+
const time = turn.time ? turn.time.slice(11, 16) : "";
|
|
166
|
+
const msg = turn.user || "(tool call)";
|
|
167
|
+
return (
|
|
168
|
+
<tr
|
|
169
|
+
key={i}
|
|
170
|
+
className="border-b border-border/50 hover:bg-accent/50 transition-colors"
|
|
171
|
+
>
|
|
172
|
+
<td className="px-3 py-1.5 text-xs text-muted-foreground">{i + 1}</td>
|
|
173
|
+
<td className="px-3 py-1.5 text-xs text-muted-foreground font-mono">{time}</td>
|
|
174
|
+
<td className="px-3 py-1.5 max-w-[300px] truncate">{msg}</td>
|
|
175
|
+
<td className="px-3 py-1.5 text-right font-mono text-xs">{fmt(turn.input)}</td>
|
|
176
|
+
<td className="px-3 py-1.5 text-right font-mono text-xs">{fmt(turn.output)}</td>
|
|
177
|
+
<td className="px-3 py-1.5 text-right font-mono text-xs">{fmt(turn.cache_read)}</td>
|
|
178
|
+
<td className="px-3 py-1.5 text-right font-mono text-xs font-semibold">{fmt(turn.total)}</td>
|
|
179
|
+
<td className="px-3 py-1.5 text-right font-mono text-xs">{fmtCost(turn.cost, currencySymbol)}</td>
|
|
180
|
+
</tr>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</div>
|
|
186
|
+
</CardContent>
|
|
187
|
+
</Card>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function MiniStat({
|
|
193
|
+
label,
|
|
194
|
+
value,
|
|
195
|
+
icon,
|
|
196
|
+
color,
|
|
197
|
+
}: {
|
|
198
|
+
label: string;
|
|
199
|
+
value: string;
|
|
200
|
+
icon: React.ReactNode;
|
|
201
|
+
color: string;
|
|
202
|
+
}) {
|
|
203
|
+
return (
|
|
204
|
+
<Card>
|
|
205
|
+
<CardContent className="p-3">
|
|
206
|
+
<div className="flex items-center gap-1.5 mb-1">
|
|
207
|
+
<div className={color}>{icon}</div>
|
|
208
|
+
<span className="text-[11px] text-muted-foreground">{label}</span>
|
|
209
|
+
</div>
|
|
210
|
+
<p className="text-sm font-bold">{value}</p>
|
|
211
|
+
</CardContent>
|
|
212
|
+
</Card>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function SortHeader({
|
|
217
|
+
label,
|
|
218
|
+
col,
|
|
219
|
+
sortBy,
|
|
220
|
+
sortDir,
|
|
221
|
+
onToggle,
|
|
222
|
+
}: {
|
|
223
|
+
label: string;
|
|
224
|
+
col: string;
|
|
225
|
+
sortBy: string;
|
|
226
|
+
sortDir: string;
|
|
227
|
+
onToggle: () => void;
|
|
228
|
+
}) {
|
|
229
|
+
const active = sortBy === col;
|
|
230
|
+
return (
|
|
231
|
+
<th
|
|
232
|
+
className="text-right px-3 py-2 text-xs text-muted-foreground font-medium cursor-pointer select-none hover:text-foreground transition-colors"
|
|
233
|
+
onClick={onToggle}
|
|
234
|
+
>
|
|
235
|
+
<span className="inline-flex items-center gap-1">
|
|
236
|
+
{label}
|
|
237
|
+
<ArrowUpDown
|
|
238
|
+
className={`w-3 h-3 ${active ? "text-primary" : "opacity-30"}`}
|
|
239
|
+
style={active && sortDir === "desc" ? { transform: "rotate(180deg)" } : undefined}
|
|
240
|
+
/>
|
|
241
|
+
</span>
|
|
242
|
+
</th>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props"
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render"
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
13
|
+
secondary:
|
|
14
|
+
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
15
|
+
destructive:
|
|
16
|
+
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
17
|
+
outline:
|
|
18
|
+
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
19
|
+
ghost:
|
|
20
|
+
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
21
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function Badge({
|
|
31
|
+
className,
|
|
32
|
+
variant = "default",
|
|
33
|
+
render,
|
|
34
|
+
...props
|
|
35
|
+
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
|
36
|
+
return useRender({
|
|
37
|
+
defaultTagName: "span",
|
|
38
|
+
props: mergeProps<"span">(
|
|
39
|
+
{
|
|
40
|
+
className: cn(badgeVariants({ variant }), className),
|
|
41
|
+
},
|
|
42
|
+
props
|
|
43
|
+
),
|
|
44
|
+
render,
|
|
45
|
+
state: {
|
|
46
|
+
slot: "badge",
|
|
47
|
+
variant,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
12
|
+
outline:
|
|
13
|
+
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
14
|
+
secondary:
|
|
15
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
16
|
+
ghost:
|
|
17
|
+
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
18
|
+
destructive:
|
|
19
|
+
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default:
|
|
24
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
25
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
26
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
27
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
28
|
+
icon: "size-8",
|
|
29
|
+
"icon-xs":
|
|
30
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
31
|
+
"icon-sm":
|
|
32
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
33
|
+
"icon-lg": "size-9",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultVariants: {
|
|
37
|
+
variant: "default",
|
|
38
|
+
size: "default",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
function Button({
|
|
44
|
+
className,
|
|
45
|
+
variant = "default",
|
|
46
|
+
size = "default",
|
|
47
|
+
...props
|
|
48
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
49
|
+
return (
|
|
50
|
+
<ButtonPrimitive
|
|
51
|
+
data-slot="button"
|
|
52
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
function Card({
|
|
6
|
+
className,
|
|
7
|
+
size = "default",
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="card"
|
|
13
|
+
data-size={size}
|
|
14
|
+
className={cn(
|
|
15
|
+
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="card-header"
|
|
27
|
+
className={cn(
|
|
28
|
+
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="card-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="card-description"
|
|
53
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="card-action"
|
|
63
|
+
className={cn(
|
|
64
|
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
data-slot="card-content"
|
|
76
|
+
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-slot="card-footer"
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
Card,
|
|
97
|
+
CardHeader,
|
|
98
|
+
CardFooter,
|
|
99
|
+
CardTitle,
|
|
100
|
+
CardAction,
|
|
101
|
+
CardDescription,
|
|
102
|
+
CardContent,
|
|
103
|
+
}
|