context.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. 'use client';
  2. import { Button } from '@/components/ui/button';
  3. import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
  4. import { Progress } from '@/components/ui/progress';
  5. import { cn } from '@/lib/utils';
  6. import type { LanguageModelUsage } from 'ai';
  7. import { type ComponentProps, createContext, useContext } from 'react';
  8. import { getUsage } from 'tokenlens';
  9. const PERCENT_MAX = 100;
  10. const ICON_RADIUS = 10;
  11. const ICON_VIEWBOX = 24;
  12. const ICON_CENTER = 12;
  13. const ICON_STROKE_WIDTH = 2;
  14. type ModelId = string;
  15. type ContextSchema = {
  16. usedTokens: number;
  17. maxTokens: number;
  18. usage?: LanguageModelUsage;
  19. modelId?: ModelId;
  20. };
  21. const ContextContext = createContext<ContextSchema | null>(null);
  22. const useContextValue = () => {
  23. const context = useContext(ContextContext);
  24. if (!context) {
  25. throw new Error('Context components must be used within Context');
  26. }
  27. return context;
  28. };
  29. export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
  30. export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => (
  31. <ContextContext.Provider
  32. value={{
  33. usedTokens,
  34. maxTokens,
  35. usage,
  36. modelId,
  37. }}
  38. >
  39. <HoverCard closeDelay={0} openDelay={0} {...props} />
  40. </ContextContext.Provider>
  41. );
  42. const ContextIcon = () => {
  43. const { usedTokens, maxTokens } = useContextValue();
  44. const circumference = 2 * Math.PI * ICON_RADIUS;
  45. const usedPercent = usedTokens / maxTokens;
  46. const dashOffset = circumference * (1 - usedPercent);
  47. return (
  48. <svg
  49. aria-label="Model context usage"
  50. height="20"
  51. role="img"
  52. style={{ color: 'currentcolor' }}
  53. viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
  54. width="20"
  55. >
  56. <circle
  57. cx={ICON_CENTER}
  58. cy={ICON_CENTER}
  59. fill="none"
  60. opacity="0.25"
  61. r={ICON_RADIUS}
  62. stroke="currentColor"
  63. strokeWidth={ICON_STROKE_WIDTH}
  64. />
  65. <circle
  66. cx={ICON_CENTER}
  67. cy={ICON_CENTER}
  68. fill="none"
  69. opacity="0.7"
  70. r={ICON_RADIUS}
  71. stroke="currentColor"
  72. strokeDasharray={`${circumference} ${circumference}`}
  73. strokeDashoffset={dashOffset}
  74. strokeLinecap="round"
  75. strokeWidth={ICON_STROKE_WIDTH}
  76. style={{ transformOrigin: 'center', transform: 'rotate(-90deg)' }}
  77. />
  78. </svg>
  79. );
  80. };
  81. export type ContextTriggerProps = ComponentProps<typeof Button>;
  82. export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
  83. const { usedTokens, maxTokens } = useContextValue();
  84. const usedPercent = usedTokens / maxTokens;
  85. const renderedPercent = new Intl.NumberFormat('en-US', {
  86. style: 'percent',
  87. maximumFractionDigits: 1,
  88. }).format(usedPercent);
  89. return (
  90. <HoverCardTrigger asChild>
  91. {children ?? (
  92. <Button type="button" variant="ghost" {...props}>
  93. <span className="font-medium text-muted-foreground">{renderedPercent}</span>
  94. <ContextIcon />
  95. </Button>
  96. )}
  97. </HoverCardTrigger>
  98. );
  99. };
  100. export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
  101. export const ContextContent = ({ className, ...props }: ContextContentProps) => (
  102. <HoverCardContent className={cn('min-w-60 divide-y overflow-hidden p-0', className)} {...props} />
  103. );
  104. export type ContextContentHeaderProps = ComponentProps<'div'>;
  105. export const ContextContentHeader = ({
  106. children,
  107. className,
  108. ...props
  109. }: ContextContentHeaderProps) => {
  110. const { usedTokens, maxTokens } = useContextValue();
  111. const usedPercent = usedTokens / maxTokens;
  112. const displayPct = new Intl.NumberFormat('en-US', {
  113. style: 'percent',
  114. maximumFractionDigits: 1,
  115. }).format(usedPercent);
  116. const used = new Intl.NumberFormat('en-US', {
  117. notation: 'compact',
  118. }).format(usedTokens);
  119. const total = new Intl.NumberFormat('en-US', {
  120. notation: 'compact',
  121. }).format(maxTokens);
  122. return (
  123. <div className={cn('w-full space-y-2 p-3', className)} {...props}>
  124. {children ?? (
  125. <>
  126. <div className="flex items-center justify-between gap-3 text-xs">
  127. <p>{displayPct}</p>
  128. <p className="font-mono text-muted-foreground">
  129. {used} / {total}
  130. </p>
  131. </div>
  132. <div className="space-y-2">
  133. <Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
  134. </div>
  135. </>
  136. )}
  137. </div>
  138. );
  139. };
  140. export type ContextContentBodyProps = ComponentProps<'div'>;
  141. export const ContextContentBody = ({ children, className, ...props }: ContextContentBodyProps) => (
  142. <div className={cn('w-full p-3', className)} {...props}>
  143. {children}
  144. </div>
  145. );
  146. export type ContextContentFooterProps = ComponentProps<'div'>;
  147. export const ContextContentFooter = ({
  148. children,
  149. className,
  150. ...props
  151. }: ContextContentFooterProps) => {
  152. const { modelId, usage } = useContextValue();
  153. const costUSD = modelId
  154. ? getUsage({
  155. modelId,
  156. usage: {
  157. input: usage?.inputTokens ?? 0,
  158. output: usage?.outputTokens ?? 0,
  159. },
  160. }).costUSD?.totalUSD
  161. : undefined;
  162. const totalCost = new Intl.NumberFormat('en-US', {
  163. style: 'currency',
  164. currency: 'USD',
  165. }).format(costUSD ?? 0);
  166. return (
  167. <div
  168. className={cn(
  169. 'flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs',
  170. className,
  171. )}
  172. {...props}
  173. >
  174. {children ?? (
  175. <>
  176. <span className="text-muted-foreground">Total cost</span>
  177. <span>{totalCost}</span>
  178. </>
  179. )}
  180. </div>
  181. );
  182. };
  183. export type ContextInputUsageProps = ComponentProps<'div'>;
  184. export const ContextInputUsage = ({ className, children, ...props }: ContextInputUsageProps) => {
  185. const { usage, modelId } = useContextValue();
  186. const inputTokens = usage?.inputTokens ?? 0;
  187. if (children) {
  188. return children;
  189. }
  190. if (!inputTokens) {
  191. return null;
  192. }
  193. const inputCost = modelId
  194. ? getUsage({
  195. modelId,
  196. usage: { input: inputTokens, output: 0 },
  197. }).costUSD?.totalUSD
  198. : undefined;
  199. const inputCostText = new Intl.NumberFormat('en-US', {
  200. style: 'currency',
  201. currency: 'USD',
  202. }).format(inputCost ?? 0);
  203. return (
  204. <div className={cn('flex items-center justify-between text-xs', className)} {...props}>
  205. <span className="text-muted-foreground">Input</span>
  206. <TokensWithCost costText={inputCostText} tokens={inputTokens} />
  207. </div>
  208. );
  209. };
  210. export type ContextOutputUsageProps = ComponentProps<'div'>;
  211. export const ContextOutputUsage = ({ className, children, ...props }: ContextOutputUsageProps) => {
  212. const { usage, modelId } = useContextValue();
  213. const outputTokens = usage?.outputTokens ?? 0;
  214. if (children) {
  215. return children;
  216. }
  217. if (!outputTokens) {
  218. return null;
  219. }
  220. const outputCost = modelId
  221. ? getUsage({
  222. modelId,
  223. usage: { input: 0, output: outputTokens },
  224. }).costUSD?.totalUSD
  225. : undefined;
  226. const outputCostText = new Intl.NumberFormat('en-US', {
  227. style: 'currency',
  228. currency: 'USD',
  229. }).format(outputCost ?? 0);
  230. return (
  231. <div className={cn('flex items-center justify-between text-xs', className)} {...props}>
  232. <span className="text-muted-foreground">Output</span>
  233. <TokensWithCost costText={outputCostText} tokens={outputTokens} />
  234. </div>
  235. );
  236. };
  237. export type ContextReasoningUsageProps = ComponentProps<'div'>;
  238. export const ContextReasoningUsage = ({
  239. className,
  240. children,
  241. ...props
  242. }: ContextReasoningUsageProps) => {
  243. const { usage, modelId } = useContextValue();
  244. const reasoningTokens = usage?.reasoningTokens ?? 0;
  245. if (children) {
  246. return children;
  247. }
  248. if (!reasoningTokens) {
  249. return null;
  250. }
  251. const reasoningCost = modelId
  252. ? getUsage({
  253. modelId,
  254. usage: { reasoningTokens },
  255. }).costUSD?.totalUSD
  256. : undefined;
  257. const reasoningCostText = new Intl.NumberFormat('en-US', {
  258. style: 'currency',
  259. currency: 'USD',
  260. }).format(reasoningCost ?? 0);
  261. return (
  262. <div className={cn('flex items-center justify-between text-xs', className)} {...props}>
  263. <span className="text-muted-foreground">Reasoning</span>
  264. <TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
  265. </div>
  266. );
  267. };
  268. export type ContextCacheUsageProps = ComponentProps<'div'>;
  269. export const ContextCacheUsage = ({ className, children, ...props }: ContextCacheUsageProps) => {
  270. const { usage, modelId } = useContextValue();
  271. const cacheTokens = usage?.cachedInputTokens ?? 0;
  272. if (children) {
  273. return children;
  274. }
  275. if (!cacheTokens) {
  276. return null;
  277. }
  278. const cacheCost = modelId
  279. ? getUsage({
  280. modelId,
  281. usage: { cacheReads: cacheTokens, input: 0, output: 0 },
  282. }).costUSD?.totalUSD
  283. : undefined;
  284. const cacheCostText = new Intl.NumberFormat('en-US', {
  285. style: 'currency',
  286. currency: 'USD',
  287. }).format(cacheCost ?? 0);
  288. return (
  289. <div className={cn('flex items-center justify-between text-xs', className)} {...props}>
  290. <span className="text-muted-foreground">Cache</span>
  291. <TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
  292. </div>
  293. );
  294. };
  295. const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: string }) => (
  296. <span>
  297. {tokens === undefined
  298. ? '—'
  299. : new Intl.NumberFormat('en-US', {
  300. notation: 'compact',
  301. }).format(tokens)}
  302. {costText ? <span className="ml-2 text-muted-foreground">• {costText}</span> : null}
  303. </span>
  304. );