diff --git a/packages/components/nodes/tools/PerplexitySearch/PerplexitySearch.ts b/packages/components/nodes/tools/PerplexitySearch/PerplexitySearch.ts new file mode 100644 index 00000000000..6d1460f4678 --- /dev/null +++ b/packages/components/nodes/tools/PerplexitySearch/PerplexitySearch.ts @@ -0,0 +1,121 @@ +import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' +import { desc, PerplexitySearchParameters, PerplexitySearchTool } from './core' + +class PerplexitySearch_Tools implements INode { + label: string + name: string + version: number + description: string + type: string + icon: string + category: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + + constructor() { + this.label = 'Perplexity Search' + this.name = 'perplexitySearch' + this.version = 1.0 + this.type = 'PerplexitySearch' + this.icon = 'perplexity.svg' + this.category = 'Tools' + this.description = "Wrapper around Perplexity's Search API for ranked web results" + this.inputs = [ + { + label: 'Tool Description', + name: 'description', + type: 'string', + rows: 4, + additionalParams: true, + optional: true, + default: desc, + description: 'Description of what the tool does. This is for the LLM to determine when to use this tool.' + }, + { + label: 'Max Results', + name: 'maxResults', + type: 'number', + step: 1, + default: 5, + additionalParams: true, + optional: true, + description: 'Maximum number of search results to return. Default is 5.' + }, + { + label: 'Search Domain Filter', + name: 'searchDomainFilter', + type: 'string', + rows: 2, + additionalParams: true, + optional: true, + description: + 'Comma-separated list of domains to restrict results to. Prefix a domain with - to exclude it (e.g. "nytimes.com,-pinterest.com"). Do not mix allow and deny entries.' + }, + { + label: 'Search Recency Filter', + name: 'searchRecencyFilter', + type: 'options', + options: [ + { label: 'Hour', name: 'hour' }, + { label: 'Day', name: 'day' }, + { label: 'Week', name: 'week' }, + { label: 'Month', name: 'month' }, + { label: 'Year', name: 'year' } + ], + additionalParams: true, + optional: true, + description: 'Filter search results to a relative time window.' + } + ] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['perplexityApi'] + } + this.baseClasses = [this.type, ...getBaseClasses(PerplexitySearchTool)] + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const description = nodeData.inputs?.description as string + const maxResults = nodeData.inputs?.maxResults as string | number | undefined + const searchDomainFilter = nodeData.inputs?.searchDomainFilter as string + const searchRecencyFilter = nodeData.inputs?.searchRecencyFilter as string + + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const perplexityApiKey = getCredentialParam('perplexityApiKey', credentialData, nodeData) + + if (!perplexityApiKey) { + throw new Error('Perplexity API Key missing from credential') + } + + const params: PerplexitySearchParameters = { + apiKey: perplexityApiKey + } + + if (description) params.description = description + + if (maxResults !== undefined && maxResults !== '') { + const parsed = typeof maxResults === 'number' ? maxResults : parseInt(maxResults as string, 10) + if (!isNaN(parsed) && parsed > 0) params.maxResults = parsed + } + + if (searchDomainFilter) { + const domains = searchDomainFilter + .split(',') + .map((d) => d.trim()) + .filter((d) => d.length > 0) + if (domains.length > 0) params.searchDomainFilter = domains + } + + if (searchRecencyFilter) { + params.searchRecencyFilter = searchRecencyFilter as PerplexitySearchParameters['searchRecencyFilter'] + } + + return new PerplexitySearchTool(params) + } +} + +module.exports = { nodeClass: PerplexitySearch_Tools } diff --git a/packages/components/nodes/tools/PerplexitySearch/core.test.ts b/packages/components/nodes/tools/PerplexitySearch/core.test.ts new file mode 100644 index 00000000000..f6e02263437 --- /dev/null +++ b/packages/components/nodes/tools/PerplexitySearch/core.test.ts @@ -0,0 +1,38 @@ +import { secureFetch } from '../../../src/httpSecurity' +import packageJson from '../../../package.json' +import { PerplexitySearchTool } from './core' + +jest.mock('../../../src/httpSecurity', () => ({ + secureFetch: jest.fn() +})) + +const mockedSecureFetch = secureFetch as jest.MockedFunction + +describe('PerplexitySearchTool', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('sends the Flowise integration attribution header', async () => { + mockedSecureFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ results: [] }) + } as unknown as Awaited>) + + const tool = new PerplexitySearchTool({ apiKey: 'test-key' }) + + await tool._call({ query: 'latest ai news' }) + + expect(mockedSecureFetch).toHaveBeenCalledWith( + 'https://api.perplexity.ai/search', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Pplx-Integration': expect.stringMatching(/^flowise\//) + }) + }) + ) + + const headers = mockedSecureFetch.mock.calls[0]?.[1]?.headers as Record + expect(headers['X-Pplx-Integration']).toBe(`flowise/${packageJson.version}`) + }) +}) diff --git a/packages/components/nodes/tools/PerplexitySearch/core.ts b/packages/components/nodes/tools/PerplexitySearch/core.ts new file mode 100644 index 00000000000..a54f2f169df --- /dev/null +++ b/packages/components/nodes/tools/PerplexitySearch/core.ts @@ -0,0 +1,111 @@ +import { z } from 'zod/v3' +import { secureFetch } from '../../../src/httpSecurity' +import { StructuredTool } from '@langchain/core/tools' +import packageJson from '../../../package.json' + +export const desc = `A wrapper around Perplexity's Search API. Useful for retrieving up-to-date, ranked web results with title, URL, and snippet for a given query.` +export const PERPLEXITY_INTEGRATION_HEADER = `flowise/${packageJson.version}` + +export interface PerplexitySearchParameters { + apiKey: string + maxResults?: number + searchDomainFilter?: string[] + searchRecencyFilter?: 'hour' | 'day' | 'week' | 'month' | 'year' + name?: string + description?: string +} + +interface PerplexitySearchResult { + title?: string + url?: string + snippet?: string + date?: string +} + +const createPerplexitySearchSchema = () => { + return z.object({ + query: z.string().describe('The search query to send to the Perplexity Search API.') + }) +} + +export class PerplexitySearchTool extends StructuredTool { + name = 'perplexity_search' + description = desc + schema = createPerplexitySearchSchema() + + apiKey: string + maxResults: number + searchDomainFilter?: string[] + searchRecencyFilter?: 'hour' | 'day' | 'week' | 'month' | 'year' + + constructor(args: PerplexitySearchParameters) { + super() + this.name = args.name || this.name + this.description = args.description || this.description + this.apiKey = args.apiKey + this.maxResults = args.maxResults ?? 5 + this.searchDomainFilter = args.searchDomainFilter + this.searchRecencyFilter = args.searchRecencyFilter + } + + private buildBody(query: string): Record { + const body: Record = { + query, + max_results: this.maxResults + } + if (this.searchDomainFilter && this.searchDomainFilter.length > 0) { + body.search_domain_filter = this.searchDomainFilter + } + if (this.searchRecencyFilter) { + body.search_recency_filter = this.searchRecencyFilter + } + return body + } + + /** @ignore */ + async _call(arg: z.infer): Promise { + const { query } = arg + + if (!query) { + throw new Error('Query is required for Perplexity Search') + } + + if (!this.apiKey) { + throw new Error('Perplexity API Key is required') + } + + const response = await secureFetch('https://api.perplexity.ai/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'X-Pplx-Integration': PERPLEXITY_INTEGRATION_HEADER + }, + body: JSON.stringify(this.buildBody(query)) + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + throw new Error(`Perplexity Search API error: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`) + } + + const data = (await response.json()) as { results?: PerplexitySearchResult[] } + const results = data.results || [] + + if (results.length === 0) { + return 'No Perplexity Search results were found.' + } + + const formatted = results + .map((result, index) => { + const title = result.title || 'Untitled' + const url = result.url || '' + const snippet = result.snippet || '' + const date = result.date ? `\nDate: ${result.date}` : '' + return `${index + 1}. ${title}\nURL: ${url}${date}\nSnippet: ${snippet}` + }) + .join('\n\n') + + return formatted + } +} diff --git a/packages/components/nodes/tools/PerplexitySearch/perplexity.svg b/packages/components/nodes/tools/PerplexitySearch/perplexity.svg new file mode 100644 index 00000000000..2aa09bef53d --- /dev/null +++ b/packages/components/nodes/tools/PerplexitySearch/perplexity.svg @@ -0,0 +1,8 @@ + + + \ No newline at end of file