diff --git a/chromium-extension/src/background/agent/chat-service.ts b/chromium-extension/src/background/agent/chat-service.ts index bd46c6e..9c29f8a 100644 --- a/chromium-extension/src/background/agent/chat-service.ts +++ b/chromium-extension/src/background/agent/chat-service.ts @@ -1,4 +1,4 @@ -import { ChatService, uuidv4, ExaSearchService } from "@openbrowser-ai/core"; +import { ChatService, uuidv4, ExaSearchService, TavilySearchService } from "@openbrowser-ai/core"; import { OpenBrowserMessage, WebSearchResult @@ -17,9 +17,14 @@ export class SimpleChatService implements ChatService { } ) => Promise; + private searchProvider: "exa" | "tavily" = "exa"; + private tavilyApiKey?: string; + constructor() { chrome.storage.sync.get(["webSearchConfig"], (result) => { if (result.webSearchConfig?.enabled) { + this.searchProvider = result.webSearchConfig.provider || "exa"; + this.tavilyApiKey = result.webSearchConfig.tavilyApiKey; this.websearch = (chatId, options) => this.websearchImpl(chatId, result.webSearchConfig.apiKey, options); } @@ -69,16 +74,28 @@ export class SimpleChatService implements ChatService { } ): Promise { try { - const content = await ExaSearchService.search( - { - query: options.query, - numResults: options.numResults || 8, - type: options.type || "auto", - livecrawl: options.livecrawl || "fallback", - contextMaxCharacters: options.contextMaxCharacters || 10000 - }, - apiKey - ); + let content: string; + + if (this.searchProvider === "tavily" && this.tavilyApiKey) { + content = await TavilySearchService.search( + { + query: options.query, + numResults: options.numResults || 8 + }, + this.tavilyApiKey + ); + } else { + content = await ExaSearchService.search( + { + query: options.query, + numResults: options.numResults || 8, + type: options.type || "auto", + livecrawl: options.livecrawl || "fallback", + contextMaxCharacters: options.contextMaxCharacters || 10000 + }, + apiKey + ); + } return [ { diff --git a/chromium-extension/src/options/index.tsx b/chromium-extension/src/options/index.tsx index 021c5cd..c67431b 100644 --- a/chromium-extension/src/options/index.tsx +++ b/chromium-extension/src/options/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; -import { Form, Input, Button, message, Select, Checkbox, Spin } from "antd"; +import { Form, Input, Button, message, Select, Checkbox, Spin, Radio } from "antd"; import { SaveOutlined, LoadingOutlined } from "@ant-design/icons"; import "../sidebar/index.css"; import { ThemeProvider } from "../sidebar/providers/ThemeProvider"; @@ -34,7 +34,9 @@ const OptionsPage = () => { const [webSearchConfig, setWebSearchConfig] = useState({ enabled: false, - apiKey: "" + apiKey: "", + provider: "exa" as "exa" | "tavily", + tavilyApiKey: "" }); const [historyLLMConfig, setHistoryLLMConfig] = useState>( @@ -124,10 +126,17 @@ const OptionsPage = () => { setHistoryLLMConfig(result.historyLLMConfig); } if (result.webSearchConfig) { - setWebSearchConfig(result.webSearchConfig); + setWebSearchConfig({ + enabled: result.webSearchConfig.enabled || false, + apiKey: result.webSearchConfig.apiKey || "", + provider: result.webSearchConfig.provider || "exa", + tavilyApiKey: result.webSearchConfig.tavilyApiKey || "" + }); form.setFieldsValue({ webSearchEnabled: result.webSearchConfig.enabled, - exaApiKey: result.webSearchConfig.apiKey + exaApiKey: result.webSearchConfig.apiKey, + webSearchProvider: result.webSearchConfig.provider || "exa", + tavilyApiKey: result.webSearchConfig.tavilyApiKey }); } } @@ -138,7 +147,7 @@ const OptionsPage = () => { form .validateFields() .then((value) => { - const { webSearchEnabled, exaApiKey, ...llmConfigValue } = value; + const { webSearchEnabled, exaApiKey, webSearchProvider, tavilyApiKey, ...llmConfigValue } = value; setConfig(llmConfigValue); setHistoryLLMConfig({ @@ -148,7 +157,9 @@ const OptionsPage = () => { const newWebSearchConfig = { enabled: webSearchEnabled || false, - apiKey: exaApiKey || "" + apiKey: exaApiKey || "", + provider: webSearchProvider || "exa", + tavilyApiKey: tavilyApiKey || "" }; setWebSearchConfig(newWebSearchConfig); @@ -401,7 +412,7 @@ const OptionsPage = () => { > - Enable web search (Exa AI) + Enable web search @@ -409,33 +420,75 @@ const OptionsPage = () => { - prevValues.webSearchEnabled !== currentValues.webSearchEnabled + prevValues.webSearchEnabled !== currentValues.webSearchEnabled || + prevValues.webSearchProvider !== currentValues.webSearchProvider } > {({ getFieldValue }) => getFieldValue("webSearchEnabled") ? ( - - Exa API Key{" "} - - (Optional) + <> + + Search Provider - - } - tooltip="Uses free tier if not provided" - > - - + } + initialValue="exa" + > + + Exa AI + Tavily + + + + {getFieldValue("webSearchProvider") === "tavily" ? ( + + Tavily API Key + + } + rules={[ + { + required: true, + message: "Tavily API key is required" + } + ]} + > + + + ) : ( + + Exa API Key{" "} + + (Optional) + + + } + tooltip="Uses free tier if not provided" + > + + + )} + ) : null } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9117b3d..25cb129 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,7 +57,7 @@ export { } from "./tools"; export type { ChatService, BrowserService } from "./service"; -export { ExaSearchService } from "./service"; +export { ExaSearchService, TavilySearchService } from "./service"; export { sub, diff --git a/packages/core/src/service/index.ts b/packages/core/src/service/index.ts index 50ff23e..1fe3c08 100644 --- a/packages/core/src/service/index.ts +++ b/packages/core/src/service/index.ts @@ -1,5 +1,6 @@ import { ChatService } from "./chat-service"; import { BrowserService } from "./browser-service"; export { ExaSearchService } from "./exa-search"; +export { TavilySearchService } from "./tavily-search"; export type { ChatService, BrowserService }; diff --git a/packages/core/src/service/tavily-search.ts b/packages/core/src/service/tavily-search.ts new file mode 100644 index 0000000..494c976 --- /dev/null +++ b/packages/core/src/service/tavily-search.ts @@ -0,0 +1,99 @@ +export interface TavilySearchOptions { + query: string; + numResults?: number; + searchDepth?: "basic" | "advanced"; + topic?: "general" | "news" | "finance"; +} + +interface TavilySearchResult { + title: string; + url: string; + content: string; + score: number; +} + +interface TavilyResponse { + results: TavilySearchResult[]; + answer?: string; +} + +/** + * Service for performing web searches using the Tavily Search API + * Returns formatted content optimized for LLM consumption + */ +export class TavilySearchService { + private static readonly BASE_API_URL = "https://api.tavily.com/search"; + private static readonly TIMEOUT_MS = 25000; + + /** + * Performs a web search and returns formatted content from Tavily + * @param options Search options + * @param apiKey Tavily API key for authentication + * @returns Formatted text content for LLM consumption + */ + static async search( + options: TavilySearchOptions, + apiKey: string + ): Promise { + const { + query, + numResults = 8, + searchDepth = "basic", + topic = "general" + } = options; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS); + + try { + const response = await fetch(this.BASE_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + api_key: apiKey, + query, + max_results: numResults, + search_depth: searchDepth, + topic, + include_answer: false, + include_raw_content: false, + include_images: false + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Tavily search failed: ${response.status} ${response.statusText}` + ); + } + + const data: TavilyResponse = await response.json(); + + if (!data.results || data.results.length === 0) { + return "No search results found. Please try a different query."; + } + + // Format results for LLM consumption + const formattedResults = data.results + .map( + (result, index) => + `[${index + 1}] ${result.title}\nURL: ${result.url}\n${result.content}` + ) + .join("\n\n"); + + return formattedResults; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Tavily search timed out"); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } +}