File size: 7,239 Bytes
4aefbee
2ef7092
 
 
 
 
4aefbee
44ebcd1
4aefbee
2ef7092
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aefbee
44ebcd1
4aefbee
 
 
 
 
 
2ef7092
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aefbee
 
 
2ef7092
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aefbee
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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;