mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-29 16:43:16 +00:00
484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
import { forwardRef, useState } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import { useSelector } from 'react-redux';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
|
import remarkGfm from 'remark-gfm';
|
|
import remarkMath from 'remark-math';
|
|
import rehypeKatex from 'rehype-katex';
|
|
import 'katex/dist/katex.min.css';
|
|
|
|
import Alert from '../assets/alert.svg';
|
|
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
|
|
import Dislike from '../assets/dislike.svg?react';
|
|
import Document from '../assets/document.svg';
|
|
import Like from '../assets/like.svg?react';
|
|
import Link from '../assets/link.svg';
|
|
import Sources from '../assets/sources.svg';
|
|
import Avatar from '../components/Avatar';
|
|
import CopyButton from '../components/CopyButton';
|
|
import Sidebar from '../components/Sidebar';
|
|
import {
|
|
selectChunks,
|
|
selectSelectedDocs,
|
|
} from '../preferences/preferenceSlice';
|
|
import classes from './ConversationBubble.module.css';
|
|
import { FEEDBACK, MESSAGE_TYPE } from './conversationModels';
|
|
|
|
const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false;
|
|
|
|
const ConversationBubble = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
message: string;
|
|
type: MESSAGE_TYPE;
|
|
className?: string;
|
|
feedback?: FEEDBACK;
|
|
handleFeedback?: (feedback: FEEDBACK) => void;
|
|
sources?: { title: string; text: string; source: string }[];
|
|
retryBtn?: React.ReactElement;
|
|
}
|
|
>(function ConversationBubble(
|
|
{ message, type, className, feedback, handleFeedback, sources, retryBtn },
|
|
ref,
|
|
) {
|
|
const chunks = useSelector(selectChunks);
|
|
const selectedDocs = useSelector(selectSelectedDocs);
|
|
const [isLikeHovered, setIsLikeHovered] = useState(false);
|
|
const [isDislikeHovered, setIsDislikeHovered] = useState(false);
|
|
const [isLikeClicked, setIsLikeClicked] = useState(false);
|
|
const [isDislikeClicked, setIsDislikeClicked] = useState(false);
|
|
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
|
|
|
|
let bubble;
|
|
if (type === 'QUESTION') {
|
|
bubble = (
|
|
<div
|
|
ref={ref}
|
|
className={`flex flex-row-reverse self-end flex-wrap ${className}`}
|
|
>
|
|
<Avatar className="mt-2 text-2xl" avatar="🧑💻"></Avatar>
|
|
<div
|
|
style={{
|
|
wordBreak: 'break-word',
|
|
}}
|
|
className="ml-10 mr-2 flex items-center rounded-[28px] bg-purple-30 py-[14px] px-[19px] text-white max-w-full whitespace-pre-wrap leading-normal"
|
|
>
|
|
{message}
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
const preprocessLaTeX = (content: string) => {
|
|
// Replace block-level LaTeX delimiters \[ \] with $$ $$
|
|
const blockProcessedContent = content.replace(
|
|
/\\\[(.*?)\\\]/gs,
|
|
(_, equation) => `$$${equation}$$`,
|
|
);
|
|
|
|
// Replace inline LaTeX delimiters \( \) with $ $
|
|
const inlineProcessedContent = blockProcessedContent.replace(
|
|
/\\\((.*?)\\\)/gs,
|
|
(_, equation) => `$${equation}$`,
|
|
);
|
|
|
|
return inlineProcessedContent;
|
|
};
|
|
bubble = (
|
|
<div
|
|
ref={ref}
|
|
className={`flex flex-wrap self-start ${className} group flex-col dark:text-bright-gray`}
|
|
>
|
|
{DisableSourceFE ||
|
|
type === 'ERROR' ||
|
|
sources?.length === 0 ||
|
|
sources?.some((source) => source.source === 'None') ? null : !sources &&
|
|
chunks !== '0' &&
|
|
selectedDocs ? (
|
|
<div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
|
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
|
<Avatar
|
|
className="h-[26px] w-[30px] text-xl"
|
|
avatar={
|
|
<img
|
|
src={Sources}
|
|
alt="Sources"
|
|
className="h-full w-full object-fill"
|
|
/>
|
|
}
|
|
/>
|
|
<p className="text-base font-semibold">Sources</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex h-28 cursor-pointer flex-col items-start gap-1 rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
|
|
>
|
|
<span className="h-px w-10 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
|
|
<span className="h-px w-24 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
|
|
<span className="h-px w-16 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
|
|
<span className="h-px w-32 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
|
|
<span className="h-px w-24 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
|
|
<span className="h-px w-20 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
sources && (
|
|
<div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
|
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
|
<Avatar
|
|
className="h-[26px] w-[30px] text-xl"
|
|
avatar={
|
|
<img
|
|
src={Sources}
|
|
alt="Sources"
|
|
className="h-full w-full object-fill"
|
|
/>
|
|
}
|
|
/>
|
|
<p className="text-base font-semibold">Sources</p>
|
|
</div>
|
|
<div className="ml-3 mr-5 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
|
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
|
{sources?.slice(0, 3)?.map((source, index) => (
|
|
<div key={index} className="relative">
|
|
<div
|
|
className="h-28 cursor-pointer rounded-[20px] bg-gray-1000 p-4 hover:bg-[#F1F1F1] dark:bg-gun-metal dark:hover:bg-[#2C2E3C]"
|
|
onMouseOver={() => setActiveTooltip(index)}
|
|
onMouseOut={() => setActiveTooltip(null)}
|
|
>
|
|
<p className="ellipsis-text h-12 break-words text-xs">
|
|
{source.text}
|
|
</p>
|
|
<div
|
|
className={`mt-[14px] flex flex-row items-center gap-[6px] underline-offset-2 ${
|
|
source.source && source.source !== 'local'
|
|
? 'hover:text-[#007DFF] hover:underline dark:hover:text-[#48A0FF]'
|
|
: ''
|
|
}`}
|
|
onClick={() =>
|
|
source.source && source.source !== 'local'
|
|
? window.open(
|
|
source.source,
|
|
'_blank',
|
|
'noopener, noreferrer',
|
|
)
|
|
: null
|
|
}
|
|
>
|
|
<img
|
|
src={Document}
|
|
alt="Document"
|
|
className="h-[17px] w-[17px] object-fill"
|
|
/>
|
|
<p
|
|
className="mt-[2px] truncate text-xs"
|
|
title={
|
|
source.source && source.source !== 'local'
|
|
? source.source
|
|
: source.title
|
|
}
|
|
>
|
|
{source.source && source.source !== 'local'
|
|
? source.source
|
|
: source.title}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{activeTooltip === index && (
|
|
<div
|
|
className={`absolute left-1/2 z-30 max-h-48 w-40 translate-x-[-50%] translate-y-[3px] rounded-xl bg-[#FBFBFB] p-4 text-black shadow-xl dark:bg-chinese-black dark:text-chinese-silver sm:w-56`}
|
|
onMouseOver={() => setActiveTooltip(index)}
|
|
onMouseOut={() => setActiveTooltip(null)}
|
|
>
|
|
<p className="max-h-[164px] overflow-y-auto break-words rounded-md text-sm">
|
|
{source.text}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(sources?.length ?? 0) > 3 && (
|
|
<div
|
|
className="flex h-28 cursor-pointer flex-col-reverse rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
|
|
onClick={() => setIsSidebarOpen(true)}
|
|
>
|
|
<p className="ellipsis-text h-22 text-xs">{`View ${
|
|
sources?.length ? sources.length - 3 : 0
|
|
} more`}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
<div className="flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
|
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
|
<Avatar
|
|
className="h-[34px] w-[34px] text-2xl"
|
|
avatar={
|
|
<img
|
|
src={DocsGPT3}
|
|
alt="DocsGPT"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
}
|
|
/>
|
|
<p className="text-base font-semibold">Answer</p>
|
|
</div>
|
|
<div
|
|
className={`ml-2 mr-5 flex max-w-[90vw] rounded-[28px] bg-gray-1000 py-[14px] px-7 dark:bg-gun-metal md:max-w-[70vw] lg:max-w-[50vw] ${
|
|
type === 'ERROR'
|
|
? 'relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal text-red-3000 dark:border-red-2000 dark:text-white'
|
|
: 'flex-col rounded-3xl'
|
|
}`}
|
|
>
|
|
{type === 'ERROR' && (
|
|
<>
|
|
<img src={Alert} alt="alert" className="mr-2 inline" />
|
|
<div className="absolute right-0 lg:-right-32 top-1/2 translate-y-full lg:-translate-y-1/2">
|
|
{retryBtn}
|
|
</div>
|
|
</>
|
|
)}
|
|
<ReactMarkdown
|
|
className="whitespace-pre-wrap break-normal leading-normal"
|
|
remarkPlugins={[remarkGfm, remarkMath]}
|
|
rehypePlugins={[rehypeKatex]}
|
|
components={{
|
|
code(props) {
|
|
const { children, className, node, ref, ...rest } = props;
|
|
const match = /language-(\w+)/.exec(className || '');
|
|
|
|
return match ? (
|
|
<div className="group relative">
|
|
<SyntaxHighlighter
|
|
{...rest}
|
|
PreTag="div"
|
|
language={match[1]}
|
|
style={vscDarkPlus}
|
|
>
|
|
{String(children).replace(/\n$/, '')}
|
|
</SyntaxHighlighter>
|
|
<div
|
|
className={`absolute right-3 top-3 lg:invisible
|
|
${type !== 'ERROR' ? 'group-hover:lg:visible' : ''} `}
|
|
>
|
|
<CopyButton
|
|
text={String(children).replace(/\n$/, '')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<code
|
|
className={className ? className : 'whitespace-pre-line'}
|
|
{...props}
|
|
>
|
|
{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">
|
|
<table className="w-full text-left text-sm text-gray-700">
|
|
{children}
|
|
</table>
|
|
</div>
|
|
);
|
|
},
|
|
thead({ children }) {
|
|
return (
|
|
<thead className="text-xs uppercase text-gray-900 [&>.table-row]:bg-gray-50">
|
|
{children}
|
|
</thead>
|
|
);
|
|
},
|
|
tr({ children }) {
|
|
return (
|
|
<tr className="table-row border-b odd:bg-white even:bg-gray-50">
|
|
{children}
|
|
</tr>
|
|
);
|
|
},
|
|
td({ children }) {
|
|
return <td className="px-6 py-3">{children}</td>;
|
|
},
|
|
th({ children }) {
|
|
return <th className="px-6 py-3">{children}</th>;
|
|
},
|
|
}}
|
|
>
|
|
{preprocessLaTeX(message)}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
<div className="my-2 ml-2 flex justify-start">
|
|
<div
|
|
className={`relative mr-5 block items-center justify-center lg:invisible
|
|
${type !== 'ERROR' ? 'group-hover:lg:visible' : ''}`}
|
|
>
|
|
<div>
|
|
<CopyButton text={message} />
|
|
</div>
|
|
</div>
|
|
{handleFeedback && (
|
|
<>
|
|
<div
|
|
className={`relative mr-5 flex items-center justify-center ${
|
|
!isLikeClicked ? 'lg:invisible' : ''
|
|
} ${
|
|
feedback === 'LIKE' || type !== 'ERROR'
|
|
? 'group-hover:lg:visible'
|
|
: ''
|
|
}`}
|
|
>
|
|
<div>
|
|
<div
|
|
className={`flex items-center justify-center rounded-full p-2 dark:bg-transparent ${
|
|
isLikeHovered
|
|
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
|
|
: 'bg-[#ffffff] dark:bg-transparent'
|
|
}`}
|
|
>
|
|
<Like
|
|
className={`cursor-pointer
|
|
${
|
|
isLikeClicked || feedback === 'LIKE'
|
|
? 'fill-white-3000 stroke-purple-30 dark:fill-transparent'
|
|
: 'fill-none stroke-gray-4000'
|
|
}`}
|
|
onClick={() => {
|
|
handleFeedback?.('LIKE');
|
|
setIsLikeClicked(true);
|
|
setIsDislikeClicked(false);
|
|
}}
|
|
onMouseEnter={() => setIsLikeHovered(true)}
|
|
onMouseLeave={() => setIsLikeHovered(false)}
|
|
></Like>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`mr-13 relative flex items-center justify-center ${
|
|
!isDislikeClicked ? 'lg:invisible' : ''
|
|
} ${
|
|
feedback === 'DISLIKE' || type !== 'ERROR'
|
|
? 'group-hover:lg:visible'
|
|
: ''
|
|
}`}
|
|
>
|
|
<div>
|
|
<div
|
|
className={`flex items-center justify-center rounded-full p-2 ${
|
|
isDislikeHovered
|
|
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
|
|
: 'bg-[#ffffff] dark:bg-transparent'
|
|
}`}
|
|
>
|
|
<Dislike
|
|
className={`cursor-pointer ${
|
|
isDislikeClicked || feedback === 'DISLIKE'
|
|
? 'fill-white-3000 stroke-red-2000 dark:fill-transparent'
|
|
: 'fill-none stroke-gray-4000'
|
|
}`}
|
|
onClick={() => {
|
|
handleFeedback?.('DISLIKE');
|
|
setIsDislikeClicked(true);
|
|
setIsLikeClicked(false);
|
|
}}
|
|
onMouseEnter={() => setIsDislikeHovered(true)}
|
|
onMouseLeave={() => setIsDislikeHovered(false)}
|
|
></Dislike>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{sources && (
|
|
<Sidebar
|
|
isOpen={isSidebarOpen}
|
|
toggleState={(state: boolean) => {
|
|
setIsSidebarOpen(state);
|
|
}}
|
|
>
|
|
<AllSources sources={sources} />
|
|
</Sidebar>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return bubble;
|
|
});
|
|
|
|
type AllSourcesProps = {
|
|
sources: { title: string; text: string; source: string }[];
|
|
};
|
|
|
|
function AllSources(sources: AllSourcesProps) {
|
|
return (
|
|
<div className="h-full w-full">
|
|
<div className="w-full">
|
|
<p className="text-left text-xl">{`${sources.sources.length} Sources`}</p>
|
|
<div className="mx-1 mt-2 h-[0.8px] w-full rounded-full bg-[#C4C4C4]/40 lg:w-[95%] "></div>
|
|
</div>
|
|
<div className="mt-6 flex h-[90%] w-60 flex-col items-center gap-4 overflow-y-auto sm:w-80">
|
|
{sources.sources.map((source, index) => (
|
|
<div
|
|
key={index}
|
|
className="min-h-32 w-full rounded-[20px] bg-gray-1000 p-4 dark:bg-[#28292E]"
|
|
>
|
|
<span className="flex flex-row">
|
|
<p
|
|
title={source.title}
|
|
className="ellipsis-text break-words text-left text-sm font-semibold"
|
|
>
|
|
{`${index + 1}. ${source.title}`}
|
|
</p>
|
|
{source.source && source.source !== 'local' ? (
|
|
<img
|
|
src={Link}
|
|
alt="Link"
|
|
className="h-3 w-3 cursor-pointer object-fill"
|
|
onClick={() =>
|
|
window.open(source.source, '_blank', 'noopener, noreferrer')
|
|
}
|
|
></img>
|
|
) : null}
|
|
</span>
|
|
<p className="mt-3 max-h-24 overflow-y-auto break-words rounded-md text-left text-xs text-black dark:text-chinese-silver">
|
|
{source.text}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ConversationBubble;
|