diff --git a/env.example b/env.example index 14ffa33a8..5ea4b4061 100644 --- a/env.example +++ b/env.example @@ -17,10 +17,11 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434 # Stock Market API Key FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key -# Web Search API Keys (Exa → Perplexity → Tavily) +# Web Search API Keys (Exa → Perplexity → Tavily → LangSearch) EXASEARCH_API_KEY=your-exa-api-key PERPLEXITY_API_KEY=your-perplexity-api-key TAVILY_API_KEY=your-tavily-api-key +LANGSEARCH_API_KEY=your-langsearch-api-key # X/Twitter API (enables x_search tool for public sentiment research) X_BEARER_TOKEN=your-X-bearer-token diff --git a/src/components/select-list.ts b/src/components/select-list.ts index da8eb3178..780948742 100644 --- a/src/components/select-list.ts +++ b/src/components/select-list.ts @@ -56,20 +56,21 @@ export function createProviderSelector( export function createSearchProviderSelector( currentProvider: string, - onSelect: (providerId: 'exa' | 'perplexity' | 'tavily') => void, + onSelect: (providerId: 'exa' | 'perplexity' | 'tavily' | 'langsearch') => void, onCancel: () => void, ) { - const providers: { id: 'exa' | 'perplexity' | 'tavily'; displayName: string }[] = [ + const providers: { id: 'exa' | 'perplexity' | 'tavily' | 'langsearch'; displayName: string }[] = [ { id: 'exa', displayName: 'Exa' }, { id: 'perplexity', displayName: 'Perplexity' }, { id: 'tavily', displayName: 'Tavily' }, + { id: 'langsearch', displayName: 'LangSearch' }, ]; const items: SelectItem[] = providers.map((provider, index) => ({ value: provider.id, label: `${index + 1}. ${provider.displayName}${currentProvider === provider.id ? ' ✓' : ''}`, })); const list = new VimSelectList(items, 5, selectListTheme); - list.onSelect = (item) => onSelect(item.value as 'exa' | 'perplexity' | 'tavily'); + list.onSelect = (item) => onSelect(item.value as 'exa' | 'perplexity' | 'tavily' | 'langsearch'); list.onCancel = () => onCancel(); return list; } diff --git a/src/controllers/search-selection.ts b/src/controllers/search-selection.ts index 7cb9e4d82..692a7fd75 100644 --- a/src/controllers/search-selection.ts +++ b/src/controllers/search-selection.ts @@ -111,13 +111,7 @@ export class SearchSelectionController { return; } - const providerId = this.pendingProviderValue; - const displayName = getSearchProviderDisplayName(providerId); - this.commitPreference(providerId); - - if (providerId !== 'perplexity') { - this.onError(`API key saved. Restart Dexter to use ${displayName}.`); - } + this.commitPreference(this.pendingProviderValue); } private commitPreference(providerId: SearchProviderId) { diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 52460c7be..76e51acc9 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,6 +1,6 @@ import { StructuredToolInterface } from '@langchain/core/tools'; import { createGetFinancials, createGetMarketData, createReadFilings, createScreenStocks } from './finance/index.js'; -import { exaSearch, perplexitySearch, tavilySearch, WEB_SEARCH_DESCRIPTION, xSearchTool, X_SEARCH_DESCRIPTION } from './search/index.js'; +import { exaSearch, perplexitySearch, tavilySearch, langSearch, WEB_SEARCH_DESCRIPTION, xSearchTool, X_SEARCH_DESCRIPTION } from './search/index.js'; import { createWebSearchTool, type WebSearchProvider } from './search/web-search.js'; import { getSetting } from '../utils/config.js'; import type { SearchProviderId } from '../utils/env.js'; @@ -156,6 +156,9 @@ export function getToolRegistry(model: string): RegisteredTool[] { if (process.env.TAVILY_API_KEY) { allWebSearchProviders.push({ id: 'tavily', name: 'Tavily', tool: tavilySearch }); } + if (process.env.LANGSEARCH_API_KEY) { + allWebSearchProviders.push({ id: 'langsearch', name: 'LangSearch', tool: langSearch }); + } if (allWebSearchProviders.length > 0) { const preferred = getSetting('webSearchPreferredProvider', undefined); diff --git a/src/tools/search/index.ts b/src/tools/search/index.ts index e8974b116..220b4541c 100644 --- a/src/tools/search/index.ts +++ b/src/tools/search/index.ts @@ -29,4 +29,5 @@ Search the web for current information on any topic. Returns relevant search res export { tavilySearch } from './tavily.js'; export { exaSearch } from './exa.js'; export { perplexitySearch } from './perplexity.js'; +export { langSearch } from './langsearch.js'; export { xSearchTool, X_SEARCH_DESCRIPTION } from './x-search.js'; diff --git a/src/tools/search/langsearch.ts b/src/tools/search/langsearch.ts new file mode 100644 index 000000000..ee5fa0a72 --- /dev/null +++ b/src/tools/search/langsearch.ts @@ -0,0 +1,91 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; +import { formatToolResult } from '../types.js'; +import { logger } from '@/utils'; + +const LANGSEARCH_API_URL = 'https://api.langsearch.com/v1/web-search'; + +interface LangSearchWebPage { + name: string; + url: string; + snippet?: string; + summary?: string; +} + +interface LangSearchResponse { + code: number; + msg?: string | null; + data?: { + _type?: string; + queryContext?: { originalQuery?: string }; + webPages?: { + totalEstimatedMatches?: number | null; + value?: LangSearchWebPage[]; + }; + }; +} + +async function callLangSearch(query: string): Promise { + const apiKey = process.env.LANGSEARCH_API_KEY; + if (!apiKey) { + throw new Error('[LangSearch API] LANGSEARCH_API_KEY is not set'); + } + + const response = await fetch(LANGSEARCH_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + summary: true, + count: 10, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`[LangSearch API] ${response.status}: ${text}`); + } + + return response.json() as Promise; +} + +export const langSearch = new DynamicStructuredTool({ + name: 'web_search', + description: + 'Search the web for current information on any topic. Returns relevant search results with URLs and content snippets.', + schema: z.object({ + query: z.string().describe('The search query to look up on the web'), + }), + func: async (input) => { + try { + const res = await callLangSearch(input.query); + + if (res.code !== 200) { + throw new Error(`[LangSearch API] Error code ${res.code}: ${res.msg ?? 'Unknown error'}`); + } + + const results = res.data?.webPages?.value ?? []; + const urls: string[] = []; + const formattedResults = results.map((r) => { + if (r.url && !urls.includes(r.url)) { + urls.push(r.url); + } + return { + title: r.name, + url: r.url, + snippet: r.summary ?? r.snippet ?? undefined, + }; + }); + + const data = { results: formattedResults }; + return formatToolResult(data, urls.length ? urls : undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[LangSearch API] error: ${message}`); + throw new Error(`[LangSearch API] ${message}`); + } + }, +}); diff --git a/src/utils/env.ts b/src/utils/env.ts index 0c1c23d01..54aceed76 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -100,12 +100,13 @@ export function saveApiKeyForProvider(providerId: string, apiKey: string): boole return saveApiKeyToEnv(apiKeyName, apiKey); } -export type SearchProviderId = 'exa' | 'perplexity' | 'tavily'; +export type SearchProviderId = 'exa' | 'perplexity' | 'tavily' | 'langsearch'; export const SEARCH_PROVIDERS: Record = { exa: { displayName: 'Exa', apiKeyEnvVar: 'EXASEARCH_API_KEY' }, perplexity: { displayName: 'Perplexity', apiKeyEnvVar: 'PERPLEXITY_API_KEY' }, tavily: { displayName: 'Tavily', apiKeyEnvVar: 'TAVILY_API_KEY' }, + langsearch: { displayName: 'LangSearch', apiKeyEnvVar: 'LANGSEARCH_API_KEY' }, }; export function getSearchProviderDisplayName(providerId: SearchProviderId): string {