This commit is contained in:
Marco Beretta 2025-12-16 10:11:31 +08:00 committed by GitHub
commit c91bc818aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1111 additions and 357 deletions

View file

@ -18,7 +18,9 @@ import {
useQueryParams,
useSubmitMessage,
useFocusChatEffect,
useTokenUsageComputation,
} from '~/hooks';
import TokenUsageIndicator from './TokenUsageIndicator';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
@ -39,6 +41,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef);
useTokenUsageComputation();
const localize = useLocalize();
const [isCollapsed, setIsCollapsed] = useState(false);
@ -332,6 +335,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
}
/>
<div className="mx-auto flex" />
<TokenUsageIndicator />
{SpeechToText && (
<AudioRecorder
methods={methods}

View file

@ -0,0 +1,278 @@
import { memo } from 'react';
import { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal } from '@librechat/client';
import { useLocalize, useTokenUsage } from '~/hooks';
import { cn } from '~/utils';
function formatTokens(n: number): string {
return new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(n);
}
interface 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>
)}
</div>
);
}
interface TokenRowProps {
label: string;
value: number;
total: number;
colorClass: string;
ariaLabel: string;
}
function TokenRow({ label, value, total, colorClass, ariaLabel }: TokenRowProps) {
const percentage = total > 0 ? Math.round((value / total) * 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">
{formatTokens(value)}
<span className="ml-1 text-xs text-text-secondary" aria-hidden="true">
({percentage}%)
</span>
</span>
</div>
<ProgressBar value={value} max={total} colorClass={colorClass} label={ariaLabel} />
</div>
);
}
function TokenUsageContent() {
const localize = useLocalize();
const { inputTokens = 0, outputTokens = 0, maxContext = null } = useTokenUsage() ?? {};
const totalUsed = inputTokens + outputTokens;
const hasMaxContext = maxContext !== null && maxContext > 0;
const percentage = hasMaxContext ? Math.min((totalUsed / maxContext) * 100, 100) : 0;
const getMainProgressColor = () => {
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 inputPercentage = totalUsed > 0 ? Math.round((inputTokens / totalUsed) * 100) : 0;
const outputPercentage = totalUsed > 0 ? Math.round((outputTokens / totalUsed) * 100) : 0;
return (
<div
className="w-full space-y-3"
role="region"
aria-label={localize('com_ui_token_usage_context')}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-text-primary" id="token-usage-title">
{localize('com_ui_token_usage_context')}
</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: Math.round(percentage).toString() })}
</span>
)}
</div>
{/* Main Progress Bar */}
<div className="space-y-1">
<ProgressBar
value={totalUsed}
max={hasMaxContext ? maxContext : 0}
colorClass={getMainProgressColor()}
label={
hasMaxContext
? `${localize('com_ui_token_usage_context')}: ${formatTokens(totalUsed)} of ${formatTokens(maxContext)}, ${Math.round(percentage)}%`
: `${localize('com_ui_token_usage_context')}: ${formatTokens(totalUsed)} tokens used, max context unknown`
}
indeterminate={!hasMaxContext}
/>
<div className="flex justify-between text-xs text-text-secondary" aria-hidden="true">
<span>{formatTokens(totalUsed)}</span>
<span>{hasMaxContext ? formatTokens(maxContext) : 'N/A'}</span>
</div>
</div>
{/* Divider */}
<div className="border-t border-border-light" role="separator" />
{/* Input/Output Breakdown */}
<div className="space-y-3">
<TokenRow
label={localize('com_ui_token_usage_input')}
value={inputTokens}
total={totalUsed}
colorClass="bg-blue-500"
ariaLabel={`${localize('com_ui_token_usage_input')}: ${formatTokens(inputTokens)}, ${inputPercentage}% of total`}
/>
<TokenRow
label={localize('com_ui_token_usage_output')}
value={outputTokens}
total={totalUsed}
colorClass="bg-green-500"
ariaLabel={`${localize('com_ui_token_usage_output')}: ${formatTokens(outputTokens)}, ${outputPercentage}% of total`}
/>
</div>
</div>
);
}
const TokenUsageIndicator = memo(function TokenUsageIndicator() {
const localize = useLocalize();
const { inputTokens = 0, outputTokens = 0, maxContext = null } = useTokenUsage() ?? {};
const totalUsed = inputTokens + outputTokens;
const hasMaxContext = maxContext !== null && maxContext > 0;
const percentage = hasMaxContext ? Math.min((totalUsed / maxContext) * 100, 100) : 0;
// Ring calculations
const size = 28;
const strokeWidth = 3.5;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
const ariaLabel = hasMaxContext
? localize('com_ui_token_usage_aria_full', {
0: formatTokens(inputTokens),
1: formatTokens(outputTokens),
2: formatTokens(maxContext),
3: Math.round(percentage).toString(),
})
: localize('com_ui_token_usage_aria_no_max', {
0: formatTokens(inputTokens),
1: formatTokens(outputTokens),
});
// Color based on percentage
const getProgressColor = () => {
if (!hasMaxContext) {
return 'stroke-text-secondary';
}
if (percentage > 90) {
return 'stroke-red-500';
}
if (percentage > 75) {
return 'stroke-yellow-500';
}
return 'stroke-green-500';
};
return (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
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"
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="rotate-[-90deg]"
aria-hidden="true"
focusable="false"
>
{/* Background ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
strokeWidth={strokeWidth}
className="stroke-border-heavy"
/>
{/* Progress ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={hasMaxContext ? offset : circumference}
strokeLinecap="round"
className={cn('transition-all duration-300', getProgressColor())}
/>
</svg>
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="top" align="end" className="p-3">
<TokenUsageContent />
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
});
export default TokenUsageIndicator;

View file

@ -35,3 +35,4 @@ export { default as useTextToSpeech } from './Input/useTextToSpeech';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
export { default as useLocalizedConfig } from './useLocalizedConfig';
export { default as useResourcePermissions } from './useResourcePermissions';
export { default as useTokenUsage, useTokenUsageComputation } from './useTokenUsage';

View file

@ -0,0 +1,107 @@
import { useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useSetAtom, useAtomValue } from 'jotai';
import { getModelMaxTokens } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { tokenUsageAtom, type TokenUsage } from '~/store/tokenUsage';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useChatContext } from '~/Providers';
/**
* Hook to compute and update token usage from conversation messages.
* Should be called in a component that has access to useChatContext.
*/
export function useTokenUsageComputation() {
const { conversation } = useChatContext();
const conversationId = conversation?.conversationId ?? '';
const setTokenUsage = useSetAtom(tokenUsageAtom);
const { conversationId: paramId } = useParams();
// Determine the query key to use - same logic as useChatHelpers
const queryParam = paramId === 'new' ? paramId : conversationId || paramId || '';
// Use the query hook to get reactive messages
// Subscribe to both the paramId-based key and conversationId-based key
const { data: messages } = useGetMessagesByConvoId(queryParam, {
enabled: !!queryParam,
});
// Also subscribe to the actual conversationId if different from queryParam
// This ensures we get updates when conversation transitions from 'new' to actual ID
const { data: messagesById } = useGetMessagesByConvoId(conversationId, {
enabled: !!conversationId && conversationId !== 'new' && conversationId !== queryParam,
});
// Use whichever has more messages (handles transition from new -> actual ID)
const effectiveMessages = useMemo(() => {
const msgArray = messages ?? [];
const msgByIdArray = messagesById ?? [];
return msgByIdArray.length > msgArray.length ? msgByIdArray : msgArray;
}, [messages, messagesById]);
// Compute token usage whenever messages change
const tokenData = useMemo(() => {
let inputTokens = 0;
let outputTokens = 0;
if (effectiveMessages && Array.isArray(effectiveMessages)) {
for (const msg of effectiveMessages as TMessage[]) {
const count = msg.tokenCount ?? 0;
if (msg.isCreatedByUser) {
inputTokens += count;
} else {
outputTokens += count;
}
}
}
// Determine max context: explicit setting or model default
let maxContext: number | null = conversation?.maxContextTokens ?? null;
// If no explicit maxContextTokens, try to look up model default
if (maxContext === null && conversation?.model) {
const endpoint = conversation.endpointType ?? conversation.endpoint ?? '';
const modelDefault = getModelMaxTokens(conversation.model, endpoint);
if (modelDefault !== undefined) {
maxContext = modelDefault;
}
}
return {
inputTokens,
outputTokens,
maxContext,
};
}, [
effectiveMessages,
conversation?.maxContextTokens,
conversation?.model,
conversation?.endpoint,
conversation?.endpointType,
]);
// Update the atom when computed values change
useEffect(() => {
setTokenUsage(tokenData);
}, [tokenData, setTokenUsage]);
// Reset token usage when starting a new conversation
useEffect(() => {
if (paramId === 'new' && effectiveMessages.length === 0) {
setTokenUsage({
inputTokens: 0,
outputTokens: 0,
maxContext: null,
});
}
}, [paramId, effectiveMessages.length, setTokenUsage]);
}
/**
* Hook to read the current token usage values.
*/
export function useTokenUsage(): TokenUsage {
return useAtomValue(tokenUsageAtom);
}
export default useTokenUsage;

View file

@ -1330,6 +1330,14 @@
"com_ui_token": "token",
"com_ui_token_exchange_method": "Token Exchange Method",
"com_ui_token_url": "Token URL",
"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",
"com_ui_token_usage_context": "Context Usage",
"com_ui_token_usage_input": "Input",
"com_ui_token_usage_max_context": "Max Context",
"com_ui_token_usage_output": "Output",
"com_ui_token_usage_percent": "{{0}}% used",
"com_ui_token_usage_total": "Total",
"com_ui_tokens": "tokens",
"com_ui_tool_collection_prefix": "A collection of tools from",
"com_ui_tool_list_collapse": "Collapse {{serverName}} tool list",

View file

@ -12,9 +12,11 @@ import lang from './language';
import settings from './settings';
import misc from './misc';
import isTemporary from './temporary';
import * as tokenUsage from './tokenUsage';
export * from './agents';
export * from './mcp';
export * from './favorites';
export * from './tokenUsage';
export default {
...artifacts,
@ -31,4 +33,5 @@ export default {
...settings,
...misc,
...isTemporary,
...tokenUsage,
};

View file

@ -0,0 +1,13 @@
import { atom } from 'jotai';
export type TokenUsage = {
inputTokens: number;
outputTokens: number;
maxContext: number | null; // null = N/A
};
export const tokenUsageAtom = atom<TokenUsage>({
inputTokens: 0,
outputTokens: 0,
maxContext: null,
});