📚 feat: Add Source Citations for File Search in Agents (#8652)

* feat: Source Citations for file_search in Agents

* Fix: Added citation limits and relevance score to app service. Removed duplicate tests

*  feat: implement Role-level toggle to optionally disable file Source Citation in Agents

* 🐛 fix: update mock for librechat-data-provider to include PermissionTypes and SystemRoles

---------

Co-authored-by: “Praneeth <praneeth.goparaju@slalom.com>
This commit is contained in:
Danny Avila 2025-07-25 00:07:37 -04:00
parent a955097faf
commit 52e59e40be
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
36 changed files with 1890 additions and 190 deletions

View file

@ -115,7 +115,7 @@ const ContentParts = memo(
<>
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{hasReasoningParts && (
<div className="mb-5">
<ThinkingButton

View file

@ -1,8 +1,12 @@
import { memo, useState, useContext } from 'react';
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;
@ -114,6 +118,8 @@ interface CitationComponentProps {
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({
@ -121,6 +127,49 @@ export function Citation(props: CitationComponentProps) {
refType: citation?.refType,
index: citation?.index || 0,
});
// Setup file download hook
const isFileType = refData?.refType === 'file' && (refData as any)?.fileId;
const { refetch: downloadFile } = useFileDownload(
user?.id ?? '',
isFileType ? (refData as any).fileId : '',
);
const handleFileDownload = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isFileType || !(refData as any)?.fileId) 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, refData, localize, showToast],
);
if (!refData) return null;
const getCitationLabel = () => {
@ -138,6 +187,8 @@ export function Citation(props: CitationComponentProps) {
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
onClick={isFileType ? handleFileDownload : undefined}
isFile={isFileType}
/>
);
}

View file

@ -28,6 +28,7 @@ const refTypeMap: Record<string | SearchRefType, string> = {
search: 'organic',
ref: 'references',
news: 'topStories',
file: 'references',
};
export function useCitation({

View file

@ -1,6 +1,6 @@
import React, { ReactNode } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { ChevronDown, Paperclip } from 'lucide-react';
import { VisuallyHidden } from '@ariakit/react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -17,6 +17,8 @@ interface SourceHovercardProps {
label: string;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
onClick?: (e: React.MouseEvent) => void;
isFile?: boolean;
children?: ReactNode;
}
@ -46,6 +48,8 @@ export function SourceHovercard({
label,
onMouseEnter,
onMouseLeave,
onClick,
isFile = false,
children,
}: SourceHovercardProps) {
const localize = useLocalize();
@ -57,16 +61,27 @@ export function SourceHovercard({
<span className="flex items-center">
<Ariakit.HovercardAnchor
render={
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:hover:bg-surface-tertiary"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{label}
</a>
isFile ? (
<button
onClick={onClick}
className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium text-blue-600 no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:text-blue-400 dark:hover:bg-surface-tertiary"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{label}
</button>
) : (
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:hover:bg-surface-tertiary"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{label}
</a>
)
}
/>
<Ariakit.HovercardDisclosure className="ml-0.5 rounded-full text-text-primary focus:outline-none focus:ring-2 focus:ring-ring">
@ -84,24 +99,51 @@ export function SourceHovercard({
{!children && (
<>
<span 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>
{isFile ? (
<div className="mr-2 flex h-4 w-4 items-center justify-center">
<Paperclip className="h-3 w-3 text-text-secondary" />
</div>
) : (
<FaviconImage domain={domain} className="mr-2" />
)}
{isFile ? (
<button
onClick={onClick}
className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || source.title || localize('com_file_source')}
</button>
) : (
<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>
)}
</span>
<h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm">
{source.title || source.link}
</h4>
{source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{source.snippet}
</span>
{isFile ? (
<>
{source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{source.snippet}
</span>
)}
</>
) : (
<>
<h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm">
{source.title || source.link}
</h4>
{source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{source.snippet}
</span>
)}
</>
)}
</>
)}

View file

@ -1,27 +1,32 @@
import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { X, Globe, Newspaper, Image, ChevronDown } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { X, Globe, Newspaper, Image, ChevronDown, File, Download } from 'lucide-react';
import {
AnimatedTabs,
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;
isNews?: boolean;
expanded?: boolean;
}
function SourceItem({ source, isNews: _isNews, expanded = false }: SourceItemProps) {
function SourceItem({ source, expanded = false }: SourceItemProps) {
const localize = useLocalize();
const domain = getCleanDomain(source.link);
@ -71,11 +76,6 @@ function SourceItem({ source, isNews: _isNews, expanded = false }: SourceItemPro
<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 md:line-clamp-3 text-xs text-text-secondary">
{source.snippet}
</span>
)} */}
</div>
</a>
}
@ -159,6 +159,202 @@ function ImageItem({ image }: { image: ImageResult }) {
);
}
// 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,
@ -185,11 +381,25 @@ export function StackedFavicons({
);
}
function SourcesGroup({ sources, limit = 3 }: { sources: ValidSource[]; limit?: number }) {
const SourcesGroup = React.memo(function SourcesGroup({
sources,
limit = 3,
}: {
sources: ValidSource[];
limit?: number;
}) {
const localize = useLocalize();
const visibleSources = sources.slice(0, limit);
const remainingSources = sources.slice(limit);
const hasMoreSources = remainingSources.length > 0;
// 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">
@ -265,6 +475,75 @@ function SourcesGroup({ sources, limit = 3 }: { sources: ValidSource[]; limit?:
</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 }) {
@ -276,75 +555,105 @@ function TabWithIcon({ label, icon }: { label: string; icon: React.ReactNode })
);
}
export default function Sources() {
interface SourcesProps {
messageId?: string;
conversationId?: string;
}
function SourcesComponent({ messageId, conversationId }: SourcesProps = {}) {
const localize = useLocalize();
const { searchResults } = useSearchContext();
const { organicSources, topStories, images, hasAnswerBox } = useMemo(() => {
// 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: [],
};
}
const organicSourcesMap = new Map<string, ValidSource>();
const topStoriesMap = new Map<string, ValidSource>();
const imagesMap = new Map<string, ImageResult>();
let hasAnswerBox = false;
// Process search results
for (const result of Object.values(searchResults)) {
if (!result) continue;
Object.values(searchResults).forEach((result) => {
if (!result) return;
// Process organic sources
result.organic?.forEach((source) => {
if (source.link) organicSourcesMap.set(source.link, source);
});
if (result.organic?.length) {
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);
}
});
}
if (result.references?.length) {
result.references.forEach((source) => {
if (source.type === 'image') {
imagesMap.set(source.link, {
...source,
imageUrl: source.link,
});
return;
}
if (source.link) {
organicSourcesMap.set(source.link, source);
}
});
}
if (result.topStories?.length) {
result.topStories.forEach((source) => {
if (source.link) {
topStoriesMap.set(source.link, source);
}
});
}
if (result.images?.length) {
result.images.forEach((image) => {
if (image.imageUrl) {
imagesMap.set(image.imageUrl, image);
}
});
}
if (result.answerBox) {
hasAnswerBox = true;
}
});
} 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]);
}, [searchResults, messageId]);
const tabs = useMemo(() => {
const availableTabs: Array<{ label: React.ReactNode; content: React.ReactNode }> = [];
@ -376,18 +685,85 @@ export default function Sources() {
});
}
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, localize]);
}, [
organicSources,
topStories,
images,
hasAnswerBox,
agentFiles,
messageId,
conversationId,
localize,
]);
if (!tabs.length) return null;
return (
<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"
/>
<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>
);
}

View file

@ -0,0 +1,58 @@
import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
showDetails?: boolean;
}
interface State {
hasError: boolean;
}
class SourcesErrorBoundary extends Component<Props, State> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Sources error:', error);
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default simple error UI (using localized strings from Sources.tsx fallback)
/* eslint-disable i18next/no-literal-string */
return (
<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">Sources temporarily unavailable</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="Reload the page"
>
Refresh
</button>
</div>
);
/* eslint-enable i18next/no-literal-string */
}
return this.props.children;
}
}
export default SourcesErrorBoundary;

View file

@ -0,0 +1,67 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import SourcesErrorBoundary from '../SourcesErrorBoundary';
// Component that throws an error for testing
const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return <div data-testid="normal-component">{'Normal component'}</div>;
};
// Mock window.location.reload
const mockReload = jest.fn();
Object.defineProperty(window, 'location', {
value: {
reload: mockReload,
},
writable: true,
});
describe('SourcesErrorBoundary - NEW COMPONENT test', () => {
beforeEach(() => {
jest.clearAllMocks();
// Suppress error console logs during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should render children when there is no error', () => {
render(
<SourcesErrorBoundary>
<ThrowError shouldThrow={false} />
</SourcesErrorBoundary>,
);
expect(screen.getByTestId('normal-component')).toBeInTheDocument();
});
it('should render default error UI when error occurs', () => {
render(
<SourcesErrorBoundary>
<ThrowError shouldThrow={true} />
</SourcesErrorBoundary>,
);
expect(screen.getByText('Sources temporarily unavailable')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reload the page' })).toBeInTheDocument();
});
it('should reload page when refresh button is clicked', () => {
render(
<SourcesErrorBoundary>
<ThrowError shouldThrow={true} />
</SourcesErrorBoundary>,
);
const refreshButton = screen.getByRole('button', { name: 'Reload the page' });
fireEvent.click(refreshButton);
expect(mockReload).toHaveBeenCalled();
});
});

View file

@ -1,26 +1,119 @@
import { useMemo } from 'react';
import { TAttachment, Tools, SearchResultData } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
interface FileSource {
fileId: string;
fileName: string;
pages?: number[];
relevance?: number;
pageRelevance?: Record<string, number>;
metadata?: any;
}
interface DeduplicatedSource {
fileId: string;
fileName: string;
pages: number[];
relevance: number;
pageRelevance: Record<string, number>;
metadata?: any;
}
/**
* Hook that creates a map of turn numbers to SearchResultData from web search attachments
* Hook that creates a map of turn numbers to SearchResultData from web search and agent file search attachments
* @param attachments Array of attachment metadata
* @returns A map of turn numbers to their corresponding search result data
*/
export function useSearchResultsByTurn(attachments?: TAttachment[]) {
const localize = useLocalize();
const searchResultsByTurn = useMemo(() => {
const turnMap: { [key: string]: SearchResultData } = {};
let agentFileSearchTurn = 0;
attachments?.forEach((attachment) => {
// Handle web search attachments (existing functionality)
if (attachment.type === Tools.web_search && attachment[Tools.web_search]) {
const searchData = attachment[Tools.web_search];
if (searchData && typeof searchData.turn === 'number') {
turnMap[searchData.turn.toString()] = searchData;
}
}
// Handle agent file search attachments (following web search pattern)
if (attachment.type === Tools.file_search && attachment[Tools.file_search]) {
const sources = attachment[Tools.file_search].sources;
// Deduplicate sources by fileId and merge pages
const deduplicatedSources = new Map<string, DeduplicatedSource>();
sources.forEach((source: FileSource) => {
const fileId = source.fileId;
if (deduplicatedSources.has(fileId)) {
// Merge pages for the same file
const existing = deduplicatedSources.get(fileId);
if (existing) {
const existingPages = existing.pages || [];
const newPages = source.pages || [];
const allPages = [...existingPages, ...newPages];
// Remove duplicates and sort
const uniquePages = [...new Set(allPages)].sort((a, b) => a - b);
// Merge page relevance mappings
const existingPageRelevance = existing.pageRelevance || {};
const newPageRelevance = source.pageRelevance || {};
const mergedPageRelevance = { ...existingPageRelevance, ...newPageRelevance };
existing.pages = uniquePages;
existing.relevance = Math.max(existing.relevance || 0, source.relevance || 0);
existing.pageRelevance = mergedPageRelevance;
}
} else {
deduplicatedSources.set(fileId, {
fileId: source.fileId,
fileName: source.fileName,
pages: source.pages || [],
relevance: source.relevance || 0.5,
pageRelevance: source.pageRelevance || {},
metadata: source.metadata,
});
}
});
// Convert agent file sources to SearchResultData format
const agentSearchData: SearchResultData = {
turn: agentFileSearchTurn,
organic: [], // Agent file search doesn't have organic web results
topStories: [], // No top stories for file search
images: [], // No images for file search
references: Array.from(deduplicatedSources.values()).map(
(source) =>
({
title: source.fileName || localize('com_file_unknown'),
link: `#file-${source.fileId}`, // Create a pseudo-link for file references
attribution: source.fileName || localize('com_file_unknown'), // Show filename in inline display
snippet:
source.pages && source.pages.length > 0
? localize('com_file_pages', { pages: source.pages.join(', ') })
: '', // Only page numbers for hover
type: 'file' as const,
// Store additional agent-specific data as properties on the reference
fileId: source.fileId,
fileName: source.fileName,
pages: source.pages,
pageRelevance: source.pageRelevance,
metadata: source.metadata,
}) as any,
),
};
turnMap[agentFileSearchTurn.toString()] = agentSearchData;
agentFileSearchTurn++;
}
});
return turnMap;
}, [attachments]);
}, [attachments, localize]);
return searchResultsByTurn;
}

View file

@ -444,6 +444,21 @@ export default function useEventHandlers({
isTemporary = false,
} = submission;
if (responseMessage?.attachments && responseMessage.attachments.length > 0) {
// Process each attachment through the attachmentHandler
responseMessage.attachments.forEach((attachment) => {
const attachmentData = {
...attachment,
messageId: responseMessage.messageId,
};
attachmentHandler({
data: attachmentData,
submission: submission as EventSubmission,
});
});
}
setShowStopButton(false);
setCompleted((prev) => new Set(prev.add(submission.initialResponse.messageId)));
@ -576,6 +591,7 @@ export default function useEventHandlers({
setShowStopButton,
location.pathname,
applyAgentTemplate,
attachmentHandler,
],
);

View file

@ -516,9 +516,24 @@
"com_sidepanel_manage_files": "Manage Files",
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
"com_sidepanel_parameters": "Parameters",
"com_sources_agent_file": "Source Document",
"com_sources_agent_files": "Agent Files",
"com_sources_download_failed": "Download failed",
"com_sources_download_local_unavailable": "Cannot download: File is not saved",
"com_sources_download_aria_label": "Download {{filename}}{{status}}",
"com_sources_downloading_status": " (downloading...)",
"com_sources_error_fallback": "Unable to load sources",
"com_sources_image_alt": "Search result image",
"com_sources_more_files": "+{{count}} files",
"com_sources_more_sources": "+{{count}} sources",
"com_sources_pages": "Pages",
"com_sources_region_label": "Search results and sources",
"com_file_unknown": "Unknown File",
"com_file_pages": "Pages: {{pages}}",
"com_file_source": "File",
"com_sources_reload_page": "Reload page",
"com_sources_tab_all": "All",
"com_sources_tab_files": "Files",
"com_sources_tab_images": "Images",
"com_sources_tab_news": "News",
"com_sources_title": "Sources",
@ -950,6 +965,7 @@
"com_ui_quality": "Quality",
"com_ui_read_aloud": "Read aloud",
"com_ui_redirecting_to_provider": "Redirecting to {{0}}, please wait...",
"com_ui_refresh": "Refresh",
"com_ui_reference_saved_memories": "Reference saved memories",
"com_ui_reference_saved_memories_description": "Allow the assistant to reference and use your saved memories when responding",
"com_ui_refresh_link": "Refresh link",

View file

@ -1,5 +1,5 @@
export const SPAN_REGEX = /(\\ue203.*?\\ue204)/g;
export const COMPOSITE_REGEX = /(\\ue200.*?\\ue201)/g;
export const STANDALONE_PATTERN = /\\ue202turn(\d+)(search|image|news|video|ref)(\d+)/g;
export const STANDALONE_PATTERN = /\\ue202turn(\d+)(search|image|news|video|ref|file)(\d+)/g;
export const CLEANUP_REGEX = /\\ue200|\\ue201|\\ue202|\\ue203|\\ue204|\\ue206/g;
export const INVALID_CITATION_REGEX = /\s*\\ue202turn\d+(search|news|image|video|ref)\d+/g;
export const INVALID_CITATION_REGEX = /\s*\\ue202turn\d+(search|news|image|video|ref|file)\d+/g;