mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
merge: Context Tracker UI
This commit is contained in:
parent
8e9f926e42
commit
11944ec9fb
2 changed files with 289 additions and 66 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue