| | 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, |
| | }; |
| | } |
| |
|