chatbot-template/components/elements/context.tsx

188 lines
5.7 KiB
TypeScript
Raw Normal View History

'use client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
import { Separator } from '@/components/ui/separator';
import { Progress } from '@/components/ui/progress';
import type { AppUsage } from '@/lib/usage';
export type ContextProps = ComponentProps<'button'> & {
/** Optional full usage payload to enable breakdown view */
usage?: AppUsage;
};
const THOUSAND = 1000;
const MILLION = 1_000_000;
const BILLION = 1_000_000_000;
const PERCENT_MAX = 100;
// Lucide CircleIcon geometry
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_RADIUS = 10;
const ICON_STROKE_WIDTH = 2;
type ContextIconProps = {
percent: number; // 0 - 100
};
export const ContextIcon = ({ percent }: ContextIconProps) => {
const radius = ICON_RADIUS;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - percent / PERCENT_MAX);
return (
<svg
aria-label={`${percent.toFixed(2)}% of model context used`}
height="28"
role="img"
style={{ color: 'currentcolor' }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="28"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={radius}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={radius}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
transform={`rotate(-90 ${ICON_CENTER} ${ICON_CENTER})`}
/>
</svg>
);
};
function InfoRow({
label,
tokens,
costText,
}: {
label: string;
tokens?: number;
costText?: string;
}) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 font-mono">
<span className="text-right min-w-[4ch]">
{tokens === undefined ? '—' : tokens.toLocaleString()}
</span>
{costText !== undefined && costText !== null && !isNaN(parseFloat(costText)) && (
<span className="text-muted-foreground">
${parseFloat(costText).toFixed(6)}
</span>
)}
</div>
</div>
);
}
export const Context = ({ className, usage, ...props }: ContextProps) => {
const used = usage?.totalTokens ?? 0;
const max =
usage?.context?.totalMax ??
usage?.context?.combinedMax ??
usage?.context?.inputMax;
const hasMax = typeof max === 'number' && Number.isFinite(max) && max > 0;
const usedPercent = hasMax ? Math.min(100, (used / max) * 100) : 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 select-none rounded-md text-sm',
2025-09-10 20:23:41 +01:00
'cursor-pointer bg-background text-foreground',
2025-09-09 22:19:34 +01:00
className,
)}
type="button"
{...props}
>
2025-09-10 20:23:41 +01:00
<span className="hidden font-medium text-muted-foreground">
{usedPercent.toFixed(1)}%
</span>
<ContextIcon percent={usedPercent} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-fit p-3">
<div className="min-w-[240px] space-y-2">
<div className="flex items-start justify-between text-sm">
<span>{usedPercent.toFixed(1)}%</span>
<span className="text-muted-foreground">
{hasMax ? `${used} / ${max} tokens` : `${used} tokens`}
</span>
</div>
<div className="space-y-2">
<Progress className="h-2 bg-muted" value={usedPercent} />
</div>
<div className="mt-1 space-y-1">
{usage?.cachedInputTokens && usage.cachedInputTokens > 0 && (
<InfoRow
label="Cache Hits"
tokens={usage?.cachedInputTokens}
costText={usage?.costUSD?.cacheReadUSD?.toString()}
/>
)}
<InfoRow
label="Input"
tokens={usage?.inputTokens}
costText={usage?.costUSD?.inputUSD?.toString()}
/>
<InfoRow
label="Output"
tokens={usage?.outputTokens}
costText={usage?.costUSD?.outputUSD?.toString()}
/>
<InfoRow
label="Reasoning"
tokens={
usage?.reasoningTokens && usage.reasoningTokens > 0
? usage.reasoningTokens
: undefined
}
costText={usage?.costUSD?.reasoningUSD?.toString()}
/>
{usage?.costUSD?.totalUSD !== undefined && (
<>
<Separator className="mt-1" />
2025-09-10 20:23:41 +01:00
<div className="flex justify-between items-center pt-1 text-xs">
<span className="text-muted-foreground">Total cost</span>
<div className="flex items-center gap-2 font-mono">
<span className="text-right min-w-[4ch]"></span>
<span>
{!isNaN(parseFloat(usage.costUSD.totalUSD.toString()))
? `$${parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`
: '—'
}
</span>
</div>
</div>
</>
)}
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};