mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
📚 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:
parent
a955097faf
commit
52e59e40be
36 changed files with 1890 additions and 190 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const refTypeMap: Record<string | SearchRefType, string> = {
|
|||
search: 'organic',
|
||||
ref: 'references',
|
||||
news: 'topStories',
|
||||
file: 'references',
|
||||
};
|
||||
|
||||
export function useCitation({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
58
client/src/components/Web/SourcesErrorBoundary.tsx
Normal file
58
client/src/components/Web/SourcesErrorBoundary.tsx
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue