diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index a2dfaf990707..68117ebe3f0a 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -33,6 +33,7 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { checkBalance } = require('~/models/balanceMethods'); const { truncateToolCallOutputs } = require('./prompts'); const TextStream = require('./TextStream'); +const { processPerplexityResponse } = require('~/server/services/Citations/perplexity'); class BaseClient { constructor(apiKey, options = {}) { @@ -702,6 +703,33 @@ class BaseClient { this.abortController.requestCompleted = true; } + // Check if citations were pre-processed (e.g., by AgentClient using librechat-agents) + // Otherwise, process Perplexity citations for other endpoints + let processedCompletion = completion; + let searchResults = null; + let turnNumber = Math.floor(this.currentMessages.length / 2); + + if (metadata?.searchResults) { + // AgentClient already processed citations - use directly + searchResults = metadata.searchResults; + turnNumber = metadata.turn ?? turnNumber; + } else { + // Fall back to Perplexity processing for non-agents endpoints + const result = processPerplexityResponse({ + completion, + metadata, + rawResponse: this.rawResponse, // May be undefined + turnNumber, + endpoint: this.options.endpoint, + baseURL: this.options.baseURL, + }); + processedCompletion = result.processedCompletion; + searchResults = result.searchResults; + } + + // Filter out internal citation fields from metadata before storing + const { searchResults: _sr, turn: _t, ...storedMetadata } = metadata ?? {}; + /** @type {TMessage} */ const responseMessage = { messageId: responseMessageId, @@ -715,36 +743,52 @@ class BaseClient { iconURL: this.options.iconURL, endpoint: this.options.endpoint, ...(this.metadata ?? {}), - metadata: Object.keys(metadata ?? {}).length > 0 ? metadata : undefined, + metadata: Object.keys(storedMetadata).length > 0 ? storedMetadata : undefined, }; - if (typeof completion === 'string') { - responseMessage.text = completion; + if (typeof processedCompletion === 'string') { + responseMessage.text = processedCompletion; } else if ( - Array.isArray(completion) && + Array.isArray(processedCompletion) && (this.clientName === EModelEndpoint.agents || isParamEndpoint(this.options.endpoint, this.options.endpointType)) ) { responseMessage.text = ''; if (!opts.editedContent || this.currentMessages.length === 0) { - responseMessage.content = completion; + responseMessage.content = processedCompletion; } else { const latestMessage = this.currentMessages[this.currentMessages.length - 1]; if (!latestMessage?.content) { - responseMessage.content = completion; + responseMessage.content = processedCompletion; } else { const existingContent = [...latestMessage.content]; const { type: editedType } = opts.editedContent; responseMessage.content = this.mergeEditedContent( existingContent, - completion, + processedCompletion, editedType, ); } } - } else if (Array.isArray(completion)) { - responseMessage.text = completion.join(''); + } else if (Array.isArray(processedCompletion)) { + responseMessage.text = processedCompletion.join(''); + } + + // Add search results as attachment if available (Perplexity citations) + if (searchResults) { + const searchAttachment = { + type: 'web_search', + web_search: { + ...searchResults, + turn: turnNumber, + }, + messageId: responseMessageId, + conversationId, + }; + + responseMessage.attachments = responseMessage.attachments || []; + responseMessage.attachments.push(searchAttachment); } if ( @@ -790,7 +834,12 @@ class BaseClient { } if (this.artifactPromises) { - responseMessage.attachments = (await Promise.all(this.artifactPromises)).filter((a) => a); + const artifactAttachments = (await Promise.all(this.artifactPromises)).filter((a) => a); + // Merge with existing attachments (e.g., searchResults) instead of overwriting + responseMessage.attachments = [ + ...(responseMessage.attachments || []), + ...artifactAttachments, + ]; } if (this.options.attachments) { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 2601fb3be03b..4cce1637e459 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -26,6 +26,7 @@ const { formatMessage, labelContentByAgent, formatAgentMessages, + processContentParts, getTokenCountForMessage, createMetadataAggregator, } = require('@librechat/agents'); @@ -763,7 +764,30 @@ class AgentClient extends BaseClient { }); const completion = filterMalformedContentParts(this.contentParts); - return { completion }; + + // Get processed citations from the graph (already transformed by librechat-agents) + const processedCitations = this.run?.Graph?.getProcessedCitations?.(); + const searchResults = processedCitations?.searchResults || null; + + // If we have search results, inject citation markers into the content + let processedCompletion = completion; + let metadata; + + if (searchResults?.organic?.length > 0) { + // Calculate turn number for citation markers + const turnNumber = Math.floor((this.currentMessages?.length || 0) / 2); + + // Inject Unicode citation markers into text content parts + processedCompletion = processContentParts(completion, turnNumber); + + // Pass pre-processed searchResults in metadata + metadata = { + searchResults, + turn: turnNumber, + }; + } + + return { completion: processedCompletion, metadata }; } /** diff --git a/api/server/services/Citations/perplexity.js b/api/server/services/Citations/perplexity.js new file mode 100644 index 000000000000..72ddd332a0ee --- /dev/null +++ b/api/server/services/Citations/perplexity.js @@ -0,0 +1,233 @@ +const { logger } = require('@librechat/data-schemas'); + +/** + * Detects if the request is for a Perplexity endpoint + * @param {string} endpoint - The endpoint name + * @param {string} [baseURL] - The base URL for the endpoint + * @returns {boolean} True if this is a Perplexity endpoint + */ +function detectPerplexityResponse(endpoint, baseURL) { + if (!endpoint && !baseURL) { + return false; + } + + // Check endpoint name + if (endpoint && typeof endpoint === 'string' && endpoint.toLowerCase().includes('perplexity')) { + return true; + } + + // Check baseURL contains perplexity + if (baseURL && typeof baseURL === 'string' && baseURL.toLowerCase().includes('perplexity')) { + return true; + } + + return false; +} + +/** + * Transforms Perplexity citations and search_results to LibreChat SearchResultData format + * @param {string[]} citations - Array of citation URLs from Perplexity + * @param {Array<{title: string, url: string, date?: string, snippet?: string}>} search_results - Detailed search results from Perplexity + * @returns {Object} SearchResultData object compatible with LibreChat's frontend + */ +function transformPerplexityCitations(citations, search_results) { + const searchResultData = { + organic: [], + topStories: [], + images: [], + videos: [], + references: [], + }; + + // Prefer search_results if available, fallback to citations + const sources = search_results && search_results.length > 0 ? search_results : citations; + + if (!sources || sources.length === 0) { + return searchResultData; + } + + // Transform to organic search results format + searchResultData.organic = sources.map((source, index) => { + // If source is a string (from citations array), create basic object + if (typeof source === 'string') { + return { + link: source, + title: `Source ${index + 1}`, + snippet: '', + date: new Date().toISOString().split('T')[0], + position: index + 1, + }; + } + + // If source is an object (from search_results array) + return { + link: source.url || source.link || '', + title: source.title || `Source ${index + 1}`, + snippet: source.snippet || '', + date: source.date || new Date().toISOString().split('T')[0], + position: index + 1, + }; + }); + + return searchResultData; +} + +/** + * Injects Unicode citation markers into response text + * Replaces [1][2] style markers with Unicode format that frontend expects + * @param {string} content - Response text with [N] style citation markers + * @param {number} turnNumber - Current conversation turn number + * @returns {string} Content with Unicode citation markers injected + */ +function injectCitationMarkers(content, turnNumber) { + if (!content || typeof content !== 'string') { + return content; + } + + // Unicode citation marker (U+E202 - Private Use Area) + // This creates the actual Unicode character, not the escape sequence + const CITATION_MARKER = String.fromCharCode(0xe202); + + // Replace [1], [2], etc. with Unicode markers + // Regex to match [number] patterns + const citationRegex = /\[(\d+)\]/g; + + const processedContent = content.replace(citationRegex, (match, num) => { + const citationIndex = parseInt(num, 10) - 1; // Convert 1-based to 0-based index + if (citationIndex < 0) { + return match; // Keep original if invalid number + } + + // Create Unicode marker: {U+E202}turn{N}search{index} + // e.g., turn0search0 for first citation on turn 0 + // The CITATION_MARKER is the actual Unicode character U+E202 + // NOTE: We add a space before the marker to prevent breaking markdown emphasis parsing. + // Without the space, `**bold**{U+E202}` is not recognized as valid bold by remark-gfm + // because U+E202 (Private Use Area) is not classified as punctuation or whitespace. + return ` ${CITATION_MARKER}turn${turnNumber}search${citationIndex}`; + }); + + return processedContent; +} + +/** + * Extracts citation data from API response metadata or raw response + * @param {Object} metadata - Response metadata object + * @param {Object} [rawResponse] - Raw API response (if available) + * @returns {{citations: string[], search_results: Array}} Extracted citation data + */ +function extractCitationData(metadata, rawResponse) { + const result = { + citations: null, + search_results: null, + }; + + // Try to extract from raw response first + if (rawResponse) { + if (Array.isArray(rawResponse.citations)) { + result.citations = rawResponse.citations; + } + if (Array.isArray(rawResponse.search_results)) { + result.search_results = rawResponse.search_results; + } + } + + // Try to extract from metadata + if (metadata) { + if (Array.isArray(metadata.citations)) { + result.citations = metadata.citations; + } + if (Array.isArray(metadata.search_results)) { + result.search_results = metadata.search_results; + } + } + + return result; +} + +/** + * Processes Perplexity API response to extract and transform citations + * This is the main entry point for Perplexity citation processing + * @param {Object} params - Processing parameters + * @param {string} params.completion - The completion text from Perplexity + * @param {Object} params.metadata - Response metadata + * @param {Object} [params.rawResponse] - Raw API response (if available) + * @param {number} params.turnNumber - Current conversation turn number + * @param {string} [params.endpoint] - Endpoint name + * @param {string} [params.baseURL] - Base URL + * @returns {{processedCompletion: string, searchResults: Object|null}} Processed response + */ +function processPerplexityResponse({ + completion, + metadata, + rawResponse, + turnNumber, + endpoint, + baseURL, +}) { + // Check if this is a Perplexity response + if (!detectPerplexityResponse(endpoint, baseURL)) { + return { + processedCompletion: completion, + searchResults: null, + }; + } + + // Extract citation data + const citationData = extractCitationData(metadata, rawResponse); + + // If no citations found, return original completion + if (!citationData.citations && !citationData.search_results) { + logger.debug('[Perplexity Citations] No citation data found in response'); + return { + processedCompletion: completion, + searchResults: null, + }; + } + + logger.info('[Perplexity Citations] Processing citations', { + citationsCount: citationData.citations?.length || 0, + searchResultsCount: citationData.search_results?.length || 0, + turnNumber, + }); + + // Transform citations to SearchResultData format + const searchResults = transformPerplexityCitations( + citationData.citations, + citationData.search_results, + ); + + // Inject Unicode citation markers into text + // Handle both string completions and array of content parts + let processedCompletion; + if (typeof completion === 'string') { + processedCompletion = injectCitationMarkers(completion, turnNumber); + } else if (Array.isArray(completion)) { + // Process each content part that has text + processedCompletion = completion.map((part) => { + if (part && part.type === 'text' && typeof part.text === 'string') { + return { + ...part, + text: injectCitationMarkers(part.text, turnNumber), + }; + } + return part; + }); + } else { + // Unknown format, return as-is + processedCompletion = completion; + } + + return { + processedCompletion, + searchResults, + }; +} + +module.exports = { + detectPerplexityResponse, + transformPerplexityCitations, + injectCitationMarkers, + extractCitationData, + processPerplexityResponse, +}; diff --git a/api/server/services/Citations/specs/perplexity.spec.js b/api/server/services/Citations/specs/perplexity.spec.js new file mode 100644 index 000000000000..cfc533f535d0 --- /dev/null +++ b/api/server/services/Citations/specs/perplexity.spec.js @@ -0,0 +1,312 @@ +const { + detectPerplexityResponse, + transformPerplexityCitations, + injectCitationMarkers, + extractCitationData, + processPerplexityResponse, +} = require('../perplexity'); + +// Unicode citation marker (U+E202) +const CITATION_MARKER = String.fromCharCode(0xe202); + +describe('Perplexity Citation Service', () => { + describe('detectPerplexityResponse', () => { + it('should return true for perplexity endpoint name', () => { + expect(detectPerplexityResponse('perplexity', null)).toBe(true); + expect(detectPerplexityResponse('Perplexity', null)).toBe(true); + expect(detectPerplexityResponse('PERPLEXITY', null)).toBe(true); + expect(detectPerplexityResponse('my-perplexity-endpoint', null)).toBe(true); + }); + + it('should return true for perplexity baseURL', () => { + expect(detectPerplexityResponse(null, 'https://api.perplexity.ai/')).toBe(true); + expect(detectPerplexityResponse(null, 'https://API.PERPLEXITY.AI/')).toBe(true); + }); + + it('should return false for non-perplexity endpoints', () => { + expect(detectPerplexityResponse('openai', 'https://api.openai.com/')).toBe(false); + expect(detectPerplexityResponse('anthropic', null)).toBe(false); + expect(detectPerplexityResponse(null, 'https://api.anthropic.com/')).toBe(false); + }); + + it('should return false for empty inputs', () => { + expect(detectPerplexityResponse(null, null)).toBe(false); + expect(detectPerplexityResponse(undefined, undefined)).toBe(false); + expect(detectPerplexityResponse('', '')).toBe(false); + }); + }); + + describe('transformPerplexityCitations', () => { + it('should transform string citations array to organic results', () => { + const citations = ['https://example.com/article1', 'https://example.com/article2']; + + const result = transformPerplexityCitations(citations, null); + + expect(result.organic).toHaveLength(2); + expect(result.organic[0]).toMatchObject({ + link: 'https://example.com/article1', + title: 'Source 1', + position: 1, + }); + expect(result.organic[1]).toMatchObject({ + link: 'https://example.com/article2', + title: 'Source 2', + position: 2, + }); + }); + + it('should transform search_results objects to organic results', () => { + const searchResults = [ + { + url: 'https://example.com/article1', + title: 'First Article', + snippet: 'This is a snippet', + date: '2024-01-15', + }, + { + url: 'https://example.com/article2', + title: 'Second Article', + snippet: 'Another snippet', + }, + ]; + + const result = transformPerplexityCitations(null, searchResults); + + expect(result.organic).toHaveLength(2); + expect(result.organic[0]).toMatchObject({ + link: 'https://example.com/article1', + title: 'First Article', + snippet: 'This is a snippet', + date: '2024-01-15', + position: 1, + }); + expect(result.organic[1]).toMatchObject({ + link: 'https://example.com/article2', + title: 'Second Article', + snippet: 'Another snippet', + position: 2, + }); + }); + + it('should prefer search_results over citations when both provided', () => { + const citations = ['https://citation.com']; + const searchResults = [{ url: 'https://searchresult.com', title: 'Search Result' }]; + + const result = transformPerplexityCitations(citations, searchResults); + + expect(result.organic).toHaveLength(1); + expect(result.organic[0].link).toBe('https://searchresult.com'); + }); + + it('should return empty organic array for empty inputs', () => { + const result = transformPerplexityCitations(null, null); + + expect(result.organic).toEqual([]); + expect(result.topStories).toEqual([]); + expect(result.images).toEqual([]); + }); + + it('should handle source objects with link instead of url', () => { + const searchResults = [{ link: 'https://example.com', title: 'Test' }]; + + const result = transformPerplexityCitations(null, searchResults); + + expect(result.organic[0].link).toBe('https://example.com'); + }); + }); + + describe('injectCitationMarkers', () => { + it('should replace [1] style markers with Unicode markers (with leading space)', () => { + const content = 'This is a fact[1] and another fact[2].'; + const result = injectCitationMarkers(content, 0); + + // Space before marker prevents breaking markdown emphasis parsing + expect(result).toBe( + `This is a fact ${CITATION_MARKER}turn0search0 and another fact ${CITATION_MARKER}turn0search1.`, + ); + }); + + it('should use correct turn number', () => { + const content = 'A fact[1].'; + const result = injectCitationMarkers(content, 5); + + expect(result).toBe(`A fact ${CITATION_MARKER}turn5search0.`); + }); + + it('should handle multiple citations in sequence', () => { + const content = 'Statement[1][2][3].'; + const result = injectCitationMarkers(content, 0); + + expect(result).toBe( + `Statement ${CITATION_MARKER}turn0search0 ${CITATION_MARKER}turn0search1 ${CITATION_MARKER}turn0search2.`, + ); + }); + + it('should handle double-digit citation numbers', () => { + const content = 'Many sources[10][11].'; + const result = injectCitationMarkers(content, 0); + + expect(result).toBe( + `Many sources ${CITATION_MARKER}turn0search9 ${CITATION_MARKER}turn0search10.`, + ); + }); + + it('should return original content if no citations found', () => { + const content = 'No citations here.'; + const result = injectCitationMarkers(content, 0); + + expect(result).toBe('No citations here.'); + }); + + it('should handle null or undefined content', () => { + expect(injectCitationMarkers(null, 0)).toBe(null); + expect(injectCitationMarkers(undefined, 0)).toBe(undefined); + }); + + it('should not replace non-citation brackets', () => { + const content = 'Array[0] is not a citation but [1] is.'; + const result = injectCitationMarkers(content, 0); + + // [0] becomes turn0search-1 which is invalid, so it stays + // Actually looking at the code, [0] would become search-1 which is kept as original + // Let me check... citationIndex = 0 - 1 = -1, which returns match (original) + expect(result).toContain('[0]'); + expect(result).toContain(` ${CITATION_MARKER}turn0search0`); + }); + }); + + describe('extractCitationData', () => { + it('should extract citations from metadata', () => { + const metadata = { + citations: ['https://example.com'], + search_results: [{ url: 'https://example.com', title: 'Test' }], + }; + + const result = extractCitationData(metadata, null); + + expect(result.citations).toEqual(['https://example.com']); + expect(result.search_results).toEqual([{ url: 'https://example.com', title: 'Test' }]); + }); + + it('should extract citations from rawResponse', () => { + const rawResponse = { + citations: ['https://raw.com'], + search_results: [{ url: 'https://raw.com', title: 'Raw' }], + }; + + const result = extractCitationData(null, rawResponse); + + expect(result.citations).toEqual(['https://raw.com']); + expect(result.search_results).toEqual([{ url: 'https://raw.com', title: 'Raw' }]); + }); + + it('should prefer metadata over rawResponse for same fields', () => { + const metadata = { citations: ['https://meta.com'] }; + const rawResponse = { citations: ['https://raw.com'] }; + + const result = extractCitationData(metadata, rawResponse); + + // metadata is processed after rawResponse, so it wins + expect(result.citations).toEqual(['https://meta.com']); + }); + + it('should return nulls for empty inputs', () => { + const result = extractCitationData(null, null); + + expect(result.citations).toBeNull(); + expect(result.search_results).toBeNull(); + }); + }); + + describe('processPerplexityResponse', () => { + it('should process perplexity response with citations', () => { + const result = processPerplexityResponse({ + completion: 'React is a library[1] for building UIs[2].', + metadata: { + citations: ['https://react.dev', 'https://example.com'], + }, + turnNumber: 0, + endpoint: 'perplexity', + baseURL: 'https://api.perplexity.ai/', + }); + + expect(result.processedCompletion).toContain(`${CITATION_MARKER}turn0search0`); + expect(result.processedCompletion).toContain(`${CITATION_MARKER}turn0search1`); + expect(result.searchResults.organic).toHaveLength(2); + }); + + it('should return original completion for non-perplexity endpoints', () => { + const result = processPerplexityResponse({ + completion: 'Some text[1].', + metadata: { citations: ['https://example.com'] }, + turnNumber: 0, + endpoint: 'openai', + baseURL: 'https://api.openai.com/', + }); + + expect(result.processedCompletion).toBe('Some text[1].'); + expect(result.searchResults).toBeNull(); + }); + + it('should return original completion when no citations found', () => { + const result = processPerplexityResponse({ + completion: 'No citations here.', + metadata: {}, + turnNumber: 0, + endpoint: 'perplexity', + baseURL: 'https://api.perplexity.ai/', + }); + + expect(result.processedCompletion).toBe('No citations here.'); + expect(result.searchResults).toBeNull(); + }); + + it('should handle array of content parts', () => { + const result = processPerplexityResponse({ + completion: [ + { type: 'text', text: 'First part[1].' }, + { type: 'text', text: 'Second part[2].' }, + { type: 'image', url: 'https://example.com/image.png' }, + ], + metadata: { + citations: ['https://example1.com', 'https://example2.com'], + }, + turnNumber: 0, + endpoint: 'perplexity', + baseURL: 'https://api.perplexity.ai/', + }); + + expect(result.processedCompletion).toHaveLength(3); + expect(result.processedCompletion[0].text).toContain(`${CITATION_MARKER}turn0search0`); + expect(result.processedCompletion[1].text).toContain(`${CITATION_MARKER}turn0search1`); + expect(result.processedCompletion[2]).toEqual({ + type: 'image', + url: 'https://example.com/image.png', + }); + }); + + it('should use search_results for richer citation data', () => { + const result = processPerplexityResponse({ + completion: 'A fact[1].', + metadata: { + search_results: [ + { + url: 'https://example.com', + title: 'Example Article', + snippet: 'This is a snippet.', + }, + ], + }, + turnNumber: 0, + endpoint: 'perplexity', + baseURL: 'https://api.perplexity.ai/', + }); + + expect(result.searchResults.organic[0]).toMatchObject({ + link: 'https://example.com', + title: 'Example Article', + snippet: 'This is a snippet.', + }); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 42ce8b8f14e9..ee43a15ffe4b 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -32,6 +32,8 @@ type ContentPartsProps = { | ((value: number) => void | React.Dispatch>) | null | undefined; + endpoint?: string; + model?: string; }; /** @@ -54,6 +56,8 @@ const ContentParts = memo(function ContentParts({ conversationId, isCreatedByUser, isLatestMessage, + endpoint, + model, }: ContentPartsProps) { const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; @@ -87,6 +91,8 @@ const ContentParts = memo(function ContentParts({ isCreatedByUser={isCreatedByUser} isLast={isLastPart} showCursor={isLastPart && isLast} + endpoint={endpoint} + model={model} /> ); @@ -100,6 +106,8 @@ const ContentParts = memo(function ContentParts({ isLast, isLatestMessage, messageId, + endpoint, + model, ], ); diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index a763885d2fb7..8290c757b2b3 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -9,6 +9,11 @@ import rehypeHighlight from 'rehype-highlight'; import remarkDirective from 'remark-directive'; import type { Pluggable } from 'unified'; import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation'; +import { + PerplexityCitation, + PerplexityCompositeCitation, + PerplexityHighlightedText, +} from '~/components/Web/PerplexityCitation'; import { mcpUIResourcePlugin, MCPUIResource, @@ -25,11 +30,20 @@ import store from '~/store'; type TContentProps = { content: string; isLatestMessage: boolean; + endpoint?: string; + model?: string; +}; + +const isPerplexityModel = (model?: string): boolean => { + if (!model) return false; + const lowerModel = model.toLowerCase(); + return lowerModel.includes('sonar') || lowerModel.includes('perplexity'); }; -const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { +const Markdown = memo(({ content = '', isLatestMessage, endpoint, model }: TContentProps) => { const LaTeXParsing = useRecoilValue(store.LaTeXParsing); const isInitializing = content === ''; + const isPerplexity = endpoint === 'perplexity' || isPerplexityModel(model); const currentContent = useMemo(() => { if (isInitializing) { @@ -89,9 +103,11 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { p, img, artifact: Artifact, - citation: Citation, - 'highlighted-text': HighlightedText, - 'composite-citation': CompositeCitation, + citation: isPerplexity ? PerplexityCitation : Citation, + 'highlighted-text': isPerplexity ? PerplexityHighlightedText : HighlightedText, + 'composite-citation': isPerplexity + ? PerplexityCompositeCitation + : CompositeCitation, 'mcp-ui-resource': MCPUIResource, 'mcp-ui-carousel': MCPUIResourceCarousel, } as { diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 7a823a07e90e..161b86ecf34f 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -102,13 +102,15 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay const content = useMemo(() => { if (!isCreatedByUser) { - return ; + return ( + + ); } if (enableUserMsgMarkdown) { return ; } return <>{text}; - }, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]); + }, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage, message.endpoint, message.model]); return ( diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index bfa2b28fac65..d1969e8b5e22 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -26,10 +26,12 @@ type PartProps = { showCursor: boolean; isCreatedByUser: boolean; attachments?: TAttachment[]; + endpoint?: string; + model?: string; }; const Part = memo( - ({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser }: PartProps) => { + ({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser, endpoint, model }: PartProps) => { if (!part) { return null; } @@ -73,7 +75,7 @@ const Part = memo( } return ( - + ); } else if (part.type === ContentTypes.THINK) { @@ -181,7 +183,7 @@ const Part = memo( if (isSubmitting && showCursor) { return ( - + ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/Text.tsx b/client/src/components/Chat/Messages/Content/Parts/Text.tsx index c926622c9db4..ffc5b01dc0fa 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Text.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Text.tsx @@ -10,6 +10,8 @@ type TextPartProps = { text: string; showCursor: boolean; isCreatedByUser: boolean; + endpoint?: string; + model?: string; }; type ContentType = @@ -17,20 +19,20 @@ type ContentType = | ReactElement> | ReactElement; -const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => { +const TextPart = memo(({ text, isCreatedByUser, showCursor, endpoint, model }: TextPartProps) => { const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]); const content: ContentType = useMemo(() => { if (!isCreatedByUser) { - return ; + return ; } else if (enableUserMsgMarkdown) { return ; } else { return <>{text}; } - }, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]); + }, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage, endpoint, model]); return (
); })} diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 0005ee04993e..055a97071c26 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -144,6 +144,8 @@ export default function Message(props: TMessageProps) { conversationId={conversation?.conversationId} isLatestMessage={messageId === latestMessage?.messageId} content={message.content as Array} + endpoint={message.endpoint} + model={message.model} />
{isLast && isSubmitting ? ( diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 5724ff77c2ea..a03f8d022349 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -160,6 +160,8 @@ const ContentRender = memo( isCreatedByUser={msg.isCreatedByUser} conversationId={conversation?.conversationId} content={msg.content as Array} + endpoint={msg.endpoint} + model={msg.model} /> {hasNoChildren && effectiveIsSubmitting ? ( diff --git a/client/src/components/Web/PerplexityCitation.tsx b/client/src/components/Web/PerplexityCitation.tsx new file mode 100644 index 000000000000..cd1dde51fca8 --- /dev/null +++ b/client/src/components/Web/PerplexityCitation.tsx @@ -0,0 +1,251 @@ +import { memo, useState, useContext, useCallback } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useToastContext } from '@librechat/client'; +import type { CitationProps } from './types'; +import { + SourceHovercard, + FaviconImage, + getCleanDomain, +} from '~/components/Web/PerplexitySourceHovercard'; +import { CitationContext, useCitation, useCompositeCitations } from './Context'; +import { useFileDownload } from '~/data-provider'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +interface CompositeCitationProps { + citationId?: string; + node?: { + properties?: CitationProps; + }; +} + +export function PerplexityCompositeCitation(props: CompositeCitationProps) { + const localize = useLocalize(); + const { citations, citationId } = props.node?.properties ?? ({} as CitationProps); + const { setHoveredCitationId } = useContext(CitationContext); + const [currentPage, setCurrentPage] = useState(0); + const sources = useCompositeCitations(citations || []); + + if (!sources || sources.length === 0) return null; + const totalPages = sources.length; + + const getCitationLabel = () => { + if (!sources || sources.length === 0) return localize('com_citation_source'); + + const firstSource = sources[0]; + const remainingCount = sources.length - 1; + // Perplexity-style: prefer domain name for compact display + const domain = getCleanDomain(firstSource.link || '') || localize('com_citation_source'); + + return remainingCount > 0 ? `${domain} +${remainingCount}` : domain; + }; + + // Get unique domains for stacked favicon display + const getUniqueDomains = () => { + if (!sources) return []; + const domains = sources.map((s) => getCleanDomain(s.link || '')).filter(Boolean); + return [...new Set(domains)].slice(0, 3); // Max 3 favicons + }; + + const handlePrevPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (currentPage > 0) { + setCurrentPage(currentPage - 1); + } + }; + + const handleNextPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (currentPage < totalPages - 1) { + setCurrentPage(currentPage + 1); + } + }; + + const currentSource = sources?.[currentPage]; + + return ( + setHoveredCitationId(citationId || null)} + onMouseLeave={() => setHoveredCitationId(null)} + > + {totalPages > 1 && ( + + + + + {currentPage + 1}/{totalPages} + + + + + + {getUniqueDomains().map((domain, i) => ( + 0 ? 'ring-1 ring-surface-secondary' : ''} + /> + ))} + + + {localize('com_citation_sources_count', { count: totalPages })} + + + + )} + + + + {getCleanDomain(currentSource.link || '')} + + + + {currentSource.title || currentSource.attribution} + + {currentSource.snippet && ( +

{currentSource.snippet}

+ )} +
+ ); +} + +interface CitationComponentProps { + citationId: string; + citationType: 'span' | 'standalone' | 'composite' | 'group' | 'navlist'; + node?: { + properties?: CitationProps; + }; +} + +export function PerplexityCitation(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({ + turn: citation?.turn || 0, + refType: citation?.refType, + index: citation?.index || 0, + }); + + // Setup file download hook + const isFileType = refData?.refType === 'file' && (refData as any)?.fileId; + const isLocalFile = isFileType && (refData as any)?.metadata?.storageType === 'local'; + const { refetch: downloadFile } = useFileDownload( + user?.id ?? '', + isFileType && !isLocalFile ? (refData as any).fileId : '', + ); + + const handleFileDownload = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!isFileType || !(refData as any)?.fileId) return; + + // Don't allow download for local files + if (isLocalFile) { + showToast({ + status: 'error', + message: localize('com_sources_download_local_unavailable'), + }); + 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, isLocalFile, refData, localize, showToast], + ); + + if (!refData) return null; + + const getCitationLabel = () => { + // Perplexity-style: prefer domain name for compact display + return ( + getCleanDomain(refData.link || '') || refData.attribution || localize('com_citation_source') + ); + }; + + return ( + setHoveredCitationId(citationId || null)} + onMouseLeave={() => setHoveredCitationId(null)} + onClick={isFileType && !isLocalFile ? handleFileDownload : undefined} + isFile={isFileType} + isLocalFile={isLocalFile} + /> + ); +} + +export interface HighlightedTextProps { + children: React.ReactNode; + citationId?: string; +} + +export function useHighlightState(citationId: string | undefined) { + const { hoveredCitationId } = useContext(CitationContext); + return citationId && hoveredCitationId === citationId; +} + +export const PerplexityHighlightedText = memo(function PerplexityHighlightedText({ + children, + citationId, +}: HighlightedTextProps) { + const isHighlighted = useHighlightState(citationId); + + return ( + + {children} + + ); +}); diff --git a/client/src/components/Web/PerplexitySourceHovercard.tsx b/client/src/components/Web/PerplexitySourceHovercard.tsx new file mode 100644 index 000000000000..0cd5b3f01c8e --- /dev/null +++ b/client/src/components/Web/PerplexitySourceHovercard.tsx @@ -0,0 +1,148 @@ +import React, { ReactNode } from 'react'; +import * as Ariakit from '@ariakit/react'; +import { ChevronDown, Paperclip } from 'lucide-react'; +import { VisuallyHidden } from '@ariakit/react'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +export interface SourceData { + link: string; + title?: string; + attribution?: string; + snippet?: string; +} + +interface SourceHovercardProps { + source: SourceData; + label: string; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onClick?: (e: React.MouseEvent) => void; + isFile?: boolean; + isLocalFile?: boolean; + children?: ReactNode; +} + +/** Helper to get domain favicon */ +function getFaviconUrl(domain: string) { + return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; +} + +/** Helper to get clean domain name */ +export function getCleanDomain(url: string) { + const domain = url.replace(/(^\w+:|^)\/\//, '').split('/')[0]; + return domain.startsWith('www.') ? domain.substring(4) : domain; +} + +export function FaviconImage({ domain, className = '' }: { domain: string; className?: string }) { + return ( +
+
+ {domain} +
+
+ ); +} + +export function SourceHovercard({ + source, + label, + onMouseEnter, + onMouseLeave, + onClick, + isFile = false, + isLocalFile = false, + children, +}: SourceHovercardProps) { + const localize = useLocalize(); + const domain = getCleanDomain(source.link || ''); + + return ( + + + + + {label} + + ) : ( + + {label} + + ) + } + /> + + {localize('com_citation_more_details', { label })} + + + + {children} + {!children && ( + <> + {/* Domain with favicon - Perplexity style */} + + {isFile ? ( + + ) : ( + + )} + + {isFile ? localize('com_file_source') : domain} + + + + {/* Title as clickable link */} + {isFile ? ( + + ) : ( + + {source.title || source.attribution || source.link} + + )} + + {/* Snippet */} + {source.snippet && ( +

{source.snippet}

+ )} + + )} +
+
+
+
+ ); +} diff --git a/client/src/components/Web/plugin.ts b/client/src/components/Web/plugin.ts index a298b28f567b..2bb97295d49a 100644 --- a/client/src/components/Web/plugin.ts +++ b/client/src/components/Web/plugin.ts @@ -214,29 +214,78 @@ function processTree(tree: Node) { } case 'standalone': { - // Extract reference info + // Extract reference info for first citation const turn = Number(match![1]); const refType = match![2]; const refIndex = Number(match![3]); - segments.push({ - type: 'citation', - data: { - hName: 'citation', - hProperties: { - citation: { - turn, - refType, - index: refIndex, + // Collect adjacent standalone citations into a group + const citations: Array = [ + { + turn, + refType, + index: refIndex, + }, + ]; + + let lookAheadPos = matchIndex + matchText.length; + + // Keep looking for adjacent standalone citations (only whitespace between them) + while (true) { + // Reset regex for fresh search + STANDALONE_PATTERN.lastIndex = lookAheadPos; + const nextStandalone = STANDALONE_PATTERN.exec(originalValue); + + if (nextStandalone && isStandaloneMarker(originalValue, nextStandalone.index)) { + const gapText = originalValue.substring(lookAheadPos, nextStandalone.index); + // Only group if there's only whitespace between citations (max 5 chars) + if (gapText.length <= 5 && /^\s*$/.test(gapText)) { + citations.push({ + turn: Number(nextStandalone[1]), + refType: nextStandalone[2], + index: Number(nextStandalone[3]), + }); + lookAheadPos = nextStandalone.index + nextStandalone[0].length; + } else { + break; + } + } else { + break; + } + } + + if (citations.length > 1) { + // Multiple adjacent citations - create composite + segments.push({ + type: 'composite-citation', + data: { + hName: 'composite-citation', + hProperties: { + citations, + citationId: citationId, }, - citationType: 'standalone', - citationId: citationId, }, - }, - }); + }); + typeCounts.composite++; + } else { + // Single citation + segments.push({ + type: 'citation', + data: { + hName: 'citation', + hProperties: { + citation: citations[0], + citationType: 'standalone', + citationId: citationId, + }, + }, + }); + typeCounts.standalone++; + } - typeCounts.standalone++; - break; + // Move position past all collected citations + currentPosition = lookAheadPos; + continue; // Skip the default position update } } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c144e8bda569..319e8b8f1a6d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -216,6 +216,7 @@ "com_auth_welcome_back": "Welcome back", "com_citation_more_details": "More details about {{label}}", "com_citation_source": "Source", + "com_citation_sources_count": "{{count}} sources", "com_click_to_download": "(click here to download)", "com_download_expired": "(download expired)", "com_download_expires": "(click here to download - expires {{0}})", diff --git a/packages/api/src/tools/toolkits/imageContext.ts b/packages/api/src/tools/toolkits/imageContext.ts index 0485ed815a52..723f1731044f 100644 --- a/packages/api/src/tools/toolkits/imageContext.ts +++ b/packages/api/src/tools/toolkits/imageContext.ts @@ -35,4 +35,3 @@ export function buildImageToolContext({ } return toolContext; } -