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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
OPENROUTER_API_KEY='add your openrouter api key here'
EXA_SEARCH_API_KEY='add your exa search api key here'
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'
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 65 additions & 26 deletions src/app/api/deep-research/research-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +64,50 @@ export async function generateSearchQueries(
}
}

async function searchWithExa(query: string): Promise<SearchResult[]> {
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<SearchResult[]> {
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,
Expand All @@ -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<string>();
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++;

Expand Down
15 changes: 15 additions & 0 deletions src/app/api/deep-research/services.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +25,20 @@ export const exa = {
findSimilarAndContents: async (...args: Parameters<Exa['findSimilarAndContents']>) => getExa().findSimilarAndContents(...args),
};

// Lazy initialization for Tavily client
let _tavily: ReturnType<typeof tavily> | 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 || "",
});