merge: Context Tracker UI

This commit is contained in:
Murillo Camargo 2026-04-04 19:11:28 -07:00
parent 8e9f926e42
commit 11944ec9fb
2 changed files with 289 additions and 66 deletions

View file

@ -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<TokenTotals, 'totalUsed'>, 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 (
<div className="flex items-center gap-2">
<div
role="progressbar"
aria-valuenow={indeterminate ? undefined : Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className="h-2 flex-1 overflow-hidden rounded-full bg-surface-secondary"
>
{indeterminate ? (
<div
className="h-full w-full rounded-full"
style={{
background:
'repeating-linear-gradient(-45deg, var(--border-medium), var(--border-medium) 4px, var(--surface-tertiary) 4px, var(--surface-tertiary) 8px)',
}}
/>
) : (
<div className="flex h-full rounded-full">
<div
className={cn('rounded-full transition-all duration-300', colorClass)}
style={{ width: `${percentage}%` }}
/>
<div className="flex-1 bg-surface-hover" />
</div>
)}
</div>
{showPercentage && !indeterminate ? (
<span className="min-w-[3rem] text-right text-xs text-text-secondary" aria-hidden="true">
{Math.round(percentage)}%
</span>
) : null}
</div>
);
}
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 (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">{label}</span>
<span className="font-medium text-text-primary">
{formatTokenCount(value)}
{hasMax ? (
<span className="ml-1 text-xs text-text-secondary" aria-hidden="true">
({percentage}%)
</span>
) : null}
</span>
</div>
<ProgressBar
value={value}
max={hasMax ? max : 0}
colorClass={colorClass}
label={ariaLabel}
indeterminate={!hasMax}
/>
</div>
);
}
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 (
<TooltipAnchor
description={tooltipDescription}
side="top"
render={
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label={localize('com_ui_context_usage')}
className={cn('rounded-full p-1.5', ringColorClass)}
className="flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={ariaLabel}
aria-haspopup="dialog"
data-testid="context-tracker"
>
<svg
width={TRACKER_SIZE}
height={TRACKER_SIZE}
viewBox={`0 0 ${TRACKER_SIZE} ${TRACKER_SIZE}`}
className="rotate-[-90deg]"
aria-hidden="true"
focusable="false"
>
<circle
cx={TRACKER_SIZE / 2}
cy={TRACKER_SIZE / 2}
r={trackerRadius}
stroke="currentColor"
strokeWidth={TRACKER_STROKE}
fill="transparent"
className="opacity-20"
strokeWidth={TRACKER_STROKE}
className="stroke-border-heavy"
/>
<circle
cx={TRACKER_SIZE / 2}
cy={TRACKER_SIZE / 2}
r={trackerRadius}
stroke="currentColor"
strokeWidth={TRACKER_STROKE}
strokeLinecap="round"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
fill="transparent"
transform={`rotate(-90 ${TRACKER_SIZE / 2} ${TRACKER_SIZE / 2})`}
className="transition-all duration-200"
strokeWidth={TRACKER_STROKE}
strokeDasharray={circumference}
strokeDashoffset={hasMaxContext ? dashOffset : circumference}
strokeLinecap="round"
className={cn('transition-all duration-300', getRingColorClass())}
/>
</svg>
</button>
}
/>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="top" align="end" className="p-3">
<div
className="w-full space-y-3"
role="region"
aria-label={localize('com_ui_context_usage')}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_context_usage')}
</span>
{hasMaxContext ? (
<span
className={cn('text-xs font-medium', {
'text-red-500': percentage > 90,
'text-yellow-500': percentage > 75 && percentage <= 90,
'text-green-500': percentage <= 75,
})}
>
{localize('com_ui_token_usage_percent', { 0: percentage.toString() })}
</span>
) : null}
</div>
<div className="space-y-1">
<ProgressBar
value={totalUsed}
max={hasMaxContext ? (maxContextTokens ?? 0) : 0}
colorClass={getMainProgressColorClass()}
label={
hasMaxContext
? localize('com_ui_context_usage_with_max', {
0: `${percentage}%`,
1: formatTokenCount(totalUsed),
2: formatTokenCount(maxContextTokens ?? 0),
})
: localize('com_ui_context_usage_unknown_max', {
0: formatTokenCount(totalUsed),
})
}
indeterminate={!hasMaxContext}
/>
<div className="flex justify-between text-xs text-text-secondary" aria-hidden="true">
<span>{formatTokenCount(totalUsed)}</span>
<span>{hasMaxContext ? formatTokenCount(maxContextTokens ?? 0) : '--'}</span>
</div>
</div>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-3">
<TokenRow
label={localize('com_ui_token_usage_input')}
value={inputTokens}
max={maxContextTokens}
colorClass="bg-blue-500"
ariaLabel={localize('com_ui_token_usage_input_aria', {
0: formatTokenCount(inputTokens),
1: formatTokenCount(maxContextTokens ?? 0),
2: inputPercentage.toString(),
})}
/>
<TokenRow
label={localize('com_ui_token_usage_output')}
value={outputTokens}
max={maxContextTokens}
colorClass="bg-green-500"
ariaLabel={localize('com_ui_token_usage_output_aria', {
0: formatTokenCount(outputTokens),
1: formatTokenCount(maxContextTokens ?? 0),
2: outputPercentage.toString(),
})}
/>
</div>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
}
export default memo(ContextTracker);

View file

@ -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",