diff --git a/client/src/components/Chat/Messages/Content/Container.tsx b/client/src/components/Chat/Messages/Content/Container.tsx index 81f97acf68..8a4806d74d 100644 --- a/client/src/components/Chat/Messages/Content/Container.tsx +++ b/client/src/components/Chat/Messages/Content/Container.tsx @@ -9,10 +9,10 @@ const Container = ({ children, message }: { children: React.ReactNode; message?: dir="auto" > {message?.isCreatedByUser === true && } - {/* Not user-only: this is the sole consumer of `message.files`, and an - imported assistant message can carry attachments — voice-mode audio — - that no content part renders. */} - {message?.files != null && message.files.length > 0 && } + {/* Not user-only: an assistant message can carry attachments — voice-mode + audio — that no content part renders. `ContentParts` renders the same + files for messages that never reach this container. */} + {message?.files != null && message.files.length > 0 && } {message?.isCreatedByUser === true && ( <> diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 5b60b534c2..d9d56f8715 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -4,6 +4,7 @@ import type { TMessageContentParts, SearchResultData, TAttachment, + TMessage, Agents, } from 'librechat-data-provider'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; @@ -16,6 +17,7 @@ import ApprovalProvider from './ApprovalContext'; import MemoryArtifacts from './MemoryArtifacts'; import ToolCallGroup from './ToolCallGroup'; import Container from './Container'; +import Files from './Files'; import Part from './Part'; const getToolCallId = (part: TMessageContentParts): string => @@ -119,6 +121,15 @@ type ContentPartsProps = { isLast: boolean; isSubmitting: boolean; isLatestMessage?: boolean; + /** + * The message's own attachments. Images among them already render as + * `image_file` parts, so only the rest are shown here — but the rest have + * no content part at all, and `Container` (the only other consumer of this + * field) is never reached by a message that has content. Without this, an + * assistant turn that carries both reasoning and a generated file renders + * the reasoning and silently drops the file. + */ + files?: TMessage['files']; edit?: boolean; enterEdit?: (cancel?: boolean) => void | null | undefined; siblingIdx?: number; @@ -136,6 +147,7 @@ type ContentPartsProps = { */ const ContentParts = memo(function ContentParts({ edit, + files, isLast, content, manualSkills, @@ -152,6 +164,10 @@ const ContentParts = memo(function ContentParts({ createdAt, }: ContentPartsProps) { const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); + const nonImageFiles = useMemo( + () => files?.filter((file) => file.type?.startsWith('image/') !== true), + [files], + ); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; const toolGroupExpansionRef = useRef(new Map()); const fallbackScopeRef = useRef({ messageId, scope: 0 }); @@ -407,6 +423,11 @@ const ContentParts = memo(function ContentParts({ + {nonImageFiles != null && nonImageFiles.length > 0 && ( + + + + )} {renderPendingSkills()} {showEmptyCursor && ( diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 4e4d85060c..1cf58edd2a 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -4,14 +4,17 @@ import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import FilePreviewDialog from './FilePreviewDialog'; import Image from './Image'; -const Files = ({ message }: { message?: TMessage }) => { +/** Takes the file list rather than the message: the content-part renderer has + * to show the non-image half of it without handing the whole message object to + * a memoized subtree. */ +const Files = ({ files }: { files?: TMessage['files'] }) => { const imageFiles = useMemo(() => { - return message?.files?.filter((file) => file.type?.startsWith('image/')) || []; - }, [message?.files]); + return files?.filter((file) => file.type?.startsWith('image/')) || []; + }, [files]); const otherFiles = useMemo(() => { - return message?.files?.filter((file) => !file.type?.startsWith('image/')) || []; - }, [message?.files]); + return files?.filter((file) => !file.type?.startsWith('image/')) || []; + }, [files]); const [selectedFile, setSelectedFile] = useState | null>(null); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 4798bcd9a0..4ce57efaf4 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -146,6 +146,7 @@ function MessageParts(props: TMessageProps) {