|
import { ReactNode, useCallback } from 'react' |
|
import { Message } from '@/api/lightrag' |
|
import useTheme from '@/hooks/useTheme' |
|
import Button from '@/components/ui/Button' |
|
import { cn } from '@/lib/utils' |
|
|
|
import ReactMarkdown from 'react-markdown' |
|
import remarkGfm from 'remark-gfm' |
|
import rehypeReact from 'rehype-react' |
|
import remarkMath from 'remark-math' |
|
|
|
import type { Element } from 'hast' |
|
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' |
|
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' |
|
|
|
import { LoaderIcon, CopyIcon } from 'lucide-react' |
|
import { useTranslation } from 'react-i18next' |
|
|
|
export type MessageWithError = Message & { |
|
isError?: boolean |
|
} |
|
|
|
export const ChatMessage = ({ message }: { message: MessageWithError }) => { |
|
const { t } = useTranslation() |
|
|
|
const handleCopyMarkdown = useCallback(async () => { |
|
if (message.content) { |
|
try { |
|
await navigator.clipboard.writeText(message.content) |
|
} catch (err) { |
|
console.error(t('chat.copyError'), err) |
|
} |
|
} |
|
}, [message]) |
|
|
|
return ( |
|
<div |
|
className={`max-w-[80%] rounded-lg px-4 py-2 ${ |
|
message.role === 'user' |
|
? 'bg-primary text-primary-foreground' |
|
: message.isError |
|
? 'bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-400' |
|
: 'bg-muted' |
|
}`} |
|
> |
|
<pre className="relative break-words whitespace-pre-wrap"> |
|
<ReactMarkdown |
|
className="dark:prose-invert max-w-none text-base text-sm" |
|
remarkPlugins={[remarkGfm, remarkMath]} |
|
rehypePlugins={[rehypeReact]} |
|
skipHtml={false} |
|
components={{ |
|
code: CodeHighlight |
|
}} |
|
> |
|
{message.content} |
|
</ReactMarkdown> |
|
{message.role === 'assistant' && message.content.length > 0 && ( |
|
<Button |
|
onClick={handleCopyMarkdown} |
|
className="absolute right-0 bottom-0 size-6 rounded-md opacity-20 transition-opacity hover:opacity-100" |
|
tooltip={t('retrievePanel.chatMessage.copyTooltip')} |
|
variant="default" |
|
size="icon" |
|
> |
|
<CopyIcon /> |
|
</Button> |
|
)} |
|
</pre> |
|
{message.content.length === 0 && <LoaderIcon className="animate-spin duration-2000" />} |
|
</div> |
|
) |
|
} |
|
|
|
interface CodeHighlightProps { |
|
inline?: boolean |
|
className?: string |
|
children?: ReactNode |
|
node?: Element |
|
} |
|
|
|
const isInlineCode = (node: Element): boolean => { |
|
const textContent = (node.children || []) |
|
.filter((child) => child.type === 'text') |
|
.map((child) => (child as any).value) |
|
.join('') |
|
|
|
return !textContent.includes('\n') |
|
} |
|
|
|
const CodeHighlight = ({ className, children, node, ...props }: CodeHighlightProps) => { |
|
const { theme } = useTheme() |
|
const match = className?.match(/language-(\w+)/) |
|
const language = match ? match[1] : undefined |
|
const inline = node ? isInlineCode(node) : false |
|
|
|
return !inline ? ( |
|
<SyntaxHighlighter |
|
style={theme === 'dark' ? oneDark : oneLight} |
|
PreTag="div" |
|
language={language} |
|
{...props} |
|
> |
|
{String(children).replace(/\n$/, '')} |
|
</SyntaxHighlighter> |
|
) : ( |
|
<code |
|
className={cn(className, 'mx-1 rounded-xs bg-black/10 px-1 dark:bg-gray-100/20')} |
|
{...props} |
|
> |
|
{children} |
|
</code> |
|
) |
|
} |
|
|