mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge branch 'main' into fix/client-image-resize-threshold
This commit is contained in:
commit
4b14978473
246 changed files with 15929 additions and 2880 deletions
|
|
@ -140,6 +140,55 @@ export function useMessagesOperations() {
|
|||
);
|
||||
}
|
||||
|
||||
type OptionalMessagesOps = Pick<
|
||||
MessagesViewContextValue,
|
||||
'ask' | 'regenerate' | 'handleContinue' | 'getMessages' | 'setMessages'
|
||||
>;
|
||||
|
||||
const NOOP_OPS: OptionalMessagesOps = {
|
||||
ask: () => {},
|
||||
regenerate: () => {},
|
||||
handleContinue: () => {},
|
||||
getMessages: () => undefined,
|
||||
setMessages: () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for components that need message operations but may render outside MessagesViewProvider
|
||||
* (e.g. the /search route). Returns no-op stubs when the provider is absent — UI actions will
|
||||
* be silently discarded rather than crashing. Callers must use optional chaining on
|
||||
* `getMessages()` results, as it returns `undefined` outside the provider.
|
||||
*/
|
||||
export function useOptionalMessagesOperations(): OptionalMessagesOps {
|
||||
const context = useContext(MessagesViewContext);
|
||||
const ask = context?.ask;
|
||||
const regenerate = context?.regenerate;
|
||||
const handleContinue = context?.handleContinue;
|
||||
const getMessages = context?.getMessages;
|
||||
const setMessages = context?.setMessages;
|
||||
return useMemo(
|
||||
() => ({
|
||||
ask: ask ?? NOOP_OPS.ask,
|
||||
regenerate: regenerate ?? NOOP_OPS.regenerate,
|
||||
handleContinue: handleContinue ?? NOOP_OPS.handleContinue,
|
||||
getMessages: getMessages ?? NOOP_OPS.getMessages,
|
||||
setMessages: setMessages ?? NOOP_OPS.setMessages,
|
||||
}),
|
||||
[ask, regenerate, handleContinue, getMessages, setMessages],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for components that need conversation data but may render outside MessagesViewProvider
|
||||
* (e.g. the /search route). Returns `undefined` for both fields when the provider is absent.
|
||||
*/
|
||||
export function useOptionalMessagesConversation() {
|
||||
const context = useContext(MessagesViewContext);
|
||||
const conversation = context?.conversation;
|
||||
const conversationId = context?.conversationId;
|
||||
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
|
||||
}
|
||||
|
||||
/** Hook for components that only need message state */
|
||||
export function useMessagesState() {
|
||||
const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext();
|
||||
|
|
|
|||
53
client/src/Providers/__tests__/MessagesViewContext.spec.tsx
Normal file
53
client/src/Providers/__tests__/MessagesViewContext.spec.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
useOptionalMessagesOperations,
|
||||
useOptionalMessagesConversation,
|
||||
} from '../MessagesViewContext';
|
||||
|
||||
describe('useOptionalMessagesOperations', () => {
|
||||
it('returns noop stubs when rendered outside MessagesViewProvider', () => {
|
||||
const { result } = renderHook(() => useOptionalMessagesOperations());
|
||||
|
||||
expect(result.current.ask).toBeInstanceOf(Function);
|
||||
expect(result.current.regenerate).toBeInstanceOf(Function);
|
||||
expect(result.current.handleContinue).toBeInstanceOf(Function);
|
||||
expect(result.current.getMessages).toBeInstanceOf(Function);
|
||||
expect(result.current.setMessages).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('noop stubs do not throw when called', () => {
|
||||
const { result } = renderHook(() => useOptionalMessagesOperations());
|
||||
|
||||
expect(() => result.current.ask({} as never)).not.toThrow();
|
||||
expect(() => result.current.regenerate({} as never)).not.toThrow();
|
||||
expect(() => result.current.handleContinue({} as never)).not.toThrow();
|
||||
expect(() => result.current.setMessages([])).not.toThrow();
|
||||
});
|
||||
|
||||
it('getMessages returns undefined outside the provider', () => {
|
||||
const { result } = renderHook(() => useOptionalMessagesOperations());
|
||||
expect(result.current.getMessages()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns stable references across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useOptionalMessagesOperations());
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useOptionalMessagesConversation', () => {
|
||||
it('returns undefined fields when rendered outside MessagesViewProvider', () => {
|
||||
const { result } = renderHook(() => useOptionalMessagesConversation());
|
||||
expect(result.current.conversation).toBeUndefined();
|
||||
expect(result.current.conversationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns stable references across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useOptionalMessagesConversation());
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
|
|
@ -355,6 +355,28 @@ export type TOptions = {
|
|||
|
||||
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;
|
||||
|
||||
/**
|
||||
* Stable context object passed from non-memo'd wrapper components (Message, MessageContent)
|
||||
* to memo'd inner components (MessageRender, ContentRender) via props.
|
||||
*
|
||||
* This avoids subscribing to ChatContext inside memo'd components, which would bypass React.memo
|
||||
* and cause unnecessary re-renders when `isSubmitting` changes during streaming.
|
||||
*
|
||||
* The `isSubmitting` property should use a getter backed by a ref so it returns the current
|
||||
* value at call-time (for callback guards) without being a reactive dependency.
|
||||
*/
|
||||
export type TMessageChatContext = {
|
||||
ask: (...args: Parameters<TAskFunction>) => void;
|
||||
index: number;
|
||||
regenerate: (message: t.TMessage, options?: { addedConvo?: t.TConversation | null }) => void;
|
||||
conversation: t.TConversation | null;
|
||||
latestMessageId: string | undefined;
|
||||
latestMessageDepth: number | undefined;
|
||||
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Should be a getter backed by a ref — reads current value without triggering re-renders */
|
||||
readonly isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export type TMessageProps = {
|
||||
conversation?: t.TConversation | null;
|
||||
messageId?: string | null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { MicOff } from 'lucide-react';
|
||||
import { useToastContext, TooltipAnchor, ListeningIcon, Spinner } from '@librechat/client';
|
||||
import { useLocalize, useSpeechToText, useGetAudioSettings } from '~/hooks';
|
||||
|
|
@ -7,7 +7,7 @@ import { globalAudioId } from '~/common';
|
|||
import { cn } from '~/utils';
|
||||
|
||||
const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external';
|
||||
export default function AudioRecorder({
|
||||
export default memo(function AudioRecorder({
|
||||
disabled,
|
||||
ask,
|
||||
methods,
|
||||
|
|
@ -26,10 +26,12 @@ export default function AudioRecorder({
|
|||
const { speechToTextEndpoint } = useGetAudioSettings();
|
||||
|
||||
const existingTextRef = useRef<string>('');
|
||||
const isSubmittingRef = useRef(isSubmitting);
|
||||
isSubmittingRef.current = isSubmitting;
|
||||
|
||||
const onTranscriptionComplete = useCallback(
|
||||
(text: string) => {
|
||||
if (isSubmitting) {
|
||||
if (isSubmittingRef.current) {
|
||||
showToast({
|
||||
message: localize('com_ui_speech_while_submitting'),
|
||||
status: 'error',
|
||||
|
|
@ -52,7 +54,7 @@ export default function AudioRecorder({
|
|||
existingTextRef.current = '';
|
||||
}
|
||||
},
|
||||
[ask, reset, showToast, localize, isSubmitting, speechToTextEndpoint],
|
||||
[ask, reset, showToast, localize, speechToTextEndpoint],
|
||||
);
|
||||
|
||||
const setText = useCallback(
|
||||
|
|
@ -125,4 +127,4 @@ export default function AudioRecorder({
|
|||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useWatch } from 'react-hook-form';
|
|||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common';
|
||||
import {
|
||||
useChatContext,
|
||||
useChatFormContext,
|
||||
|
|
@ -35,7 +37,30 @@ import BadgeRow from './BadgeRow';
|
|||
import Mention from './Mention';
|
||||
import store from '~/store';
|
||||
|
||||
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
interface ChatFormProps {
|
||||
index: number;
|
||||
/** From ChatContext — individual values so memo can compare them */
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
conversation: TConversation | null;
|
||||
isSubmitting: boolean;
|
||||
filesLoading: boolean;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
newConversation: ConvoGenerator;
|
||||
handleStopGenerating: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
const ChatForm = memo(function ChatForm({
|
||||
index,
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
filesLoading,
|
||||
setFilesLoading,
|
||||
newConversation,
|
||||
handleStopGenerating,
|
||||
}: ChatFormProps) {
|
||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
useFocusChatEffect(textAreaRef);
|
||||
|
|
@ -65,15 +90,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
|
||||
const { requiresKey } = useRequiresKey();
|
||||
const methods = useChatFormContext();
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
filesLoading,
|
||||
newConversation,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
const {
|
||||
generateConversation,
|
||||
conversation: addedConvo,
|
||||
|
|
@ -120,6 +136,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const handleTextareaFocus = useCallback(() => {
|
||||
handleFocusOrClick();
|
||||
setIsTextAreaFocused(true);
|
||||
}, [handleFocusOrClick]);
|
||||
|
||||
const handleTextareaBlur = useCallback(() => {
|
||||
setIsTextAreaFocused(false);
|
||||
}, []);
|
||||
|
||||
useAutoSave({
|
||||
files,
|
||||
setFiles,
|
||||
|
|
@ -253,7 +278,12 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
handleSaveBadges={handleSaveBadges}
|
||||
setBadges={setBadges}
|
||||
/>
|
||||
<FileFormChat conversation={conversation} />
|
||||
<FileFormChat
|
||||
conversation={conversation}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
/>
|
||||
{endpoint && (
|
||||
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
||||
<div
|
||||
|
|
@ -284,11 +314,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
tabIndex={0}
|
||||
data-testid="text-input"
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
handleFocusOrClick();
|
||||
setIsTextAreaFocused(true);
|
||||
}}
|
||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||
onFocus={handleTextareaFocus}
|
||||
onBlur={handleTextareaBlur}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
onClick={handleFocusOrClick}
|
||||
style={{ height: 44, overflowY: 'auto' }}
|
||||
|
|
@ -315,7 +342,13 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
)}
|
||||
>
|
||||
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
|
||||
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
|
||||
<AttachFileChat
|
||||
conversation={conversation}
|
||||
disableInputs={disableInputs}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
/>
|
||||
</div>
|
||||
<BadgeRow
|
||||
showEphemeralBadges={
|
||||
|
|
@ -360,5 +393,77 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
</form>
|
||||
);
|
||||
});
|
||||
ChatForm.displayName = 'ChatForm';
|
||||
|
||||
export default ChatForm;
|
||||
/**
|
||||
* Wrapper that subscribes to ChatContext and passes stable individual values
|
||||
* to the memo'd ChatForm. This prevents ChatForm from re-rendering on every
|
||||
* streaming chunk — it only re-renders when the specific values it uses change.
|
||||
*/
|
||||
function ChatFormWrapper({ index = 0 }: { index?: number }) {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
filesLoading,
|
||||
setFilesLoading,
|
||||
newConversation,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
|
||||
/**
|
||||
* Stabilize conversation reference: only update when rendering-relevant fields change,
|
||||
* not on every metadata update (e.g., title generation during streaming).
|
||||
*/
|
||||
const hasMessages = (conversation?.messages?.length ?? 0) > 0;
|
||||
const stableConversation = useMemo(
|
||||
() => conversation,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
conversation?.conversationId,
|
||||
conversation?.endpoint,
|
||||
conversation?.endpointType,
|
||||
conversation?.agent_id,
|
||||
conversation?.assistant_id,
|
||||
conversation?.spec,
|
||||
conversation?.useResponsesApi,
|
||||
conversation?.model,
|
||||
hasMessages,
|
||||
],
|
||||
);
|
||||
|
||||
/** Stabilize function refs so they never trigger ChatForm re-renders */
|
||||
const handleStopRef = useRef(handleStopGenerating);
|
||||
handleStopRef.current = handleStopGenerating;
|
||||
const stableHandleStop = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => handleStopRef.current(e),
|
||||
[],
|
||||
);
|
||||
|
||||
const newConvoRef = useRef(newConversation);
|
||||
newConvoRef.current = newConversation;
|
||||
const stableNewConversation: ConvoGenerator = useCallback(
|
||||
(...args: Parameters<ConvoGenerator>): ReturnType<ConvoGenerator> =>
|
||||
newConvoRef.current(...args),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatForm
|
||||
index={index}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
conversation={stableConversation}
|
||||
isSubmitting={isSubmitting}
|
||||
filesLoading={filesLoading}
|
||||
setFilesLoading={setFilesLoading}
|
||||
newConversation={stableNewConversation}
|
||||
handleStopGenerating={stableHandleStop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ChatFormWrapper.displayName = 'ChatFormWrapper';
|
||||
|
||||
export default ChatFormWrapper;
|
||||
|
|
|
|||
|
|
@ -52,4 +52,4 @@ const CollapseChat = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default CollapseChat;
|
||||
export default React.memo(CollapseChat);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,33 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { FileUpload, TooltipAnchor, AttachmentIcon } from '@librechat/client';
|
||||
import { useLocalize, useFileHandling } from '~/hooks';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useFileHandlingNoChatContext, useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
|
||||
const AttachFile = ({
|
||||
disabled,
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
conversation,
|
||||
}: {
|
||||
disabled?: boolean | null;
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
conversation: TConversation | null;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
|
||||
const { handleFileChange } = useFileHandling();
|
||||
const { handleFileChange } = useFileHandlingNoChatContext(undefined, {
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
conversation,
|
||||
});
|
||||
|
||||
return (
|
||||
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useGetFileConfig, useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
|
|
@ -17,9 +18,15 @@ import AttachFile from './AttachFile';
|
|||
function AttachFileChat({
|
||||
disableInputs,
|
||||
conversation,
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
}: {
|
||||
disableInputs: boolean;
|
||||
conversation: TConversation | null;
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
const { endpoint } = conversation ?? { endpoint: null };
|
||||
|
|
@ -90,7 +97,15 @@ function AttachFileChat({
|
|||
);
|
||||
|
||||
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
|
||||
return <AttachFile disabled={disableInputs} />;
|
||||
return (
|
||||
<AttachFile
|
||||
disabled={disableInputs}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
conversation={conversation}
|
||||
/>
|
||||
);
|
||||
} else if ((isAgents || endpointSupportsFiles) && !isUploadDisabled) {
|
||||
return (
|
||||
<AttachFileMenu
|
||||
|
|
@ -101,6 +116,10 @@ function AttachFileChat({
|
|||
agentId={conversation?.agent_id}
|
||||
endpointFileConfig={endpointFileConfig}
|
||||
useResponsesApi={useResponsesApi}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
conversation={conversation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,15 +23,16 @@ import {
|
|||
bedrockDocumentExtensions,
|
||||
isDocumentSupportedProvider,
|
||||
} from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import {
|
||||
useAgentToolPermissions,
|
||||
useAgentCapabilities,
|
||||
useGetAgentsConfig,
|
||||
useFileHandling,
|
||||
useFileHandlingNoChatContext,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
|
||||
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
|
||||
import { SharePointPickerDialog } from '~/components/SharePoint';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
|
@ -53,6 +54,10 @@ interface AttachFileMenuProps {
|
|||
endpointType?: EModelEndpoint | string;
|
||||
endpointFileConfig?: EndpointFileConfig;
|
||||
useResponsesApi?: boolean;
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
conversation: TConversation | null;
|
||||
}
|
||||
|
||||
const AttachFileMenu = ({
|
||||
|
|
@ -63,6 +68,10 @@ const AttachFileMenu = ({
|
|||
conversationId,
|
||||
endpointFileConfig,
|
||||
useResponsesApi,
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
conversation,
|
||||
}: AttachFileMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
|
|
@ -72,10 +81,17 @@ const AttachFileMenu = ({
|
|||
ephemeralAgentByConvoId(conversationId),
|
||||
);
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { handleFileChange } = useFileHandling();
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
|
||||
toolResource,
|
||||
const { handleFileChange } = useFileHandlingNoChatContext(undefined, {
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
conversation,
|
||||
});
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } =
|
||||
useSharePointFileHandlingNoChatContext(
|
||||
{ toolResource },
|
||||
{ files, setFiles, setFilesLoading, conversation },
|
||||
);
|
||||
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useFileHandlingNoChatContext } from '~/hooks';
|
||||
import FileRow from './FileRow';
|
||||
import store from '~/store';
|
||||
|
||||
function FileFormChat({ conversation }: { conversation: TConversation | null }) {
|
||||
const { files, setFiles, setFilesLoading } = useChatContext();
|
||||
function FileFormChat({
|
||||
conversation,
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
}: {
|
||||
conversation: TConversation | null;
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
|
||||
const { abortUpload } = useFileHandling();
|
||||
const { abortUpload } = useFileHandlingNoChatContext(undefined, {
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
conversation,
|
||||
});
|
||||
|
||||
const isRTL = chatDirection === 'rtl';
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,13 @@ function renderComponent(conversation: Record<string, unknown> | null, disableIn
|
|||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<AttachFileChat conversation={conversation as never} disableInputs={disableInputs} />
|
||||
<AttachFileChat
|
||||
conversation={conversation as never}
|
||||
disableInputs={disableInputs}
|
||||
files={new Map()}
|
||||
setFiles={() => {}}
|
||||
setFilesLoading={() => {}}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ jest.mock('~/hooks', () => ({
|
|||
useAgentToolPermissions: jest.fn(),
|
||||
useAgentCapabilities: jest.fn(),
|
||||
useGetAgentsConfig: jest.fn(),
|
||||
useFileHandling: jest.fn(),
|
||||
useFileHandlingNoChatContext: jest.fn(),
|
||||
useLocalize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
useSharePointFileHandlingNoChatContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
|
|
@ -52,6 +53,7 @@ jest.mock('@librechat/client', () => {
|
|||
),
|
||||
AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }),
|
||||
SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }),
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -66,11 +68,14 @@ jest.mock('@ariakit/react', () => {
|
|||
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
|
||||
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
|
||||
const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig;
|
||||
const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling;
|
||||
const mockUseFileHandlingNoChatContext = jest.requireMock('~/hooks').useFileHandlingNoChatContext;
|
||||
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
|
||||
const mockUseSharePointFileHandling = jest.requireMock(
|
||||
'~/hooks/Files/useSharePointFileHandling',
|
||||
).default;
|
||||
const mockUseSharePointFileHandlingNoChatContext = jest.requireMock(
|
||||
'~/hooks/Files/useSharePointFileHandling',
|
||||
).useSharePointFileHandlingNoChatContext;
|
||||
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
|
@ -92,12 +97,15 @@ function setupMocks(overrides: { provider?: string } = {}) {
|
|||
codeEnabled: false,
|
||||
});
|
||||
mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} });
|
||||
mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() });
|
||||
mockUseSharePointFileHandling.mockReturnValue({
|
||||
mockUseFileHandlingNoChatContext.mockReturnValue({ handleFileChange: jest.fn() });
|
||||
const sharePointReturnValue = {
|
||||
handleSharePointFiles: jest.fn(),
|
||||
isProcessing: false,
|
||||
downloadProgress: 0,
|
||||
});
|
||||
error: null,
|
||||
};
|
||||
mockUseSharePointFileHandling.mockReturnValue(sharePointReturnValue);
|
||||
mockUseSharePointFileHandlingNoChatContext.mockReturnValue(sharePointReturnValue);
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } });
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
|
|
@ -110,7 +118,14 @@ function renderMenu(props: Record<string, unknown> = {}) {
|
|||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<AttachFileMenu conversationId="test-convo" {...props} />
|
||||
<AttachFileMenu
|
||||
conversationId="test-convo"
|
||||
files={new Map()}
|
||||
setFiles={() => {}}
|
||||
setFilesLoading={() => {}}
|
||||
conversation={null}
|
||||
{...props}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { memo } from 'react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function StopButton({ stop, setShowStopButton }) {
|
||||
export default memo(function StopButton({
|
||||
stop,
|
||||
setShowStopButton,
|
||||
}: {
|
||||
stop: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
setShowStopButton: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
|
|
@ -34,4 +41,4 @@ export default function StopButton({ stop, setShowStopButton }) {
|
|||
}
|
||||
></TooltipAnchor>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { memo } from 'react';
|
||||
import AddedConvo from './AddedConvo';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
|
||||
export default function TextareaHeader({
|
||||
export default memo(function TextareaHeader({
|
||||
addedConvo,
|
||||
setAddedConvo,
|
||||
}: {
|
||||
|
|
@ -17,4 +18,4 @@ export default function TextareaHeader({
|
|||
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,19 +49,47 @@ export default function ToolCall({
|
|||
}
|
||||
}, [autoExpand, hasOutput]);
|
||||
|
||||
const parsedAuthUrl = useMemo(() => {
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(auth);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
const { function_name, domain, isMCPToolCall, mcpServerName } = useMemo(() => {
|
||||
if (typeof name !== 'string') {
|
||||
return { function_name: '', domain: null, isMCPToolCall: false, mcpServerName: '' };
|
||||
}
|
||||
if (name.includes(Constants.mcp_delimiter)) {
|
||||
const [func, server] = name.split(Constants.mcp_delimiter);
|
||||
const parts = name.split(Constants.mcp_delimiter);
|
||||
const func = parts[0];
|
||||
const server = parts.slice(1).join(Constants.mcp_delimiter);
|
||||
const displayName = func === 'oauth' ? server : func;
|
||||
return {
|
||||
function_name: func || '',
|
||||
function_name: displayName || '',
|
||||
domain: server && (server.replaceAll(actionDomainSeparator, '.') || null),
|
||||
isMCPToolCall: true,
|
||||
mcpServerName: server || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedAuthUrl) {
|
||||
const redirectUri = parsedAuthUrl.searchParams.get('redirect_uri') || '';
|
||||
const mcpMatch = redirectUri.match(/\/api\/mcp\/([^/]+)\/oauth\/callback/);
|
||||
if (mcpMatch?.[1]) {
|
||||
return {
|
||||
function_name: mcpMatch[1],
|
||||
domain: null,
|
||||
isMCPToolCall: true,
|
||||
mcpServerName: mcpMatch[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [func, _domain] = name.includes(actionDelimiter)
|
||||
? name.split(actionDelimiter)
|
||||
: [name, ''];
|
||||
|
|
@ -71,25 +99,20 @@ export default function ToolCall({
|
|||
isMCPToolCall: false,
|
||||
mcpServerName: '',
|
||||
};
|
||||
}, [name]);
|
||||
}, [name, parsedAuthUrl]);
|
||||
|
||||
const toolIconType = useMemo(() => getToolIconType(name), [name]);
|
||||
const mcpIconMap = useMCPIconMap();
|
||||
const mcpIconUrl = isMCPToolCall ? mcpIconMap.get(mcpServerName) : undefined;
|
||||
|
||||
const actionId = useMemo(() => {
|
||||
if (isMCPToolCall || !auth) {
|
||||
if (isMCPToolCall || !parsedAuthUrl) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const url = new URL(auth);
|
||||
const redirectUri = url.searchParams.get('redirect_uri') || '';
|
||||
const match = redirectUri.match(/\/api\/actions\/([^/]+)\/oauth\/callback/);
|
||||
return match?.[1] || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [auth, isMCPToolCall]);
|
||||
const redirectUri = parsedAuthUrl.searchParams.get('redirect_uri') || '';
|
||||
const match = redirectUri.match(/\/api\/actions\/([^/]+)\/oauth\/callback/);
|
||||
return match?.[1] || '';
|
||||
}, [parsedAuthUrl, isMCPToolCall]);
|
||||
|
||||
const handleOAuthClick = useCallback(async () => {
|
||||
if (!auth) {
|
||||
|
|
@ -132,21 +155,8 @@ export default function ToolCall({
|
|||
);
|
||||
|
||||
const authDomain = useMemo(() => {
|
||||
const authURL = auth ?? '';
|
||||
if (!authURL) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const url = new URL(authURL);
|
||||
return url.hostname;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to parse auth URL',
|
||||
e,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}, [auth]);
|
||||
return parsedAuthUrl?.hostname ?? '';
|
||||
}, [parsedAuthUrl]);
|
||||
|
||||
const progress = useProgress(initialProgress);
|
||||
const showCancelled = cancelled || (errorState && !output);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { ChevronDown } from 'lucide-react';
|
|||
import { Tools } from 'librechat-data-provider';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { TAttachment, UIResource } from 'librechat-data-provider';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import UIResourceCarousel from './UIResourceCarousel';
|
||||
import { useMessagesOperations } from '~/Providers';
|
||||
import { OutputRenderer } from './ToolOutput';
|
||||
import { handleUIAction, cn } from '~/utils';
|
||||
import { OutputRenderer } from './ToolOutput';
|
||||
|
||||
function isSimpleObject(obj: unknown): obj is Record<string, string | number | boolean | null> {
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
||||
|
|
@ -102,7 +102,7 @@ export default function ToolCallInfo({
|
|||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { ask } = useMessagesOperations();
|
||||
const { ask } = useOptionalMessagesOperations();
|
||||
const [showParams, setShowParams] = useState(false);
|
||||
const { style: paramsExpandStyle, ref: paramsExpandRef } = useExpandCollapse(showParams);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import { useMessagesOperations } from '~/Providers';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import { handleUIAction } from '~/utils';
|
||||
|
||||
interface UIResourceCarouselProps {
|
||||
|
|
@ -13,7 +13,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
|||
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ask } = useMessagesOperations();
|
||||
const { ask } = useOptionalMessagesOperations();
|
||||
|
||||
const handleScroll = React.useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { render, screen } from '@testing-library/react';
|
|||
import Markdown from '../Markdown';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
|
||||
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
|
||||
import {
|
||||
useMessageContext,
|
||||
useOptionalMessagesConversation,
|
||||
useOptionalMessagesOperations,
|
||||
} from '~/Providers';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -12,8 +16,8 @@ import { useLocalize } from '~/hooks';
|
|||
jest.mock('~/Providers', () => ({
|
||||
...jest.requireActual('~/Providers'),
|
||||
useMessageContext: jest.fn(),
|
||||
useMessagesConversation: jest.fn(),
|
||||
useMessagesOperations: jest.fn(),
|
||||
useOptionalMessagesConversation: jest.fn(),
|
||||
useOptionalMessagesOperations: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/data-provider');
|
||||
jest.mock('~/hooks');
|
||||
|
|
@ -26,11 +30,11 @@ jest.mock('@mcp-ui/client', () => ({
|
|||
}));
|
||||
|
||||
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
|
||||
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
|
||||
typeof useMessagesConversation
|
||||
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
|
||||
typeof useOptionalMessagesConversation
|
||||
>;
|
||||
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
|
||||
typeof useMessagesOperations
|
||||
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
|
||||
typeof useOptionalMessagesOperations
|
||||
>;
|
||||
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
|
||||
typeof useGetMessagesByConvoId
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { Tools, Constants } from 'librechat-data-provider';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ToolCall from '../ToolCall';
|
||||
|
||||
|
|
@ -53,9 +53,20 @@ jest.mock('../ToolCallInfo', () => ({
|
|||
|
||||
jest.mock('../ProgressText', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
|
||||
default: ({
|
||||
onClick,
|
||||
inProgressText,
|
||||
finishedText,
|
||||
subtitle,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
inProgressText?: string;
|
||||
finishedText?: string;
|
||||
subtitle?: string;
|
||||
}) => (
|
||||
<div data-testid="progress-text" onClick={onClick}>
|
||||
{finishedText || inProgressText}
|
||||
{subtitle && <span data-testid="subtitle">{subtitle}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
|
@ -346,6 +357,141 @@ describe('ToolCall', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('MCP OAuth detection', () => {
|
||||
const d = Constants.mcp_delimiter;
|
||||
|
||||
it('should detect MCP OAuth from delimiter in tool-call name', () => {
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name={`oauth${d}my-server`}
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
auth="https://auth.example.com"
|
||||
/>,
|
||||
);
|
||||
const subtitle = screen.getByTestId('subtitle');
|
||||
expect(subtitle.textContent).toBe('via my-server');
|
||||
});
|
||||
|
||||
it('should preserve full server name when it contains the delimiter substring', () => {
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name={`oauth${d}foo${d}bar`}
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
auth="https://auth.example.com"
|
||||
/>,
|
||||
);
|
||||
const subtitle = screen.getByTestId('subtitle');
|
||||
expect(subtitle.textContent).toBe(`via foo${d}bar`);
|
||||
});
|
||||
|
||||
it('should display server name (not "oauth") as function_name for OAuth tool calls', () => {
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name={`oauth${d}my-server`}
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
output="done"
|
||||
auth="https://auth.example.com"
|
||||
/>,
|
||||
);
|
||||
const progressText = screen.getByTestId('progress-text');
|
||||
expect(progressText.textContent).toContain('Completed my-server');
|
||||
expect(progressText.textContent).not.toContain('Completed oauth');
|
||||
});
|
||||
|
||||
it('should display server name even when auth is cleared (post-completion)', () => {
|
||||
// After OAuth completes, createOAuthEnd re-emits the toolCall without auth.
|
||||
// The display should still show the server name, not literal "oauth".
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name={`oauth${d}my-server`}
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
output="done"
|
||||
/>,
|
||||
);
|
||||
const progressText = screen.getByTestId('progress-text');
|
||||
expect(progressText.textContent).toContain('Completed my-server');
|
||||
expect(progressText.textContent).not.toContain('Completed oauth');
|
||||
});
|
||||
|
||||
it('should fallback to auth URL redirect_uri when name lacks delimiter', () => {
|
||||
const authUrl =
|
||||
'https://oauth.example.com/authorize?redirect_uri=' +
|
||||
encodeURIComponent('https://app.example.com/api/mcp/my-server/oauth/callback');
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name="bare_name"
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
auth={authUrl}
|
||||
/>,
|
||||
);
|
||||
const subtitle = screen.getByTestId('subtitle');
|
||||
expect(subtitle.textContent).toBe('via my-server');
|
||||
});
|
||||
|
||||
it('should display server name (not raw tool-call ID) in fallback path finished text', () => {
|
||||
const authUrl =
|
||||
'https://oauth.example.com/authorize?redirect_uri=' +
|
||||
encodeURIComponent('https://app.example.com/api/mcp/my-server/oauth/callback');
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name="bare_name"
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
output="done"
|
||||
auth={authUrl}
|
||||
/>,
|
||||
);
|
||||
const progressText = screen.getByTestId('progress-text');
|
||||
expect(progressText.textContent).toContain('Completed my-server');
|
||||
expect(progressText.textContent).not.toContain('bare_name');
|
||||
});
|
||||
|
||||
it('should show normalized server name when it contains _mcp_ after prefixing', () => {
|
||||
// Server named oauth@mcp@server normalizes to oauth_mcp_server,
|
||||
// gets prefixed to oauth_mcp_oauth_mcp_server. Client parses:
|
||||
// func="oauth", server="oauth_mcp_server". Visually awkward but
|
||||
// semantically correct — the normalized name IS oauth_mcp_server.
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name={`oauth${d}oauth${d}server`}
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
auth="https://auth.example.com"
|
||||
/>,
|
||||
);
|
||||
const subtitle = screen.getByTestId('subtitle');
|
||||
expect(subtitle.textContent).toBe(`via oauth${d}server`);
|
||||
});
|
||||
|
||||
it('should not misidentify non-MCP action auth as MCP via fallback', () => {
|
||||
const authUrl =
|
||||
'https://oauth.example.com/authorize?redirect_uri=' +
|
||||
encodeURIComponent('https://app.example.com/api/actions/xyz/oauth/callback');
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
name="action_name"
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
auth={authUrl}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('subtitle')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A11Y-04: screen reader status announcements', () => {
|
||||
it('includes sr-only aria-live region for status announcements', () => {
|
||||
renderWithRecoil(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jest.mock('~/hooks', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useMessagesOperations: () => ({
|
||||
useOptionalMessagesOperations: () => ({
|
||||
ask: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ jest.mock('@mcp-ui/client', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
// Mock useMessagesOperations hook
|
||||
// Mock useOptionalMessagesOperations hook
|
||||
const mockAsk = jest.fn();
|
||||
jest.mock('~/Providers', () => ({
|
||||
useMessagesOperations: () => ({
|
||||
useOptionalMessagesOperations: () => ({
|
||||
ask: mockAsk,
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import { useMessageProcess, useMemoizedChatContext } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageRender from './ui/MessageRender';
|
||||
import MultiMessage from './MultiMessage';
|
||||
|
|
@ -23,10 +23,11 @@ const MessageContainer = React.memo(function MessageContainer({
|
|||
});
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const { conversation, handleScroll } = useMessageProcess({
|
||||
const { conversation, handleScroll, isSubmitting } = useMessageProcess({
|
||||
message: props.message,
|
||||
});
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting);
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null;
|
||||
|
|
@ -38,7 +39,11 @@ export default function Message(props: TMessageProps) {
|
|||
<>
|
||||
<MessageContainer handleScroll={handleScroll}>
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<MessageRender {...props} />
|
||||
<MessageRender
|
||||
{...props}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
chatContext={chatContext}
|
||||
/>
|
||||
</div>
|
||||
</MessageContainer>
|
||||
<MultiMessage
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useCallback, useMemo, memo } from 'react';
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
|
|
@ -17,12 +17,73 @@ import store from '~/store';
|
|||
|
||||
type MessageRenderProps = {
|
||||
message?: TMessage;
|
||||
/**
|
||||
* Effective isSubmitting: false for non-latest messages, real value for latest.
|
||||
* Computed by the wrapper (Message.tsx) so this memo'd component only re-renders
|
||||
* when the value actually matters.
|
||||
*/
|
||||
isSubmitting?: boolean;
|
||||
/** Stable context object from wrapper — avoids ChatContext subscription inside memo */
|
||||
chatContext: TMessageChatContext;
|
||||
} & Pick<
|
||||
TMessageProps,
|
||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Custom comparator for React.memo: compares `message` by key fields instead of reference
|
||||
* because `buildTree` creates new message objects on every streaming update for ALL messages,
|
||||
* even when only the latest message's text changed.
|
||||
*/
|
||||
function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRenderProps): boolean {
|
||||
if (prev.isSubmitting !== next.isSubmitting) {
|
||||
return false;
|
||||
}
|
||||
if (prev.chatContext !== next.chatContext) {
|
||||
return false;
|
||||
}
|
||||
if (prev.siblingIdx !== next.siblingIdx) {
|
||||
return false;
|
||||
}
|
||||
if (prev.siblingCount !== next.siblingCount) {
|
||||
return false;
|
||||
}
|
||||
if (prev.currentEditId !== next.currentEditId) {
|
||||
return false;
|
||||
}
|
||||
if (prev.setSiblingIdx !== next.setSiblingIdx) {
|
||||
return false;
|
||||
}
|
||||
if (prev.setCurrentEditId !== next.setCurrentEditId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevMsg = prev.message;
|
||||
const nextMsg = next.message;
|
||||
if (prevMsg === nextMsg) {
|
||||
return true;
|
||||
}
|
||||
if (!prevMsg || !nextMsg) {
|
||||
return prevMsg === nextMsg;
|
||||
}
|
||||
|
||||
return (
|
||||
prevMsg.messageId === nextMsg.messageId &&
|
||||
prevMsg.text === nextMsg.text &&
|
||||
prevMsg.error === nextMsg.error &&
|
||||
prevMsg.unfinished === nextMsg.unfinished &&
|
||||
prevMsg.depth === nextMsg.depth &&
|
||||
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&
|
||||
(prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) &&
|
||||
prevMsg.content === nextMsg.content &&
|
||||
prevMsg.model === nextMsg.model &&
|
||||
prevMsg.endpoint === nextMsg.endpoint &&
|
||||
prevMsg.iconURL === nextMsg.iconURL &&
|
||||
prevMsg.feedback?.rating === nextMsg.feedback?.rating &&
|
||||
(prevMsg.files?.length ?? 0) === (nextMsg.files?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
const MessageRender = memo(function MessageRender({
|
||||
message: msg,
|
||||
siblingIdx,
|
||||
|
|
@ -31,6 +92,7 @@ const MessageRender = memo(function MessageRender({
|
|||
currentEditId,
|
||||
setCurrentEditId,
|
||||
isSubmitting = false,
|
||||
chatContext,
|
||||
}: MessageRenderProps) {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
|
|
@ -52,6 +114,7 @@ const MessageRender = memo(function MessageRender({
|
|||
message: msg,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
chatContext,
|
||||
});
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
|
|
@ -63,8 +126,6 @@ const MessageRender = memo(function MessageRender({
|
|||
[hasNoChildren, msg?.depth, latestMessageDepth],
|
||||
);
|
||||
const isLatestMessage = msg?.messageId === latestMessageId;
|
||||
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const iconData: TMessageIcon = useMemo(
|
||||
() => ({
|
||||
|
|
@ -92,10 +153,10 @@ const MessageRender = memo(function MessageRender({
|
|||
messageId,
|
||||
isLatestMessage,
|
||||
isExpanded: false as const,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isSubmitting,
|
||||
conversationId: conversation?.conversationId,
|
||||
}),
|
||||
[messageId, conversation?.conversationId, effectiveIsSubmitting, isLatestMessage],
|
||||
[messageId, conversation?.conversationId, isSubmitting, isLatestMessage],
|
||||
);
|
||||
|
||||
if (!msg) {
|
||||
|
|
@ -165,7 +226,7 @@ const MessageRender = memo(function MessageRender({
|
|||
message={msg}
|
||||
enterEdit={enterEdit}
|
||||
error={!!(msg.error ?? false)}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
unfinished={msg.unfinished ?? false}
|
||||
isCreatedByUser={msg.isCreatedByUser ?? true}
|
||||
siblingIdx={siblingIdx ?? 0}
|
||||
|
|
@ -173,7 +234,7 @@ const MessageRender = memo(function MessageRender({
|
|||
/>
|
||||
</MessageContext.Provider>
|
||||
</div>
|
||||
{hasNoChildren && effectiveIsSubmitting ? (
|
||||
{hasNoChildren && isSubmitting ? (
|
||||
<PlaceholderRow />
|
||||
) : (
|
||||
<SubRow classes="text-xs">
|
||||
|
|
@ -187,7 +248,7 @@ const MessageRender = memo(function MessageRender({
|
|||
isEditing={edit}
|
||||
message={msg}
|
||||
enterEdit={enterEdit}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={chatContext.isSubmitting}
|
||||
conversation={conversation ?? null}
|
||||
regenerate={handleRegenerateMessage}
|
||||
copyToClipboard={copyToClipboard}
|
||||
|
|
@ -202,7 +263,7 @@ const MessageRender = memo(function MessageRender({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, areMessageRenderPropsEqual);
|
||||
MessageRender.displayName = 'MessageRender';
|
||||
|
||||
export default MessageRender;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import { handleUIAction } from '~/utils';
|
||||
import { useOptionalMessagesConversation, useOptionalMessagesOperations } from '~/Providers';
|
||||
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
|
||||
import { useMessagesConversation, useMessagesOperations } from '~/Providers';
|
||||
import { handleUIAction } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface MCPUIResourceProps {
|
||||
|
|
@ -13,19 +13,14 @@ interface MCPUIResourceProps {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders an MCP UI resource based on its resource ID.
|
||||
* Works in both main app and share view.
|
||||
*/
|
||||
/** Renders an MCP UI resource based on its resource ID. Works in chat, share, and search views. */
|
||||
export function MCPUIResource(props: MCPUIResourceProps) {
|
||||
const { resourceId } = props.node.properties;
|
||||
const localize = useLocalize();
|
||||
const { ask } = useMessagesOperations();
|
||||
const { conversation } = useMessagesConversation();
|
||||
const { ask } = useOptionalMessagesOperations();
|
||||
const { conversationId } = useOptionalMessagesConversation();
|
||||
|
||||
const conversationResourceMap = useConversationUIResources(
|
||||
conversation?.conversationId ?? undefined,
|
||||
);
|
||||
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
|
||||
|
||||
const uiResource = conversationResourceMap.get(resourceId ?? '');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
|
||||
import { useMessagesConversation } from '~/Providers';
|
||||
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
|
||||
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
|
||||
import { useOptionalMessagesConversation } from '~/Providers';
|
||||
|
||||
interface MCPUIResourceCarouselProps {
|
||||
node: {
|
||||
|
|
@ -12,16 +12,11 @@ interface MCPUIResourceCarouselProps {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders multiple MCP UI resources in a carousel.
|
||||
* Works in both main app and share view.
|
||||
*/
|
||||
/** Renders multiple MCP UI resources in a carousel. Works in chat, share, and search views. */
|
||||
export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) {
|
||||
const { conversation } = useMessagesConversation();
|
||||
const { conversationId } = useOptionalMessagesConversation();
|
||||
|
||||
const conversationResourceMap = useConversationUIResources(
|
||||
conversation?.conversationId ?? undefined,
|
||||
);
|
||||
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
|
||||
|
||||
const uiResources = useMemo(() => {
|
||||
const { resourceIds = [] } = props.node.properties;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import React from 'react';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { MCPUIResource } from '../MCPUIResource';
|
||||
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
|
||||
import {
|
||||
useMessageContext,
|
||||
useOptionalMessagesConversation,
|
||||
useOptionalMessagesOperations,
|
||||
} from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { handleUIAction } from '~/utils';
|
||||
|
||||
|
|
@ -22,11 +26,11 @@ jest.mock('@mcp-ui/client', () => ({
|
|||
}));
|
||||
|
||||
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
|
||||
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
|
||||
typeof useMessagesConversation
|
||||
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
|
||||
typeof useOptionalMessagesConversation
|
||||
>;
|
||||
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
|
||||
typeof useMessagesOperations
|
||||
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
|
||||
typeof useOptionalMessagesOperations
|
||||
>;
|
||||
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
|
||||
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import React from 'react';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
|
||||
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
|
||||
import {
|
||||
useMessageContext,
|
||||
useOptionalMessagesConversation,
|
||||
useOptionalMessagesOperations,
|
||||
} from '~/Providers';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('~/Providers');
|
||||
|
|
@ -19,11 +23,11 @@ jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
|
|||
}));
|
||||
|
||||
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
|
||||
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
|
||||
typeof useMessagesConversation
|
||||
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
|
||||
typeof useOptionalMessagesConversation
|
||||
>;
|
||||
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
|
||||
typeof useMessagesOperations
|
||||
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
|
||||
typeof useOptionalMessagesOperations
|
||||
>;
|
||||
|
||||
describe('MCPUIResourceCarousel', () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useMemo, memo } from 'react';
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
|
||||
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
|
|
@ -16,12 +16,72 @@ import store from '~/store';
|
|||
|
||||
type ContentRenderProps = {
|
||||
message?: TMessage;
|
||||
/**
|
||||
* Effective isSubmitting: false for non-latest messages, real value for latest.
|
||||
* Computed by the wrapper (MessageContent.tsx) so this memo'd component only re-renders
|
||||
* when the value actually matters.
|
||||
*/
|
||||
isSubmitting?: boolean;
|
||||
/** Stable context object from wrapper — avoids ChatContext subscription inside memo */
|
||||
chatContext: TMessageChatContext;
|
||||
} & Pick<
|
||||
TMessageProps,
|
||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Custom comparator for React.memo: compares `message` by key fields instead of reference
|
||||
* because `buildTree` creates new message objects on every streaming update for ALL messages.
|
||||
*/
|
||||
function areContentRenderPropsEqual(prev: ContentRenderProps, next: ContentRenderProps): boolean {
|
||||
if (prev.isSubmitting !== next.isSubmitting) {
|
||||
return false;
|
||||
}
|
||||
if (prev.chatContext !== next.chatContext) {
|
||||
return false;
|
||||
}
|
||||
if (prev.siblingIdx !== next.siblingIdx) {
|
||||
return false;
|
||||
}
|
||||
if (prev.siblingCount !== next.siblingCount) {
|
||||
return false;
|
||||
}
|
||||
if (prev.currentEditId !== next.currentEditId) {
|
||||
return false;
|
||||
}
|
||||
if (prev.setSiblingIdx !== next.setSiblingIdx) {
|
||||
return false;
|
||||
}
|
||||
if (prev.setCurrentEditId !== next.setCurrentEditId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevMsg = prev.message;
|
||||
const nextMsg = next.message;
|
||||
if (prevMsg === nextMsg) {
|
||||
return true;
|
||||
}
|
||||
if (!prevMsg || !nextMsg) {
|
||||
return prevMsg === nextMsg;
|
||||
}
|
||||
|
||||
return (
|
||||
prevMsg.messageId === nextMsg.messageId &&
|
||||
prevMsg.text === nextMsg.text &&
|
||||
prevMsg.error === nextMsg.error &&
|
||||
prevMsg.unfinished === nextMsg.unfinished &&
|
||||
prevMsg.depth === nextMsg.depth &&
|
||||
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&
|
||||
(prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) &&
|
||||
prevMsg.content === nextMsg.content &&
|
||||
prevMsg.model === nextMsg.model &&
|
||||
prevMsg.endpoint === nextMsg.endpoint &&
|
||||
prevMsg.iconURL === nextMsg.iconURL &&
|
||||
prevMsg.feedback?.rating === nextMsg.feedback?.rating &&
|
||||
(prevMsg.attachments?.length ?? 0) === (nextMsg.attachments?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
const ContentRender = memo(function ContentRender({
|
||||
message: msg,
|
||||
siblingIdx,
|
||||
|
|
@ -30,6 +90,7 @@ const ContentRender = memo(function ContentRender({
|
|||
currentEditId,
|
||||
setCurrentEditId,
|
||||
isSubmitting = false,
|
||||
chatContext,
|
||||
}: ContentRenderProps) {
|
||||
const localize = useLocalize();
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
|
|
@ -55,6 +116,7 @@ const ContentRender = memo(function ContentRender({
|
|||
searchResults,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
chatContext,
|
||||
});
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
|
|
@ -66,8 +128,6 @@ const ContentRender = memo(function ContentRender({
|
|||
);
|
||||
const hasNoChildren = !(msg?.children?.length ?? 0);
|
||||
const isLatestMessage = msg?.messageId === latestMessageId;
|
||||
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const iconData: TMessageIcon = useMemo(
|
||||
() => ({
|
||||
|
|
@ -158,13 +218,13 @@ const ContentRender = memo(function ContentRender({
|
|||
searchResults={searchResults}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
isLatestMessage={isLatestMessage}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
isCreatedByUser={msg.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={msg.content as Array<TMessageContentParts | undefined>}
|
||||
/>
|
||||
</div>
|
||||
{hasNoChildren && effectiveIsSubmitting ? (
|
||||
{hasNoChildren && isSubmitting ? (
|
||||
<PlaceholderRow />
|
||||
) : (
|
||||
<SubRow classes="text-xs">
|
||||
|
|
@ -178,7 +238,7 @@ const ContentRender = memo(function ContentRender({
|
|||
message={msg}
|
||||
isEditing={edit}
|
||||
enterEdit={enterEdit}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={chatContext.isSubmitting}
|
||||
conversation={conversation ?? null}
|
||||
regenerate={handleRegenerateMessage}
|
||||
copyToClipboard={copyToClipboard}
|
||||
|
|
@ -193,7 +253,7 @@ const ContentRender = memo(function ContentRender({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, areContentRenderPropsEqual);
|
||||
ContentRender.displayName = 'ContentRender';
|
||||
|
||||
export default ContentRender;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import { useMessageProcess, useMemoizedChatContext } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
|
||||
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
|
||||
|
|
@ -28,6 +28,7 @@ export default function MessageContent(props: TMessageProps) {
|
|||
message: props.message,
|
||||
});
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting);
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null;
|
||||
|
|
@ -39,7 +40,11 @@ export default function MessageContent(props: TMessageProps) {
|
|||
<>
|
||||
<MessageContainer handleScroll={handleScroll}>
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<ContentRender {...props} isSubmitting={isSubmitting} />
|
||||
<ContentRender
|
||||
{...props}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
chatContext={chatContext}
|
||||
/>
|
||||
</div>
|
||||
</MessageContainer>
|
||||
<MultiMessage
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
|
|
@ -122,9 +122,12 @@ export default function useAddedResponse() {
|
|||
],
|
||||
);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
setConversation,
|
||||
generateConversation,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
conversation,
|
||||
setConversation,
|
||||
generateConversation,
|
||||
}),
|
||||
[conversation, setConversation, generateConversation],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable';
|
||||
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
|
||||
export { default as useFileHandling } from './useFileHandling';
|
||||
export { default as useFileHandling, useFileHandlingNoChatContext } from './useFileHandling';
|
||||
export { default as useFileDeletion } from './useFileDeletion';
|
||||
export { default as useUpdateFiles } from './useUpdateFiles';
|
||||
export { default as useDragHelpers } from './useDragHelpers';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export { default as useSubmitMessage } from './useSubmitMessage';
|
|||
export type { ContentMetadataResult } from './useContentMetadata';
|
||||
export { default as useExpandCollapse } from './useExpandCollapse';
|
||||
export { default as useMessageActions } from './useMessageActions';
|
||||
export { default as useMemoizedChatContext } from './useMemoizedChatContext';
|
||||
export { default as useMessageProcess } from './useMessageProcess';
|
||||
export { default as useMessageHelpers } from './useMessageHelpers';
|
||||
export { default as useCopyToClipboard } from './useCopyToClipboard';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import type { TAttachment, UIResource } from 'librechat-data-provider';
|
||||
import { useMessagesOperations } from '~/Providers';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +16,7 @@ import store from '~/store';
|
|||
export function useConversationUIResources(
|
||||
conversationId: string | undefined,
|
||||
): Map<string, UIResource> {
|
||||
const { getMessages } = useMessagesOperations();
|
||||
const { getMessages } = useOptionalMessagesOperations();
|
||||
|
||||
const conversationAttachmentsMap = useRecoilValue(
|
||||
store.conversationAttachmentsSelector(conversationId),
|
||||
|
|
|
|||
80
client/src/hooks/Messages/useMemoizedChatContext.ts
Normal file
80
client/src/hooks/Messages/useMemoizedChatContext.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useRef, useMemo } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageChatContext } from '~/common/types';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
/**
|
||||
* Creates a stable `TMessageChatContext` object for memo'd message components.
|
||||
*
|
||||
* Subscribes to `useChatContext()` internally (intended to be called from non-memo'd
|
||||
* wrapper components like `Message` and `MessageContent`), then produces:
|
||||
* - A `chatContext` object that stays referentially stable during streaming
|
||||
* (uses a getter for `isSubmitting` backed by a ref)
|
||||
* - A stable `conversation` reference that only updates when rendering-relevant fields change
|
||||
* - An `effectiveIsSubmitting` value (false for non-latest messages)
|
||||
*/
|
||||
export default function useMemoizedChatContext(
|
||||
message: TMessage | null | undefined,
|
||||
isSubmitting: boolean,
|
||||
) {
|
||||
const chatCtx = useChatContext();
|
||||
|
||||
const isSubmittingRef = useRef(isSubmitting);
|
||||
isSubmittingRef.current = isSubmitting;
|
||||
|
||||
/**
|
||||
* Stabilize conversation: only update when rendering-relevant fields change,
|
||||
* not on every metadata update (e.g., title generation).
|
||||
*/
|
||||
const stableConversation = useMemo(
|
||||
() => chatCtx.conversation,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
chatCtx.conversation?.conversationId,
|
||||
chatCtx.conversation?.endpoint,
|
||||
chatCtx.conversation?.endpointType,
|
||||
chatCtx.conversation?.model,
|
||||
chatCtx.conversation?.agent_id,
|
||||
chatCtx.conversation?.assistant_id,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* `isSubmitting` is included in deps so that chatContext gets a new reference
|
||||
* when streaming starts/ends (2x per session). This ensures HoverButtons
|
||||
* re-renders to update regenerate/edit button visibility via useGenerationsByLatest.
|
||||
* The getter pattern is still valuable: callbacks reading chatContext.isSubmitting
|
||||
* at call-time always get the current value even between these re-renders.
|
||||
*/
|
||||
const chatContext: TMessageChatContext = useMemo(
|
||||
() => ({
|
||||
ask: chatCtx.ask,
|
||||
index: chatCtx.index,
|
||||
regenerate: chatCtx.regenerate,
|
||||
conversation: stableConversation,
|
||||
latestMessageId: chatCtx.latestMessageId,
|
||||
latestMessageDepth: chatCtx.latestMessageDepth,
|
||||
handleContinue: chatCtx.handleContinue,
|
||||
get isSubmitting() {
|
||||
return isSubmittingRef.current;
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
chatCtx.ask,
|
||||
chatCtx.index,
|
||||
chatCtx.regenerate,
|
||||
stableConversation,
|
||||
chatCtx.latestMessageId,
|
||||
chatCtx.latestMessageDepth,
|
||||
chatCtx.handleContinue,
|
||||
isSubmitting, // intentional: forces new reference on streaming start/end so HoverButtons re-renders
|
||||
],
|
||||
);
|
||||
|
||||
const messageId = message?.messageId ?? null;
|
||||
const isLatestMessage = messageId === chatCtx.latestMessageId;
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
return { chatContext, effectiveIsSubmitting };
|
||||
}
|
||||
|
|
@ -11,7 +11,8 @@ import {
|
|||
TUpdateFeedbackRequest,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
||||
import type { TMessageChatContext } from '~/common/types';
|
||||
import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
||||
import useCopyToClipboard from './useCopyToClipboard';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useGetAddedConvo } from '~/hooks/Chat';
|
||||
|
|
@ -23,24 +24,33 @@ export type TMessageActions = Pick<
|
|||
'message' | 'currentEditId' | 'setCurrentEditId'
|
||||
> & {
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
/**
|
||||
* Stable context object passed from wrapper components to avoid subscribing
|
||||
* to ChatContext inside memo'd components (which would bypass React.memo).
|
||||
* The `isSubmitting` property uses a getter backed by a ref, so it always
|
||||
* returns the current value at call-time without triggering re-renders.
|
||||
*/
|
||||
chatContext: TMessageChatContext;
|
||||
};
|
||||
|
||||
export default function useMessageActions(props: TMessageActions) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
|
||||
const { message, currentEditId, setCurrentEditId, searchResults } = props;
|
||||
const { message, currentEditId, setCurrentEditId, searchResults, chatContext } = props;
|
||||
|
||||
const {
|
||||
ask,
|
||||
index,
|
||||
regenerate,
|
||||
isSubmitting,
|
||||
conversation,
|
||||
latestMessageId,
|
||||
latestMessageDepth,
|
||||
handleContinue,
|
||||
} = useChatContext();
|
||||
// NOTE: isSubmitting is intentionally NOT destructured here.
|
||||
// chatContext.isSubmitting is a getter backed by a ref — destructuring
|
||||
// would capture a one-time snapshot. Always access via chatContext.isSubmitting.
|
||||
} = chatContext;
|
||||
|
||||
const getAddedConvo = useGetAddedConvo();
|
||||
|
||||
|
|
@ -98,13 +108,18 @@ export default function useMessageActions(props: TMessageActions) {
|
|||
}
|
||||
}, [agentsMap, conversation?.agent_id, conversation?.endpoint, message?.model]);
|
||||
|
||||
/**
|
||||
* chatContext.isSubmitting is a getter backed by the wrapper's ref,
|
||||
* so it always returns the current value at call-time — even for
|
||||
* non-latest messages that don't re-render during streaming.
|
||||
*/
|
||||
const regenerateMessage = useCallback(() => {
|
||||
if ((isSubmitting && isCreatedByUser === true) || !message) {
|
||||
if ((chatContext.isSubmitting && isCreatedByUser === true) || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
regenerate(message, { addedConvo: getAddedConvo() });
|
||||
}, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo]);
|
||||
}, [chatContext, isCreatedByUser, message, regenerate, getAddedConvo]);
|
||||
|
||||
const copyToClipboard = useCopyToClipboard({ text, content, searchResults });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import throttle from 'lodash/throttle';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
|
||||
import { useMessagesViewContext } from '~/Providers';
|
||||
|
|
@ -56,24 +56,25 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
|
|||
}
|
||||
}, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: unknown | TouchEvent | WheelEvent) => {
|
||||
throttle(() => {
|
||||
/** Use ref for isSubmitting to stabilize handleScroll across isSubmitting changes */
|
||||
const isSubmittingRef = useRef(isSubmitting);
|
||||
isSubmittingRef.current = isSubmitting;
|
||||
|
||||
const handleScroll = useMemo(
|
||||
() =>
|
||||
throttle((event: unknown) => {
|
||||
logger.log(
|
||||
'message_scrolling',
|
||||
`useMessageProcess: setting abort scroll to ${isSubmitting}, handleScroll event`,
|
||||
`useMessageProcess: setting abort scroll to ${isSubmittingRef.current}, handleScroll event`,
|
||||
event,
|
||||
);
|
||||
if (isSubmitting) {
|
||||
setAbortScroll(true);
|
||||
} else {
|
||||
setAbortScroll(false);
|
||||
}
|
||||
}, 500)();
|
||||
},
|
||||
[isSubmitting, setAbortScroll],
|
||||
setAbortScroll(isSubmittingRef.current);
|
||||
}, 500),
|
||||
[setAbortScroll],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleScroll.cancel(), [handleScroll]);
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
isSubmitting,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { Constants, ErrorTypes, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { TSubmission } from 'librechat-data-provider';
|
||||
|
||||
type SSEEventListener = (e: Partial<MessageEvent> & { responseCode?: number }) => void;
|
||||
|
|
@ -34,7 +34,13 @@ jest.mock('sse.js', () => ({
|
|||
}));
|
||||
|
||||
const mockSetQueryData = jest.fn();
|
||||
const mockQueryClient = { setQueryData: mockSetQueryData };
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
const mockRemoveQueries = jest.fn();
|
||||
const mockQueryClient = {
|
||||
setQueryData: mockSetQueryData,
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
removeQueries: mockRemoveQueries,
|
||||
};
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
...jest.requireActual('@tanstack/react-query'),
|
||||
|
|
@ -63,6 +69,7 @@ jest.mock('~/data-provider', () => ({
|
|||
useGetStartupConfig: () => ({ data: { balance: { enabled: false } } }),
|
||||
useGetUserBalance: () => ({ refetch: jest.fn() }),
|
||||
queueTitleGeneration: jest.fn(),
|
||||
streamStatusQueryKey: (conversationId: string) => ['streamStatus', conversationId],
|
||||
}));
|
||||
|
||||
const mockErrorHandler = jest.fn();
|
||||
|
|
@ -162,6 +169,11 @@ describe('useResumableSSE - 404 error path', () => {
|
|||
beforeEach(() => {
|
||||
mockSSEInstances.length = 0;
|
||||
localStorage.clear();
|
||||
mockErrorHandler.mockClear();
|
||||
mockClearStepMaps.mockClear();
|
||||
mockSetIsSubmitting.mockClear();
|
||||
mockInvalidateQueries.mockClear();
|
||||
mockRemoveQueries.mockClear();
|
||||
});
|
||||
|
||||
const seedDraft = (conversationId: string) => {
|
||||
|
|
@ -200,19 +212,18 @@ describe('useResumableSSE - 404 error path', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('calls errorHandler with STREAM_EXPIRED error type on 404', async () => {
|
||||
it('invalidates message cache and clears stream status on 404 instead of showing error', async () => {
|
||||
const { unmount } = await render404Scenario(CONV_ID);
|
||||
|
||||
expect(mockErrorHandler).toHaveBeenCalledTimes(1);
|
||||
const call = mockErrorHandler.mock.calls[0][0];
|
||||
expect(call.data).toBeDefined();
|
||||
const parsed = JSON.parse(call.data.text);
|
||||
expect(parsed.type).toBe(ErrorTypes.STREAM_EXPIRED);
|
||||
expect(call.submission).toEqual(
|
||||
expect.objectContaining({
|
||||
conversation: expect.objectContaining({ conversationId: CONV_ID }),
|
||||
}),
|
||||
);
|
||||
expect(mockErrorHandler).not.toHaveBeenCalled();
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['messages', CONV_ID],
|
||||
});
|
||||
expect(mockRemoveQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['streamStatus', CONV_ID],
|
||||
});
|
||||
expect(mockClearStepMaps).toHaveBeenCalled();
|
||||
expect(mockSetIsSubmitting).toHaveBeenCalledWith(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { TMessage, TPayload, TSubmission, EventSubmission } from 'librechat-data-provider';
|
||||
import type { EventHandlerParams } from './useEventHandlers';
|
||||
import { useGetStartupConfig, useGetUserBalance, queueTitleGeneration } from '~/data-provider';
|
||||
import {
|
||||
useGetUserBalance,
|
||||
useGetStartupConfig,
|
||||
queueTitleGeneration,
|
||||
streamStatusQueryKey,
|
||||
} from '~/data-provider';
|
||||
import type { ActiveJobsResponse } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import useEventHandlers from './useEventHandlers';
|
||||
|
|
@ -343,18 +348,20 @@ export default function useResumableSSE(
|
|||
/* @ts-ignore - sse.js types don't expose responseCode */
|
||||
const responseCode = e.responseCode;
|
||||
|
||||
// 404 means job doesn't exist (completed/deleted) - don't retry
|
||||
// 404 → job completed & was cleaned up; messages are persisted in DB.
|
||||
// Invalidate cache once so react-query refetches instead of showing an error.
|
||||
if (responseCode === 404) {
|
||||
console.log('[ResumableSSE] Stream not found (404) - job completed or expired');
|
||||
const convoId = currentSubmission.conversation?.conversationId;
|
||||
console.log('[ResumableSSE] Stream 404, invalidating messages for:', convoId);
|
||||
sse.close();
|
||||
removeActiveJob(currentStreamId);
|
||||
clearAllDrafts(currentSubmission.conversation?.conversationId);
|
||||
errorHandler({
|
||||
data: {
|
||||
text: JSON.stringify({ type: ErrorTypes.STREAM_EXPIRED }),
|
||||
} as unknown as Parameters<typeof errorHandler>[0]['data'],
|
||||
submission: currentSubmission as EventSubmission,
|
||||
});
|
||||
clearAllDrafts(convoId);
|
||||
clearStepMaps();
|
||||
if (convoId) {
|
||||
queryClient.invalidateQueries({ queryKey: [QueryKeys.messages, convoId] });
|
||||
queryClient.removeQueries({ queryKey: streamStatusQueryKey(convoId) });
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
setShowStopButton(false);
|
||||
setStreamId(null);
|
||||
reconnectAttemptRef.current = 0;
|
||||
|
|
@ -544,6 +551,7 @@ export default function useResumableSSE(
|
|||
startupConfig?.balance?.enabled,
|
||||
balanceQuery,
|
||||
removeActiveJob,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -125,7 +125,11 @@ export default function useResumeOnLoad(
|
|||
conversationId !== Constants.NEW_CONVO &&
|
||||
processedConvoRef.current !== conversationId; // Don't re-check processed convos
|
||||
|
||||
const { data: streamStatus, isSuccess } = useStreamStatus(conversationId, shouldCheck);
|
||||
const {
|
||||
data: streamStatus,
|
||||
isSuccess,
|
||||
isFetching,
|
||||
} = useStreamStatus(conversationId, shouldCheck);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[ResumeOnLoad] Effect check', {
|
||||
|
|
@ -135,6 +139,7 @@ export default function useResumeOnLoad(
|
|||
hasCurrentSubmission: !!currentSubmission,
|
||||
currentSubmissionConvoId: currentSubmission?.conversation?.conversationId,
|
||||
isSuccess,
|
||||
isFetching,
|
||||
streamStatusActive: streamStatus?.active,
|
||||
streamStatusStreamId: streamStatus?.streamId,
|
||||
processedConvoRef: processedConvoRef.current,
|
||||
|
|
@ -171,8 +176,9 @@ export default function useResumeOnLoad(
|
|||
);
|
||||
}
|
||||
|
||||
// Wait for stream status query to complete
|
||||
if (!isSuccess || !streamStatus) {
|
||||
// Wait for stream status query to complete (including background refetches
|
||||
// that may replace a stale cached result with fresh data)
|
||||
if (!isSuccess || !streamStatus || isFetching) {
|
||||
console.log('[ResumeOnLoad] Waiting for stream status query');
|
||||
return;
|
||||
}
|
||||
|
|
@ -183,15 +189,12 @@ export default function useResumeOnLoad(
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if there's an active job to resume
|
||||
// DON'T mark as processed here - only mark when we actually create a submission
|
||||
// This prevents stale cache data from blocking subsequent resume attempts
|
||||
if (!streamStatus.active || !streamStatus.streamId) {
|
||||
console.log('[ResumeOnLoad] No active job to resume for:', conversationId);
|
||||
processedConvoRef.current = conversationId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed NOW - we verified there's an active job and will create submission
|
||||
processedConvoRef.current = conversationId;
|
||||
|
||||
console.log('[ResumeOnLoad] Found active job, creating submission...', {
|
||||
|
|
@ -241,6 +244,7 @@ export default function useResumeOnLoad(
|
|||
submissionConvoId,
|
||||
currentSubmission,
|
||||
isSuccess,
|
||||
isFetching,
|
||||
streamStatus,
|
||||
getMessages,
|
||||
setSubmission,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue