mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
🧩 feat: Web Search Config Validations & Clipboard Citation Processing (#7530)
* 🔧 chore: Add missing optional `scraperTimeout` to webSearchSchema
* chore: Add missing optional `scraperTimeout` to web search authentication result
* chore: linting
* feat: Integrate attachment handling and citation processing in message components
- Added `useAttachments` hook to manage message attachments and search results.
- Updated `MessageParts`, `ContentParts`, and `ContentRender` components to utilize the new hook for improved attachment handling.
- Enhanced `useCopyToClipboard` to format citations correctly, including support for composite citations and deduplication.
- Introduced utility functions for citation processing and cleanup.
- Added tests for the new `useCopyToClipboard` functionality to ensure proper citation formatting and handling.
* feat: Add configuration for LibreChat Code Interpreter API and Web Search variables
* fix: Update searchResults type to use SearchResultData for better type safety
* feat: Add web search configuration validation and logging
- Introduced `checkWebSearchConfig` function to validate web search configuration values, ensuring they are environment variable references.
- Added logging for proper configuration and warnings for incorrect values.
- Created unit tests for `checkWebSearchConfig` to cover various scenarios, including valid and invalid configurations.
* docs: Update README to include Web Search feature details
- Added a section for the Web Search feature, highlighting its capabilities to search the internet and enhance AI context.
- Included links for further information on the Web Search functionality.
* ci: Add mock for checkWebSearchConfig in AppService tests
* chore: linting
* feat: Enhance Shared Messages with Web Search UI by adding searchResults prop to SearchContent and MinimalHoverButtons components
* chore: linting
* refactor: remove Meilisearch index sync from importConversations function
* feat: update safeSearch implementation to use SafeSearchTypes enum
* refactor: remove commented-out code in loadTools function
* fix: ensure responseMessageId handles latestMessage ID correctly
* feat: enhance Vite configuration for improved chunking and caching
- Added additional globIgnores for map files in Workbox configuration.
- Implemented high-impact chunking for various large libraries to optimize performance.
- Increased chunkSizeWarningLimit from 1200 to 1500 for better handling of larger chunks.
* refactor: move health check hook to Root, fix bad setState for Temporary state
- Enhanced the `useHealthCheck` hook to initiate health checks only when the user is authenticated.
- Added logic for managing health check intervals and handling window focus events.
- Introduced a new test suite for `useHealthCheck` to cover various scenarios including authentication state changes and error handling.
- Removed the health check invocation from `ChatRoute` and added it to `Root` for global health monitoring.
* fix: update font alias in Vite configuration for correct path resolution
This commit is contained in:
parent
cede5d120c
commit
b2f44fc90f
34 changed files with 1709 additions and 140 deletions
|
|
@ -245,7 +245,8 @@ export default function useChatFunctions({
|
|||
const generation = editedText ?? latestMessage?.text ?? '';
|
||||
const responseText = isEditOrContinue ? generation : '';
|
||||
|
||||
const responseMessageId = editedMessageId ?? latestMessage?.messageId + '_' ?? null;
|
||||
const responseMessageId =
|
||||
editedMessageId ?? (latestMessage?.messageId ? latestMessage?.messageId + '_' : null) ?? null;
|
||||
const initialResponse: TMessage = {
|
||||
sender: responseSender,
|
||||
text: responseText,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { default as useAvatar } from './useAvatar';
|
||||
export { default as useProgress } from './useProgress';
|
||||
export { default as useAttachments } from './useAttachments';
|
||||
export { default as useSubmitMessage } from './useSubmitMessage';
|
||||
export { default as useMessageActions } from './useMessageActions';
|
||||
export { default as useMessageProcess } from './useMessageProcess';
|
||||
|
|
|
|||
26
client/src/hooks/Messages/useAttachments.ts
Normal file
26
client/src/hooks/Messages/useAttachments.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import { useSearchResultsByTurn } from './useSearchResultsByTurn';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useAttachments({
|
||||
messageId,
|
||||
attachments,
|
||||
}: {
|
||||
messageId?: string;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap);
|
||||
const messageAttachments = useMemo(
|
||||
() => attachments ?? messageAttachmentsMap[messageId ?? ''] ?? [],
|
||||
[attachments, messageAttachmentsMap, messageId],
|
||||
);
|
||||
|
||||
const searchResults = useSearchResultsByTurn(messageAttachments);
|
||||
|
||||
return {
|
||||
attachments: messageAttachments,
|
||||
searchResults,
|
||||
};
|
||||
}
|
||||
494
client/src/hooks/Messages/useCopyToClipboard.spec.ts
Normal file
494
client/src/hooks/Messages/useCopyToClipboard.spec.ts
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
SearchResultData,
|
||||
ProcessedOrganic,
|
||||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import useCopyToClipboard from '~/hooks/Messages/useCopyToClipboard';
|
||||
|
||||
// Mock the copy-to-clipboard module
|
||||
jest.mock('copy-to-clipboard');
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
const mockSetIsCopied = jest.fn();
|
||||
const mockCopy = copy as jest.MockedFunction<typeof copy>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('should copy plain text without citations', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text: 'Simple text without citations',
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Simple text without citations', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
expect(mockSetIsCopied).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should handle content array with text types', () => {
|
||||
const content = [
|
||||
{ type: ContentTypes.TEXT, text: 'First line' },
|
||||
{ type: ContentTypes.TEXT, text: 'Second line' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
content: content as TMessageContentParts[],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('First line\nSecond line', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset isCopied after timeout', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text: 'Test text',
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(mockSetIsCopied).toHaveBeenCalledWith(true);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(mockSetIsCopied).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citation formatting', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{
|
||||
link: 'https://example.com/search1',
|
||||
title: 'Search Result 1',
|
||||
snippet: 'This is a search result',
|
||||
},
|
||||
],
|
||||
topStories: [
|
||||
{
|
||||
link: 'https://example.com/news1',
|
||||
title: 'News Story 1',
|
||||
},
|
||||
{
|
||||
link: 'https://example.com/news2',
|
||||
title: 'News Story 2',
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
link: 'https://example.com/image1',
|
||||
title: 'Image 1',
|
||||
},
|
||||
],
|
||||
videos: [
|
||||
{
|
||||
link: 'https://example.com/video1',
|
||||
title: 'Video 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('should format standalone search citations', () => {
|
||||
const text = 'This is a fact \\ue202turn0search0 from search.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `This is a fact [1] from search.
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
|
||||
it('should format news citations with correct mapping', () => {
|
||||
const text = 'Breaking news \\ue202turn0news0 and more news \\ue202turn0news1.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `Breaking news [1] and more news [2].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/news1
|
||||
[2] https://example.com/news2
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
|
||||
it('should handle highlighted text with citations', () => {
|
||||
const text = '\\ue203This is highlighted text\\ue204 \\ue202turn0search0 with citation.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `**This is highlighted text** [1] with citation.
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
|
||||
it('should handle composite citations', () => {
|
||||
const text =
|
||||
'Multiple sources \\ue200\\ue202turn0search0\\ue202turn0news0\\ue202turn0news1\\ue201.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `Multiple sources [1][2][3].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search1
|
||||
[2] https://example.com/news1
|
||||
[3] https://example.com/news2
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citation deduplication', () => {
|
||||
it('should use same number for duplicate URLs', () => {
|
||||
const mockSearchResultsWithDupes: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{
|
||||
link: 'https://example.com/article',
|
||||
title: 'Article from search',
|
||||
},
|
||||
],
|
||||
topStories: [
|
||||
{
|
||||
link: 'https://example.com/article', // Same URL
|
||||
title: 'Article from news',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const text = 'First citation \\ue202turn0search0 and second \\ue202turn0news0.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResultsWithDupes,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `First citation [1] and second [1].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/article
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
|
||||
it('should handle multiple citations of the same source', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{
|
||||
link: 'https://example.com/source1',
|
||||
title: 'Source 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const text =
|
||||
'First mention \\ue202turn0search0. Second mention \\ue202turn0search0. Third \\ue202turn0search0.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `First mention [1]. Second mention [1]. Third [1].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/source1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle missing search results gracefully', () => {
|
||||
const text = 'Text with citation \\ue202turn0search0 but no data.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: {},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
// Updated expectation: Citation marker should be removed
|
||||
expect(mockCopy).toHaveBeenCalledWith('Text with citation but no data.', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid citation indices', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{
|
||||
link: 'https://example.com/search1',
|
||||
title: 'Search Result 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const text = 'Valid citation \\ue202turn0search0 and invalid \\ue202turn0search5.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
// Updated expectation: Invalid citation marker should be removed
|
||||
const expectedText = `Valid citation [1] and invalid.
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
|
||||
it('should handle citations without links', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{
|
||||
title: 'No link source',
|
||||
// No link property
|
||||
} as ProcessedOrganic,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const text = 'Citation without link \\ue202turn0search0.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
// Updated expectation: Citation marker without link should be removed
|
||||
expect(mockCopy).toHaveBeenCalledWith('Citation without link.', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean up orphaned citation lists at the end', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{ link: 'https://example.com/1', title: 'Source 1' },
|
||||
{ link: 'https://example.com/2', title: 'Source 2' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const text = 'Text with citations \\ue202turn0search0.\n\n[1][2]';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `Text with citations [1].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('All citation types', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [{ link: 'https://example.com/search', title: 'Search' }],
|
||||
topStories: [{ link: 'https://example.com/news', title: 'News' }],
|
||||
images: [{ link: 'https://example.com/image', title: 'Image' }],
|
||||
videos: [{ link: 'https://example.com/video', title: 'Video' }],
|
||||
references: [{ link: 'https://example.com/ref', title: 'Reference', type: 'link' }],
|
||||
},
|
||||
};
|
||||
|
||||
it('should handle all citation types correctly', () => {
|
||||
const text =
|
||||
'Search \\ue202turn0search0, news \\ue202turn0news0, image \\ue202turn0image0, video \\ue202turn0video0, ref \\ue202turn0ref0.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `Search [1], news [2], image [3], video [4], ref [5].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search
|
||||
[2] https://example.com/news
|
||||
[3] https://example.com/image
|
||||
[4] https://example.com/video
|
||||
[5] https://example.com/ref
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex scenarios', () => {
|
||||
it('should handle mixed highlighted text and composite citations', () => {
|
||||
const mockSearchResults: { [key: string]: SearchResultData } = {
|
||||
'0': {
|
||||
organic: [
|
||||
{ link: 'https://example.com/1', title: 'Source 1' },
|
||||
{ link: 'https://example.com/2', title: 'Source 2' },
|
||||
],
|
||||
topStories: [{ link: 'https://example.com/3', title: 'News 1' }],
|
||||
},
|
||||
};
|
||||
|
||||
const text =
|
||||
'\\ue203Highlighted text with citation\\ue204 \\ue202turn0search0 and composite \\ue200\\ue202turn0search1\\ue202turn0news0\\ue201.';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `**Highlighted text with citation** [1] and composite [2][3].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/1
|
||||
[2] https://example.com/2
|
||||
[3] https://example.com/3
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,41 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { ContentTypes, SearchResultData } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import {
|
||||
SPAN_REGEX,
|
||||
CLEANUP_REGEX,
|
||||
COMPOSITE_REGEX,
|
||||
STANDALONE_PATTERN,
|
||||
INVALID_CITATION_REGEX,
|
||||
} from '~/utils/citations';
|
||||
|
||||
type Source = {
|
||||
link: string;
|
||||
title: string;
|
||||
attribution?: string;
|
||||
type: string;
|
||||
typeIndex: number;
|
||||
citationKey: string; // Used for deduplication
|
||||
};
|
||||
|
||||
const refTypeMap: Record<string, string> = {
|
||||
search: 'organic',
|
||||
ref: 'references',
|
||||
news: 'topStories',
|
||||
image: 'images',
|
||||
video: 'videos',
|
||||
};
|
||||
|
||||
export default function useCopyToClipboard({
|
||||
text,
|
||||
content,
|
||||
}: Partial<Pick<TMessage, 'text' | 'content'>>) {
|
||||
searchResults,
|
||||
}: Partial<Pick<TMessage, 'text' | 'content'>> & {
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
}) {
|
||||
const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
|
|
@ -22,6 +50,8 @@ export default function useCopyToClipboard({
|
|||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
setIsCopied(true);
|
||||
|
||||
// Get the message text from content or text
|
||||
let messageText = text ?? '';
|
||||
if (content) {
|
||||
messageText = content.reduce((acc, curr, i) => {
|
||||
|
|
@ -32,14 +62,282 @@ export default function useCopyToClipboard({
|
|||
return acc;
|
||||
}, '');
|
||||
}
|
||||
copy(messageText, { format: 'text/plain' });
|
||||
|
||||
// Early return if no search data
|
||||
if (!searchResults || Object.keys(searchResults).length === 0) {
|
||||
// Clean up any citation markers before returning
|
||||
const cleanedText = messageText
|
||||
.replace(INVALID_CITATION_REGEX, '')
|
||||
.replace(CLEANUP_REGEX, '');
|
||||
|
||||
copy(cleanedText, { format: 'text/plain' });
|
||||
copyTimeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process citations and build a citation manager
|
||||
const citationManager = processCitations(messageText, searchResults);
|
||||
let processedText = citationManager.formattedText;
|
||||
|
||||
// Add citations list at the end if we have any
|
||||
if (citationManager.citations.size > 0) {
|
||||
processedText += '\n\nCitations:\n';
|
||||
// Sort citations by their reference number
|
||||
const sortedCitations = Array.from(citationManager.citations.entries()).sort(
|
||||
(a, b) => a[1].referenceNumber - b[1].referenceNumber,
|
||||
);
|
||||
|
||||
// Add each citation to the text
|
||||
for (const [_, citation] of sortedCitations) {
|
||||
processedText += `[${citation.referenceNumber}] ${citation.link}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
copy(processedText, { format: 'text/plain' });
|
||||
copyTimeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
},
|
||||
[text, content],
|
||||
[text, content, searchResults],
|
||||
);
|
||||
|
||||
return copyToClipboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process citations in the text and format them properly
|
||||
*/
|
||||
function processCitations(text: string, searchResults: { [key: string]: SearchResultData }) {
|
||||
// Maps citation keys to their info including reference numbers
|
||||
const citations = new Map<
|
||||
string,
|
||||
{
|
||||
referenceNumber: number;
|
||||
link: string;
|
||||
title?: string;
|
||||
source: Source;
|
||||
}
|
||||
>();
|
||||
|
||||
// Map to track URLs to citation keys for deduplication
|
||||
const urlToCitationKey = new Map<string, string>();
|
||||
|
||||
let nextReferenceNumber = 1;
|
||||
let formattedText = text;
|
||||
|
||||
// Step 1: Process highlighted text first (simplify by just making it bold in markdown)
|
||||
formattedText = formattedText.replace(SPAN_REGEX, (match) => {
|
||||
const text = match.replace(/\\ue203|\\ue204/g, '');
|
||||
return `**${text}**`;
|
||||
});
|
||||
|
||||
// Step 2: Find all standalone citations and composite citation blocks
|
||||
const allCitations: Array<{
|
||||
turn: string;
|
||||
type: string;
|
||||
index: string;
|
||||
position: number;
|
||||
fullMatch: string;
|
||||
isComposite: boolean;
|
||||
}> = [];
|
||||
|
||||
// Find standalone citations
|
||||
let standaloneMatch: RegExpExecArray | null;
|
||||
const standaloneCopy = new RegExp(STANDALONE_PATTERN.source, 'g');
|
||||
while ((standaloneMatch = standaloneCopy.exec(formattedText)) !== null) {
|
||||
allCitations.push({
|
||||
turn: standaloneMatch[1],
|
||||
type: standaloneMatch[2],
|
||||
index: standaloneMatch[3],
|
||||
position: standaloneMatch.index,
|
||||
fullMatch: standaloneMatch[0],
|
||||
isComposite: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Find composite citation blocks
|
||||
let compositeMatch: RegExpExecArray | null;
|
||||
const compositeCopy = new RegExp(COMPOSITE_REGEX.source, 'g');
|
||||
while ((compositeMatch = compositeCopy.exec(formattedText)) !== null) {
|
||||
const block = compositeMatch[0];
|
||||
const blockStart = compositeMatch.index;
|
||||
|
||||
// Extract individual citations within the composite block
|
||||
let citationMatch: RegExpExecArray | null;
|
||||
const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g');
|
||||
while ((citationMatch = citationPattern.exec(block)) !== null) {
|
||||
allCitations.push({
|
||||
turn: citationMatch[1],
|
||||
type: citationMatch[2],
|
||||
index: citationMatch[3],
|
||||
position: blockStart + citationMatch.index,
|
||||
fullMatch: block, // Store the full composite block
|
||||
isComposite: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort citations by their position in the text
|
||||
allCitations.sort((a, b) => a.position - b.position);
|
||||
|
||||
// Step 3: Process each citation and build the reference mapping
|
||||
const processedCitations = new Set<string>();
|
||||
const replacements: Array<[string, string]> = [];
|
||||
const compositeCitationsMap = new Map<string, number[]>();
|
||||
|
||||
for (const citation of allCitations) {
|
||||
const { turn, type, index, fullMatch, isComposite } = citation;
|
||||
const searchData = searchResults[turn];
|
||||
|
||||
if (!searchData) continue;
|
||||
|
||||
const dataType = refTypeMap[type.toLowerCase()] || type.toLowerCase();
|
||||
const idx = parseInt(index, 10);
|
||||
|
||||
// Skip if no matching data
|
||||
if (!searchData[dataType] || !searchData[dataType][idx]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get source data
|
||||
const sourceData = searchData[dataType][idx];
|
||||
const sourceUrl = sourceData.link || '';
|
||||
|
||||
// Skip if no link
|
||||
if (!sourceUrl) continue;
|
||||
|
||||
// Check if this URL has already been cited
|
||||
let citationKey = urlToCitationKey.get(sourceUrl);
|
||||
|
||||
// If not, create a new citation key
|
||||
if (!citationKey) {
|
||||
citationKey = `${turn}-${dataType}-${idx}`;
|
||||
urlToCitationKey.set(sourceUrl, citationKey);
|
||||
}
|
||||
|
||||
const source: Source = {
|
||||
link: sourceUrl,
|
||||
title: sourceData.title || sourceData.name || '',
|
||||
attribution: sourceData.attribution || sourceData.source || '',
|
||||
type: dataType,
|
||||
typeIndex: idx,
|
||||
citationKey,
|
||||
};
|
||||
|
||||
// Skip if already processed this citation in a composite block
|
||||
if (isComposite && processedCitations.has(fullMatch)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let referenceText = '';
|
||||
|
||||
// Check if this source has been cited before
|
||||
let existingCitation = citations.get(citationKey);
|
||||
|
||||
if (!existingCitation) {
|
||||
// New citation
|
||||
existingCitation = {
|
||||
referenceNumber: nextReferenceNumber++,
|
||||
link: source.link,
|
||||
title: source.title,
|
||||
source,
|
||||
};
|
||||
citations.set(citationKey, existingCitation);
|
||||
}
|
||||
|
||||
if (existingCitation) {
|
||||
// For composite blocks, we need to find all citations and create a combined reference
|
||||
if (isComposite) {
|
||||
// Parse all citations in this composite block if we haven't processed it yet
|
||||
if (!processedCitations.has(fullMatch)) {
|
||||
const compositeCitations: number[] = [];
|
||||
let citationMatch: RegExpExecArray | null;
|
||||
const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g');
|
||||
|
||||
while ((citationMatch = citationPattern.exec(fullMatch)) !== null) {
|
||||
const cTurn = citationMatch[1];
|
||||
const cType = citationMatch[2];
|
||||
const cIndex = citationMatch[3];
|
||||
const cDataType = refTypeMap[cType.toLowerCase()] || cType.toLowerCase();
|
||||
|
||||
const cSource = searchResults[cTurn]?.[cDataType]?.[parseInt(cIndex, 10)];
|
||||
if (cSource && cSource.link) {
|
||||
// Check if we've already created a citation for this URL
|
||||
const cUrl = cSource.link;
|
||||
let cKey = urlToCitationKey.get(cUrl);
|
||||
|
||||
if (!cKey) {
|
||||
cKey = `${cTurn}-${cDataType}-${cIndex}`;
|
||||
urlToCitationKey.set(cUrl, cKey);
|
||||
}
|
||||
|
||||
let cCitation = citations.get(cKey);
|
||||
|
||||
if (!cCitation) {
|
||||
cCitation = {
|
||||
referenceNumber: nextReferenceNumber++,
|
||||
link: cSource.link,
|
||||
title: cSource.title || cSource.name || '',
|
||||
source: {
|
||||
link: cSource.link,
|
||||
title: cSource.title || cSource.name || '',
|
||||
attribution: cSource.attribution || cSource.source || '',
|
||||
type: cDataType,
|
||||
typeIndex: parseInt(cIndex, 10),
|
||||
citationKey: cKey,
|
||||
},
|
||||
};
|
||||
citations.set(cKey, cCitation);
|
||||
}
|
||||
|
||||
if (cCitation) {
|
||||
compositeCitations.push(cCitation.referenceNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and deduplicate the composite citations
|
||||
const uniqueSortedCitations = [...new Set(compositeCitations)].sort((a, b) => a - b);
|
||||
|
||||
// Create combined reference numbers for all citations in this composite
|
||||
referenceText =
|
||||
uniqueSortedCitations.length > 0
|
||||
? uniqueSortedCitations.map((num) => `[${num}]`).join('')
|
||||
: '';
|
||||
|
||||
processedCitations.add(fullMatch);
|
||||
compositeCitationsMap.set(fullMatch, uniqueSortedCitations);
|
||||
replacements.push([fullMatch, referenceText]);
|
||||
}
|
||||
|
||||
// Skip further processing since we've handled the entire composite block
|
||||
continue;
|
||||
} else {
|
||||
// Single citation
|
||||
referenceText = `[${existingCitation.referenceNumber}]`;
|
||||
replacements.push([fullMatch, referenceText]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Apply all replacements (from longest to shortest to avoid nested replacement issues)
|
||||
replacements.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
formattedText = formattedText.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
// Step 5: Remove any orphaned composite blocks at the end of the text
|
||||
// This prevents the [1][2][3][4] list that might appear at the end if there's a composite there
|
||||
formattedText = formattedText.replace(/\n\s*\[\d+\](\[\d+\])*\s*$/g, '');
|
||||
|
||||
// Step 6: Clean up any remaining citation markers
|
||||
formattedText = formattedText.replace(INVALID_CITATION_REGEX, '');
|
||||
formattedText = formattedText.replace(CLEANUP_REGEX, '');
|
||||
|
||||
return {
|
||||
formattedText,
|
||||
citations,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { SearchResultData } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import {
|
||||
useChatContext,
|
||||
|
|
@ -18,12 +19,14 @@ export type TMessageActions = Pick<
|
|||
'message' | 'currentEditId' | 'setCurrentEditId'
|
||||
> & {
|
||||
isMultiMessage?: boolean;
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
};
|
||||
|
||||
export default function useMessageActions(props: TMessageActions) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
|
||||
const { message, currentEditId, setCurrentEditId, isMultiMessage } = props;
|
||||
const { message, currentEditId, setCurrentEditId, isMultiMessage, searchResults } = props;
|
||||
|
||||
const {
|
||||
ask,
|
||||
|
|
@ -96,7 +99,7 @@ export default function useMessageActions(props: TMessageActions) {
|
|||
regenerate(message);
|
||||
}, [isSubmitting, isCreatedByUser, message, regenerate]);
|
||||
|
||||
const copyToClipboard = useCopyToClipboard({ text, content });
|
||||
const copyToClipboard = useCopyToClipboard({ text, content, searchResults });
|
||||
|
||||
const messageLabel = useMemo(() => {
|
||||
if (message?.isCreatedByUser === true) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue