mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(ai): add token-usage dashboard (AI Agents → Usage)
Surfaces the ai_usage_log data added in the previous commit: - GET /api/ai/usage — admin-only aggregation (totals, per-mode, per-model, zero-filled daily series) over a 7/30/90-day window, mirroring the ai_usage_log SELECT policy - AiUsageCard — stat tiles + daily token bar chart + by-model list, rendered under a new admin-only "Usage" tab on the AI Agents page Counts only; no message content is stored or shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b0194dfc27
commit
c736e7a43f
@ -35,9 +35,12 @@ logged**.
|
||||
short **internal note** summarizing the exchange for whoever picks it
|
||||
up. Assigning fires the existing assignment notification. Pick the
|
||||
target under **AI Agents → Setup → Hand off to**.
|
||||
- **Token-usage logging.** Every draft and auto-reply records its
|
||||
provider token counts to the new `ai_usage_log` table (admin-readable),
|
||||
for cost visibility on your BYO key.
|
||||
- **Token-usage logging + dashboard.** Every draft and auto-reply records
|
||||
its provider token counts to the new `ai_usage_log` table
|
||||
(admin-readable). A new **AI Agents → Usage** tab (admin-only) charts
|
||||
daily token spend on your BYO key with per-mode and per-model
|
||||
breakdowns, backed by `GET /api/ai/usage`. Counts only — no message
|
||||
content is stored or shown.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, Sparkles, Settings2 } from 'lucide-react';
|
||||
import { Bot, Sparkles, Settings2, BarChart3 } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { AiPlayground } from '@/components/agents/ai-playground';
|
||||
import { AiUsageCard } from '@/components/agents/ai-usage';
|
||||
import { AiConfig } from '@/components/settings/ai-config';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { canEditSettings } from '@/lib/auth/roles';
|
||||
|
||||
type Tab = 'playground' | 'setup';
|
||||
type Tab = 'playground' | 'setup' | 'usage';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { accountRole } = useAuth();
|
||||
const canViewUsage = accountRole ? canEditSettings(accountRole) : false;
|
||||
const [tab, setTab] = useState<Tab>('playground');
|
||||
const [decided, setDecided] = useState(false);
|
||||
|
||||
@ -57,6 +62,11 @@ export default function AgentsPage() {
|
||||
<TabsTrigger value="setup">
|
||||
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
|
||||
</TabsTrigger>
|
||||
{canViewUsage && (
|
||||
<TabsTrigger value="usage">
|
||||
<BarChart3 className="mr-1.5 h-4 w-4" /> Usage
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="playground" className="mt-4">
|
||||
@ -66,6 +76,12 @@ export default function AgentsPage() {
|
||||
<TabsContent value="setup" className="mt-4">
|
||||
<AiConfig />
|
||||
</TabsContent>
|
||||
|
||||
{canViewUsage && (
|
||||
<TabsContent value="usage" className="mt-4">
|
||||
<AiUsageCard />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
134
src/app/api/ai/usage/route.ts
Normal file
134
src/app/api/ai/usage/route.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireRole, toErrorResponse } from '@/lib/auth/account'
|
||||
|
||||
// Rows are aggregated in-process over a bounded window. An active
|
||||
// account writes a handful of rows per conversation, so 30 days sits
|
||||
// comfortably under this cap; we surface `truncated` when it doesn't so
|
||||
// the UI can say "showing a partial window" rather than under-reporting
|
||||
// silently.
|
||||
const MAX_ROWS = 10_000
|
||||
const DEFAULT_WINDOW_DAYS = 30
|
||||
|
||||
interface UsageRow {
|
||||
created_at: string
|
||||
mode: 'auto_reply' | 'draft'
|
||||
provider: string
|
||||
model: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
/** UTC yyyy-mm-dd — stable regardless of server timezone. */
|
||||
function dayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/usage?days=30 (admin+)
|
||||
*
|
||||
* Token-spend summary for the account's BYO key over the last `days`
|
||||
* (1–90, default 30): totals, per-mode + per-model breakdowns, and a
|
||||
* zero-filled daily series for charting. Admin-only, mirroring the
|
||||
* `ai_usage_log` SELECT policy — spend is billing-class.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { supabase, accountId } = await requireRole('admin')
|
||||
|
||||
const url = new URL(request.url)
|
||||
const rawDays = Number(url.searchParams.get('days'))
|
||||
const days = Number.isFinite(rawDays)
|
||||
? Math.min(90, Math.max(1, Math.floor(rawDays)))
|
||||
: DEFAULT_WINDOW_DAYS
|
||||
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('ai_usage_log')
|
||||
.select(
|
||||
'created_at, mode, provider, model, prompt_tokens, completion_tokens, total_tokens',
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.gte('created_at', since.toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(MAX_ROWS + 1)
|
||||
|
||||
if (error) {
|
||||
console.error('[ai/usage GET] fetch error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load usage' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const all = (data ?? []) as UsageRow[]
|
||||
const truncated = all.length > MAX_ROWS
|
||||
const rows = truncated ? all.slice(0, MAX_ROWS) : all
|
||||
|
||||
// Totals.
|
||||
let promptTokens = 0
|
||||
let completionTokens = 0
|
||||
let totalTokens = 0
|
||||
|
||||
// Per-mode + per-model tallies.
|
||||
const byMode = {
|
||||
auto_reply: { calls: 0, tokens: 0 },
|
||||
draft: { calls: 0, tokens: 0 },
|
||||
}
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{ model: string; provider: string; calls: number; tokens: number }
|
||||
>()
|
||||
|
||||
// Zero-filled daily buckets so the chart shows quiet days as gaps,
|
||||
// not missing points. Keyed by UTC date, oldest → newest.
|
||||
const daily = new Map<string, { date: string; tokens: number; calls: number }>()
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000))
|
||||
daily.set(key, { date: key, tokens: 0, calls: 0 })
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
promptTokens += r.prompt_tokens
|
||||
completionTokens += r.completion_tokens
|
||||
totalTokens += r.total_tokens
|
||||
|
||||
const mode = r.mode === 'draft' ? 'draft' : 'auto_reply'
|
||||
byMode[mode].calls += 1
|
||||
byMode[mode].tokens += r.total_tokens
|
||||
|
||||
const mk = `${r.provider}:${r.model}`
|
||||
const m =
|
||||
modelMap.get(mk) ??
|
||||
{ model: r.model, provider: r.provider, calls: 0, tokens: 0 }
|
||||
m.calls += 1
|
||||
m.tokens += r.total_tokens
|
||||
modelMap.set(mk, m)
|
||||
|
||||
const bucket = daily.get(dayKey(new Date(r.created_at)))
|
||||
if (bucket) {
|
||||
bucket.tokens += r.total_tokens
|
||||
bucket.calls += 1
|
||||
}
|
||||
}
|
||||
|
||||
const byModel = [...modelMap.values()].sort((a, b) => b.tokens - a.tokens)
|
||||
|
||||
return NextResponse.json({
|
||||
window_days: days,
|
||||
truncated,
|
||||
totals: {
|
||||
calls: rows.length,
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: totalTokens,
|
||||
},
|
||||
by_mode: byMode,
|
||||
by_model: byModel,
|
||||
daily: [...daily.values()],
|
||||
})
|
||||
} catch (err) {
|
||||
return toErrorResponse(err)
|
||||
}
|
||||
}
|
||||
250
src/components/agents/ai-usage.tsx
Normal file
250
src/components/agents/ai-usage.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { BarChart3, Bot, PencilLine } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { canEditSettings } from '@/lib/auth/roles';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/dashboard/skeleton';
|
||||
import { BarChart } from '@/components/tremor/bar-chart';
|
||||
|
||||
interface UsageResponse {
|
||||
window_days: number;
|
||||
truncated: boolean;
|
||||
totals: {
|
||||
calls: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
by_mode: {
|
||||
auto_reply: { calls: number; tokens: number };
|
||||
draft: { calls: number; tokens: number };
|
||||
};
|
||||
by_model: {
|
||||
model: string;
|
||||
provider: string;
|
||||
calls: number;
|
||||
tokens: number;
|
||||
}[];
|
||||
daily: { date: string; tokens: number; calls: number }[];
|
||||
}
|
||||
|
||||
const WINDOWS = [7, 30, 90] as const;
|
||||
|
||||
/** Compact token count — 1234 → "1.2k", 1_200_000 → "1.2M". */
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** "2026-07-08" → "Jul 8" for the chart axis. */
|
||||
function shortDay(iso: string): string {
|
||||
const [, m, d] = iso.split('-');
|
||||
const month = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
][Number(m) - 1];
|
||||
return `${month} ${Number(d)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-spend dashboard for the account's BYO key. Admin-only (spend is
|
||||
* billing-class), mirroring the `ai_usage_log` SELECT policy and the
|
||||
* `GET /api/ai/usage` route. Renders nothing for non-admins.
|
||||
*/
|
||||
export function AiUsageCard() {
|
||||
const { accountId, accountRole, profileLoading } = useAuth();
|
||||
const canView = accountRole ? canEditSettings(accountRole) : false;
|
||||
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<UsageResponse | null>(null);
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
|
||||
const fetchUsage = useCallback(async (windowDays: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/ai/usage?days=${windowDays}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
toast.error(json?.error ?? 'Failed to load usage');
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setData(json as UsageResponse);
|
||||
} catch {
|
||||
toast.error('Failed to load usage');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView || !accountId) return;
|
||||
// Refetch on account switch or window change.
|
||||
const key = `${accountId}:${days}`;
|
||||
if (loadedRef.current === key) return;
|
||||
loadedRef.current = key;
|
||||
void fetchUsage(days);
|
||||
}, [canView, accountId, days, fetchUsage]);
|
||||
|
||||
if (profileLoading || !canView) return null;
|
||||
|
||||
const chartData =
|
||||
data?.daily.map((d) => ({ day: shortDay(d.date), Tokens: d.tokens })) ?? [];
|
||||
const hasSpend = (data?.totals.total_tokens ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="h-4 w-4 text-primary" /> Token usage
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tokens spent on your provider key by drafts and the auto-reply
|
||||
bot. Counts only — no message content is stored here.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Select
|
||||
value={String(days)}
|
||||
onValueChange={(v) => setDays(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-32 flex-shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WINDOWS.map((w) => (
|
||||
<SelectItem key={w} value={String(w)}>
|
||||
Last {w} days
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : !hasSpend ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 opacity-40" />
|
||||
<p>No AI usage in the last {data.window_days} days yet.</p>
|
||||
<p className="text-xs">
|
||||
This fills in as the assistant drafts and auto-replies.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Total tokens" value={fmt(data.totals.total_tokens)} />
|
||||
<Stat label="LLM calls" value={String(data.totals.calls)} />
|
||||
<Stat
|
||||
label="Auto-reply"
|
||||
value={fmt(data.by_mode.auto_reply.tokens)}
|
||||
icon={Bot}
|
||||
/>
|
||||
<Stat
|
||||
label="Drafts"
|
||||
value={fmt(data.by_mode.draft.tokens)}
|
||||
icon={PencilLine}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Tokens per day
|
||||
</p>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="day"
|
||||
categories={['Tokens']}
|
||||
colors={['violet']}
|
||||
valueFormatter={(v) => fmt(v)}
|
||||
showLegend={false}
|
||||
yAxisWidth={48}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.by_model.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
By model
|
||||
</p>
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{data.by_model.map((m) => (
|
||||
<li
|
||||
key={`${m.provider}:${m.model}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="text-foreground">{m.model}</span>{' '}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({m.provider})
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 tabular-nums text-muted-foreground">
|
||||
{fmt(m.tokens)} tok · {m.calls}{' '}
|
||||
{m.calls === 1 ? 'call' : 'calls'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.truncated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing a partial window — usage is high enough that only the
|
||||
most recent records are summarized here.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: typeof Bot;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold tabular-nums text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user