From 2ddfcc7cf6836ee99bd7825b8d58c742eb9fc826 Mon Sep 17 00:00:00 2001 From: balibabu Date: Mon, 22 Dec 2025 14:41:02 +0800 Subject: [PATCH] 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) --- .../next-markdown-content/index.tsx | 138 +++++++++++++----- web/src/locales/en.ts | 6 +- web/src/locales/zh.ts | 2 + .../form/begin-form/webhook/response.tsx | 3 +- .../next-search/markdown-content/index.tsx | 129 ++++++++++------ 5 files changed, 194 insertions(+), 84 deletions(-) diff --git a/web/src/components/next-markdown-content/index.tsx b/web/src/components/next-markdown-content/index.tsx index 71d4611b4..cf13b9d02 100644 --- a/web/src/components/next-markdown-content/index.tsx +++ b/web/src/components/next-markdown-content/index.tsx @@ -5,7 +5,6 @@ import { getExtension } from '@/utils/document-util'; import DOMPurify from 'dompurify'; import { memo, useCallback, useEffect, useMemo } from 'react'; import Markdown from 'react-markdown'; -import reactStringReplace from 'react-string-replace'; import SyntaxHighlighter from 'react-syntax-highlighter'; import rehypeKatex from 'rehype-katex'; 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 { - currentReg, preprocessLaTeX, replaceTextByOldReg, replaceThinkToSection, @@ -31,6 +29,11 @@ import classNames from 'classnames'; import { omit } from 'lodash'; import { pipe } from 'lodash/fp'; 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 { HoverCard, @@ -39,6 +42,19 @@ import { } from '../ui/hover-card'; 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); // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem. function MarkdownContent({ @@ -211,47 +227,95 @@ function MarkdownContent({ const renderReference = useCallback( (text: string) => { - let replacedText = reactStringReplace(text, currentReg, (match, i) => { - const chunkIndex = getChunkIndex(match); + const groups = groupConsecutiveReferences(text); + const elements = []; + let lastIndex = 0; - const { documentUrl, fileExtension, imageId, chunkItem, documentId } = - getReferenceInfo(chunkIndex); + const convertedReference = reference + ? convertReferenceObjectToReference(reference) + : null; - const docType = chunkItem?.doc_type; + groups.forEach((group, groupIndex) => { + if (group[0].start > lastIndex) { + elements.push(text.substring(lastIndex, group[0].start)); + } - return showImage(docType) ? ( -
- {} - } - > - {imageId} -
- ) : ( - - - - - - {renderPopoverContent(chunkIndex)} - - - ); + if ( + convertedReference && + shouldShowCarousel(group, convertedReference) + ) { + elements.push( + , + ); + } 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( +
+ {} + } + /> + {imageId} +
, + ); + } else { + elements.push( + + + + + + {renderPopoverContent(chunkIndex)} + + , + ); + } + }); + } + + 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 ( diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 97a94a137..5116f359a 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2084,12 +2084,14 @@ Important structured information may include: names, dates, locations, events, k schema: 'Schema', response: 'Response', 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', authType: 'Authentication type', limit: 'Request limit', per: 'Time period', maxBodySize: 'Maximum body size', - ipWhitelist: 'Ip whitelist', + ipWhitelist: 'IP whitelist', tokenHeader: 'Token header', tokenValue: 'Token value', username: 'Username', @@ -2109,6 +2111,8 @@ Important structured information may include: names, dates, locations, events, k queryParameters: 'Query parameters', headerParameters: 'Header parameters', requestBodyParameters: 'Request body parameters', + streaming: 'Accepted response', + immediately: 'Final response', }, }, llmTools: { diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 399425391..16bdffb94 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1835,6 +1835,8 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`, schema: '模式', response: '响应', executionMode: '执行模式', + executionModeTip: + 'Accepted Response:请求校验通过后立即返回接收成功响应,工作流在后台异步执行;Final Response:系统在工作流执行完成后返回最终处理结果', authMethods: '认证方法', authType: '认证类型', limit: '请求限制', diff --git a/web/src/pages/agent/form/begin-form/webhook/response.tsx b/web/src/pages/agent/form/begin-form/webhook/response.tsx index 69fb8f09b..a1a26b4f9 100644 --- a/web/src/pages/agent/form/begin-form/webhook/response.tsx +++ b/web/src/pages/agent/form/begin-form/webhook/response.tsx @@ -24,9 +24,10 @@ export function WebhookResponse() { {executionMode === WebhookExecutionMode.Immediately && ( diff --git a/web/src/pages/next-search/markdown-content/index.tsx b/web/src/pages/next-search/markdown-content/index.tsx index 00594e98f..8934e277a 100644 --- a/web/src/pages/next-search/markdown-content/index.tsx +++ b/web/src/pages/next-search/markdown-content/index.tsx @@ -4,9 +4,8 @@ import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; import { getExtension } from '@/utils/document-util'; import { InfoCircleOutlined } from '@ant-design/icons'; 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 reactStringReplace from 'react-string-replace'; import SyntaxHighlighter from 'react-syntax-highlighter'; import rehypeKatex from 'rehype-katex'; 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 ImageCarousel from '@/components/markdown-content/image-carousel'; +import { + groupConsecutiveReferences, + shouldShowCarousel, + type ReferenceGroup, +} from '@/components/markdown-content/reference-utils'; import { - currentReg, preprocessLaTeX, replaceTextByOldReg, replaceThinkToSection, @@ -37,8 +41,6 @@ import classNames from 'classnames'; import { omit } from 'lodash'; import { pipe } from 'lodash/fp'; -const getChunkIndex = (match: string) => Number(match); - // Defining Tailwind CSS class name constants const styles = { referenceChunkImage: 'w-[10vw] object-contain', @@ -86,18 +88,11 @@ const MarkdownContent = ({ ( documentId: string, chunk: IReferenceChunk, - // isPdf: boolean, - // documentUrl?: string, + isPdf: boolean = false, + documentUrl?: string, ) => () => { - // if (!isPdf) { - // if (!documentUrl) { - // return; - // } - // window.open(documentUrl, '_blank'); - // } else { clickDocumentButton?.(documentId, chunk); - // } }, [clickDocumentButton], ); @@ -218,43 +213,83 @@ const MarkdownContent = ({ const renderReference = useCallback( (text: string) => { - let replacedText = reactStringReplace(text, currentReg, (match, i) => { - const chunkIndex = getChunkIndex(match); + const groups = groupConsecutiveReferences(text); + 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)); + } - const docType = chunkItem?.doc_type; + // Determine if this group should be a carousel + if (shouldShowCarousel(group, reference)) { + // Render carousel for consecutive image group + elements.push( + , + ); + } 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; - return showImage(docType) ? ( - {} + if (showImage(docType)) { + elements.push( + {} + } + />, + ); + } else { + elements.push( + + + + + + {getPopoverContent(chunkIndex)} + + , + ); } - > - ) : ( - - - - - - {getPopoverContent(chunkIndex)} - - - ); + // 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 ( @@ -265,7 +300,11 @@ const MarkdownContent = ({ components={ { 'custom-typography': ({ children }: { children: string }) => - renderReference(children), + Array.isArray(renderReference(children)) + ? renderReference(children).map((element, index) => ( + {element} + )) + : renderReference(children), code(props: any) { const { children, className, ...rest } = props; const restProps = omit(rest, 'node');