From 11944ec9fb0f459ff53cc532aca29f17251e7de6 Mon Sep 17 00:00:00 2001 From: Murillo Camargo Date: Sat, 4 Apr 2026 19:11:28 -0700 Subject: [PATCH] merge: Context Tracker UI --- .../components/Chat/Input/ContextTracker.tsx | 348 ++++++++++++++---- client/src/locales/en/translation.json | 7 + 2 files changed, 289 insertions(+), 66 deletions(-) diff --git a/client/src/components/Chat/Input/ContextTracker.tsx b/client/src/components/Chat/Input/ContextTracker.tsx index a664a8091b..2f4485b81f 100644 --- a/client/src/components/Chat/Input/ContextTracker.tsx +++ b/client/src/components/Chat/Input/ContextTracker.tsx @@ -1,9 +1,9 @@ -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, Constants } from 'librechat-data-provider'; import type { TConversation, TMessage, TModelSpec, TStartupConfig } from 'librechat-data-provider'; +import { Constants, QueryKeys } from 'librechat-data-provider'; +import { memo, useCallback, useMemo, useSyncExternalStore } from 'react'; import { useRecoilValue } from 'recoil'; -import { TooltipAnchor } from '@librechat/client'; import { useGetStartupConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -14,33 +14,47 @@ type ContextTrackerProps = { }; type MessageWithTokenCount = TMessage & { tokenCount?: number }; +type TokenTotals = { inputTokens: number; outputTokens: number; totalUsed: number }; -const TRACKER_SIZE = 24; -const TRACKER_STROKE = 2.5; +const TRACKER_SIZE = 28; +const TRACKER_STROKE = 3.5; const formatTokenCount = (count: number): string => { - if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(1)}M`; - } - if (count >= 1_000) { - return `${(count / 1_000).toFixed(1)}K`; - } - return count.toString(); + const formatted = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(count); + return formatted.replace(/\.0(?=[A-Za-z]|$)/, ''); }; -const getUsedTokens = (messages: TMessage[] | undefined): number => { +const getTokenTotals = (messages: TMessage[] | undefined): TokenTotals => { if (!messages?.length) { - return 0; + return { inputTokens: 0, outputTokens: 0, totalUsed: 0 }; } - return messages.reduce((totalTokens, message) => { - const tokenCount = (message as MessageWithTokenCount).tokenCount; - if (typeof tokenCount !== 'number' || !Number.isFinite(tokenCount) || tokenCount <= 0) { - return totalTokens; - } + const totals = messages.reduce( + (accumulator: Omit, message) => { + const tokenCount = (message as MessageWithTokenCount).tokenCount; + if (typeof tokenCount !== 'number' || !Number.isFinite(tokenCount) || tokenCount <= 0) { + return accumulator; + } - return totalTokens + tokenCount; - }, 0); + if (message.isCreatedByUser) { + accumulator.inputTokens += tokenCount; + } else { + accumulator.outputTokens += tokenCount; + } + + return accumulator; + }, + { inputTokens: 0, outputTokens: 0 }, + ); + + return { + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + totalUsed: totals.inputTokens + totals.outputTokens, + }; }; const getSpecMaxContextTokens = ( @@ -55,6 +69,7 @@ const getSpecMaxContextTokens = ( (spec: TModelSpec) => spec.name === specName, ); const maxContextTokens = modelSpec?.preset?.maxContextTokens; + if ( typeof maxContextTokens !== 'number' || !Number.isFinite(maxContextTokens) || @@ -66,7 +81,99 @@ const getSpecMaxContextTokens = ( return maxContextTokens; }; -export default function ContextTracker({ conversation }: ContextTrackerProps) { +type ProgressBarProps = { + value: number; + max: number; + colorClass: string; + label: string; + showPercentage?: boolean; + indeterminate?: boolean; +}; + +function ProgressBar({ + value, + max, + colorClass, + label, + showPercentage = false, + indeterminate = false, +}: ProgressBarProps) { + const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0; + + return ( +
+
+ {indeterminate ? ( +
+ ) : ( +
+
+
+
+ )} +
+ {showPercentage && !indeterminate ? ( + + ) : null} +
+ ); +} + +type TokenRowProps = { + label: string; + value: number; + max: number | null; + colorClass: string; + ariaLabel: string; +}; + +function TokenRow({ label, value, max, colorClass, ariaLabel }: TokenRowProps) { + const hasMax = max != null && max > 0; + const percentage = hasMax ? Math.round(Math.min((value / max) * 100, 100)) : 0; + + return ( +
+
+ {label} + + {formatTokenCount(value)} + {hasMax ? ( + + ) : null} + +
+ +
+ ); +} + +function ContextTracker({ conversation }: ContextTrackerProps) { const localize = useLocalize(); const queryClient = useQueryClient(); const { data: startupConfig } = useGetStartupConfig(); @@ -97,7 +204,10 @@ export default function ContextTracker({ conversation }: ContextTrackerProps) { getMessagesSnapshot, getMessagesSnapshot, ); - const usedTokens = useMemo(() => getUsedTokens(messages), [messages]); + const { inputTokens, outputTokens, totalUsed } = useMemo( + () => getTokenTotals(messages), + [messages], + ); const maxContextTokens = typeof conversation?.maxContextTokens === 'number' && @@ -106,87 +216,193 @@ export default function ContextTracker({ conversation }: ContextTrackerProps) { ? conversation.maxContextTokens : getSpecMaxContextTokens(startupConfig, conversation?.spec); + const hasMaxContext = maxContextTokens != null && maxContextTokens > 0; const usageRatio = useMemo(() => { - if (maxContextTokens == null || maxContextTokens <= 0) { + if (!hasMaxContext || maxContextTokens == null) { return 0; } - return Math.min(usedTokens / maxContextTokens, 1); - }, [maxContextTokens, usedTokens]); + return Math.min(totalUsed / maxContextTokens, 1); + }, [hasMaxContext, maxContextTokens, totalUsed]); + const percentage = Math.round(usageRatio * 100); + const inputPercentage = + hasMaxContext && maxContextTokens != null + ? Math.round(Math.min((inputTokens / maxContextTokens) * 100, 100)) + : 0; + const outputPercentage = + hasMaxContext && maxContextTokens != null + ? Math.round(Math.min((outputTokens / maxContextTokens) * 100, 100)) + : 0; const trackerRadius = useMemo(() => (TRACKER_SIZE - TRACKER_STROKE) / 2, []); const circumference = useMemo(() => 2 * Math.PI * trackerRadius, [trackerRadius]); - const dashOffset = useMemo(() => circumference * (1 - usageRatio), [circumference, usageRatio]); + const dashOffset = useMemo( + () => circumference - (percentage / 100) * circumference, + [circumference, percentage], + ); - let ringColorClass = 'text-text-primary'; - if (maxContextTokens == null) { - ringColorClass = 'text-text-secondary'; - } else if (usageRatio > 0.9) { - ringColorClass = 'text-red-500'; - } else if (usageRatio > 0.75) { - ringColorClass = 'text-yellow-500'; - } - - const tooltipDescription = useMemo(() => { - if (maxContextTokens == null) { - return localize('com_ui_context_usage_unknown_max', { - 0: formatTokenCount(usedTokens), - }); + const getRingColorClass = () => { + if (!hasMaxContext) { + return 'stroke-text-secondary'; } + if (percentage > 90) { + return 'stroke-red-500'; + } + if (percentage > 75) { + return 'stroke-yellow-500'; + } + return 'stroke-green-500'; + }; - const percentage = (usageRatio * 100).toFixed(1) + '%'; - return localize('com_ui_context_usage_with_max', { - 0: percentage, - 1: formatTokenCount(usedTokens), - 2: formatTokenCount(maxContextTokens), - }); - }, [localize, maxContextTokens, usedTokens, usageRatio]); + const getMainProgressColorClass = () => { + if (!hasMaxContext) { + return 'bg-text-secondary'; + } + if (percentage > 90) { + return 'bg-red-500'; + } + if (percentage > 75) { + return 'bg-yellow-500'; + } + return 'bg-green-500'; + }; + + const ariaLabel = hasMaxContext + ? localize('com_ui_token_usage_aria_full', { + 0: formatTokenCount(inputTokens), + 1: formatTokenCount(outputTokens), + 2: formatTokenCount(maxContextTokens ?? 0), + 3: percentage.toString(), + }) + : localize('com_ui_token_usage_aria_no_max', { + 0: formatTokenCount(inputTokens), + 1: formatTokenCount(outputTokens), + 2: formatTokenCount(totalUsed), + }); if (!showContextTracker) { return null; } return ( - + - } - /> + + + +
+
+ + {localize('com_ui_context_usage')} + + {hasMaxContext ? ( + 90, + 'text-yellow-500': percentage > 75 && percentage <= 90, + 'text-green-500': percentage <= 75, + })} + > + {localize('com_ui_token_usage_percent', { 0: percentage.toString() })} + + ) : null} +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + ); } + +export default memo(ContextTracker); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 55a6b8fefe..e6cacb2a2d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1526,6 +1526,13 @@ "com_ui_context_usage": "Context usage", "com_ui_context_usage_unknown_max": "{{0}} tokens used (max unavailable)", "com_ui_context_usage_with_max": "{{0}} ยท {{1}} / {{2}} context used", + "com_ui_token_usage_input": "Input", + "com_ui_token_usage_output": "Output", + "com_ui_token_usage_percent": "{{0}}% used", + "com_ui_token_usage_input_aria": "Input usage: {{0}} of {{1}} max context, {{2}}% used", + "com_ui_token_usage_output_aria": "Output usage: {{0}} of {{1}} max context, {{2}}% used", + "com_ui_token_usage_aria_full": "Token usage: {{0}} input, {{1}} output, {{2}} max context, {{3}}% used", + "com_ui_token_usage_aria_no_max": "Token usage: {{0}} input, {{1}} output, {{2}} total tokens used", "com_ui_usage": "Usage", "com_ui_use_2fa_code": "Use 2FA Code Instead", "com_ui_use_backup_code": "Use Backup Code Instead",