Images that appear consecutively in the dialogue are displayed using a carousel. #12076 (#12077)

### What problem does this PR solve?

Images that appear consecutively in the dialogue are displayed using a
carousel. #12076

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-12-22 14:41:02 +08:00
committed by GitHub
parent 5ba51b21c9
commit 2ddfcc7cf6
5 changed files with 194 additions and 84 deletions

View File

@ -5,7 +5,6 @@ import { getExtension } from '@/utils/document-util';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { memo, useCallback, useEffect, useMemo } from 'react'; import { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter'; import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@ -18,7 +17,6 @@ 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,
@ -31,6 +29,11 @@ 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 { CircleAlert } from 'lucide-react';
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,
@ -39,6 +42,19 @@ 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({
@ -211,16 +227,47 @@ function MarkdownContent({
const renderReference = useCallback( const renderReference = useCallback(
(text: string) => { (text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => { const groups = groupConsecutiveReferences(text);
const chunkIndex = getChunkIndex(match); const elements = [];
let lastIndex = 0;
const { documentUrl, fileExtension, imageId, chunkItem, documentId } = const convertedReference = reference
getReferenceInfo(chunkIndex); ? convertReferenceObjectToReference(reference)
: null;
groups.forEach((group, groupIndex) => {
if (group[0].start > lastIndex) {
elements.push(text.substring(lastIndex, group[0].start));
}
if (
convertedReference &&
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; const docType = chunkItem?.doc_type;
return showImage(docType) ? ( if (showImage(docType)) {
<section> elements.push(
<section key={ref.id}>
<Image <Image
id={imageId} id={imageId}
className={styles.referenceInnerChunkImage} className={styles.referenceInnerChunkImage}
@ -234,24 +281,41 @@ function MarkdownContent({
) )
: () => {} : () => {}
} }
></Image> />
<span className="text-accent-primary"> {imageId}</span> <span className="text-accent-primary"> {imageId}</span>
</section> </section>,
) : ( );
<HoverCard key={i}> } else {
elements.push(
<HoverCard key={ref.id}>
<HoverCardTrigger> <HoverCardTrigger>
<CircleAlert className="size-4 inline-block" /> <CircleAlert className="size-4 inline-block" />
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="max-w-3xl"> <HoverCardContent className="max-w-3xl">
{renderPopoverContent(chunkIndex)} {renderPopoverContent(chunkIndex)}
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>,
); );
}
});
}
lastIndex = group[group.length - 1].end;
}); });
return replacedText; if (lastIndex < text.length) {
elements.push(text.substring(lastIndex));
}
return elements;
}, },
[renderPopoverContent, getReferenceInfo, handleDocumentButtonClick], [
renderPopoverContent,
getReferenceInfo,
handleDocumentButtonClick,
reference,
fileThumbnails,
],
); );
return ( return (

View File

@ -2084,12 +2084,14 @@ Important structured information may include: names, dates, locations, events, k
schema: 'Schema', schema: 'Schema',
response: 'Response', response: 'Response',
executionMode: 'Execution mode', executionMode: 'Execution mode',
executionModeTip:
'Accepted Response: The system returns an acknowledgment immediately after the request is validated, while the workflow continues to execute asynchronously in the background. /Final Response: The system returns a response only after the workflow execution is completed.',
authMethods: 'Authentication methods', authMethods: 'Authentication methods',
authType: 'Authentication type', authType: 'Authentication type',
limit: 'Request limit', limit: 'Request limit',
per: 'Time period', per: 'Time period',
maxBodySize: 'Maximum body size', maxBodySize: 'Maximum body size',
ipWhitelist: 'Ip whitelist', ipWhitelist: 'IP whitelist',
tokenHeader: 'Token header', tokenHeader: 'Token header',
tokenValue: 'Token value', tokenValue: 'Token value',
username: 'Username', username: 'Username',
@ -2109,6 +2111,8 @@ Important structured information may include: names, dates, locations, events, k
queryParameters: 'Query parameters', queryParameters: 'Query parameters',
headerParameters: 'Header parameters', headerParameters: 'Header parameters',
requestBodyParameters: 'Request body parameters', requestBodyParameters: 'Request body parameters',
streaming: 'Accepted response',
immediately: 'Final response',
}, },
}, },
llmTools: { llmTools: {

View File

@ -1835,6 +1835,8 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
schema: '模式', schema: '模式',
response: '响应', response: '响应',
executionMode: '执行模式', executionMode: '执行模式',
executionModeTip:
'Accepted Response请求校验通过后立即返回接收成功响应工作流在后台异步执行Final Response系统在工作流执行完成后返回最终处理结果',
authMethods: '认证方法', authMethods: '认证方法',
authType: '认证类型', authType: '认证类型',
limit: '请求限制', limit: '请求限制',

View File

@ -24,9 +24,10 @@ export function WebhookResponse() {
<RAGFlowFormItem <RAGFlowFormItem
name="execution_mode" name="execution_mode"
label={t('flow.webhook.executionMode')} label={t('flow.webhook.executionMode')}
tooltip={t('flow.webhook.executionModeTip')}
> >
<SelectWithSearch <SelectWithSearch
options={buildOptions(WebhookExecutionMode)} options={buildOptions(WebhookExecutionMode, t, 'flow.webhook')}
></SelectWithSearch> ></SelectWithSearch>
</RAGFlowFormItem> </RAGFlowFormItem>
{executionMode === WebhookExecutionMode.Immediately && ( {executionMode === WebhookExecutionMode.Immediately && (

View File

@ -4,9 +4,8 @@ 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 { InfoCircleOutlined } from '@ant-design/icons';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { memo, useCallback, useEffect, useMemo } from 'react'; import React, { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter'; import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@ -18,8 +17,13 @@ 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,
@ -37,8 +41,6 @@ import classNames from 'classnames';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { pipe } from 'lodash/fp'; import { pipe } from 'lodash/fp';
const getChunkIndex = (match: string) => Number(match);
// Defining Tailwind CSS class name constants // Defining Tailwind CSS class name constants
const styles = { const styles = {
referenceChunkImage: 'w-[10vw] object-contain', referenceChunkImage: 'w-[10vw] object-contain',
@ -86,18 +88,11 @@ const MarkdownContent = ({
( (
documentId: string, documentId: string,
chunk: IReferenceChunk, chunk: IReferenceChunk,
// isPdf: boolean, isPdf: boolean = false,
// documentUrl?: string, documentUrl?: string,
) => ) =>
() => { () => {
// if (!isPdf) {
// if (!documentUrl) {
// return;
// }
// window.open(documentUrl, '_blank');
// } else {
clickDocumentButton?.(documentId, chunk); clickDocumentButton?.(documentId, chunk);
// }
}, },
[clickDocumentButton], [clickDocumentButton],
); );
@ -218,43 +213,83 @@ const MarkdownContent = ({
const renderReference = useCallback( const renderReference = useCallback(
(text: string) => { (text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => { const groups = groupConsecutiveReferences(text);
const chunkIndex = getChunkIndex(match); const elements: React.ReactNode[] = [];
let lastIndex = 0;
const { imageId, chunkItem, documentId } = getReferenceInfo(chunkIndex); groups.forEach((group: ReferenceGroup) => {
// Add text before the group
if (group[0].start > lastIndex) {
elements.push(text.slice(lastIndex, group[0].start));
}
// Determine if this group should be a carousel
if (shouldShowCarousel(group, reference)) {
// Render carousel for consecutive image group
elements.push(
<ImageCarousel
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; const docType = chunkItem?.doc_type;
return showImage(docType) ? ( if (showImage(docType)) {
elements.push(
<Image <Image
key={ref.id}
id={imageId} id={imageId}
className={styles.referenceInnerChunkImage} className={styles.referenceInnerChunkImage}
onClick={ onClick={
documentId documentId
? handleDocumentButtonClick( ? handleDocumentButtonClick(documentId, chunkItem)
documentId,
chunkItem,
// fileExtension === 'pdf',
// documentUrl,
)
: () => {} : () => {}
} }
></Image> />,
) : ( );
<Popover> } else {
elements.push(
<Popover key={ref.id}>
<PopoverTrigger> <PopoverTrigger>
<InfoCircleOutlined className={styles.referenceIcon} /> <InfoCircleOutlined className={styles.referenceIcon} />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="!w-fit"> <PopoverContent className="!w-fit">
{getPopoverContent(chunkIndex)} {getPopoverContent(chunkIndex)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>,
); );
}
// Add the original reference text
elements.push(ref.fullMatch);
});
}
lastIndex = group[group.length - 1].end;
}); });
return replacedText; // Add any remaining text after the last group
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
}
return elements;
}, },
[getPopoverContent, getReferenceInfo, handleDocumentButtonClick], [
reference,
fileThumbnails,
handleDocumentButtonClick,
getReferenceInfo,
getPopoverContent,
],
); );
return ( return (
@ -265,7 +300,11 @@ const MarkdownContent = ({
components={ components={
{ {
'custom-typography': ({ children }: { children: string }) => 'custom-typography': ({ children }: { children: string }) =>
renderReference(children), Array.isArray(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');