mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
✨ feat: add ExaMCP free web search provider (#11874)
* ✨ feat: add ExaMCP free web search provider
Add a new web search provider that uses Exa's free MCP API endpoint,
requiring no API key. This provides users with a free alternative
to the existing Exa provider.
- Add 'exa-mcp' to WebSearchProviderIds
- Create ExaMcpProvider using JSON-RPC/SSE protocol
- Add provider config and migration for existing users
- Use same Exa logo in settings UI
* Add robust text chunk parser for ExaMcpProvider results
This commit is contained in:
parent
71df9d61fd
commit
aeebd343d7
@ -31,6 +31,11 @@ export const WEB_SEARCH_PROVIDER_CONFIG: Record<WebSearchProviderId, WebSearchPr
|
|||||||
apiKey: 'https://dashboard.exa.ai/api-keys'
|
apiKey: 'https://dashboard.exa.ai/api-keys'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'exa-mcp': {
|
||||||
|
websites: {
|
||||||
|
official: 'https://exa.ai'
|
||||||
|
}
|
||||||
|
},
|
||||||
bocha: {
|
bocha: {
|
||||||
websites: {
|
websites: {
|
||||||
official: 'https://bochaai.com',
|
official: 'https://bochaai.com',
|
||||||
@ -80,6 +85,11 @@ export const WEB_SEARCH_PROVIDERS: WebSearchProvider[] = [
|
|||||||
apiHost: 'https://api.exa.ai',
|
apiHost: 'https://api.exa.ai',
|
||||||
apiKey: ''
|
apiKey: ''
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'exa-mcp',
|
||||||
|
name: 'ExaMCP',
|
||||||
|
apiHost: 'https://mcp.exa.ai/mcp'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'bocha',
|
id: 'bocha',
|
||||||
name: 'Bocha',
|
name: 'Bocha',
|
||||||
|
|||||||
@ -145,6 +145,7 @@ const WebSearchProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
case 'searxng':
|
case 'searxng':
|
||||||
return SearxngLogo
|
return SearxngLogo
|
||||||
case 'exa':
|
case 'exa':
|
||||||
|
case 'exa-mcp':
|
||||||
return ExaLogo
|
return ExaLogo
|
||||||
case 'bocha':
|
case 'bocha':
|
||||||
return BochaLogo
|
return BochaLogo
|
||||||
|
|||||||
209
src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts
Normal file
209
src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts
Normal file
@ -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<WebSearchProviderResponse> {
|
||||||
|
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: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import type { WebSearchProvider } from '@renderer/types'
|
|||||||
import type BaseWebSearchProvider from './BaseWebSearchProvider'
|
import type BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
import BochaProvider from './BochaProvider'
|
import BochaProvider from './BochaProvider'
|
||||||
import DefaultProvider from './DefaultProvider'
|
import DefaultProvider from './DefaultProvider'
|
||||||
|
import ExaMcpProvider from './ExaMcpProvider'
|
||||||
import ExaProvider from './ExaProvider'
|
import ExaProvider from './ExaProvider'
|
||||||
import LocalBaiduProvider from './LocalBaiduProvider'
|
import LocalBaiduProvider from './LocalBaiduProvider'
|
||||||
import LocalBingProvider from './LocalBingProvider'
|
import LocalBingProvider from './LocalBingProvider'
|
||||||
@ -24,6 +25,8 @@ export default class WebSearchProviderFactory {
|
|||||||
return new SearxngProvider(provider)
|
return new SearxngProvider(provider)
|
||||||
case 'exa':
|
case 'exa':
|
||||||
return new ExaProvider(provider)
|
return new ExaProvider(provider)
|
||||||
|
case 'exa-mcp':
|
||||||
|
return new ExaMcpProvider(provider)
|
||||||
case 'local-google':
|
case 'local-google':
|
||||||
return new LocalGoogleProvider(provider)
|
return new LocalGoogleProvider(provider)
|
||||||
case 'local-baidu':
|
case 'local-baidu':
|
||||||
|
|||||||
@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
|||||||
{
|
{
|
||||||
key: 'cherry-studio',
|
key: 'cherry-studio',
|
||||||
storage,
|
storage,
|
||||||
version: 183,
|
version: 184,
|
||||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||||
migrate
|
migrate
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2992,6 +2992,31 @@ const migrateConfig = {
|
|||||||
logger.error('migrate 183 error', error as Error)
|
logger.error('migrate 183 error', error as Error)
|
||||||
return state
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -600,6 +600,7 @@ export const WebSearchProviderIds = {
|
|||||||
tavily: 'tavily',
|
tavily: 'tavily',
|
||||||
searxng: 'searxng',
|
searxng: 'searxng',
|
||||||
exa: 'exa',
|
exa: 'exa',
|
||||||
|
'exa-mcp': 'exa-mcp',
|
||||||
bocha: 'bocha',
|
bocha: 'bocha',
|
||||||
'local-google': 'local-google',
|
'local-google': 'local-google',
|
||||||
'local-bing': 'local-bing',
|
'local-bing': 'local-bing',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user