mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-29 08:33:20 +00:00
Merge pull request #1732 from asminkarki012/feature/mermaid-integration
feat[mermaid]:integration of mermaid
This commit is contained in:
1318
frontend/package-lock.json
generated
1318
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"mermaid": "^11.6.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
@@ -41,6 +42,7 @@
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
|
||||
419
frontend/src/components/MermaidRenderer.tsx
Normal file
419
frontend/src/components/MermaidRenderer.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import CopyButton from './CopyButton';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneLight, vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import { MermaidRendererProps } from './types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStatus } from '../conversation/conversationSlice';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
|
||||
const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
code,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const diagramId = useRef(`mermaid-${crypto.randomUUID()}`);
|
||||
const status = useSelector(selectStatus);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCode, setShowCode] = useState<boolean>(false);
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState<boolean>(false);
|
||||
const downloadMenuRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number, y: number } | null>(null);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const [zoomFactor, setZoomFactor] = useState<number>(2);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = (event.clientX - rect.left) / rect.width;
|
||||
const y = (event.clientY - rect.top) / rect.height;
|
||||
|
||||
setHoverPosition({ x, y });
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => setIsHovering(true);
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovering(false);
|
||||
setHoverPosition(null);
|
||||
};
|
||||
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (!isHovering) return;
|
||||
|
||||
if (event.key === '+' || event.key === '=') {
|
||||
setZoomFactor(prev => Math.min(6, prev + 0.5)); // Cap at 6x
|
||||
event.preventDefault();
|
||||
}
|
||||
else if (event.key === '-') {
|
||||
setZoomFactor(prev => Math.max(1, prev - 0.5)); // Minimum 1x
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (event: React.WheelEvent) => {
|
||||
if (!isHovering) return;
|
||||
|
||||
if ( event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.deltaY < 0) {
|
||||
setZoomFactor(prev => Math.min(6, prev + 0.25));
|
||||
} else {
|
||||
setZoomFactor(prev => Math.max(1, prev - 0.25));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTransformOrigin = () => {
|
||||
if (!hoverPosition) return 'center center';
|
||||
return `${hoverPosition.x * 100}% ${hoverPosition.y * 100}%`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const renderDiagram = async () => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: isDarkTheme ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
const isCurrentlyLoading = isLoading !== undefined ? isLoading : status === 'loading';
|
||||
if (!isCurrentlyLoading && code) {
|
||||
try {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (element) {
|
||||
element.removeAttribute('data-processed');
|
||||
await mermaid.parse(code); //syntax check
|
||||
mermaid.contentLoaded();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error rendering mermaid diagram:', err);
|
||||
setError(`Failed to render diagram: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [code, isDarkTheme, isLoading]);
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
downloadMenuRef.current &&
|
||||
!downloadMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDownloadMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showDownloadMenu]);
|
||||
|
||||
|
||||
const downloadSvg = (): void => {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (!element) return;
|
||||
const svgElement = element.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
|
||||
const svgClone = svgElement.cloneNode(true) as SVGElement;
|
||||
|
||||
if (!svgClone.hasAttribute('xmlns')) {
|
||||
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
}
|
||||
|
||||
if (!svgClone.hasAttribute('width') || !svgClone.hasAttribute('height')) {
|
||||
const viewBox = svgClone.getAttribute('viewBox')?.split(' ') || [];
|
||||
if (viewBox.length === 4) {
|
||||
svgClone.setAttribute('width', viewBox[2]);
|
||||
svgClone.setAttribute('height', viewBox[3]);
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgClone);
|
||||
|
||||
const svgBlob = new Blob(
|
||||
[`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n${svgString}`],
|
||||
{ type: 'image/svg+xml' }
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'diagram.svg';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadPng = (): void => {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (!element) return;
|
||||
|
||||
const svgElement = element.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
|
||||
const svgClone = svgElement.cloneNode(true) as SVGElement;
|
||||
|
||||
if (!svgClone.hasAttribute('xmlns')) {
|
||||
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
}
|
||||
|
||||
let width = parseInt(svgClone.getAttribute('width') || '0');
|
||||
let height = parseInt(svgClone.getAttribute('height') || '0');
|
||||
|
||||
if (!width || !height) {
|
||||
const viewBox = svgClone.getAttribute('viewBox')?.split(' ') || [];
|
||||
if (viewBox.length === 4) {
|
||||
width = parseInt(viewBox[2]);
|
||||
height = parseInt(viewBox[3]);
|
||||
svgClone.setAttribute('width', width.toString());
|
||||
svgClone.setAttribute('height', height.toString());
|
||||
} else {
|
||||
width = 800;
|
||||
height = 600;
|
||||
svgClone.setAttribute('width', width.toString());
|
||||
svgClone.setAttribute('height', height.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgClone);
|
||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
|
||||
const dataUrl = `data:image/svg+xml;base64,${svgBase64}`;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = function(): void {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.error('Could not get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
try {
|
||||
const pngUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.download = 'diagram.png';
|
||||
link.href = pngUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
console.error('Failed to create PNG:', e);
|
||||
// Fallback to SVG download if PNG fails
|
||||
downloadSvg();
|
||||
}
|
||||
};
|
||||
|
||||
img.src = dataUrl;
|
||||
};
|
||||
|
||||
const downloadMmd = (): void => {
|
||||
const blob = new Blob([code], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'diagram.mmd';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
|
||||
const downloadOptions = [
|
||||
{ label: 'Download as SVG', action: downloadSvg },
|
||||
{ label: 'Download as PNG', action: downloadPng },
|
||||
{ label: 'Download as MMD', action: downloadMmd },
|
||||
];
|
||||
|
||||
const isCurrentlyLoading = isLoading !== undefined ? isLoading : status === 'loading';
|
||||
const showDiagramOptions = !isCurrentlyLoading && !error;
|
||||
const errorRender = !isCurrentlyLoading && error;
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-lg border border-light-silver dark:border-raisin-black bg-white dark:bg-eerie-black w-inherit">
|
||||
<div className="flex justify-between items-center px-2 py-1 bg-platinum dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
mermaid
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyButton textToCopy={String(code).replace(/\n$/, '')} />
|
||||
|
||||
{showDiagramOptions && (
|
||||
<div className="relative" ref={downloadMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||||
className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded flex items-center h-full"
|
||||
title="Download options"
|
||||
>
|
||||
Download <span className="ml-1">▼</span>
|
||||
</button>
|
||||
{showDownloadMenu && (
|
||||
<div className="absolute right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-10 w-40">
|
||||
<ul>
|
||||
{downloadOptions.map((option, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
onClick={() => {
|
||||
option.action();
|
||||
setShowDownloadMenu(false);
|
||||
}}
|
||||
className="text-xs px-4 py-2 w-full text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDiagramOptions && (
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className={`text-xs px-2 py-1 rounded flex items-center h-full ${
|
||||
showCode
|
||||
? 'bg-blue-200 dark:bg-blue-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700'
|
||||
}`}
|
||||
title="View Code"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCurrentlyLoading ? (
|
||||
<div className="p-4 bg-white dark:bg-eerie-black flex justify-center items-center">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading diagram...
|
||||
</div>
|
||||
</div>
|
||||
) : errorRender ? (
|
||||
<div className="border-2 border-red-400 dark:border-red-700 rounded m-2">
|
||||
<div className="bg-red-100 dark:bg-red-900/30 px-4 py-2 text-red-800 dark:text-red-300 text-sm whitespace-normal break-words overflow-auto">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className=" no-scrollbar p-4 block w-full bg-white dark:bg-eerie-black relative"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
width: '100%',
|
||||
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
tabIndex={0}
|
||||
|
||||
>
|
||||
{isHovering && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded z-10 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setZoomFactor(prev => Math.max(1, prev - 0.5))}
|
||||
className="hover:bg-gray-600 px-1 rounded"
|
||||
title="Decrease zoom"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
setZoomFactor(2);
|
||||
}}
|
||||
title="Reset zoom"
|
||||
>
|
||||
{zoomFactor.toFixed(1)}x
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoomFactor(prev => Math.min(6, prev + 0.5))}
|
||||
className="hover:bg-gray-600 px-1 rounded"
|
||||
title="Increase zoom"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<pre
|
||||
className="mermaid select-none w-full"
|
||||
id={diagramId.current}
|
||||
key={`mermaid-${diagramId.current}`}
|
||||
style={{
|
||||
transform: isHovering ? `scale(${zoomFactor})` : `scale(1)`,
|
||||
transformOrigin: getTransformOrigin(),
|
||||
transition: 'transform 0.2s ease',
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{showCode && (
|
||||
<div className="border-t border-light-silver dark:border-raisin-black">
|
||||
<div className="p-2 bg-platinum dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
Mermaid Code
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language="mermaid"
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
scrollbarWidth: 'thin',
|
||||
maxHeight: '300px'
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidRenderer;
|
||||
@@ -23,3 +23,8 @@ export type InputProps = {
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type MermaidRendererProps = {
|
||||
code: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import { forwardRef, useRef, useState } from 'react';
|
||||
import { forwardRef, Fragment, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -30,6 +30,7 @@ import { selectChunks, selectSelectedDocs } from '../preferences/preferenceSlice
|
||||
import classes from './ConversationBubble.module.css';
|
||||
import { FEEDBACK, MESSAGE_TYPE } from './conversationModels';
|
||||
import { ToolCallsType } from './types';
|
||||
import MermaidRenderer from '../components/MermaidRenderer';
|
||||
|
||||
const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false;
|
||||
|
||||
@@ -46,6 +47,7 @@ const ConversationBubble = forwardRef<
|
||||
toolCalls?: ToolCallsType[];
|
||||
retryBtn?: React.ReactElement;
|
||||
questionNumber?: number;
|
||||
isStreaming?: boolean;
|
||||
handleUpdatedQuestionSubmission?: (
|
||||
updatedquestion?: string,
|
||||
updated?: boolean,
|
||||
@@ -64,6 +66,7 @@ const ConversationBubble = forwardRef<
|
||||
toolCalls,
|
||||
retryBtn,
|
||||
questionNumber,
|
||||
isStreaming,
|
||||
handleUpdatedQuestionSubmission,
|
||||
},
|
||||
ref,
|
||||
@@ -186,6 +189,33 @@ const ConversationBubble = forwardRef<
|
||||
|
||||
return inlineProcessedContent;
|
||||
};
|
||||
const processMarkdownContent = (content: string) => {
|
||||
const processedContent = preprocessLaTeX(content);
|
||||
|
||||
const contentSegments: Array<{type: 'text' | 'mermaid', content: string}> = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
const regex = /```mermaid\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(processedContent)) !== null) {
|
||||
const textBefore = processedContent.substring(lastIndex, match.index);
|
||||
if (textBefore) {
|
||||
contentSegments.push({ type: 'text', content: textBefore });
|
||||
}
|
||||
|
||||
contentSegments.push({ type: 'mermaid', content: match[1].trim() });
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const textAfter = processedContent.substring(lastIndex);
|
||||
if (textAfter) {
|
||||
contentSegments.push({ type: 'text', content: textAfter });
|
||||
}
|
||||
|
||||
return contentSegments;
|
||||
};
|
||||
bubble = (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -354,105 +384,125 @@ const ConversationBubble = forwardRef<
|
||||
: 'flex-col rounded-3xl'
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
className="fade-in whitespace-pre-wrap break-words leading-normal"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code(props) {
|
||||
const { children, className, node, ref, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
{(() => {
|
||||
const contentSegments = processMarkdownContent(message);
|
||||
return (
|
||||
<>
|
||||
{contentSegments.map((segment, index) => (
|
||||
<Fragment key={index}>
|
||||
{segment.type === 'text' ? (
|
||||
<ReactMarkdown
|
||||
className="fade-in whitespace-pre-wrap break-words leading-normal"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code(props) {
|
||||
const { children, className, node, ref, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
return match ? (
|
||||
<div className="group relative rounded-[14px] overflow-hidden border border-light-silver dark:border-raisin-black">
|
||||
<div className="flex justify-between items-center px-2 py-1 bg-platinum dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
{language}
|
||||
</span>
|
||||
<CopyButton
|
||||
textToCopy={String(children).replace(/\n$/, '')}
|
||||
/>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
className="!mt-0"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
scrollbarWidth: 'thin',
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className="whitespace-pre-line rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal dark:bg-independence dark:text-bright-gray">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return (
|
||||
<ul
|
||||
className={`list-inside list-disc whitespace-normal pl-4 ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol
|
||||
className={`list-inside list-decimal whitespace-normal pl-4 ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="relative overflow-x-auto rounded-lg border border-silver/40 dark:border-silver/40">
|
||||
<table className="w-full text-left text-gray-700 dark:text-bright-gray">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return (
|
||||
<thead className="text-xs uppercase text-gray-900 dark:text-bright-gray bg-gray-50 dark:bg-[#26272E]/50">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tr({ children }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-200 dark:border-silver/40 odd:bg-white dark:odd:bg-[#26272E] even:bg-gray-50 dark:even:bg-[#26272E]/50">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-3">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{preprocessLaTeX(message)}
|
||||
</ReactMarkdown>
|
||||
return match ? (
|
||||
<div className="group relative rounded-[14px] overflow-hidden border border-light-silver dark:border-raisin-black">
|
||||
<div className="flex justify-between items-center px-2 py-1 bg-platinum dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
{language}
|
||||
</span>
|
||||
<CopyButton
|
||||
textToCopy={String(children).replace(/\n$/, '')}
|
||||
/>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
className="!mt-0"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
scrollbarWidth: 'thin',
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className="whitespace-pre-line rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal dark:bg-independence dark:text-bright-gray">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return (
|
||||
<ul
|
||||
className={`list-inside list-disc whitespace-normal pl-4 ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol
|
||||
className={`list-inside list-decimal whitespace-normal pl-4 ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="relative overflow-x-auto rounded-lg border border-silver/40 dark:border-silver/40">
|
||||
<table className="w-full text-left text-gray-700 dark:text-bright-gray">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return (
|
||||
<thead className="text-xs uppercase text-gray-900 dark:text-bright-gray bg-gray-50 dark:bg-[#26272E]/50">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tr({ children }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-200 dark:border-silver/40 odd:bg-white dark:odd:bg-[#26272E] even:bg-gray-50 dark:even:bg-[#26272E]/50">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-3">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{segment.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="my-4 w-full" style={{ minWidth: '100%' }}>
|
||||
<MermaidRenderer
|
||||
code={segment.content}
|
||||
isLoading={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="my-2 ml-2 flex justify-start">
|
||||
<div
|
||||
className={`relative mr-2 block items-center justify-center lg:invisible
|
||||
className={`relative mr-2 block items-center justify-center lg:invisible
|
||||
${type !== 'ERROR' ? 'group-hover:lg:visible' : 'hidden'}`}
|
||||
>
|
||||
<div>
|
||||
@@ -460,7 +510,7 @@ const ConversationBubble = forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`relative mr-2 block items-center justify-center lg:invisible
|
||||
className={`relative mr-2 block items-center justify-center lg:invisible
|
||||
${type !== 'ERROR' ? 'group-hover:lg:visible' : 'hidden'}`}
|
||||
>
|
||||
<div>
|
||||
@@ -491,7 +541,7 @@ const ConversationBubble = forwardRef<
|
||||
}`}
|
||||
>
|
||||
<Like
|
||||
className={`cursor-pointer
|
||||
className={`cursor-pointer
|
||||
${
|
||||
isLikeClicked || feedback === 'LIKE'
|
||||
? 'fill-white-3000 stroke-purple-30 dark:fill-transparent'
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function ConversationMessages({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const conversationRef = useRef<HTMLDivElement>(null);
|
||||
const [hasScrolledToLast, setHasScrolledToLast] = useState(true);
|
||||
const [atLast,setAtLast] = useState(true);
|
||||
const [eventInterrupt, setEventInterrupt] = useState(false);
|
||||
|
||||
const handleUserInterruption = () => {
|
||||
@@ -47,24 +47,29 @@ export default function ConversationMessages({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const scrollIntoView = () => {
|
||||
if (!conversationRef?.current || eventInterrupt) return;
|
||||
|
||||
if (status === 'idle' || !queries[queries.length - 1]?.response) {
|
||||
conversationRef.current.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: conversationRef.current.scrollHeight,
|
||||
});
|
||||
} else {
|
||||
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!conversationRef?.current) return;
|
||||
|
||||
if (status === 'idle' || !queries[queries.length - 1]?.response) {
|
||||
conversationRef.current.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: conversationRef.current.scrollHeight,
|
||||
});
|
||||
} else {
|
||||
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const checkScroll = () => {
|
||||
const el = conversationRef.current;
|
||||
if (!el) return;
|
||||
const isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10;
|
||||
setHasScrolledToLast(isBottom);
|
||||
setAtLast(isBottom);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,6 +92,7 @@ export default function ConversationMessages({
|
||||
const prepResponseView = (query: Query, index: number) => {
|
||||
let responseView;
|
||||
if (query.thought || query.response) {
|
||||
const isCurrentlyStreaming = status === 'loading' && index === queries.length - 1;
|
||||
responseView = (
|
||||
<ConversationBubble
|
||||
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'}`}
|
||||
@@ -97,6 +103,7 @@ export default function ConversationMessages({
|
||||
sources={query.sources}
|
||||
toolCalls={query.tool_calls}
|
||||
feedback={query.feedback}
|
||||
isStreaming={isCurrentlyStreaming}
|
||||
handleFeedback={
|
||||
handleFeedback
|
||||
? (feedback) => handleFeedback(query, feedback, index)
|
||||
@@ -143,9 +150,9 @@ export default function ConversationMessages({
|
||||
ref={conversationRef}
|
||||
onWheel={handleUserInterruption}
|
||||
onTouchMove={handleUserInterruption}
|
||||
className="flex justify-center w-full overflow-y-auto h-full sm:pt-12"
|
||||
className="flex justify-center w-full overflow-y-auto h-full sm:pt-12 will-change-scroll"
|
||||
>
|
||||
{queries.length > 0 && !hasScrolledToLast && (
|
||||
{queries.length > 0 && !atLast && (
|
||||
<button
|
||||
onClick={scrollIntoView}
|
||||
aria-label="scroll to bottom"
|
||||
|
||||
Reference in New Issue
Block a user