mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
Merge 3a1267b341
into 68c9f668c1
This commit is contained in:
commit
6e08f72830
10 changed files with 183 additions and 12 deletions
|
@ -92,11 +92,11 @@ describe('Conversation Structure Tests', () => {
|
|||
conversationId,
|
||||
user: userId,
|
||||
text: `Message ${i}`,
|
||||
createdAt: new Date(Date.now() + (i % 2 === 0 ? i * 500000 : -i * 500000)),
|
||||
createdAt: new Date(Date.now() + i * 1000),
|
||||
}));
|
||||
|
||||
// Save messages with new timestamps being generated (message objects ignored)
|
||||
await bulkSaveMessages(messages);
|
||||
await bulkSaveMessages(messages, true);
|
||||
|
||||
// Retrieve messages (this will sort by createdAt, but it shouldn't matter now)
|
||||
const retrievedMessages = await getMessages({ conversationId, user: userId });
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
|
@ -11,8 +11,10 @@ import HoverButtons from './HoverButtons';
|
|||
import SubRow from './SubRow';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
import Timestamp from './Timestamp';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const localize = useLocalize();
|
||||
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
|
||||
props;
|
||||
|
@ -95,7 +97,9 @@ export default function Message(props: TMessageProps) {
|
|||
<div
|
||||
id={messageId ?? ''}
|
||||
aria-label={`message-${message.depth}-${messageId}`}
|
||||
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
|
||||
className={cn(baseClasses.common, baseClasses.chat, 'message-render', 'relative')}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
|
||||
|
@ -110,6 +114,7 @@ export default function Message(props: TMessageProps) {
|
|||
>
|
||||
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
|
||||
{name}
|
||||
<Timestamp message={message} isVisible={isHovered} />
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
|
48
client/src/components/Chat/Messages/Timestamp.tsx
Normal file
48
client/src/components/Chat/Messages/Timestamp.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import React, { memo } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
import { formatTimestamp } from '~/utils/dateFormatter';
|
||||
import { type TMessage } from 'librechat-data-provider';
|
||||
import { useMessageTimestamp } from '~/hooks/Messages';
|
||||
|
||||
interface TimestampProps {
|
||||
message: TMessage;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const Timestamp = memo(
|
||||
({ message, isVisible }: TimestampProps) => {
|
||||
try {
|
||||
const timestamp = useMessageTimestamp(message.messageId, message);
|
||||
const displayTimestamp = timestamp || message.clientTimestamp;
|
||||
|
||||
if (!displayTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedTime = formatTimestamp(displayTimestamp);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs font-normal text-text-secondary-alt transition-opacity duration-300',
|
||||
isVisible ? 'opacity-100' : 'opacity-0',
|
||||
'inline-block',
|
||||
)}
|
||||
>
|
||||
{formattedTime}
|
||||
</span>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to render timestamp:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message.messageId === nextProps.message.messageId &&
|
||||
prevProps.isVisible === nextProps.isVisible
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Timestamp;
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import React, { useCallback, useMemo, memo, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
|
@ -13,6 +13,7 @@ import { MessageContext } from '~/Providers';
|
|||
import { useMessageActions } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
import Timestamp from '../Timestamp';
|
||||
|
||||
type MessageRenderProps = {
|
||||
message?: TMessage;
|
||||
|
@ -36,6 +37,7 @@ const MessageRender = memo(
|
|||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
}: MessageRenderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const {
|
||||
ask,
|
||||
edit,
|
||||
|
@ -141,6 +143,8 @@ const MessageRender = memo(
|
|||
clickHandler();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
role={showCardRender ? 'button' : undefined}
|
||||
tabIndex={showCardRender ? 0 : undefined}
|
||||
>
|
||||
|
@ -160,7 +164,10 @@ 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}
|
||||
<Timestamp message={msg} isVisible={isHovered} />
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useCallback, useMemo, memo } from 'react';
|
||||
import { useCallback, useMemo, memo, useState } from 'react';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
|
@ -11,6 +11,7 @@ import { useAttachments, useMessageActions } from '~/hooks';
|
|||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
import Timestamp from '../Chat/Messages/Timestamp';
|
||||
|
||||
type ContentRenderProps = {
|
||||
message?: TMessage;
|
||||
|
@ -34,6 +35,7 @@ const ContentRender = memo(
|
|||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
}: ContentRenderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
messageId: msg?.messageId,
|
||||
attachments: msg?.attachments,
|
||||
|
@ -135,6 +137,8 @@ const ContentRender = memo(
|
|||
'message-render',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
|
||||
clickHandler();
|
||||
|
@ -159,7 +163,10 @@ 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}
|
||||
<Timestamp message={msg} isVisible={isHovered} />
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
|
|
@ -6,3 +6,4 @@ export { default as useMessageProcess } from './useMessageProcess';
|
|||
export { default as useMessageHelpers } from './useMessageHelpers';
|
||||
export { default as useCopyToClipboard } from './useCopyToClipboard';
|
||||
export { default as useMessageScrolling } from './useMessageScrolling';
|
||||
export { useMessageTimestamp } from './useMessageTimestamp';
|
||||
|
|
22
client/src/hooks/Messages/useMessageTimestamp.ts
Normal file
22
client/src/hooks/Messages/useMessageTimestamp.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* Hook to get the timestamp for a message.
|
||||
* Returns the message's createdAt field if it exists (backend data takes priority),
|
||||
* otherwise returns the locked timestamp from Recoil.
|
||||
* This ensures that once a timestamp is set for a message during streaming,
|
||||
* it won't change even if the component rerenders frequently.
|
||||
*/
|
||||
export const useMessageTimestamp = (messageId: string, message?: TMessage): string | null => {
|
||||
const lockedTimestamp = useRecoilValue(store.messageTimestampState(messageId));
|
||||
|
||||
// Backend data takes priority - use createdAt if available
|
||||
if (message?.createdAt) {
|
||||
return message.createdAt;
|
||||
}
|
||||
|
||||
// Otherwise return the locked timestamp from Recoil
|
||||
return lockedTimestamp;
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { SSE } from 'sse.js';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useSetRecoilState, useRecoilCallback } from 'recoil';
|
||||
import {
|
||||
request,
|
||||
Constants,
|
||||
|
@ -47,6 +47,18 @@ export default function useSSE(
|
|||
const genTitle = useGenTitleMutation();
|
||||
const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex));
|
||||
|
||||
const lockMessageTimestamp = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
async (messageId: string) => {
|
||||
const currentTimestamp = await snapshot.getPromise(store.messageTimestampState(messageId));
|
||||
if (currentTimestamp === null) {
|
||||
// Only set timestamp if it hasn't been set already (lock on first write)
|
||||
set(store.messageTimestampState(messageId), new Date().toISOString());
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { token, isAuthenticated } = useAuthContext();
|
||||
const [completed, setCompleted] = useState(new Set());
|
||||
const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex));
|
||||
|
@ -120,13 +132,17 @@ export default function useSSE(
|
|||
|
||||
sse.addEventListener('message', (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (data.final != null) {
|
||||
clearDraft(submission.conversation?.conversationId);
|
||||
const { plugins } = data;
|
||||
finalHandler(data, { ...submission, plugins } as EventSubmission);
|
||||
(startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch();
|
||||
console.log('final', data);
|
||||
|
||||
// Lock the timestamp for the response message when stream finishes
|
||||
if (data.responseMessage?.messageId) {
|
||||
lockMessageTimestamp(data.responseMessage.messageId);
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (data.created != null) {
|
||||
const runId = v4();
|
||||
|
@ -137,6 +153,11 @@ export default function useSSE(
|
|||
overrideParentMessageId: userMessage.overrideParentMessageId,
|
||||
};
|
||||
|
||||
// Lock the timestamp for the user message when it's created
|
||||
if (userMessage.messageId) {
|
||||
lockMessageTimestamp(userMessage.messageId);
|
||||
}
|
||||
|
||||
createdHandler(data, { ...submission, userMessage } as EventSubmission);
|
||||
} else if (data.event != null) {
|
||||
stepHandler(data, { ...submission, userMessage } as EventSubmission);
|
||||
|
@ -170,7 +191,6 @@ export default function useSSE(
|
|||
|
||||
sse.addEventListener('open', () => {
|
||||
setAbortScroll(false);
|
||||
console.log('connection is opened');
|
||||
});
|
||||
|
||||
sse.addEventListener('cancel', async () => {
|
||||
|
|
|
@ -255,6 +255,11 @@ const messagesSiblingIdxFamily = atomFamily<number, string | null | undefined>({
|
|||
default: 0,
|
||||
});
|
||||
|
||||
const messageTimestampState = atomFamily<string | null, string>({
|
||||
key: 'messageTimestampState',
|
||||
default: null,
|
||||
});
|
||||
|
||||
function useCreateConversationAtom(key: string | number) {
|
||||
const hasSetConversation = useSetConvoContext();
|
||||
const [keys, setKeys] = useRecoilState(conversationKeysAtom);
|
||||
|
@ -399,6 +404,7 @@ export default {
|
|||
showPopoverFamily,
|
||||
latestMessageFamily,
|
||||
messagesSiblingIdxFamily,
|
||||
messageTimestampState,
|
||||
allConversationsSelector,
|
||||
conversationByKeySelector,
|
||||
useClearConvoState,
|
||||
|
|
55
client/src/utils/dateFormatter.ts
Normal file
55
client/src/utils/dateFormatter.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Efficient date formatter using cached Intl.DateTimeFormat
|
||||
* Avoids repeated locale database lookups that occur with toLocaleString()
|
||||
*/
|
||||
class DateFormatter {
|
||||
private static instance: DateFormatter;
|
||||
private formatter: Intl.DateTimeFormat;
|
||||
|
||||
private constructor() {
|
||||
// Create a single formatter instance with desired options
|
||||
this.formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(): DateFormatter {
|
||||
if (!DateFormatter.instance) {
|
||||
DateFormatter.instance = new DateFormatter();
|
||||
}
|
||||
return DateFormatter.instance;
|
||||
}
|
||||
|
||||
public format(date: Date): string {
|
||||
return this.formatter.format(date);
|
||||
}
|
||||
|
||||
public formatTimestamp(timestamp: string | null | undefined): string {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
return this.format(date);
|
||||
} catch (error) {
|
||||
console.error('Failed to format timestamp:', error);
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export convenience function
|
||||
export const formatTimestamp = (timestamp: string | null | undefined): string => {
|
||||
return DateFormatter.getInstance().formatTimestamp(timestamp);
|
||||
};
|
||||
|
||||
export default DateFormatter;
|
Loading…
Add table
Add a link
Reference in a new issue