fix(chat): render non-image attachments on content-part messages

Container is only reached by messages that carry no content, so an
assistant turn with both reasoning and a generated file rendered the
reasoning and dropped the file: images have an image_file part, everything
else lives only on message.files. ContentParts now renders the non-image
half of that list.
This commit is contained in:
Marco Beretta 2026-07-29 02:29:00 +02:00
parent a4bd2ed2ae
commit f025c19085
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 35 additions and 9 deletions

View file

@ -9,10 +9,10 @@ const Container = ({ children, message }: { children: React.ReactNode; message?:
dir="auto"
>
{message?.isCreatedByUser === true && <MessageQuotes quotes={message.quotes} />}
{/* 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 && <Files message={message} />}
{/* 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 && <Files files={message.files} />}
{message?.isCreatedByUser === true && (
<>
<SkillPills skills={message.alwaysAppliedSkills} source="always-apply" />

View file

@ -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<string, ToolCallGroupExpansionState>());
const fallbackScopeRef = useRef({ messageId, scope: 0 });
@ -407,6 +423,11 @@ const ContentParts = memo(function ContentParts({
<ApprovalProvider>
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
{nonImageFiles != null && nonImageFiles.length > 0 && (
<Container>
<Files files={nonImageFiles} />
</Container>
)}
{renderPendingSkills()}
{showEmptyCursor && (
<Container>

View file

@ -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<Partial<TFile> | null>(null);

View file

@ -146,6 +146,7 @@ function MessageParts(props: TMessageProps) {
<ContentParts
edit={edit}
isLast={isLast}
files={message.files}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
attachments={attachments}

View file

@ -195,6 +195,7 @@ const ContentRender = memo(function ContentRender({
<ContentParts
edit={edit}
isLast={isLast}
files={msg.files}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
messageId={msg.messageId}