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 0000000000..ee21dc16e9 Binary files /dev/null and b/src/renderer/src/assets/images/search/bocha.webp differ 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 }