| import React, { memo, useEffect, useRef, useState } from 'react'; |
| import copy from 'copy-to-clipboard'; |
| import rehypeKatex from 'rehype-katex'; |
| import ReactMarkdown from 'react-markdown'; |
| import { Button } from '@librechat/client'; |
| import rehypeHighlight from 'rehype-highlight'; |
| import { Copy, CircleCheckBig } from 'lucide-react'; |
| import { handleDoubleClick, langSubset } from '~/utils'; |
| import { useLocalize } from '~/hooks'; |
|
|
| type TCodeProps = { |
| inline: boolean; |
| className?: string; |
| children: React.ReactNode; |
| }; |
|
|
| export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => { |
| const match = /language-(\w+)/.exec(className ?? ''); |
| const lang = match && match[1]; |
|
|
| if (inline) { |
| return ( |
| <code onDoubleClick={handleDoubleClick} className={className}> |
| {children} |
| </code> |
| ); |
| } |
|
|
| return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>; |
| }); |
|
|
| export const CodeMarkdown = memo( |
| ({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => { |
| const scrollRef = useRef<HTMLDivElement>(null); |
| const [userScrolled, setUserScrolled] = useState(false); |
| const currentContent = content; |
| const rehypePlugins = [ |
| [rehypeKatex], |
| [ |
| rehypeHighlight, |
| { |
| detect: true, |
| ignoreMissing: true, |
| subset: langSubset, |
| }, |
| ], |
| ]; |
|
|
| useEffect(() => { |
| const scrollContainer = scrollRef.current; |
| if (!scrollContainer) { |
| return; |
| } |
|
|
| const handleScroll = () => { |
| const { scrollTop, scrollHeight, clientHeight } = scrollContainer; |
| const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; |
|
|
| if (!isNearBottom) { |
| setUserScrolled(true); |
| } else { |
| setUserScrolled(false); |
| } |
| }; |
|
|
| scrollContainer.addEventListener('scroll', handleScroll); |
|
|
| return () => { |
| scrollContainer.removeEventListener('scroll', handleScroll); |
| }; |
| }, []); |
|
|
| useEffect(() => { |
| const scrollContainer = scrollRef.current; |
| if (!scrollContainer || !isSubmitting || userScrolled) { |
| return; |
| } |
|
|
| scrollContainer.scrollTop = scrollContainer.scrollHeight; |
| }, [content, isSubmitting, userScrolled]); |
|
|
| return ( |
| <div ref={scrollRef} className="max-h-full overflow-y-auto"> |
| <ReactMarkdown |
| /* @ts-ignore */ |
| rehypePlugins={rehypePlugins} |
| components={ |
| { code } as { |
| [key: string]: React.ElementType; |
| } |
| } |
| > |
| {currentContent} |
| </ReactMarkdown> |
| </div> |
| ); |
| }, |
| ); |
|
|
| export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => { |
| const localize = useLocalize(); |
| const [isCopied, setIsCopied] = useState(false); |
|
|
| const handleCopy = () => { |
| copy(content, { format: 'text/plain' }); |
| setIsCopied(true); |
| setTimeout(() => setIsCopied(false), 3000); |
| }; |
|
|
| return ( |
| <Button |
| size="icon" |
| variant="ghost" |
| onClick={handleCopy} |
| aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')} |
| > |
| {isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />} |
| </Button> |
| ); |
| }; |
|
|