helloya20's picture
Upload 2345 files
f0743f4 verified
import React, { useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { Tools } from 'librechat-data-provider';
import { X, Globe, Newspaper, Image, ChevronDown, File, Download } from 'lucide-react';
import {
OGDialog,
AnimatedTabs,
OGDialogClose,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { ValidSource, ImageResult } from 'librechat-data-provider';
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import SourcesErrorBoundary from './SourcesErrorBoundary';
import { useFileDownload } from '~/data-provider';
import { useSearchContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import store from '~/store';
interface SourceItemProps {
source: ValidSource;
expanded?: boolean;
}
function SourceItem({ source, expanded = false }: SourceItemProps) {
const localize = useLocalize();
const domain = getCleanDomain(source.link);
if (expanded) {
return (
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="flex w-full flex-col rounded-lg bg-surface-primary-contrast px-3 py-2 text-sm transition-all duration-300 hover:bg-surface-tertiary"
>
<div className="flex items-center gap-2">
<FaviconImage domain={domain} />
<span className="truncate text-xs font-medium text-text-secondary">{domain}</span>
</div>
<div className="mt-1">
<span className="line-clamp-2 text-sm font-medium text-text-primary md:line-clamp-3">
{source.title || source.link}
</span>
{'snippet' in source && source.snippet && (
<span className="mt-1 line-clamp-2 text-xs text-text-secondary md:line-clamp-3">
{source.snippet}
</span>
)}
</div>
</a>
);
}
return (
<span className="not-prose relative inline-block h-full w-full">
<Ariakit.HovercardProvider showTimeout={150} hideTimeout={150}>
<div className="flex h-full items-center">
<Ariakit.HovercardAnchor
render={
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="flex h-full w-full flex-col rounded-lg bg-surface-primary-contrast px-3 py-2 text-sm transition-all duration-300 hover:bg-surface-tertiary"
>
<div className="flex items-center gap-2">
<FaviconImage domain={domain} />
<span className="truncate text-xs font-medium text-text-secondary">{domain}</span>
</div>
<div className="mt-1">
<span className="line-clamp-2 text-sm font-medium text-text-primary md:line-clamp-3">
{source.title || source.link}
</span>
</div>
</a>
}
/>
<Ariakit.HovercardDisclosure className="absolute right-2 rounded-full text-text-primary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_citation_more_details', { label: domain })}
</VisuallyHidden>
<ChevronDown className="icon-sm" />
</Ariakit.HovercardDisclosure>
<Ariakit.Hovercard
gutter={16}
className="dark:shadow-lg-dark z-[999] w-[300px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg"
portal={true}
unmountOnHide={true}
>
<div className="flex gap-3">
<div className="flex-1">
<div className="mb-2 flex items-center">
<FaviconImage domain={domain} className="mr-2" />
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || domain}
</a>
</div>
<h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm">
{source.title || source.link}
</h4>
{'snippet' in source && source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{source.snippet}
</span>
)}
</div>
{'imageUrl' in source && source.imageUrl && (
<div className="h-24 w-24 flex-shrink-0 overflow-hidden rounded-md">
<img
src={source.imageUrl}
alt={source.title || localize('com_sources_image_alt')}
className="h-full w-full object-cover"
/>
</div>
)}
</div>
</Ariakit.Hovercard>
</div>
</Ariakit.HovercardProvider>
</span>
);
}
function ImageItem({ image }: { image: ImageResult }) {
const localize = useLocalize();
return (
<a
href={image.imageUrl}
target="_blank"
rel="noopener noreferrer"
className="group overflow-hidden rounded-lg bg-surface-secondary transition-all duration-300 hover:bg-surface-tertiary"
>
{image.imageUrl && (
<div className="relative aspect-square w-full overflow-hidden">
<img
src={image.imageUrl}
alt={image.title || localize('com_sources_image_alt')}
className="size-full object-cover"
/>
{image.title && (
<div className="absolute bottom-0 left-0 right-0 w-full border-none bg-gray-900/80 p-1 text-xs font-medium text-white backdrop-blur-sm">
<span className="truncate">{image.title}</span>
</div>
)}
</div>
)}
</a>
);
}
// Type for agent file sources (simplified for file citations)
type AgentFileSource = {
file_id: string;
filename: string;
bytes?: number;
type?: string;
pages?: number[];
relevance?: number;
pageRelevance?: Record<number, number>;
messageId: string;
toolCallId: string;
metadata?: any;
};
interface FileItemProps {
file: AgentFileSource;
messageId: string;
conversationId: string;
expanded?: boolean;
}
/**
* Sorts page numbers by their relevance scores in descending order (highest first)
*/
function sortPagesByRelevance(pages: number[], pageRelevance?: Record<number, number>): number[] {
if (!pageRelevance || Object.keys(pageRelevance).length === 0) {
return pages; // Return original order if no relevance data
}
return [...pages].sort((a, b) => {
const relevanceA = pageRelevance[a] || 0;
const relevanceB = pageRelevance[b] || 0;
return relevanceB - relevanceA; // Highest relevance first
});
}
const FileItem = React.memo(function FileItem({
file,
messageId: _messageId,
conversationId: _conversationId,
expanded = false,
}: FileItemProps) {
const localize = useLocalize();
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file.file_id);
// Extract error message logic to avoid duplication
const getErrorMessage = useCallback(
(error: any) => {
const errorString = JSON.stringify(error);
const errorWithResponse = error as any;
const isLocalFileError =
error?.message?.includes('local files') ||
errorWithResponse?.response?.data?.error?.includes('local files') ||
errorWithResponse?.response?.status === 403 ||
errorString.includes('local files') ||
errorString.includes('403');
return isLocalFileError
? localize('com_sources_download_local_unavailable')
: localize('com_sources_download_failed');
},
[localize],
);
// Check if file is from local storage
const isLocalFile = file.metadata?.storageType === 'local';
const handleDownload = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Don't allow download for local files
if (isLocalFile) {
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', file.filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
}
},
[downloadFile, file.filename, isLocalFile, localize, showToast],
);
const isLoading = false;
// Memoize file icon computation for performance
const fileIcon = useMemo(() => {
const fileType = file.type?.toLowerCase() || '';
if (fileType.includes('pdf')) return '📄';
if (fileType.includes('image')) return '🖼️';
if (fileType.includes('text')) return '📝';
if (fileType.includes('word') || fileType.includes('doc')) return '📄';
if (fileType.includes('excel') || fileType.includes('sheet')) return '📊';
if (fileType.includes('powerpoint') || fileType.includes('presentation')) return '📈';
return '📎';
}, [file.type]);
// Simple aria label
const downloadAriaLabel = localize('com_sources_download_aria_label', {
filename: file.filename,
status: isLoading ? localize('com_sources_downloading_status') : '',
});
const error = null;
if (expanded) {
return (
<button
onClick={isLocalFile ? undefined : handleDownload}
disabled={isLoading}
className={`flex w-full flex-col rounded-lg bg-surface-primary-contrast px-3 py-2 text-sm transition-all duration-300 disabled:opacity-50 ${
isLocalFile ? 'cursor-default' : 'hover:bg-surface-tertiary'
}`}
aria-label={
isLocalFile ? localize('com_sources_download_local_unavailable') : downloadAriaLabel
}
>
<div className="flex items-center gap-2">
<span className="text-base">{fileIcon}</span>
<span className="truncate text-xs font-medium text-text-secondary">
{localize('com_sources_agent_file')}
</span>
{!isLocalFile && <Download className="ml-auto h-3 w-3" />}
</div>
<div className="mt-1 min-w-0">
<span className="line-clamp-2 break-all text-left text-sm font-medium text-text-primary md:line-clamp-3">
{file.filename}
</span>
{file.pages && file.pages.length > 0 && (
<span className="mt-1 line-clamp-1 text-left text-xs text-text-secondary">
{localize('com_sources_pages')}:{' '}
{sortPagesByRelevance(file.pages, file.pageRelevance).join(', ')}
</span>
)}
{file.bytes && (
<span className="mt-1 line-clamp-1 text-xs text-text-secondary">
{(file.bytes / 1024).toFixed(1)} KB
</span>
)}
</div>
{error && <div className="mt-1 text-xs text-red-500">{getErrorMessage(error)}</div>}
</button>
);
}
return (
<button
onClick={isLocalFile ? undefined : handleDownload}
disabled={isLoading}
className={`flex h-full w-full flex-col rounded-lg bg-surface-primary-contrast px-3 py-2 text-sm transition-all duration-300 disabled:opacity-50 ${
isLocalFile ? 'cursor-default' : 'hover:bg-surface-tertiary'
}`}
aria-label={
isLocalFile ? localize('com_sources_download_local_unavailable') : downloadAriaLabel
}
>
<div className="flex items-center gap-2">
<span className="text-base">{fileIcon}</span>
<span className="truncate text-xs font-medium text-text-secondary">
{localize('com_sources_agent_file')}
</span>
{!isLocalFile && <Download className="ml-auto h-3 w-3" />}
</div>
<div className="mt-1 min-w-0">
<span className="line-clamp-2 break-all text-left text-sm font-medium text-text-primary md:line-clamp-3">
{file.filename}
</span>
{file.pages && file.pages.length > 0 && (
<span className="mt-1 line-clamp-1 text-left text-xs text-text-secondary">
{localize('com_sources_pages')}:{' '}
{sortPagesByRelevance(file.pages, file.pageRelevance).join(', ')}
</span>
)}
</div>
{error && <div className="mt-1 text-xs text-red-500">{getErrorMessage(error)}</div>}
</button>
);
});
export function StackedFavicons({
sources,
start = 0,
end = 3,
}: {
sources: ValidSource[];
start?: number;
end?: number;
}) {
let slice = [start, end];
if (start < 0) {
slice = [start];
}
return (
<div className="relative flex">
{sources.slice(...slice).map((source, i) => (
<FaviconImage
key={`icon-${i}`}
domain={getCleanDomain(source.link)}
className={i > 0 ? 'ml-[-6px]' : ''}
/>
))}
</div>
);
}
const SourcesGroup = React.memo(function SourcesGroup({
sources,
limit = 3,
}: {
sources: ValidSource[];
limit?: number;
}) {
const localize = useLocalize();
// Memoize source slicing for better performance
const { visibleSources, remainingSources, hasMoreSources } = useMemo(() => {
const visible = sources.slice(0, limit);
const remaining = sources.slice(limit);
return {
visibleSources: visible,
remainingSources: remaining,
hasMoreSources: remaining.length > 0,
};
}, [sources, limit]);
return (
<div className="scrollbar-none grid w-full grid-cols-4 gap-2 overflow-x-auto">
<OGDialog>
{visibleSources.map((source, i) => (
<div key={`source-${i}`} className="w-full min-w-[120px]">
<SourceItem source={source} />
</div>
))}
{hasMoreSources && (
<OGDialogTrigger className="flex flex-col rounded-lg bg-surface-primary-contrast px-3 py-2 text-sm transition-all duration-300 hover:bg-surface-tertiary">
<div className="flex items-center gap-2">
<StackedFavicons sources={remainingSources} />
<span className="truncate text-xs font-medium text-text-secondary">
{localize('com_sources_more_sources', { count: remainingSources.length })}
</span>
</div>
</OGDialogTrigger>
)}
<OGDialogContent className="flex max-h-[80vh] max-w-full flex-col overflow-hidden rounded-lg bg-surface-primary p-0 md:max-w-[600px]">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border-light bg-surface-primary px-3 py-2">
<OGDialogTitle className="text-base font-medium">
{localize('com_sources_title')}
</OGDialogTitle>
<OGDialogClose
className="rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary"
aria-label={localize('com_ui_close')}
>
<X className="h-4 w-4" />
</OGDialogClose>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-2">
{[...visibleSources, ...remainingSources].map((source, i) => (
<a
key={`more-source-${i}`}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="flex gap-2 rounded-lg px-2 py-2 transition-colors hover:bg-surface-tertiary"
>
<FaviconImage
domain={getCleanDomain(source.link)}
className="h-5 w-5 flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<h3 className="mb-0.5 truncate text-sm font-medium text-text-primary">
{source.title || source.link}
</h3>
{'snippet' in source && source.snippet && (
<p className="mb-1 line-clamp-2 text-xs text-text-secondary md:line-clamp-3">
{source.snippet}
</p>
)}
<span className="text-xs text-text-secondary-alt">
{getCleanDomain(source.link)}
</span>
</div>
{'imageUrl' in source && source.imageUrl && (
<div className="hidden h-12 w-12 flex-shrink-0 overflow-hidden rounded-md sm:block">
<img
src={source.imageUrl}
alt={source.title || localize('com_sources_image_alt')}
className="h-full w-full object-cover"
/>
</div>
)}
</a>
))}
</div>
</div>
</OGDialogContent>
</OGDialog>
</div>
);
});
interface FilesGroupProps {
files: AgentFileSource[];
messageId: string;
conversationId: string;
limit?: number;
}
function FilesGroup({ files, messageId, conversationId, limit = 3 }: FilesGroupProps) {
const localize = useLocalize();
// If there's only 1 remaining file, show it instead of "+1 files"
const shouldShowAll = files.length <= limit + 1;
const actualLimit = shouldShowAll ? files.length : limit;
const visibleFiles = files.slice(0, actualLimit);
const remainingFiles = files.slice(actualLimit);
const hasMoreFiles = remainingFiles.length > 0;
return (
<div className="scrollbar-none grid w-full grid-cols-4 gap-2 overflow-x-auto">
<OGDialog>
{visibleFiles.map((file, i) => (
<div key={`file-${i}`} className="w-full min-w-[120px]">
<FileItem file={file} messageId={messageId} conversationId={conversationId} />
</div>
))}
{hasMoreFiles && (
<OGDialogTrigger className="flex flex-col rounded-lg bg-surface-primary-contrast px-3 py-2 text-sm transition-all duration-300 hover:bg-surface-tertiary">
<div className="flex items-center gap-2">
<div className="relative flex">
{remainingFiles.slice(0, 3).map((_, i) => (
<File key={`file-icon-${i}`} className={`h-4 w-4 ${i > 0 ? 'ml-[-6px]' : ''}`} />
))}
</div>
<span className="truncate text-xs font-medium text-text-secondary">
{localize('com_sources_more_files', { count: remainingFiles.length })}
</span>
</div>
</OGDialogTrigger>
)}
<OGDialogContent className="flex max-h-[80vh] max-w-full flex-col overflow-hidden rounded-lg bg-surface-primary p-0 md:max-w-[600px]">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border-light bg-surface-primary px-3 py-2">
<OGDialogTitle className="text-base font-medium">
{localize('com_sources_agent_files')}
</OGDialogTitle>
<OGDialogClose
className="rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary"
aria-label={localize('com_ui_close')}
>
<X className="h-4 w-4" />
</OGDialogClose>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-2">
{[...visibleFiles, ...remainingFiles].map((file, i) => (
<FileItem
key={`more-file-${i}`}
file={file}
messageId={messageId}
conversationId={conversationId}
expanded={true}
/>
))}
</div>
</div>
</OGDialogContent>
</OGDialog>
</div>
);
}
function TabWithIcon({ label, icon }: { label: string; icon: React.ReactNode }) {
return (
<div className="flex items-center gap-2 rounded-md px-3 py-1 text-sm transition-colors hover:bg-surface-tertiary hover:text-text-primary">
{React.cloneElement(icon as React.ReactElement, { size: 14 })}
<span>{label}</span>
</div>
);
}
interface SourcesProps {
messageId?: string;
conversationId?: string;
}
function SourcesComponent({ messageId, conversationId }: SourcesProps = {}) {
const localize = useLocalize();
const { searchResults } = useSearchContext();
// Simple search results processing with good memoization
const { organicSources, topStories, images, hasAnswerBox, agentFiles } = useMemo(() => {
const organicSourcesMap = new Map<string, ValidSource>();
const topStoriesMap = new Map<string, ValidSource>();
const imagesMap = new Map<string, ImageResult>();
const agentFilesMap = new Map<string, AgentFileSource>();
let hasAnswerBox = false;
if (!searchResults) {
return {
organicSources: [],
topStories: [],
images: [],
hasAnswerBox: false,
agentFiles: [],
};
}
// Process search results
for (const result of Object.values(searchResults)) {
if (!result) continue;
// Process organic sources
result.organic?.forEach((source) => {
if (source.link) organicSourcesMap.set(source.link, source);
});
// Process references
result.references?.forEach((source) => {
if (source.type === 'image') {
imagesMap.set(source.link, { ...source, imageUrl: source.link });
} else if ((source as any).type === 'file') {
const fileId = (source as any).fileId || 'unknown';
const fileName = source.title || 'Unknown File';
const uniqueKey = `${fileId}_${fileName}`;
if (agentFilesMap.has(uniqueKey)) {
// Merge pages for the same file
const existing = agentFilesMap.get(uniqueKey)!;
const existingPages = existing.pages || [];
const newPages = (source as any).pages || [];
const uniquePages = [...new Set([...existingPages, ...newPages])].sort((a, b) => a - b);
existing.pages = uniquePages;
existing.relevance = Math.max(existing.relevance || 0, (source as any).relevance || 0);
existing.pageRelevance = {
...existing.pageRelevance,
...(source as any).pageRelevance,
};
} else {
const agentFile: AgentFileSource = {
type: Tools.file_search,
file_id: fileId,
filename: fileName,
bytes: undefined,
metadata: (source as any).metadata,
pages: (source as any).pages,
relevance: (source as any).relevance,
pageRelevance: (source as any).pageRelevance,
messageId: messageId || '',
toolCallId: 'file_search_results',
};
agentFilesMap.set(uniqueKey, agentFile);
}
} else if (source.link) {
organicSourcesMap.set(source.link, source);
}
});
// Process top stories
result.topStories?.forEach((source) => {
if (source.link) topStoriesMap.set(source.link, source);
});
// Process images
result.images?.forEach((image) => {
if (image.imageUrl) imagesMap.set(image.imageUrl, image);
});
if (result.answerBox) hasAnswerBox = true;
}
return {
organicSources: Array.from(organicSourcesMap.values()),
topStories: Array.from(topStoriesMap.values()),
images: Array.from(imagesMap.values()),
hasAnswerBox,
agentFiles: Array.from(agentFilesMap.values()),
};
}, [searchResults, messageId]);
const tabs = useMemo(() => {
const availableTabs: Array<{ label: React.ReactNode; content: React.ReactNode }> = [];
if (organicSources.length || topStories.length || hasAnswerBox) {
availableTabs.push({
label: <TabWithIcon label={localize('com_sources_tab_all')} icon={<Globe />} />,
content: <SourcesGroup sources={[...organicSources, ...topStories]} />,
});
}
if (topStories.length) {
availableTabs.push({
label: <TabWithIcon label={localize('com_sources_tab_news')} icon={<Newspaper />} />,
content: <SourcesGroup sources={topStories} limit={3} />,
});
}
if (images.length) {
availableTabs.push({
label: <TabWithIcon label={localize('com_sources_tab_images')} icon={<Image />} />,
content: (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{images.map((item, i) => (
<ImageItem key={`image-${i}`} image={item} />
))}
</div>
),
});
}
if (agentFiles.length && messageId && conversationId) {
availableTabs.push({
label: <TabWithIcon label={localize('com_sources_tab_files')} icon={<File />} />,
content: (
<FilesGroup
files={agentFiles}
messageId={messageId}
conversationId={conversationId}
limit={3}
/>
),
});
}
return availableTabs;
}, [
organicSources,
topStories,
images,
hasAnswerBox,
agentFiles,
messageId,
conversationId,
localize,
]);
if (!tabs.length) return null;
return (
<div role="region" aria-label={localize('com_sources_region_label')}>
<AnimatedTabs
tabs={tabs}
containerClassName="flex min-w-full mb-4"
tabListClassName="flex items-center mb-2 border-b border-border-light overflow-x-auto"
tabPanelClassName="w-full overflow-x-auto scrollbar-none md:mx-0 md:px-0"
tabClassName="flex items-center whitespace-nowrap text-xs font-medium text-token-text-secondary px-1 pt-2 pb-1 border-b-2 border-transparent data-[state=active]:text-text-primary outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
</div>
);
}
// Enhanced error boundary wrapper with accessibility features
export default function Sources(props: SourcesProps) {
const localize = useLocalize();
const handleError = (error: Error, errorInfo: React.ErrorInfo) => {
// Log error for monitoring/analytics
console.error('Sources component error:', { error, errorInfo });
// Could send to error tracking service here
// analytics.track('sources_error', { error: error.message });
};
const fallbackUI = (
<div
className="flex flex-col items-center justify-center rounded-lg border border-border-medium bg-surface-secondary p-4 text-center"
role="alert"
aria-live="polite"
>
<div className="mb-2 text-sm text-text-secondary">
{localize('com_sources_error_fallback')}
</div>
<button
onClick={() => window.location.reload()}
className="hover:bg-surface-primary-hover rounded-md bg-surface-primary px-3 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_sources_reload_page')}
>
{localize('com_ui_refresh')}
</button>
</div>
);
return (
<SourcesErrorBoundary
onError={handleError}
fallback={fallbackUI}
showDetails={process.env.NODE_ENV === 'development'}
>
<SourcesComponent {...props} />
</SourcesErrorBoundary>
);
}