Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 59 additions & 10 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 25 additions & 1 deletion api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
formatMessage,
labelContentByAgent,
formatAgentMessages,
processContentParts,
getTokenCountForMessage,
createMetadataAggregator,
} = require('@librechat/agents');
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
233 changes: 233 additions & 0 deletions api/server/services/Citations/perplexity.js
Original file line number Diff line number Diff line change
@@ -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<Object>}} 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,
};
Loading