diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index 1ce3297af..169ae3593 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -31,6 +31,11 @@ export const WEB_SEARCH_PROVIDER_CONFIG: Record = ({ providerId }) => { case 'searxng': return SearxngLogo case 'exa': + case 'exa-mcp': return ExaLogo case 'bocha': return BochaLogo diff --git a/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts b/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts new file mode 100644 index 000000000..8e04ba0a6 --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts @@ -0,0 +1,209 @@ +import { loggerService } from '@logger' +import type { WebSearchState } from '@renderer/store/websearch' +import type { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' + +import BaseWebSearchProvider from './BaseWebSearchProvider' + +const logger = loggerService.withContext('ExaMcpProvider') + +interface McpSearchRequest { + jsonrpc: string + id: number + method: string + params: { + name: string + arguments: { + query: string + numResults?: number + livecrawl?: 'fallback' | 'preferred' + type?: 'auto' | 'fast' | 'deep' + } + } +} + +interface McpSearchResponse { + jsonrpc: string + result: { + content: Array<{ type: string; text: string }> + } +} + +interface ExaSearchResult { + title?: string + url?: string + text?: string + publishedDate?: string + author?: string +} + +interface ExaSearchResults { + results?: ExaSearchResult[] + autopromptString?: string +} + +const DEFAULT_API_HOST = 'https://mcp.exa.ai/mcp' +const DEFAULT_NUM_RESULTS = 8 +const REQUEST_TIMEOUT_MS = 25000 + +export default class ExaMcpProvider extends BaseWebSearchProvider { + constructor(provider: WebSearchProvider) { + super(provider) + if (!this.apiHost) { + this.apiHost = DEFAULT_API_HOST + } + } + + public async search( + query: string, + websearch: WebSearchState, + httpOptions?: RequestInit + ): Promise { + try { + if (!query.trim()) { + throw new Error('Search query cannot be empty') + } + + const searchRequest: McpSearchRequest = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'web_search_exa', + arguments: { + query, + type: 'auto', + numResults: websearch.maxResults || DEFAULT_NUM_RESULTS, + livecrawl: 'fallback' + } + } + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + try { + const response = await fetch(this.apiHost!, { + method: 'POST', + headers: { + ...this.defaultHeaders(), + accept: 'application/json, text/event-stream', + 'content-type': 'application/json' + }, + body: JSON.stringify(searchRequest), + signal: httpOptions?.signal ? AbortSignal.any([controller.signal, httpOptions.signal]) : controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Search error (${response.status}): ${errorText}`) + } + + const responseText = await response.text() + const searchResults = this.parseResponse(responseText) + + return { + query: searchResults.autopromptString || query, + results: (searchResults.results || []).slice(0, websearch.maxResults).map((result) => ({ + title: result.title || 'No title', + content: result.text || '', + url: result.url || '' + })) + } + } catch (error) { + clearTimeout(timeoutId) + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Search request timed out') + } + + throw error + } + } catch (error) { + logger.error('Exa MCP search failed:', error as Error) + throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + private parsetextChunk(raw: string): ExaSearchResult[] { + const items: ExaSearchResult[] = [] + for (const chunk of raw.split('\n\n')) { + // logger.debug('Parsing chunk:', {"chunks": chunk}) + // 3. Parse the labeled lines inside the text block + const lines = chunk.split('\n') + // logger.debug('Lines:', lines); + let title = '' + let publishedDate = '' + let url = '' + let fullText = '' + + // We’ll capture everything after the first "Text:" as the article text + let textStartIndex = -1 + + lines.forEach((line, idx) => { + if (line.startsWith('Title:')) { + title = line.replace(/^Title:\s*/, '') + } else if (line.startsWith('Published Date:')) { + publishedDate = line.replace(/^Published Date:\s*/, '') + } else if (line.startsWith('URL:')) { + url = line.replace(/^URL:\s*/, '') + } else if (line.startsWith('Text:') && textStartIndex === -1) { + // mark where "Text:" starts + textStartIndex = idx + // text on the same line after "Text: " + fullText = line.replace(/^Text:\s*/, '') + } + }) + if (textStartIndex !== -1) { + const rest = lines.slice(textStartIndex + 1).join('\n') + if (rest.trim().length > 0) { + fullText = (fullText ? fullText + '\n' : '') + rest + } + } + + // If we at least got a title or URL, treat it as a valid article + if (title || url || fullText) { + items.push({ + title, + publishedDate, + url, + text: fullText + }) + } + } + return items + } + + private parseResponse(responseText: string): ExaSearchResults { + // Parse SSE response format + const lines = responseText.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data: McpSearchResponse = JSON.parse(line.substring(6)) + if (data.result?.content?.[0]?.text) { + // The text content contains stringified JSON with the actual results + return { results: this.parsetextChunk(data.result.content[0].text) } + } + } catch { + // Continue to next line if parsing fails + logger.warn('Failed to parse SSE line:', { line }) + } + } + } + + // Try parsing as direct JSON response (non-SSE) + try { + const data: McpSearchResponse = JSON.parse(responseText) + if (data.result?.content?.[0]?.text) { + return { results: this.parsetextChunk(data.result.content[0].text) } + } + } catch { + // Ignore parsing errors + logger.warn('Failed to parse direct JSON response:', { responseText }) + } + + return { results: [] } + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts index 0b961c23d..4adface5a 100644 --- a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts +++ b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts @@ -3,6 +3,7 @@ import type { WebSearchProvider } from '@renderer/types' import type BaseWebSearchProvider from './BaseWebSearchProvider' import BochaProvider from './BochaProvider' import DefaultProvider from './DefaultProvider' +import ExaMcpProvider from './ExaMcpProvider' import ExaProvider from './ExaProvider' import LocalBaiduProvider from './LocalBaiduProvider' import LocalBingProvider from './LocalBingProvider' @@ -24,6 +25,8 @@ export default class WebSearchProviderFactory { return new SearxngProvider(provider) case 'exa': return new ExaProvider(provider) + case 'exa-mcp': + return new ExaMcpProvider(provider) case 'local-google': return new LocalGoogleProvider(provider) case 'local-baidu': diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 30b6b7212..7eb762afd 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 183, + version: 184, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8559a39e2..0a1f8ea70 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2992,6 +2992,31 @@ const migrateConfig = { logger.error('migrate 183 error', error as Error) return state } + }, + '184': (state: RootState) => { + try { + // Add exa-mcp (free) web search provider if not exists + const exaMcpExists = state.websearch.providers.some((p) => p.id === 'exa-mcp') + if (!exaMcpExists) { + // Find the index of 'exa' provider to insert after it + const exaIndex = state.websearch.providers.findIndex((p) => p.id === 'exa') + const newProvider = { + id: 'exa-mcp' as const, + name: 'ExaMCP', + apiHost: 'https://mcp.exa.ai/mcp' + } + if (exaIndex !== -1) { + state.websearch.providers.splice(exaIndex + 1, 0, newProvider) + } else { + state.websearch.providers.push(newProvider) + } + } + logger.info('migrate 184 success') + return state + } catch (error) { + logger.error('migrate 184 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6e7e4e41e..0ff813162 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -600,6 +600,7 @@ export const WebSearchProviderIds = { tavily: 'tavily', searxng: 'searxng', exa: 'exa', + 'exa-mcp': 'exa-mcp', bocha: 'bocha', 'local-google': 'local-google', 'local-bing': 'local-bing',