| | 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 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; |
| | } |
| |
|
| | |
| | |
| | |
| | function sortPagesByRelevance(pages: number[], pageRelevance?: Record<number, number>): number[] { |
| | if (!pageRelevance || Object.keys(pageRelevance).length === 0) { |
| | return pages; |
| | } |
| |
|
| | return [...pages].sort((a, b) => { |
| | const relevanceA = pageRelevance[a] || 0; |
| | const relevanceB = pageRelevance[b] || 0; |
| | return relevanceB - relevanceA; |
| | }); |
| | } |
| |
|
| | 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); |
| |
|
| | |
| | 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], |
| | ); |
| |
|
| | |
| | const isLocalFile = file.metadata?.storageType === 'local'; |
| |
|
| | const handleDownload = useCallback( |
| | async (e: React.MouseEvent) => { |
| | e.preventDefault(); |
| | e.stopPropagation(); |
| |
|
| | |
| | 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; |
| |
|
| | |
| | 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]); |
| |
|
| | |
| | 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(); |
| |
|
| | |
| | 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(); |
| | |
| | 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(); |
| |
|
| | |
| | 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: [], |
| | }; |
| | } |
| |
|
| | |
| | for (const result of Object.values(searchResults)) { |
| | if (!result) continue; |
| |
|
| | |
| | result.organic?.forEach((source) => { |
| | if (source.link) organicSourcesMap.set(source.link, source); |
| | }); |
| |
|
| | |
| | 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)) { |
| | |
| | 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); |
| | } |
| | }); |
| |
|
| | |
| | result.topStories?.forEach((source) => { |
| | if (source.link) topStoriesMap.set(source.link, source); |
| | }); |
| |
|
| | |
| | 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> |
| | ); |
| | } |
| |
|
| | |
| | export default function Sources(props: SourcesProps) { |
| | const localize = useLocalize(); |
| |
|
| | const handleError = (error: Error, errorInfo: React.ErrorInfo) => { |
| | |
| | console.error('Sources component error:', { error, errorInfo }); |
| |
|
| | |
| | |
| | }; |
| |
|
| | 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> |
| | ); |
| | } |
| |
|