Spaces:
Paused
Paused
| import React, { useEffect, useRef } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; | |
| import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; | |
| import remarkGfm from 'remark-gfm'; | |
| import rehypeRaw from 'rehype-raw'; | |
| import './Streaming.css'; | |
| import './SourceRef.css'; | |
| // Helper function to normalize various citation formats (e.g., [1,2], [1, 2]) into the standard [1][2] format | |
| const normalizeCitations = (text) => { | |
| if (!text) return ''; | |
| const citationRegex = /\[(\d+(?:,\s*\d+)+)\]/g; | |
| return text.replace(citationRegex, (match, capturedNumbers) => { | |
| const numbers = capturedNumbers | |
| .split(/,\s*/) | |
| .map(numStr => numStr.trim()) | |
| .filter(Boolean); | |
| if (numbers.length <= 1) { | |
| return match; | |
| } | |
| return numbers.map(num => `[${num}]`).join(''); | |
| }); | |
| }; | |
| // Streaming component for rendering markdown content | |
| const Streaming = ({ content, isStreaming, onContentRef, showSourcePopup, hideSourcePopup }) => { | |
| const contentRef = useRef(null); | |
| useEffect(() => { | |
| if (contentRef.current && onContentRef) { | |
| onContentRef(contentRef.current); | |
| } | |
| }, [content, onContentRef]); | |
| const displayContent = isStreaming ? `${content}▌` : (content || ''); | |
| const normalizedContent = normalizeCitations(displayContent); | |
| // Custom renderer for text nodes to handle source references | |
| const renderWithSourceRefs = (elementType) => { | |
| const ElementComponent = elementType; // e.g., 'p', 'li' | |
| // Helper to gather plain text | |
| const getFullText = (something) => { | |
| if (typeof something === 'string') return something; | |
| if (Array.isArray(something)) return something.map(getFullText).join(''); | |
| if (React.isValidElement(something) && something.props?.children) | |
| return getFullText(React.Children.toArray(something.props.children)); | |
| return ''; | |
| }; | |
| return (props) => { | |
| // Plain‑text version of this block (paragraph / list‑item) | |
| const fullText = getFullText(props.children); | |
| // Same regex the backend used | |
| const sentenceRegex = /[^.!?\n]+[.!?]+[\])'"`’”]*|[^.!?\n]+$/g; | |
| const sentencesArr = fullText.match(sentenceRegex) || [fullText]; | |
| // Helper function to find the sentence that contains position `pos` | |
| const sentenceByPos = (pos) => { | |
| let run = 0; | |
| for (const s of sentencesArr) { | |
| const end = run + s.length; | |
| if (pos >= run && pos < end) return s.trim(); | |
| run = end; | |
| } | |
| return fullText.trim(); | |
| }; | |
| // Cursor that advances through fullText so each subsequent | |
| // indexOf search starts AFTER the previous match | |
| let searchCursor = 0; | |
| // Recursive renderer that preserves existing markup | |
| const processNode = (node, keyPrefix = 'node') => { | |
| if (typeof node === 'string') { | |
| const citationRegex = /\[(\d+)\]/g; | |
| let last = 0; | |
| let parts = []; | |
| let m; | |
| while ((m = citationRegex.exec(node))) { | |
| const sliceBefore = node.slice(last, m.index); | |
| if (sliceBefore) parts.push(sliceBefore); | |
| const localIdx = m.index; | |
| const num = parseInt(m[1], 10); | |
| const citStr = m[0]; | |
| // Find this specific occurrence in fullText, starting at searchCursor | |
| const absIdx = fullText.indexOf(citStr, searchCursor); | |
| if (absIdx !== -1) searchCursor = absIdx + citStr.length; | |
| const sentenceForPopup = sentenceByPos(absIdx); | |
| parts.push( | |
| <sup | |
| key={`${keyPrefix}-ref-${num}-${localIdx}`} | |
| className="source-reference" | |
| onMouseEnter={(e) => | |
| showSourcePopup && | |
| showSourcePopup(num - 1, e.target, sentenceForPopup) | |
| } | |
| onMouseLeave={hideSourcePopup} | |
| > | |
| {num} | |
| </sup> | |
| ); | |
| last = localIdx + citStr.length; | |
| } | |
| if (last < node.length) parts.push(node.slice(last)); | |
| return parts; | |
| } | |
| // For non‑string children, recurse (preserves <em>, <strong>, links, etc.) | |
| if (React.isValidElement(node) && node.props?.children) { | |
| const processed = React.Children.map(node.props.children, (child, i) => | |
| processNode(child, `${keyPrefix}-${i}`) | |
| ); | |
| return React.cloneElement(node, { children: processed }); | |
| } | |
| return node; // element without children or unknown type | |
| }; | |
| const processedChildren = React.Children.map(props.children, (child, i) => | |
| processNode(child, `root-${i}`) | |
| ); | |
| // Render original element (p, li, …) with processed children | |
| return <ElementComponent {...props}>{processedChildren}</ElementComponent>; | |
| }; | |
| }; | |
| return ( | |
| <div className="streaming-content" ref={contentRef}> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| rehypePlugins={[rehypeRaw]} | |
| components={{ | |
| p: renderWithSourceRefs('p'), | |
| li: renderWithSourceRefs('li'), | |
| // Add other block elements if citations might appear directly within them | |
| // blockquote: renderWithSourceRefs('blockquote'), | |
| // div: renderWithSourceRefs('div'), // Be cautious with generic divs | |
| code({node, inline, className, children, ...props}) { | |
| const match = /language-(\w+)/.exec(className || ''); | |
| return !inline ? ( | |
| <div className="code-block-container"> | |
| <div className="code-block-header"> | |
| <span>{match ? match[1] : 'code'}</span> | |
| </div> | |
| <SyntaxHighlighter | |
| style={atomDark} | |
| language={match ? match[1] : 'text'} | |
| PreTag="div" | |
| {...props} | |
| > | |
| {String(children).replace(/\n$/, '')} | |
| </SyntaxHighlighter> | |
| </div> | |
| ) : ( | |
| <code className={className} {...props}> | |
| {children} | |
| </code> | |
| ); | |
| }, | |
| table({node, ...props}) { | |
| return ( | |
| <div className="table-container"> | |
| <table {...props} /> | |
| </div> | |
| ); | |
| }, | |
| a({node, children, href, ...props}) { | |
| return ( | |
| <a | |
| href={href} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="markdown-link" | |
| {...props} | |
| > | |
| {children} | |
| </a> | |
| ); | |
| }, | |
| blockquote({node, ...props}) { | |
| return ( | |
| <blockquote className="markdown-blockquote" {...props} /> | |
| ); | |
| } | |
| }} | |
| > | |
| {normalizedContent} | |
| </ReactMarkdown> | |
| </div> | |
| ); | |
| }; | |
| export default Streaming; |