From 12b210dcce4eda938c645a4c65d192047d56c91a Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 10 May 2025 16:42:09 +0800 Subject: [PATCH] feat(webSearch): add Bocha web search provider integration (#5608) * feat(webSearch): add Bocha web search provider integration - Introduced BochaProvider for handling web search queries. - Added Bocha logo and updated web search provider configuration. - Implemented API host and key validation for Bocha. - Enhanced web search settings to support Bocha provider. - Updated Redux store to include Bocha in the web search provider list. - Added validation schemas for Bocha search parameters and responses. * fix(WebSearch): improve error handling in BochaProvider and validate web search questions - Added error handling for failed Bocha search responses. - Enhanced validation for web search questions to ensure they are an array and not empty. * fix(WebSearch): add API host validation for Tavily provider * chore: remove api host check button * fix: add check for unnecessary web search in fetchExternalTool --------- Co-authored-by: eeee0717 --- .../src/assets/images/search/bocha.webp | Bin 0 -> 4792 bytes src/renderer/src/config/webSearchProviders.ts | 10 + .../WebSearchProviderSetting.tsx | 11 +- .../BaseWebSearchProvider.ts | 14 ++ .../WebSearchProvider/BochaProvider.ts | 70 ++++++ .../WebSearchProvider/ExaProvider.ts | 5 +- .../WebSearchProvider/SearxngProvider.ts | 1 - .../WebSearchProvider/TavilyProvider.ts | 5 +- .../WebSearchProviderFactory.ts | 3 + src/renderer/src/services/ApiService.ts | 2 + src/renderer/src/services/WebSearchService.ts | 2 +- src/renderer/src/store/migrate.ts | 42 +++- src/renderer/src/store/websearch.ts | 27 ++- src/renderer/src/types/bocha.ts | 207 ++++++++++++++++++ 14 files changed, 384 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/assets/images/search/bocha.webp create mode 100644 src/renderer/src/providers/WebSearchProvider/BochaProvider.ts create mode 100644 src/renderer/src/types/bocha.ts diff --git a/src/renderer/src/assets/images/search/bocha.webp b/src/renderer/src/assets/images/search/bocha.webp new file mode 100644 index 0000000000000000000000000000000000000000..ee21dc16e9bd0af6887f107cf31a702eb047da1c GIT binary patch literal 4792 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 ST1&>Ls6m>olumg?<^TXNUpTb@ literal 0 HcmV?d00001 diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index 1fc9638169..d33e9cba35 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -1,6 +1,8 @@ +import BochaLogo from '@renderer/assets/images/search/bocha.webp' import ExaLogo from '@renderer/assets/images/search/exa.png' import SearxngLogo from '@renderer/assets/images/search/searxng.svg' import TavilyLogo from '@renderer/assets/images/search/tavily.png' + export function getWebSearchProviderLogo(providerId: string) { switch (providerId) { case 'tavily': @@ -9,6 +11,8 @@ export function getWebSearchProviderLogo(providerId: string) { return SearxngLogo case 'exa': return ExaLogo + case 'bocha': + return BochaLogo default: return undefined } @@ -32,6 +36,12 @@ export const WEB_SEARCH_PROVIDER_CONFIG = { apiKey: 'https://dashboard.exa.ai/api-keys' } }, + bocha: { + websites: { + official: 'https://bochaai.com', + apiKey: 'https://open.bochaai.com/overview' + } + }, 'local-google': { websites: { official: 'https://www.google.com' diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 7e058278b7..cfac0071de 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -192,14 +192,11 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { onChange={(e) => setApiHost(e.target.value)} onBlur={onUpdateApiHost} /> - + + )} + {hasObjectKey(provider, 'basicAuthUsername') && ( + <> {t('settings.provider.basic_auth')} diff --git a/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts index 031ad88de8..558a328b5e 100644 --- a/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts @@ -4,18 +4,32 @@ import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' export default abstract class BaseWebSearchProvider { // @ts-ignore this protected provider: WebSearchProvider + protected apiHost?: string protected apiKey: string constructor(provider: WebSearchProvider) { this.provider = provider + this.apiHost = this.getApiHost() this.apiKey = this.getApiKey() } + abstract search( query: string, websearch: WebSearchState, httpOptions?: RequestInit ): Promise + public getApiHost() { + return this.provider.apiHost + } + + public defaultHeaders() { + return { + 'HTTP-Referer': 'https://cherry-ai.com', + 'X-Title': 'Cherry Studio' + } + } + public getApiKey() { const keys = this.provider.apiKey?.split(',').map((key) => key.trim()) || [] const keyName = `web-search-provider:${this.provider.id}:last_used_key` diff --git a/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts b/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts new file mode 100644 index 0000000000..63ef9b0a9e --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts @@ -0,0 +1,70 @@ +import { WebSearchState } from '@renderer/store/websearch' +import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' +import { BochaSearchParams, BochaSearchResponse } from '@renderer/types/bocha' + +import BaseWebSearchProvider from './BaseWebSearchProvider' + +export default class BochaProvider extends BaseWebSearchProvider { + constructor(provider: WebSearchProvider) { + super(provider) + if (!this.apiKey) { + throw new Error('API key is required for Bocha provider') + } + if (!this.apiHost) { + throw new Error('API host is required for Bocha provider') + } + } + + public async search(query: string, websearch: WebSearchState): Promise { + try { + if (!query.trim()) { + throw new Error('Search query cannot be empty') + } + + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}` + } + + const contentLimit = websearch.contentLimit + + const params: BochaSearchParams = { + query, + count: websearch.maxResults, + exclude: websearch.excludeDomains.join(','), + freshness: websearch.searchWithTime ? 'oneDay' : 'noLimit', + summary: false, + page: contentLimit ? Math.ceil(contentLimit / websearch.maxResults) : 1 + } + + const response = await fetch(`${this.apiHost}/v1/web-search`, { + method: 'POST', + body: JSON.stringify(params), + headers: { + ...this.defaultHeaders(), + ...headers + } + }) + + if (!response.ok) { + throw new Error(`Bocha search failed: ${response.status} ${response.statusText}`) + } + + const resp: BochaSearchResponse = await response.json() + if (resp.code !== 200) { + throw new Error(`Bocha search failed: ${resp.msg}`) + } + return { + query: resp.data.queryContext.originalQuery, + results: resp.data.webPages.value.map((result) => ({ + title: result.name, + content: result.snippet, + url: result.url + })) + } + } catch (error) { + console.error('Bocha search failed:', error) + throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts b/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts index c1f4a1887a..8f65449b05 100644 --- a/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts @@ -12,7 +12,10 @@ export default class ExaProvider extends BaseWebSearchProvider { if (!this.apiKey) { throw new Error('API key is required for Exa provider') } - this.exa = new ExaClient({ apiKey: this.apiKey }) + if (!this.apiHost) { + throw new Error('API host is required for Exa provider') + } + this.exa = new ExaClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost }) } public async search(query: string, websearch: WebSearchState): Promise { diff --git a/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts b/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts index fde9f88200..6ced57d136 100644 --- a/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts @@ -10,7 +10,6 @@ import BaseWebSearchProvider from './BaseWebSearchProvider' export default class SearxngProvider extends BaseWebSearchProvider { private searxng: SearxngClient private engines: string[] = [] - private readonly apiHost: string private readonly basicAuthUsername?: string private readonly basicAuthPassword?: string private isInitialized = false diff --git a/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts b/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts index ef03a64224..e38b2661d9 100644 --- a/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts @@ -12,7 +12,10 @@ export default class TavilyProvider extends BaseWebSearchProvider { if (!this.apiKey) { throw new Error('API key is required for Tavily provider') } - this.tvly = new TavilyClient({ apiKey: this.apiKey }) + if (!this.apiHost) { + throw new Error('API host is required for Tavily provider') + } + this.tvly = new TavilyClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost }) } public async search(query: string, websearch: WebSearchState): Promise { diff --git a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts index df5c08b989..2ec5d343e3 100644 --- a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts +++ b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts @@ -1,6 +1,7 @@ import { WebSearchProvider } from '@renderer/types' import BaseWebSearchProvider from './BaseWebSearchProvider' +import BochaProvider from './BochaProvider' import DefaultProvider from './DefaultProvider' import ExaProvider from './ExaProvider' import LocalBaiduProvider from './LocalBaiduProvider' @@ -14,6 +15,8 @@ export default class WebSearchProviderFactory { switch (provider.id) { case 'tavily': return new TavilyProvider(provider) + case 'bocha': + return new BochaProvider(provider) case 'searxng': return new SearxngProvider(provider) case 'exa': diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 670eeb8a22..18d578dc5b 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -125,6 +125,8 @@ async function fetchExternalTool( return } + if (extractResults.websearch.question[0] === 'not_needed') return + // Add check for assistant.model before using it if (!assistant.model) { console.warn('searchTheWeb called without assistant.model') diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index 3f3aeddae6..f57c39c698 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -106,7 +106,7 @@ class WebSearchService { const webSearchEngine = new WebSearchEngineProvider(provider) let formattedQuery = query - // 有待商榷,效果一般 + // FIXME: 有待商榷,效果一般 if (websearch.searchWithTime) { formattedQuery = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}` } diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f09ce39536..0545a6af78 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -5,7 +5,7 @@ import { SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import db from '@renderer/databases' import i18n from '@renderer/i18n' -import { Assistant } from '@renderer/types' +import { Assistant, WebSearchProvider } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' import { isEmpty } from 'lodash' import { createMigrate } from 'redux-persist' @@ -64,6 +64,18 @@ function addWebSearchProvider(state: RootState, id: string) { } } +function updateWebSearchProvider(state: RootState, provider: Partial) { + if (state.websearch && state.websearch.providers) { + const index = state.websearch.providers.findIndex((p) => p.id === provider.id) + if (index !== -1) { + state.websearch.providers[index] = { + ...state.websearch.providers[index], + ...provider + } + } + } +} + const migrateConfig = { '2': (state: RootState) => { try { @@ -1252,6 +1264,34 @@ const migrateConfig = { } catch (error) { return state } + }, + '99': (state: RootState) => { + try { + addWebSearchProvider(state, 'bocha') + updateWebSearchProvider(state, { + id: 'exa', + apiHost: 'https://api.exa.ai' + }) + updateWebSearchProvider(state, { + id: 'tavily', + apiHost: 'https://api.tavily.com' + }) + + // Remove basic auth fields from exa and tavily + if (state.websearch?.providers) { + state.websearch.providers = state.websearch.providers.map((provider) => { + if (provider.id === 'exa' || provider.id === 'tavily') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { basicAuthUsername, basicAuthPassword, ...rest } = provider + return rest + } + return provider + }) + } + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 2a4a999b13..4f223ccbf1 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -25,6 +25,8 @@ export interface WebSearchState { /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ overwrite: boolean contentLimit?: number + // 具体供应商的配置 + providerConfig: Record } const initialState: WebSearchState = { @@ -33,16 +35,26 @@ const initialState: WebSearchState = { { id: 'tavily', name: 'Tavily', + apiHost: 'https://api.tavily.com', apiKey: '' }, { id: 'searxng', name: 'Searxng', - apiHost: '' + apiHost: '', + basicAuthUsername: '', + basicAuthPassword: '' }, { id: 'exa', name: 'Exa', + apiHost: 'https://api.exa.ai', + apiKey: '' + }, + { + id: 'bocha', + name: 'Bocha', + apiHost: 'https://api.bochaai.com', apiKey: '' }, { @@ -65,7 +77,8 @@ const initialState: WebSearchState = { maxResults: 5, excludeDomains: [], subscribeSources: [], - overwrite: false + overwrite: false, + providerConfig: {} } export const defaultWebSearchProviders = initialState.providers @@ -139,6 +152,12 @@ const websearchSlice = createSlice({ }, setContentLimit: (state, action: PayloadAction) => { state.contentLimit = action.payload + }, + setProviderConfig: (state, action: PayloadAction>) => { + state.providerConfig = action.payload + }, + updateProviderConfig: (state, action: PayloadAction>) => { + state.providerConfig = { ...state.providerConfig, ...action.payload } } } }) @@ -157,7 +176,9 @@ export const { setSubscribeSources, setOverwrite, addWebSearchProvider, - setContentLimit + setContentLimit, + setProviderConfig, + updateProviderConfig } = websearchSlice.actions export default websearchSlice.reducer diff --git a/src/renderer/src/types/bocha.ts b/src/renderer/src/types/bocha.ts new file mode 100644 index 0000000000..45d83cd8bf --- /dev/null +++ b/src/renderer/src/types/bocha.ts @@ -0,0 +1,207 @@ +import { z } from 'zod' + +export const freshnessOptions = ['oneDay', 'oneWeek', 'oneMonth', 'oneYear', 'noLimit'] as const + +const isValidDate = (dateStr: string): boolean => { + // First check basic format + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return false + } + + const [year, month, day] = dateStr.split('-').map(Number) + + if (year < 1900 || year > 2100) { + return false + } + + // Check month range + if (month < 1 || month > 12) { + return false + } + + // Get last day of the month + const lastDay = new Date(year, month, 0).getDate() + + // Check day range + if (day < 1 || day > lastDay) { + return false + } + + return true +} + +const isValidDateRange = (dateRangeStr: string): boolean => { + // Check if it's a single date + if (/^\d{4}-\d{2}-\d{2}$/.test(dateRangeStr)) { + return isValidDate(dateRangeStr) + } + + // Check if it's a date range + if (!/^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/.test(dateRangeStr)) { + return false + } + + const [startDate, endDate] = dateRangeStr.split('..') + + // Validate both dates + if (!isValidDate(startDate) || !isValidDate(endDate)) { + return false + } + + // Check if start date is before or equal to end date + const start = new Date(startDate) + const end = new Date(endDate) + return start <= end +} + +const isValidExcludeDomains = (excludeStr: string): boolean => { + if (!excludeStr) return true + + // Split by either | or , + const domains = excludeStr + .split(/[|,]/) + .map((d) => d.trim()) + .filter(Boolean) + + // Check number of domains + if (domains.length > 20) { + return false + } + + // Domain name regex (supports both root domains and subdomains) + const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ + + // Check each domain + return domains.every((domain) => domainRegex.test(domain)) +} + +const BochaSearchParamsSchema = z.object({ + query: z.string(), + freshness: z + .union([ + z.enum(freshnessOptions), + z + .string() + .regex( + /^(\d{4}-\d{2}-\d{2})(\.\.\d{4}-\d{2}-\d{2})?$/, + 'Date must be in YYYY-MM-DD or YYYY-MM-DD..YYYY-MM-DD format' + ) + .refine(isValidDateRange, { + message: 'Invalid date range - please provide valid dates in YYYY-MM-DD or YYYY-MM-DD..YYYY-MM-DD format' + }) + ]) + .optional() + .default('noLimit'), + summary: z.boolean().optional().default(false), + exclude: z + .string() + .optional() + .refine((val) => !val || isValidExcludeDomains(val), { + message: + 'Invalid exclude format. Please provide valid domain names separated by | or ,. Maximum 20 domains allowed.' + }), + page: z.number().optional().default(1), + count: z.number().optional().default(10) +}) + +const BochaSearchResponseDataSchema = z.object({ + type: z.string(), + queryContext: z.object({ + originalQuery: z.string() + }), + webPages: z.object({ + webSearchUrl: z.string(), + totalEstimatedMatches: z.number(), + value: z.array( + z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + displayUrl: z.string(), + snippet: z.string(), + summary: z.string().optional(), + siteName: z.string(), + siteIcon: z.string(), + datePublished: z.string(), + dateLastCrawled: z.string(), + cachedPageUrl: z.string(), + language: z.string(), + isFamilyFriendly: z.boolean(), + isNavigational: z.boolean() + }) + ), + someResultsRemoved: z.boolean() + }), + images: z.object({ + id: z.string(), + readLink: z.string(), + webSearchUrl: z.string(), + name: z.string(), + value: z.array( + z.object({ + webSearchUrl: z.string(), + name: z.string(), + thumbnailUrl: z.string(), + datePublished: z.string(), + contentUrl: z.string(), + hostPageUrl: z.string(), + contentSize: z.string(), + encodingFormat: z.string(), + hostPageDisplayUrl: z.string(), + width: z.number(), + height: z.number(), + thumbnail: z.object({ + width: z.number(), + height: z.number() + }) + }) + ) + }), + videos: z.object({ + id: z.string(), + readLink: z.string(), + webSearchUrl: z.string(), + isFamilyFriendly: z.boolean(), + scenario: z.string(), + name: z.string(), + value: z.array( + z.object({ + webSearchUrl: z.string(), + name: z.string(), + description: z.string(), + thumbnailUrl: z.string(), + publisher: z.string(), + creator: z.string(), + contentUrl: z.string(), + hostPageUrl: z.string(), + encodingFormat: z.string(), + hostPageDisplayUrl: z.string(), + width: z.number(), + height: z.number(), + duration: z.number(), + motionThumbnailUrl: z.string(), + embedHtml: z.string(), + allowHttpsEmbed: z.boolean(), + viewCount: z.number(), + thumbnail: z.object({ + width: z.number(), + height: z.number() + }), + allowMobileEmbed: z.boolean(), + isSuperfresh: z.boolean(), + datePublished: z.string() + }) + ) + }) +}) + +const BochaSearchResponseSchema = z.object({ + code: z.number(), + logId: z.string(), + data: BochaSearchResponseDataSchema, + msg: z.string().optional() +}) + +export type BochaSearchParams = z.infer +export type BochaSearchResponse = z.infer +export { BochaSearchParamsSchema, BochaSearchResponseSchema }