import { memo, useState, useContext, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import type { CitationProps } from './types';
import { SourceHovercard, FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import { CitationContext, useCitation, useCompositeCitations } from './Context';
import { useFileDownload } from '~/data-provider';
import { useLocalize } from '~/hooks';
import store from '~/store';
interface CompositeCitationProps {
citationId?: string;
node?: {
properties?: CitationProps;
};
}
export function CompositeCitation(props: CompositeCitationProps) {
const localize = useLocalize();
const { citations, citationId } = props.node?.properties ?? ({} as CitationProps);
const { setHoveredCitationId } = useContext(CitationContext);
const [currentPage, setCurrentPage] = useState(0);
const sources = useCompositeCitations(citations || []);
if (!sources || sources.length === 0) return null;
const totalPages = sources.length;
const getCitationLabel = () => {
if (!sources || sources.length === 0) return localize('com_citation_source');
const firstSource = sources[0];
const remainingCount = sources.length - 1;
const attribution =
firstSource.attribution ||
firstSource.title ||
getCleanDomain(firstSource.link || '') ||
localize('com_citation_source');
return remainingCount > 0 ? `${attribution} +${remainingCount}` : attribution;
};
const handlePrevPage = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (currentPage > 0) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (currentPage < totalPages - 1) {
setCurrentPage(currentPage + 1);
}
};
const currentSource = sources?.[currentPage];
return (
setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
>
{totalPages > 1 && (
{currentPage + 1}/{totalPages}
)}
{currentSource.attribution}
{currentSource.title}
{currentSource.snippet}
);
}
interface CitationComponentProps {
citationId: string;
citationType: 'span' | 'standalone' | 'composite' | 'group' | 'navlist';
node?: {
properties?: CitationProps;
};
}
export function Citation(props: CitationComponentProps) {
const localize = useLocalize();
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const { citation, citationId } = props.node?.properties ?? {};
const { setHoveredCitationId } = useContext(CitationContext);
const refData = useCitation({
turn: citation?.turn || 0,
refType: citation?.refType,
index: citation?.index || 0,
});
// Setup file download hook
const isFileType = refData?.refType === 'file' && (refData as any)?.fileId;
const isLocalFile = isFileType && (refData as any)?.metadata?.storageType === 'local';
const { refetch: downloadFile } = useFileDownload(
user?.id ?? '',
isFileType && !isLocalFile ? (refData as any).fileId : '',
);
const handleFileDownload = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isFileType || !(refData as any)?.fileId) return;
// Don't allow download for local files
if (isLocalFile) {
showToast({
status: 'error',
message: localize('com_sources_download_local_unavailable'),
});
return;
}
try {
const stream = await downloadFile();
if (stream.data == null || stream.data === '') {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', (refData as any).fileName || 'file');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
}
},
[downloadFile, isFileType, isLocalFile, refData, localize, showToast],
);
if (!refData) return null;
const getCitationLabel = () => {
return (
refData.attribution ||
refData.title ||
getCleanDomain(refData.link || '') ||
localize('com_citation_source')
);
};
return (
setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
onClick={isFileType && !isLocalFile ? handleFileDownload : undefined}
isFile={isFileType}
isLocalFile={isLocalFile}
/>
);
}
export interface HighlightedTextProps {
children: React.ReactNode;
citationId?: string;
}
export function useHighlightState(citationId: string | undefined) {
const { hoveredCitationId } = useContext(CitationContext);
return citationId && hoveredCitationId === citationId;
}
export const HighlightedText = memo(function HighlightedText({
children,
citationId,
}: HighlightedTextProps) {
const isHighlighted = useHighlightState(citationId);
return (
{children}
);
});