feat: add frontend component for displaying convo total, per message cost and tokens, and hide total block on scroll

This commit is contained in:
Dustin Healy 2025-08-21 01:57:01 -07:00
parent c1b0f13360
commit 3b1c07ff46
4 changed files with 225 additions and 5 deletions

View file

@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo, useCallback, useState, useEffect, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { Spinner } from '@librechat/client';
@ -7,9 +7,9 @@ import { Constants, buildTree } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useGetMessagesByConvoId, useGetConversationCosts } from '~/data-provider';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@ -37,6 +37,9 @@ function ChatView({ index = 0 }: { index?: number }) {
const fileMap = useFileMapContext();
const [showCostBar, setShowCostBar] = useState(true);
const lastScrollY = useRef(0);
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
select: useCallback(
(data: TMessage[]) => {
@ -48,12 +51,46 @@ function ChatView({ index = 0 }: { index?: number }) {
enabled: !!fileMap,
});
const { data: conversationCosts } = useGetConversationCosts(conversationId ?? '', {
enabled: !!conversationId && conversationId !== Constants.NEW_CONVO,
});
const chatHelpers = useChatHelpers(index, conversationId);
const addedChatHelpers = useAddedResponse({ rootIndex: index });
useSSE(rootSubmission, chatHelpers, false);
useSSE(addedSubmission, addedChatHelpers, true);
useEffect(() => {
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement;
const currentScrollY = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollY - clientHeight;
const isAtBottom = distanceFromBottom < 10;
setShowCostBar(isAtBottom);
lastScrollY.current = currentScrollY;
};
const findAndAttachScrollListener = () => {
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
if (messagesContainer) {
messagesContainer.addEventListener('scroll', handleScroll, { passive: true });
return () => {
messagesContainer.removeEventListener('scroll', handleScroll);
};
}
setTimeout(findAndAttachScrollListener, 100);
};
const cleanup = findAndAttachScrollListener();
return cleanup;
}, [messagesTree]);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
@ -69,7 +106,70 @@ function ChatView({ index = 0 }: { index?: number }) {
} else if ((isLoading || isNavigating) && !isLandingPage) {
content = <LoadingSpinner />;
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
content = (
<MessagesView
messagesTree={messagesTree}
costBar={
!isLandingPage &&
conversationCosts &&
conversationCosts.totals && (
<div
className={cn(
'mx-auto w-full max-w-md px-4 text-xs text-muted-foreground transition-all duration-300 ease-in-out',
showCostBar ? 'opacity-100' : 'opacity-0',
)}
>
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className="inline"
>
<path
fillRule="evenodd"
d="M11.293 5.293a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1-1.414 1.414L13 8.414V18a1 1 0 1 1-2 0V8.414l-3.293 3.293a1 1 0 0 1-1.414-1.414l5-5Z"
clipRule="evenodd"
></path>
</svg>
{conversationCosts.totals.prompt.tokenCount}t
</div>
<div>${Math.abs(conversationCosts.totals.prompt.usd).toFixed(6)}</div>
</div>
<div>
<div>{conversationCosts.totals.total.tokenCount}t</div>
<div>${Math.abs(conversationCosts.totals.total.usd).toFixed(6)}</div>
</div>
<div>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className="inline"
>
<path
fillRule="evenodd"
d="M12.707 18.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 1 1 1.414-1.414L11 15.586V6a1 1 0 1 1 2 0v9.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-5 5Z"
clipRule="evenodd"
></path>
</svg>
{conversationCosts.totals.completion.tokenCount}t
</div>
<div>${Math.abs(conversationCosts.totals.completion.usd).toFixed(6)}</div>
</div>
</div>
</div>
)
}
/>
);
} else {
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}

View file

@ -10,8 +10,10 @@ import store from '~/store';
export default function MessagesView({
messagesTree: _messagesTree,
costBar,
}: {
messagesTree?: TMessage[] | null;
costBar?: React.ReactNode;
}) {
const localize = useLocalize();
const fontSize = useRecoilValue(store.fontSize);
@ -75,6 +77,12 @@ export default function MessagesView({
</div>
</div>
{costBar && (
<div className="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2">
{costBar}
</div>
)}
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{

View file

@ -7,6 +7,7 @@ import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useGetConversationCosts } from '~/data-provider';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { MessageContext } from '~/Providers';
@ -60,6 +61,21 @@ const MessageRender = memo(
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const convoId = conversation?.conversationId ?? '';
const { data: convoCosts } = useGetConversationCosts(convoId, {
enabled: !!convoId,
});
const perMessageCost = useMemo(() => {
if (!convoCosts || !convoCosts.perMessage || !msg?.messageId) {
return null;
}
const entry = convoCosts.perMessage.find((p) => p.messageId === msg.messageId);
if (!entry) {
return null;
}
return entry;
}, [convoCosts, msg?.messageId]);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const hasNoChildren = !(msg?.children?.length ?? 0);
@ -157,7 +173,50 @@ const MessageRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
{perMessageCost && (
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
{perMessageCost.tokenCount > 0 && (
<span>
{perMessageCost.tokenType === 'prompt' ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className="inline"
>
<path
fillRule="evenodd"
d="M11.293 5.293a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1-1.414 1.414L13 8.414V18a1 1 0 1 1-2 0V8.414l-3.293 3.293a1 1 0 0 1-1.414-1.414l5-5Z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className="inline"
>
<path
fillRule="evenodd"
d="M12.707 18.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 1 1 1.414-1.414L11 15.586V6a1 1 0 1 1 2 0v9.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-5 5Z"
clipRule="evenodd"
></path>
</svg>
)}
{perMessageCost.tokenCount}t
</span>
)}
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
</span>
)}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View file

@ -8,6 +8,7 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAttachments, useMessageActions } from '~/hooks';
import { useGetConversationCosts } from '~/data-provider';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, logger } from '~/utils';
import store from '~/store';
@ -62,6 +63,15 @@ const ContentRender = memo(
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const convoId = conversation?.conversationId ?? '';
const { data: convoCosts } = useGetConversationCosts(convoId, { enabled: !!convoId });
const perMessageCost = useMemo(() => {
if (!convoCosts || !convoCosts.perMessage || !msg?.messageId) {
return null;
}
return convoCosts.perMessage.find((p) => p.messageId === msg.messageId) ?? null;
}, [convoCosts, msg?.messageId]);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo(
@ -159,7 +169,50 @@ const ContentRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
{perMessageCost && (
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
{perMessageCost.tokenCount > 0 && (
<span className="mr-2">
{perMessageCost.tokenType === 'prompt' ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className="inline"
>
<path
fillRule="evenodd"
d="M11.293 5.293a1 1 0 0 1 1.414 0l5 5a1 1 0 0 1-1.414 1.414L13 8.414V18a1 1 0 1 1-2 0V8.414l-3.293 3.293a1 1 0 0 1-1.414-1.414l5-5Z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 24 24"
className="inline"
>
<path
fillRule="evenodd"
d="M12.707 18.707a1 1 0 0 1-1.414 0l-5-5a1 1 0 1 1 1.414-1.414L11 15.586V6a1 1 0 1 1 2 0v9.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-5 5Z"
clipRule="evenodd"
></path>
</svg>
)}
{perMessageCost.tokenCount}t
</span>
)}
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
</span>
)}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">