Perplexica/src/components/MarkdownRenderer.tsx

209 lines
6.6 KiB
TypeScript
Raw Normal View History

/* eslint-disable @next/next/no-img-element */
'use client';
import { cn } from '@/lib/utils';
import { CheckCheck, Copy as CopyIcon, Brain } from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import ThinkBox from './ThinkBox';
// Helper functions for think overlay
const extractThinkContent = (content: string): string | null => {
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
const matches = content.match(thinkRegex);
if (!matches) return null;
// Extract content between think tags and join if multiple
const extractedContent = matches
.map((match) => match.replace(/<\/?think>/g, ''))
.join('\n\n');
// Return null if content is empty or only whitespace
return extractedContent.trim().length === 0 ? null : extractedContent;
};
const removeThinkTags = (content: string): string => {
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
};
const ThinkTagProcessor = ({
children,
isOverlayMode = false,
}: {
children: React.ReactNode;
isOverlayMode?: boolean;
}) => {
// In overlay mode, don't render anything (content will be handled by overlay)
if (isOverlayMode) {
return null;
}
return <ThinkBox content={children} />;
};
const CodeBlock = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
// Extract language from className (format could be "language-javascript" or "lang-javascript")
let language = '';
if (className) {
if (className.startsWith('language-')) {
language = className.replace('language-', '');
} else if (className.startsWith('lang-')) {
language = className.replace('lang-', '');
}
}
const content = children as string;
const [isCopied, setIsCopied] = useState(false);
const handleCopyCode = () => {
navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className="rounded-md overflow-hidden my-4 relative group border border-dark-secondary">
<div className="flex justify-between items-center px-4 py-2 bg-dark-200 border-b border-dark-secondary text-xs text-white/70 font-mono">
<span>{language}</span>
<button
onClick={handleCopyCode}
className="p-1 rounded-md hover:bg-dark-secondary transition duration-200"
aria-label="Copy code to clipboard"
>
{isCopied ? (
<CheckCheck size={14} className="text-green-500" />
) : (
<CopyIcon size={14} className="text-white/70" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
customStyle={{
margin: 0,
padding: '1rem',
borderRadius: 0,
backgroundColor: '#1c1c1c',
}}
wrapLines={true}
wrapLongLines={true}
showLineNumbers={language !== '' && content.split('\n').length > 1}
useInlineStyles={true}
PreTag="div"
>
{content}
</SyntaxHighlighter>
</div>
);
};
interface MarkdownRendererProps {
content: string;
className?: string;
thinkOverlay?: boolean;
}
const MarkdownRenderer = ({
content,
className,
thinkOverlay = false,
}: MarkdownRendererProps) => {
const [showThinkBox, setShowThinkBox] = useState(false);
// Extract think content from the markdown
const thinkContent = thinkOverlay ? extractThinkContent(content) : null;
const contentWithoutThink = thinkOverlay ? removeThinkTags(content) : content;
// Markdown formatting options
const markdownOverrides: MarkdownToJSX.Options = {
overrides: {
think: {
component: ({ children }) => (
<ThinkTagProcessor isOverlayMode={thinkOverlay}>
{children}
</ThinkTagProcessor>
),
},
code: {
component: ({ className, children }) => {
// Check if it's an inline code block or a fenced code block
if (className) {
// This is a fenced code block (```code```)
return <CodeBlock className={className}>{children}</CodeBlock>;
}
// This is an inline code block (`code`)
return (
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
{children}
</code>
);
},
},
pre: {
component: ({ children }) => children,
},
a: {
component: (props) => (
<a {...props} target='_blank' rel='noopener noreferrer' />
),
},
// Prevent rendering of certain HTML elements for security
iframe: () => null, // Don't render iframes
script: () => null, // Don't render scripts
object: () => null, // Don't render objects
style: () => null, // Don't render styles
},
};
return (
<div className="relative">
{/* Think box when expanded - shows above markdown */}
{thinkOverlay && thinkContent && showThinkBox && (
<div className="mb-4">
<ThinkBox
content={thinkContent}
expanded={true}
onToggle={() => setShowThinkBox(false)}
/>
</div>
)}
<Markdown
className={cn(
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
'break-words text-black dark:text-white max-w-full',
className,
)}
options={markdownOverrides}
>
{thinkOverlay ? contentWithoutThink : content}
</Markdown>
{/* Overlay icon when think box is collapsed */}
{thinkOverlay && thinkContent && !showThinkBox && (
<button
onClick={() => setShowThinkBox(true)}
className="absolute top-2 right-2 p-2 rounded-lg bg-black/20 dark:bg-white/20 backdrop-blur-sm opacity-30 hover:opacity-100 transition-opacity duration-200 group"
title="Show thinking process"
>
<Brain
size={16}
className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
/>
</button>
)}
</div>
);
};
export default MarkdownRenderer;