From bece37e6c8676ca04e8a96239d6efe8b335a4bc2 Mon Sep 17 00:00:00 2001 From: Adrian Gora <47756404+adagora@users.noreply.github.com> Date: Sun, 28 Sep 2025 07:58:10 +0200 Subject: [PATCH] Fix: floating widget match style with original one (#10317) ### What problem does this PR solve? These changes are intended to implement the remaining functionalities of the fullscreen widget. The question arises: how to display document prieview of PDFs in this floating widget? - simply enlarge the widget window - implement zoom in/out - render outside the iframe? ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) --- .../floating-chat-widget-markdown.less | 58 ++++ .../floating-chat-widget-markdown.tsx | 199 ++++++++++++ web/src/components/floating-chat-widget.tsx | 293 ++++++++++-------- web/src/components/pdf-drawer/index.tsx | 9 +- 4 files changed, 429 insertions(+), 130 deletions(-) create mode 100644 web/src/components/floating-chat-widget-markdown.less create mode 100644 web/src/components/floating-chat-widget-markdown.tsx diff --git a/web/src/components/floating-chat-widget-markdown.less b/web/src/components/floating-chat-widget-markdown.less new file mode 100644 index 000000000..5df14edb6 --- /dev/null +++ b/web/src/components/floating-chat-widget-markdown.less @@ -0,0 +1,58 @@ +/* floating-chat-widget-markdown.less */ + +.widget-citation-popover { + max-width: 90vw; + /* Use viewport width for better responsiveness */ + width: max-content; + + .ant-popover-inner { + max-height: 400px; + overflow-y: auto; + } + + .ant-popover-inner-content { + padding: 12px; + } +} + +/* Responsive breakpoints for popover width */ +@media (min-width: 480px) { + .widget-citation-popover { + max-width: 360px; + } +} + +.widget-citation-content { + + p, + div, + span, + button { + word-break: break-word; + overflow-wrap: break-word; + white-space: normal; + } +} + +.floating-chat-widget { + + /* General styles for markdown content within the widget */ + p, + div, + ul, + ol, + blockquote { + line-height: 1.6; + } + + /* Enhanced image styles */ + img, + .ant-image, + .ant-image-img { + max-width: 100% !important; + height: auto !important; + border-radius: 8px; + margin: 8px 0 !important; + display: inline-block !important; + } +} \ No newline at end of file diff --git a/web/src/components/floating-chat-widget-markdown.tsx b/web/src/components/floating-chat-widget-markdown.tsx new file mode 100644 index 000000000..d9ac1dc06 --- /dev/null +++ b/web/src/components/floating-chat-widget-markdown.tsx @@ -0,0 +1,199 @@ +import Image from '@/components/image'; +import SvgIcon from '@/components/svg-icon'; +import { useFetchDocumentThumbnailsByIds, useGetDocumentUrl } from '@/hooks/document-hooks'; +import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; +import { preprocessLaTeX, replaceThinkToSection, showImage } from '@/utils/chat'; +import { getExtension } from '@/utils/document-util'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Button, Flex, Popover, Tooltip } from 'antd'; +import classNames from 'classnames'; +import DOMPurify from 'dompurify'; +import { omit } from 'lodash'; +import { pipe } from 'lodash/fp'; +import 'katex/dist/katex.min.css'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import Markdown from 'react-markdown'; +import reactStringReplace from 'react-string-replace'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import rehypeKatex from 'rehype-katex'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import { visitParents } from 'unist-util-visit-parents'; +import { currentReg, replaceTextByOldReg } from '../pages/next-chats/utils'; +import styles from './floating-chat-widget-markdown.less'; +import { useIsDarkTheme } from './theme-provider'; + +const getChunkIndex = (match: string) => Number(match.replace(/\[|\]/g, '')); + +const FloatingChatWidgetMarkdown = ({ + reference, + clickDocumentButton, + content, +}: { + content: string; + loading: boolean; + reference: IReference; + clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void; +}) => { + const { t } = useTranslation(); + const { setDocumentIds, data: fileThumbnails } = useFetchDocumentThumbnailsByIds(); + const getDocumentUrl = useGetDocumentUrl(); + const isDarkTheme = useIsDarkTheme(); + + const contentWithCursor = useMemo(() => { + let text = content === '' ? t('chat.searching') : content; + const nextText = replaceTextByOldReg(text); + return pipe(replaceThinkToSection, preprocessLaTeX)(nextText); + }, [content, t]); + + useEffect(() => { + const docAggs = reference?.doc_aggs; + const docList = Array.isArray(docAggs) ? docAggs : Object.values(docAggs ?? {}); + setDocumentIds(docList.map((x: any) => x.doc_id).filter(Boolean)); + }, [reference, setDocumentIds]); + + const handleDocumentButtonClick = useCallback((documentId: string, chunk: IReferenceChunk, isPdf: boolean, documentUrl?: string) => () => { + if (!documentId) return; + if (!isPdf && documentUrl) { + window.open(documentUrl, '_blank'); + } else if (clickDocumentButton) { + clickDocumentButton(documentId, chunk); + } + }, [clickDocumentButton]); + + const rehypeWrapReference = () => (tree: any) => { + visitParents(tree, 'text', (node, ancestors) => { + const latestAncestor = ancestors[ancestors.length - 1]; + if (latestAncestor.tagName !== 'custom-typography' && latestAncestor.tagName !== 'code') { + node.type = 'element'; + node.tagName = 'custom-typography'; + node.properties = {}; + node.children = [{ type: 'text', value: node.value }]; + } + }); + }; + + const getReferenceInfo = useCallback((chunkIndex: number) => { + const chunkItem = reference?.chunks?.[chunkIndex]; + if (!chunkItem) return null; + const docAggsArray = Array.isArray(reference?.doc_aggs) ? reference.doc_aggs : Object.values(reference?.doc_aggs ?? {}); + const document = docAggsArray.find((x: any) => x?.doc_id === chunkItem?.document_id) as any; + const documentId = document?.doc_id; + const documentUrl = document?.url ?? (documentId ? getDocumentUrl(documentId) : undefined); + const fileThumbnail = documentId ? fileThumbnails[documentId] : ''; + const fileExtension = documentId ? getExtension(document?.doc_name ?? '') : ''; + return { documentUrl, fileThumbnail, fileExtension, imageId: chunkItem.image_id, chunkItem, documentId, document }; + }, [fileThumbnails, reference, getDocumentUrl]); + + const getPopoverContent = useCallback((chunkIndex: number) => { + const info = getReferenceInfo(chunkIndex); + + if (!info) { + return
Error: Missing document information.
; + } + + const { documentUrl, fileThumbnail, fileExtension, imageId, chunkItem, documentId, document } = info; + + return ( +
+ {imageId && ( + }> + + + )} +
+
+ {documentId && ( + + {fileThumbnail ? ( + {document?.doc_name} + ) : ( + + )} + + + + + )} +
+
+ ); + }, [getReferenceInfo, handleDocumentButtonClick]); + + const renderReference = useCallback((text: string) => { + return reactStringReplace(text, currentReg, (match, i) => { + const chunkIndex = getChunkIndex(match); + const info = getReferenceInfo(chunkIndex); + + if (!info) { + return ; + } + + const { imageId, chunkItem, documentId, fileExtension, documentUrl } = info; + + if (showImage(chunkItem?.doc_type)) { + return ; + } + + return ( + + + + ); + }); + }, [getPopoverContent, getReferenceInfo, handleDocumentButtonClick]); + + return ( +
+ renderReference(children), + code(props: any) { + const { children, className, node, ...rest } = props; + const match = /language-(\w+)/.exec(className || ''); + return match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); + }, + } as any} + > + {contentWithCursor} + +
+ ); +}; + +export default FloatingChatWidgetMarkdown; \ No newline at end of file diff --git a/web/src/components/floating-chat-widget.tsx b/web/src/components/floating-chat-widget.tsx index 41abb4724..cbb83cbde 100644 --- a/web/src/components/floating-chat-widget.tsx +++ b/web/src/components/floating-chat-widget.tsx @@ -2,8 +2,10 @@ import { MessageType, SharedFrom } from '@/constants/chat'; import { useFetchNextConversationSSE } from '@/hooks/chat-hooks'; import { useFetchFlowSSE } from '@/hooks/flow-hooks'; import { useFetchExternalChatInfo } from '@/hooks/use-chat-request'; +import { useClickDrawer } from '@/components/pdf-drawer/hooks'; import i18n from '@/locales/config'; import { MessageCircle, Minimize2, Send, X } from 'lucide-react'; +import PdfDrawer from '@/components/pdf-drawer'; import React, { useCallback, useEffect, @@ -15,6 +17,7 @@ import { useGetSharedChatSearchParams, useSendSharedMessage, } from '../pages/next-chats/hooks/use-send-shared-message'; +import FloatingChatWidgetMarkdown from './floating-chat-widget-markdown'; const FloatingChatWidget = () => { const [isOpen, setIsOpen] = useState(false); @@ -63,6 +66,14 @@ const FloatingChatWidget = () => { const { data: avatarData } = useFetchAvatar(); + const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = + useClickDrawer(); + + // PDF drawer state tracking + useEffect(() => { + // Drawer state management + }, [visible, documentId, selectedChunk]); + // Play sound when opening const playNotificationSound = useCallback(() => { try { @@ -223,7 +234,7 @@ const FloatingChatWidget = () => { const syntheticEvent = { target: { value: inputValue }, currentTarget: { value: inputValue }, - preventDefault: () => {}, + preventDefault: () => { }, } as any; handleInputChange(syntheticEvent); @@ -314,9 +325,8 @@ const FloatingChatWidget = () => { '*', ); }} - className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${ - isOpen ? 'scale-95' : 'scale-100 hover:scale-105' - }`} + className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${isOpen ? 'scale-95' : 'scale-100 hover:scale-105' + }`} >
{ >