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 <chentao020717Work@outlook.com>
This commit is contained in:
SuYao 2025-05-10 16:42:09 +08:00 committed by GitHub
parent a8b183b2a6
commit 12b210dcce
14 changed files with 384 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -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'

View File

@ -192,14 +192,11 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
<Button
ghost={apiValid}
type={apiValid ? 'primary' : 'default'}
onClick={checkSearch}
disabled={apiChecking}>
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')}
</Button>
</Flex>
</>
)}
{hasObjectKey(provider, 'basicAuthUsername') && (
<>
<SettingDivider style={{ marginTop: 12, marginBottom: 12 }} />
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
{t('settings.provider.basic_auth')}

View File

@ -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<WebSearchProviderResponse>
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`

View File

@ -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<WebSearchProviderResponse> {
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'}`)
}
}
}

View File

@ -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<WebSearchProviderResponse> {

View File

@ -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

View File

@ -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<WebSearchProviderResponse> {

View File

@ -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':

View File

@ -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')

View File

@ -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}`
}

View File

@ -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<WebSearchProvider>) {
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
}
}
}

View File

@ -25,6 +25,8 @@ export interface WebSearchState {
/** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */
overwrite: boolean
contentLimit?: number
// 具体供应商的配置
providerConfig: Record<string, any>
}
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<number | undefined>) => {
state.contentLimit = action.payload
},
setProviderConfig: (state, action: PayloadAction<Record<string, any>>) => {
state.providerConfig = action.payload
},
updateProviderConfig: (state, action: PayloadAction<Record<string, any>>) => {
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

View File

@ -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<typeof BochaSearchParamsSchema>
export type BochaSearchResponse = z.infer<typeof BochaSearchResponseSchema>
export { BochaSearchParamsSchema, BochaSearchResponseSchema }