diff --git a/.env.example b/.env.example index b1391dd..14a01d6 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ OPENROUTER_API_KEY='add your openrouter api key here' -EXA_SEARCH_API_KEY='add your exa search api key here' \ No newline at end of file +EXA_SEARCH_API_KEY='add your exa search api key here' +TAVILY_API_KEY='add your tavily api key here' +SEARCH_PROVIDER='exa' # Options: 'exa' | 'tavily' | 'both' \ No newline at end of file diff --git a/package.json b/package.json index 7679cef..e3cd9f5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "@tavily/core": "^0.0.7", "exa-js": "^2.0.0", "lucide-react": "^0.562.0", "next": "^16.0.0", diff --git a/src/app/api/deep-research/research-functions.ts b/src/app/api/deep-research/research-functions.ts index 9afb629..b15e634 100644 --- a/src/app/api/deep-research/research-functions.ts +++ b/src/app/api/deep-research/research-functions.ts @@ -17,7 +17,7 @@ import { REPORT_SYSTEM_PROMPT, } from "./prompts"; import { callModel } from "./model-caller"; -import { exa } from "./services"; +import { exa, getTavily } from "./services"; import { combineFindings, handleError } from "./utils"; import { MAX_CONTENT_CHARS, @@ -64,6 +64,50 @@ export async function generateSearchQueries( } } +async function searchWithExa(query: string): Promise { + const searchResult = await exa.searchAndContents(query, { + type: "keyword", + numResults: MAX_SEARCH_RESULTS, + startPublishedDate: new Date( + Date.now() - 365 * 24 * 60 * 60 * 1000 + ).toISOString(), + endPublishedDate: new Date().toISOString(), + startCrawlDate: new Date( + Date.now() - 365 * 24 * 60 * 60 * 1000 + ).toISOString(), + endCrawlDate: new Date().toISOString(), + excludeDomains: ["https://youtube.com"], + text: { + maxCharacters: MAX_CONTENT_CHARS, + }, + }); + + return searchResult.results + .filter((r: { title: string | null; text?: string }) => r.title && r.text !== undefined) + .map((r: { title: string | null; url: string; text?: string }) => ({ + title: r.title || "", + url: r.url, + content: r.text || "", + })); +} + +async function searchWithTavily(query: string): Promise { + const client = getTavily(); + const response = await client.search(query, { + maxResults: MAX_SEARCH_RESULTS, + searchDepth: "advanced", + excludeDomains: ["youtube.com"], + }); + + return response.results + .filter((r: { title: string; url: string; content: string; score: number }) => r.title && r.content && r.score > 0) + .map((r: { title: string; url: string; content: string }) => ({ + title: r.title, + url: r.url, + content: r.content, + })); +} + export async function search( query: string, researchState: ResearchState, @@ -73,32 +117,27 @@ export async function search( activityTracker.add("search","pending",`Searching for ${query}`); try { - // exa-js v2: searchAndContents is deprecated but still works - // Using it because the search() method overloads have type issues with TypeScript - const searchResult = await exa.searchAndContents(query, { - type: "keyword", - numResults: MAX_SEARCH_RESULTS, - startPublishedDate: new Date( - Date.now() - 365 * 24 * 60 * 60 * 1000 - ).toISOString(), - endPublishedDate: new Date().toISOString(), - startCrawlDate: new Date( - Date.now() - 365 * 24 * 60 * 60 * 1000 - ).toISOString(), - endCrawlDate: new Date().toISOString(), - excludeDomains: ["https://youtube.com"], - text: { - maxCharacters: MAX_CONTENT_CHARS, - }, - }); + const provider = process.env.SEARCH_PROVIDER || "exa"; + let filteredResults: SearchResult[]; - const filteredResults = searchResult.results - .filter((r: { title: string | null; text?: string }) => r.title && r.text !== undefined) - .map((r: { title: string | null; url: string; text?: string }) => ({ - title: r.title || "", - url: r.url, - content: r.text || "", - })); + if (provider === "both") { + const [exaResults, tavilyResults] = await Promise.all([ + searchWithExa(query), + searchWithTavily(query), + ]); + // Merge and deduplicate by URL + const seen = new Set(); + filteredResults = [...exaResults, ...tavilyResults].filter((r) => { + if (seen.has(r.url)) return false; + seen.add(r.url); + return true; + }); + } else if (provider === "tavily") { + filteredResults = await searchWithTavily(query); + } else { + // Default to exa + filteredResults = await searchWithExa(query); + } researchState.completedSteps++; diff --git a/src/app/api/deep-research/services.ts b/src/app/api/deep-research/services.ts index 172f92c..2174aae 100644 --- a/src/app/api/deep-research/services.ts +++ b/src/app/api/deep-research/services.ts @@ -1,5 +1,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import Exa from "exa-js" +import { tavily } from "@tavily/core" // Lazy initialization to avoid build-time errors when env vars are not available let _exa: Exa | null = null; @@ -24,6 +25,20 @@ export const exa = { findSimilarAndContents: async (...args: Parameters) => getExa().findSimilarAndContents(...args), }; +// Lazy initialization for Tavily client +let _tavily: ReturnType | null = null; + +export const getTavily = () => { + if (!_tavily) { + const apiKey = process.env.TAVILY_API_KEY; + if (!apiKey) { + throw new Error("TAVILY_API_KEY environment variable is required"); + } + _tavily = tavily({ apiKey }); + } + return _tavily; +}; + export const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY || "", });