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 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )}
+
+
+ );
+ }, [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'
+ }`}
>
{
>