| import { useCallback, useEffect, useRef } from 'react'; |
| import copy from 'copy-to-clipboard'; |
| import { ContentTypes, SearchResultData } from 'librechat-data-provider'; |
| import type { TMessage } from 'librechat-data-provider'; |
| import { |
| SPAN_REGEX, |
| CLEANUP_REGEX, |
| COMPOSITE_REGEX, |
| STANDALONE_PATTERN, |
| INVALID_CITATION_REGEX, |
| } from '~/utils/citations'; |
|
|
| type Source = { |
| link: string; |
| title: string; |
| attribution?: string; |
| type: string; |
| typeIndex: number; |
| citationKey: string; |
| }; |
|
|
| const refTypeMap: Record<string, string> = { |
| search: 'organic', |
| ref: 'references', |
| news: 'topStories', |
| image: 'images', |
| video: 'videos', |
| }; |
|
|
| export default function useCopyToClipboard({ |
| text, |
| content, |
| searchResults, |
| }: Partial<Pick<TMessage, 'text' | 'content'>> & { |
| searchResults?: { [key: string]: SearchResultData }; |
| }) { |
| const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
|
|
| useEffect(() => { |
| return () => { |
| if (copyTimeoutRef.current) { |
| clearTimeout(copyTimeoutRef.current); |
| } |
| }; |
| }, []); |
|
|
| const copyToClipboard = useCallback( |
| (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => { |
| if (copyTimeoutRef.current) { |
| clearTimeout(copyTimeoutRef.current); |
| } |
| setIsCopied(true); |
|
|
| |
| let messageText = text ?? ''; |
| if (content) { |
| messageText = content.reduce((acc, curr, i) => { |
| if (curr.type === ContentTypes.TEXT) { |
| const text = typeof curr.text === 'string' ? curr.text : curr.text.value; |
| return acc + text + (i === content.length - 1 ? '' : '\n'); |
| } |
| return acc; |
| }, ''); |
| } |
|
|
| |
| if (!searchResults || Object.keys(searchResults).length === 0) { |
| |
| const cleanedText = messageText |
| .replace(INVALID_CITATION_REGEX, '') |
| .replace(CLEANUP_REGEX, ''); |
|
|
| copy(cleanedText, { format: 'text/plain' }); |
| copyTimeoutRef.current = setTimeout(() => { |
| setIsCopied(false); |
| }, 3000); |
| return; |
| } |
|
|
| |
| const citationManager = processCitations(messageText, searchResults); |
| let processedText = citationManager.formattedText; |
|
|
| |
| if (citationManager.citations.size > 0) { |
| processedText += '\n\nCitations:\n'; |
| |
| const sortedCitations = Array.from(citationManager.citations.entries()).sort( |
| (a, b) => a[1].referenceNumber - b[1].referenceNumber, |
| ); |
|
|
| |
| for (const [_, citation] of sortedCitations) { |
| processedText += `[${citation.referenceNumber}] ${citation.link}\n`; |
| } |
| } |
|
|
| copy(processedText, { format: 'text/plain' }); |
| copyTimeoutRef.current = setTimeout(() => { |
| setIsCopied(false); |
| }, 3000); |
| }, |
| [text, content, searchResults], |
| ); |
|
|
| return copyToClipboard; |
| } |
|
|
| |
| |
| |
| function processCitations(text: string, searchResults: { [key: string]: SearchResultData }) { |
| |
| const citations = new Map< |
| string, |
| { |
| referenceNumber: number; |
| link: string; |
| title?: string; |
| source: Source; |
| } |
| >(); |
|
|
| |
| const urlToCitationKey = new Map<string, string>(); |
|
|
| let nextReferenceNumber = 1; |
| let formattedText = text; |
|
|
| |
| formattedText = formattedText.replace(SPAN_REGEX, (match) => { |
| const text = match.replace(/\\ue203|\\ue204|\ue203|\ue204/g, ''); |
| return `**${text}**`; |
| }); |
|
|
| |
| const allCitations: Array<{ |
| turn: string; |
| type: string; |
| index: string; |
| position: number; |
| fullMatch: string; |
| isComposite: boolean; |
| }> = []; |
|
|
| |
| let standaloneMatch: RegExpExecArray | null; |
| const standaloneCopy = new RegExp(STANDALONE_PATTERN.source, 'g'); |
| while ((standaloneMatch = standaloneCopy.exec(formattedText)) !== null) { |
| allCitations.push({ |
| turn: standaloneMatch[1], |
| type: standaloneMatch[2], |
| index: standaloneMatch[3], |
| position: standaloneMatch.index, |
| fullMatch: standaloneMatch[0], |
| isComposite: false, |
| }); |
| } |
|
|
| |
| let compositeMatch: RegExpExecArray | null; |
| const compositeCopy = new RegExp(COMPOSITE_REGEX.source, 'g'); |
| while ((compositeMatch = compositeCopy.exec(formattedText)) !== null) { |
| const block = compositeMatch[0]; |
| const blockStart = compositeMatch.index; |
|
|
| |
| let citationMatch: RegExpExecArray | null; |
| const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g'); |
| while ((citationMatch = citationPattern.exec(block)) !== null) { |
| allCitations.push({ |
| turn: citationMatch[1], |
| type: citationMatch[2], |
| index: citationMatch[3], |
| position: blockStart + citationMatch.index, |
| fullMatch: block, |
| isComposite: true, |
| }); |
| } |
| } |
|
|
| |
| allCitations.sort((a, b) => a.position - b.position); |
|
|
| |
| const processedCitations = new Set<string>(); |
| const replacements: Array<[string, string]> = []; |
| const compositeCitationsMap = new Map<string, number[]>(); |
|
|
| for (const citation of allCitations) { |
| const { turn, type, index, fullMatch, isComposite } = citation; |
| const searchData = searchResults[turn]; |
|
|
| if (!searchData) continue; |
|
|
| const dataType = refTypeMap[type.toLowerCase()] || type.toLowerCase(); |
| const idx = parseInt(index, 10); |
|
|
| |
| if (!searchData[dataType] || !searchData[dataType][idx]) { |
| continue; |
| } |
|
|
| |
| const sourceData = searchData[dataType][idx]; |
| const sourceUrl = sourceData.link || ''; |
|
|
| |
| if (!sourceUrl) continue; |
|
|
| |
| let citationKey = urlToCitationKey.get(sourceUrl); |
|
|
| |
| if (!citationKey) { |
| citationKey = `${turn}-${dataType}-${idx}`; |
| urlToCitationKey.set(sourceUrl, citationKey); |
| } |
|
|
| const source: Source = { |
| link: sourceUrl, |
| title: sourceData.title || sourceData.name || '', |
| attribution: sourceData.attribution || sourceData.source || '', |
| type: dataType, |
| typeIndex: idx, |
| citationKey, |
| }; |
|
|
| |
| if (isComposite && processedCitations.has(fullMatch)) { |
| continue; |
| } |
|
|
| let referenceText = ''; |
|
|
| |
| let existingCitation = citations.get(citationKey); |
|
|
| if (!existingCitation) { |
| |
| existingCitation = { |
| referenceNumber: nextReferenceNumber++, |
| link: source.link, |
| title: source.title, |
| source, |
| }; |
| citations.set(citationKey, existingCitation); |
| } |
|
|
| if (existingCitation) { |
| |
| if (isComposite) { |
| |
| if (!processedCitations.has(fullMatch)) { |
| const compositeCitations: number[] = []; |
| let citationMatch: RegExpExecArray | null; |
| const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g'); |
|
|
| while ((citationMatch = citationPattern.exec(fullMatch)) !== null) { |
| const cTurn = citationMatch[1]; |
| const cType = citationMatch[2]; |
| const cIndex = citationMatch[3]; |
| const cDataType = refTypeMap[cType.toLowerCase()] || cType.toLowerCase(); |
|
|
| const cSource = searchResults[cTurn]?.[cDataType]?.[parseInt(cIndex, 10)]; |
| if (cSource && cSource.link) { |
| |
| const cUrl = cSource.link; |
| let cKey = urlToCitationKey.get(cUrl); |
|
|
| if (!cKey) { |
| cKey = `${cTurn}-${cDataType}-${cIndex}`; |
| urlToCitationKey.set(cUrl, cKey); |
| } |
|
|
| let cCitation = citations.get(cKey); |
|
|
| if (!cCitation) { |
| cCitation = { |
| referenceNumber: nextReferenceNumber++, |
| link: cSource.link, |
| title: cSource.title || cSource.name || '', |
| source: { |
| link: cSource.link, |
| title: cSource.title || cSource.name || '', |
| attribution: cSource.attribution || cSource.source || '', |
| type: cDataType, |
| typeIndex: parseInt(cIndex, 10), |
| citationKey: cKey, |
| }, |
| }; |
| citations.set(cKey, cCitation); |
| } |
|
|
| if (cCitation) { |
| compositeCitations.push(cCitation.referenceNumber); |
| } |
| } |
| } |
|
|
| |
| const uniqueSortedCitations = [...new Set(compositeCitations)].sort((a, b) => a - b); |
|
|
| |
| referenceText = |
| uniqueSortedCitations.length > 0 |
| ? uniqueSortedCitations.map((num) => `[${num}]`).join('') |
| : ''; |
|
|
| processedCitations.add(fullMatch); |
| compositeCitationsMap.set(fullMatch, uniqueSortedCitations); |
| replacements.push([fullMatch, referenceText]); |
| } |
|
|
| |
| continue; |
| } else { |
| |
| referenceText = `[${existingCitation.referenceNumber}]`; |
| replacements.push([fullMatch, referenceText]); |
| } |
| } |
| } |
|
|
| |
| replacements.sort((a, b) => b[0].length - a[0].length); |
| for (const [pattern, replacement] of replacements) { |
| formattedText = formattedText.replace(pattern, replacement); |
| } |
|
|
| |
| |
| formattedText = formattedText.replace(/\n\s*\[\d+\](\[\d+\])*\s*$/g, ''); |
|
|
| |
| formattedText = formattedText.replace(INVALID_CITATION_REGEX, ''); |
| formattedText = formattedText.replace(CLEANUP_REGEX, ''); |
|
|
| return { |
| formattedText, |
| citations, |
| }; |
| } |
|
|