mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
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:
parent
a8b183b2a6
commit
12b210dcce
BIN
src/renderer/src/assets/images/search/bocha.webp
Normal file
BIN
src/renderer/src/assets/images/search/bocha.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@ -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'
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
207
src/renderer/src/types/bocha.ts
Normal file
207
src/renderer/src/types/bocha.ts
Normal 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 }
|
||||
Loading…
Reference in New Issue
Block a user