Skip to content
Open
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
121 changes: 121 additions & 0 deletions packages/components/nodes/tools/PerplexitySearch/PerplexitySearch.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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 }
38 changes: 38 additions & 0 deletions packages/components/nodes/tools/PerplexitySearch/core.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof secureFetch>

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<ReturnType<typeof secureFetch>>)

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<string, string>
expect(headers['X-Pplx-Integration']).toBe(`flowise/${packageJson.version}`)
})
})
111 changes: 111 additions & 0 deletions packages/components/nodes/tools/PerplexitySearch/core.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> {
const body: Record<string, any> = {
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<typeof this.schema>): Promise<string> {
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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.