- Add token_usage table with composite unique index for claw_id + date - Add API endpoints: POST /token, GET /token/leaderboard, GET /token/stats - Add TokenLeaderboard component with daily/total period toggle - Add CLI commands: `token` and `stats` for reporting and viewing usage - Add Redis caching for leaderboard with 1-minute TTL - Add shared utilities: authenticateRequest, getTodayDateString - Use UPSERT pattern for atomic token updates - Add i18n translations (en/zh) for new UI elements
156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useMemo } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
import { motion } from "framer-motion";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface LeaderboardEntry {
|
|
rank: number;
|
|
clawId: string;
|
|
clawName: string;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalTokens: number;
|
|
}
|
|
|
|
interface LeaderboardData {
|
|
period: string;
|
|
date: string;
|
|
leaderboard: LeaderboardEntry[];
|
|
}
|
|
|
|
type Period = "daily" | "total";
|
|
|
|
function formatTokens(num: number): string {
|
|
if (num >= 1000000) {
|
|
return `${(num / 1000000).toFixed(1)}M`;
|
|
}
|
|
if (num >= 1000) {
|
|
return `${(num / 1000).toFixed(1)}K`;
|
|
}
|
|
return num.toString();
|
|
}
|
|
|
|
export function TokenLeaderboard() {
|
|
const t = useTranslations("tokenLeaderboard");
|
|
const [period, setPeriod] = useState<Period>("daily");
|
|
const [data, setData] = useState<LeaderboardEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const res = await fetch(`/api/v1/token/leaderboard?period=${period}`);
|
|
if (res.ok) {
|
|
const json: LeaderboardData = await res.json();
|
|
setData(json.leaderboard);
|
|
}
|
|
} catch {
|
|
// retry on interval
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
setLoading(true);
|
|
fetchData();
|
|
const interval = setInterval(fetchData, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [period]);
|
|
|
|
const maxTokens = useMemo(
|
|
() => Math.max(...data.map((d) => d.totalTokens), 1),
|
|
[data]
|
|
);
|
|
|
|
return (
|
|
<Card className="border-white/5">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>{t("title")}</CardTitle>
|
|
<div className="flex rounded-md border border-white/10 bg-white/5 p-0.5">
|
|
<button
|
|
onClick={() => setPeriod("daily")}
|
|
className={cn(
|
|
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
|
period === "daily"
|
|
? "bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)]"
|
|
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
|
)}
|
|
>
|
|
{t("daily")}
|
|
</button>
|
|
<button
|
|
onClick={() => setPeriod("total")}
|
|
className={cn(
|
|
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
|
period === "total"
|
|
? "bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)]"
|
|
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
|
)}
|
|
>
|
|
{t("total")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 p-4 pt-0">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
|
|
</div>
|
|
) : data.length === 0 ? (
|
|
<p className="py-4 text-center text-xs text-[var(--text-muted)]">{t("noData")}</p>
|
|
) : (
|
|
data.slice(0, 10).map((entry) => (
|
|
<div key={entry.clawId} className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"flex h-5 w-5 items-center justify-center rounded text-xs font-bold",
|
|
entry.rank === 1
|
|
? "bg-yellow-500/20 text-yellow-400"
|
|
: entry.rank === 2
|
|
? "bg-gray-400/20 text-gray-300"
|
|
: entry.rank === 3
|
|
? "bg-orange-500/20 text-orange-400"
|
|
: "bg-white/5 text-[var(--text-muted)]"
|
|
)}
|
|
>
|
|
{entry.rank}
|
|
</span>
|
|
<span className="max-w-[100px] truncate text-sm font-medium">
|
|
{entry.clawName}
|
|
</span>
|
|
</div>
|
|
<span className="font-mono text-xs text-[var(--accent-cyan)]">
|
|
{formatTokens(entry.totalTokens)}
|
|
</span>
|
|
</div>
|
|
<div className="h-1 overflow-hidden rounded-full bg-white/5">
|
|
<motion.div
|
|
className="h-full rounded-full bg-gradient-to-r from-[var(--accent-cyan)] to-[var(--accent-purple)]"
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${(entry.totalTokens / maxTokens) * 100}%` }}
|
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<span className="font-mono text-[10px] text-[var(--text-muted)]">
|
|
{t("inputOutput", {
|
|
input: formatTokens(entry.inputTokens),
|
|
output: formatTokens(entry.outputTokens),
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|