Feat: An image carousel is displayed at the bottom of the agent's chat messages. #12076 (#12215)

### What problem does this PR solve?

Feat: An image carousel is displayed at the bottom of the agent's chat
messages. #12076

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-12-25 19:02:49 +08:00
committed by GitHub
parent cfd1250615
commit c7b5bfb809
7 changed files with 1665 additions and 440 deletions

1761
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -133,6 +133,7 @@
"@storybook/addon-styling-webpack": "^2.0.0", "@storybook/addon-styling-webpack": "^2.0.0",
"@storybook/addon-webpack5-compiler-swc": "^4.0.1", "@storybook/addon-webpack5-compiler-swc": "^4.0.1",
"@storybook/react-webpack5": "^9.1.4", "@storybook/react-webpack5": "^9.1.4",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/jest-dom": "^6.4.5", "@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7", "@testing-library/react": "^15.0.7",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",

View File

@ -17,10 +17,10 @@ import { useTranslation } from 'react-i18next';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { import {
currentReg,
preprocessLaTeX, preprocessLaTeX,
replaceTextByOldReg, replaceTextByOldReg,
replaceThinkToSection, replaceThinkToSection,
showImage,
} from '@/utils/chat'; } from '@/utils/chat';
import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request'; import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
@ -28,12 +28,7 @@ import { cn } from '@/lib/utils';
import classNames from 'classnames'; import classNames from 'classnames';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { pipe } from 'lodash/fp'; import { pipe } from 'lodash/fp';
import { CircleAlert } from 'lucide-react'; import reactStringReplace from 'react-string-replace';
import { ImageCarousel } from '../markdown-content/image-carousel';
import {
groupConsecutiveReferences,
shouldShowCarousel,
} from '../markdown-content/reference-utils';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { import {
HoverCard, HoverCard,
@ -42,19 +37,6 @@ import {
} from '../ui/hover-card'; } from '../ui/hover-card';
import styles from './index.less'; import styles from './index.less';
// Helper function to convert IReferenceObject to IReference
const convertReferenceObjectToReference = (
referenceObject: IReferenceObject,
) => {
const chunks = Object.values(referenceObject.chunks);
const docAggs = Object.values(referenceObject.doc_aggs);
return {
chunks,
doc_aggs: docAggs,
total: chunks.length,
};
};
const getChunkIndex = (match: string) => Number(match); const getChunkIndex = (match: string) => Number(match);
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem. // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
function MarkdownContent({ function MarkdownContent({
@ -227,95 +209,26 @@ function MarkdownContent({
const renderReference = useCallback( const renderReference = useCallback(
(text: string) => { (text: string) => {
const groups = groupConsecutiveReferences(text); let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const elements = []; const chunkIndex = getChunkIndex(match);
let lastIndex = 0;
const convertedReference = reference return (
? convertReferenceObjectToReference(reference) <HoverCard key={i}>
: null; <HoverCardTrigger>
<span className="text-text-secondary bg-bg-card rounded-2xl px-1 mx-1 text-nowrap">
groups.forEach((group, groupIndex) => { Fig. {chunkIndex + 1}
if (group[0].start > lastIndex) { </span>
elements.push(text.substring(lastIndex, group[0].start)); </HoverCardTrigger>
} <HoverCardContent className="max-w-3xl">
{renderPopoverContent(chunkIndex)}
if ( </HoverCardContent>
convertedReference && </HoverCard>
shouldShowCarousel(group, convertedReference) );
) {
elements.push(
<ImageCarousel
key={`carousel-${groupIndex}`}
group={group}
reference={convertedReference}
fileThumbnails={fileThumbnails}
onImageClick={handleDocumentButtonClick}
/>,
);
} else {
group.forEach((ref) => {
const chunkIndex = getChunkIndex(ref.id);
const {
documentUrl,
fileExtension,
imageId,
chunkItem,
documentId,
} = getReferenceInfo(chunkIndex);
const docType = chunkItem?.doc_type;
if (showImage(docType)) {
elements.push(
<section key={ref.id}>
<Image
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)
: () => {}
}
/>
<span className="text-accent-primary"> {imageId}</span>
</section>,
);
} else {
elements.push(
<HoverCard key={ref.id}>
<HoverCardTrigger>
<CircleAlert className="size-4 inline-block" />
</HoverCardTrigger>
<HoverCardContent className="max-w-3xl">
{renderPopoverContent(chunkIndex)}
</HoverCardContent>
</HoverCard>,
);
}
});
}
lastIndex = group[group.length - 1].end;
}); });
if (lastIndex < text.length) { return replacedText;
elements.push(text.substring(lastIndex));
}
return elements;
}, },
[ [renderPopoverContent],
renderPopoverContent,
getReferenceInfo,
handleDocumentButtonClick,
reference,
fileThumbnails,
],
); );
return ( return (

View File

@ -36,6 +36,7 @@ import { Button } from '../ui/button';
import { AssistantGroupButton, UserGroupButton } from './group-button'; import { AssistantGroupButton, UserGroupButton } from './group-button';
import styles from './index.less'; import styles from './index.less';
import { ReferenceDocumentList } from './reference-document-list'; import { ReferenceDocumentList } from './reference-document-list';
import { ReferenceImageList } from './reference-image-list';
import { UploadedMessageFiles } from './uploaded-message-files'; import { UploadedMessageFiles } from './uploaded-message-files';
interface IProps interface IProps
@ -295,6 +296,13 @@ function MessageItem({
{renderContent()} {renderContent()}
{isAssistant && (
<ReferenceImageList
referenceChunks={reference?.chunks}
messageContent={messageContent}
></ReferenceImageList>
)}
{isAssistant && referenceDocuments.length > 0 && ( {isAssistant && referenceDocuments.length > 0 && (
<ReferenceDocumentList <ReferenceDocumentList
list={referenceDocuments} list={referenceDocuments}

View File

@ -7,22 +7,34 @@ import {
CarouselPrevious, CarouselPrevious,
} from '@/components/ui/carousel'; } from '@/components/ui/carousel';
import { IReferenceChunk } from '@/interfaces/database/chat'; import { IReferenceChunk } from '@/interfaces/database/chat';
import { useResponsive } from 'ahooks'; import { isPlainObject } from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { extractNumbersFromMessageContent } from './utils'; import { extractNumbersFromMessageContent } from './utils';
type IProps = { type IProps = {
referenceChunks: IReferenceChunk[]; referenceChunks?: IReferenceChunk[] | Record<string, IReferenceChunk>;
messageContent: string; messageContent: string;
}; };
function ImageCarousel({ type ImageItem = {
imageIds, id: string;
hideButtons, index: number;
}: { };
hideButtons?: boolean;
imageIds: string[]; const getButtonVisibilityClass = (imageCount: number) => {
}) { const map: Record<number, string> = {
1: 'hidden',
2: '@sm:hidden',
3: '@md:hidden',
4: '@lg:hidden',
5: '@lg:hidden',
};
return map[imageCount] || (imageCount >= 6 ? '@2xl:hidden' : '');
};
function ImageCarousel({ images }: { images: ImageItem[] }) {
const buttonVisibilityClass = getButtonVisibilityClass(images.length);
return ( return (
<Carousel <Carousel
className="w-full" className="w-full"
@ -31,22 +43,27 @@ function ImageCarousel({
}} }}
> >
<CarouselContent> <CarouselContent>
{imageIds.map((imageId, index) => ( {images.map(({ id, index }) => (
<CarouselItem key={index} className="md:basis-1/2 2xl:basis-1/6"> <CarouselItem
key={index}
className="
basis-full
@sm:basis-1/2
@md:basis-1/3
@lg:basis-1/4
@2xl:basis-1/6
"
>
<Image <Image
id={imageId} id={id}
className="h-40 w-full" className="h-40 w-full"
label={`Fig. ${(index + 1).toString()}`} label={`Fig. ${(index + 1).toString()}`}
/> />
</CarouselItem> </CarouselItem>
))} ))}
</CarouselContent> </CarouselContent>
{!hideButtons && ( <CarouselPrevious className={buttonVisibilityClass} />
<> <CarouselNext className={buttonVisibilityClass} />
<CarouselPrevious />
<CarouselNext />
</>
)}
</Carousel> </Carousel>
); );
} }
@ -55,30 +72,38 @@ export function ReferenceImageList({
referenceChunks, referenceChunks,
messageContent, messageContent,
}: IProps) { }: IProps) {
const imageIds = useMemo(() => { const allChunkIndexes = extractNumbersFromMessageContent(messageContent);
return referenceChunks const images = useMemo(() => {
.filter((_, idx) => if (Array.isArray(referenceChunks)) {
extractNumbersFromMessageContent(messageContent).includes(idx), return referenceChunks
) .map((chunk, idx) => ({ id: chunk.image_id, index: idx }))
.map((chunk) => chunk.image_id); .filter((item, idx) => allChunkIndexes.includes(idx) && item.id);
}, [messageContent, referenceChunks]); }
const imageCount = imageIds.length;
const responsive = useResponsive(); if (isPlainObject(referenceChunks)) {
return Object.entries(referenceChunks || {}).reduce<ImageItem[]>(
(pre, [idx, chunk]) => {
if (allChunkIndexes.includes(Number(idx)) && chunk.image_id) {
return pre.concat({ id: chunk.image_id, index: Number(idx) });
}
return pre;
},
[],
);
}
const { isMd, is2xl } = useMemo(() => { return [];
return { }, [allChunkIndexes, referenceChunks]);
isMd: responsive.md,
is2xl: responsive['2xl'],
};
}, [responsive]);
// If there are few images, hide the previous/next buttons. const imageCount = images?.length || 0;
const hideButtons = is2xl ? imageCount <= 6 : isMd ? imageCount <= 2 : false;
if (imageCount === 0) { if (imageCount === 0) {
return <></>; return <></>;
} }
return <ImageCarousel imageIds={imageIds} hideButtons={hideButtons} />; return (
<section className="@container w-full">
<ImageCarousel images={images} />
</section>
);
} }

View File

@ -2,9 +2,8 @@ import Image from '@/components/image';
import SvgIcon from '@/components/svg-icon'; import SvgIcon from '@/components/svg-icon';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import { getExtension } from '@/utils/document-util'; import { getExtension } from '@/utils/document-util';
import { InfoCircleOutlined } from '@ant-design/icons';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import React, { memo, useCallback, useEffect, useMemo } from 'react'; import { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import SyntaxHighlighter from 'react-syntax-highlighter'; import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
@ -17,17 +16,11 @@ import { useTranslation } from 'react-i18next';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import ImageCarousel from '@/components/markdown-content/image-carousel';
import {
groupConsecutiveReferences,
shouldShowCarousel,
type ReferenceGroup,
} from '@/components/markdown-content/reference-utils';
import { import {
currentReg,
preprocessLaTeX, preprocessLaTeX,
replaceTextByOldReg, replaceTextByOldReg,
replaceThinkToSection, replaceThinkToSection,
showImage,
} from '@/utils/chat'; } from '@/utils/chat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -40,6 +33,7 @@ import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
import classNames from 'classnames'; import classNames from 'classnames';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { pipe } from 'lodash/fp'; import { pipe } from 'lodash/fp';
import reactStringReplace from 'react-string-replace';
// Defining Tailwind CSS class name constants // Defining Tailwind CSS class name constants
const styles = { const styles = {
@ -52,6 +46,8 @@ const styles = {
fileThumbnail: 'inline-block max-w-[40px]', fileThumbnail: 'inline-block max-w-[40px]',
}; };
const getChunkIndex = (match: string) => Number(match);
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem. // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
const MarkdownContent = ({ const MarkdownContent = ({
reference, reference,
@ -192,7 +188,10 @@ const MarkdownContent = ({
)} )}
<Button <Button
variant="link" variant="link"
className={classNames(styles.documentLink, 'text-wrap')} className={classNames(
styles.documentLink,
'text-wrap flex-1 h-auto',
)}
onClick={handleDocumentButtonClick( onClick={handleDocumentButtonClick(
documentId, documentId,
chunkItem, chunkItem,
@ -213,83 +212,26 @@ const MarkdownContent = ({
const renderReference = useCallback( const renderReference = useCallback(
(text: string) => { (text: string) => {
const groups = groupConsecutiveReferences(text); let replacedText = reactStringReplace(text, currentReg, (match) => {
const elements: React.ReactNode[] = []; const chunkIndex = getChunkIndex(match);
let lastIndex = 0;
groups.forEach((group: ReferenceGroup) => { return (
// Add text before the group <Popover>
if (group[0].start > lastIndex) { <PopoverTrigger>
elements.push(text.slice(lastIndex, group[0].start)); <span className="text-text-secondary bg-bg-card rounded-2xl px-1 mx-1 text-nowrap">
} Fig. {chunkIndex + 1}
</span>
// Determine if this group should be a carousel </PopoverTrigger>
if (shouldShowCarousel(group, reference)) { <PopoverContent className="!w-fit">
// Render carousel for consecutive image group {getPopoverContent(chunkIndex)}
elements.push( </PopoverContent>
<ImageCarousel </Popover>
key={`carousel-${group[0].id}`} );
group={group}
reference={reference}
fileThumbnails={fileThumbnails}
onImageClick={handleDocumentButtonClick}
/>,
);
} else {
// Render individual references in the group
group.forEach((ref) => {
const chunkIndex = Number(ref.id);
const { imageId, chunkItem, documentId } =
getReferenceInfo(chunkIndex);
const docType = chunkItem?.doc_type;
if (showImage(docType)) {
elements.push(
<Image
key={ref.id}
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(documentId, chunkItem)
: () => {}
}
/>,
);
} else {
elements.push(
<Popover key={ref.id}>
<PopoverTrigger>
<InfoCircleOutlined className={styles.referenceIcon} />
</PopoverTrigger>
<PopoverContent className="!w-fit">
{getPopoverContent(chunkIndex)}
</PopoverContent>
</Popover>,
);
}
// Add the original reference text
elements.push(ref.fullMatch);
});
}
lastIndex = group[group.length - 1].end;
}); });
// Add any remaining text after the last group return replacedText;
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
}
return elements;
}, },
[ [getPopoverContent],
reference,
fileThumbnails,
handleDocumentButtonClick,
getReferenceInfo,
getPopoverContent,
],
); );
return ( return (
@ -300,11 +242,7 @@ const MarkdownContent = ({
components={ components={
{ {
'custom-typography': ({ children }: { children: string }) => 'custom-typography': ({ children }: { children: string }) =>
Array.isArray(renderReference(children)) renderReference(children),
? renderReference(children).map((element, index) => (
<React.Fragment key={index}>{element}</React.Fragment>
))
: renderReference(children),
code(props: any) { code(props: any) {
const { children, className, ...rest } = props; const { children, className, ...rest } = props;
const restProps = omit(rest, 'node'); const restProps = omit(rest, 'node');

View File

@ -223,5 +223,6 @@ module.exports = {
require('tailwindcss-animate'), require('tailwindcss-animate'),
require('@tailwindcss/line-clamp'), require('@tailwindcss/line-clamp'),
require('tailwind-scrollbar'), require('tailwind-scrollbar'),
require('@tailwindcss/container-queries'),
], ],
}; };