feat: add web search settings (#2314)

* fix: add time when using web search

* feat: add optional

* chore

* chore

* chore

* clean code

* feat: set search max results

* feat: add manual blacklist

* clean code

* chore

* chore

* clean
This commit is contained in:
Chen Tao 2025-02-25 23:46:51 +08:00 committed by GitHub
parent e08029a6f5
commit 4bc69b7c5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 135 additions and 11 deletions

View File

@ -828,7 +828,12 @@
"api_key": "Tavily API 密钥",
"api_key.placeholder": "请输入 Tavily API 密钥"
},
"search_with_time": "搜索包含日期"
"search_with_time": "搜索包含日期",
"search_max_result": "搜索结果个数",
"search_result_default": "默认",
"blacklist": "黑名单",
"blacklist_description": "在搜索结果中不会出现以下网站的结果",
"blacklist_tooltip": "请使用以下格式(换行分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com"
}
},
"translate": {

View File

@ -4,8 +4,10 @@ import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setSearchWithTime } from '@renderer/store/websearch'
import { Input, Switch, Typography } from 'antd'
import { setExcludeDomains, setMaxResult, setSearchWithTime } from '@renderer/store/websearch'
import { formatDomains } from '@renderer/utils/blacklist'
import { Alert, Input, Slider, Switch, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -29,6 +31,11 @@ const WebSearchSettings: FC = () => {
const [apiKey, setApiKey] = useState(provider.apiKey)
const logo = theme === 'dark' ? tavilyLogoDark : tavilyLogo
const searchWithTime = useAppSelector((state) => state.websearch.searchWithTime)
const maxResults = useAppSelector((state) => state.websearch.maxResults)
const excludeDomains = useAppSelector((state) => state.websearch.excludeDomains)
const [errFormat, setErrFormat] = useState(false)
const [blacklistInput, setBlacklistInput] = useState('')
const dispatch = useAppDispatch()
useEffect(() => {
@ -39,6 +46,20 @@ const WebSearchSettings: FC = () => {
}
}, [apiKey, provider, updateProvider])
useEffect(() => {
if (excludeDomains) {
setBlacklistInput(excludeDomains.join('\n'))
}
}, [excludeDomains])
function updateManualBlacklist(blacklist: string) {
const blacklistDomains = blacklist.split('\n').filter((url) => url.trim() !== '')
const { formattedDomains, hasError } = formatDomains(blacklistDomains)
setErrFormat(hasError)
if (hasError) return
dispatch(setExcludeDomains(formattedDomains))
}
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
@ -70,6 +91,34 @@ const WebSearchSettings: FC = () => {
<SettingRowTitle>{t('settings.websearch.search_with_time')}</SettingRowTitle>
<Switch checked={searchWithTime} onChange={(checked) => dispatch(setSearchWithTime(checked))} />
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.websearch.search_max_result')}</SettingRowTitle>
<Slider
defaultValue={maxResults}
style={{ width: '200px' }}
min={1}
max={20}
step={1}
marks={{ 1: '1', 5: t('settings.websearch.search_result_default'), 20: '20' }}
onChangeComplete={(value) => dispatch(setMaxResult(value))}
/>
</SettingRow>
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.websearch.blacklist')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.websearch.blacklist_description')}</SettingRowTitle>
</SettingRow>
<TextArea
value={blacklistInput}
onChange={(e) => setBlacklistInput(e.target.value)}
onBlur={() => updateManualBlacklist(blacklistInput)}
placeholder={t('settings.websearch.blacklist_tooltip')}
autoSize={{ minRows: 2, maxRows: 6 }}
rows={4}
/>
{errFormat && <Alert message={t('settings.websearch.blacklist_tooltip')} type="error" />}
</SettingGroup>
</SettingContainer>
)

View File

@ -25,18 +25,20 @@ class WebSearchService {
public async search(query: string) {
const searchWithTime = store.getState().websearch.searchWithTime
const maxResults = store.getState().websearch.maxResults
const excludeDomains = store.getState().websearch.excludeDomains
let formatted_query = query
if (searchWithTime) {
formatted_query = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}`
}
const provider = this.getWebSearchProvider()
const tvly = tavily({ apiKey: provider.apiKey })
return await tvly.search(formatted_query, {
maxResults: 5
const result = await tvly.search(formatted_query, {
maxResults: maxResults,
excludeDomains: excludeDomains
})
return result
}
}

View File

@ -1124,6 +1124,8 @@ const migrateConfig = {
'73': (state: RootState) => {
if (state.websearch) {
state.websearch.searchWithTime = true
state.websearch.maxResults = 5
state.websearch.excludeDomains = []
}
if (!state.llm.providers.find((provider) => provider.id === 'lmstudio')) {
state.llm.providers.push({

View File

@ -4,6 +4,8 @@ export interface WebSearchState {
defaultProvider: string
providers: WebSearchProvider[]
searchWithTime: boolean
maxResults: number
excludeDomains: string[]
}
const initialState: WebSearchState = {
@ -15,7 +17,9 @@ const initialState: WebSearchState = {
apiKey: ''
}
],
searchWithTime: true
searchWithTime: true,
maxResults: 5,
excludeDomains: []
}
const websearchSlice = createSlice({
@ -36,11 +40,23 @@ const websearchSlice = createSlice({
},
setSearchWithTime: (state, action: PayloadAction<boolean>) => {
state.searchWithTime = action.payload
},
setMaxResult: (state, action: PayloadAction<number>) => {
state.maxResults = action.payload
},
setExcludeDomains: (state, action: PayloadAction<string[]>) => {
state.excludeDomains = action.payload
}
}
})
export const { setWebSearchProviders, updateWebSearchProvider, setDefaultProvider, setSearchWithTime } =
websearchSlice.actions
export const {
setWebSearchProviders,
updateWebSearchProvider,
setDefaultProvider,
setSearchWithTime,
setExcludeDomains,
setMaxResult
} = websearchSlice.actions
export default websearchSlice.reducer

View File

@ -0,0 +1,50 @@
interface FormatDomainsResult {
formattedDomains: string[]
hasError: boolean
}
export function formatDomains(urls: string[]): FormatDomainsResult {
let hasError = false
const formattedDomains: string[] = []
for (const urlString of urls) {
try {
let modifiedUrlString = urlString
// 1. 处理通配符协议 (*://)
if (modifiedUrlString.startsWith('*://')) {
modifiedUrlString = modifiedUrlString.substring(4)
}
// 2. 检查并添加协议前缀
if (!modifiedUrlString.match(/^[a-zA-Z]+:\/\//)) {
modifiedUrlString = 'https://' + modifiedUrlString
}
// 3. URL 解析和验证
const url = new URL(modifiedUrlString)
if (url.protocol !== 'https:') {
if (url.protocol !== 'http:') {
hasError = true
} else {
url.protocol = 'https:'
}
}
// 4. 通配符处理
let domain = url.hostname
if (domain.startsWith('*.')) {
domain = domain.substring(2)
}
// 5. 格式化
const formattedDomain = `https://${domain}`
formattedDomains.push(formattedDomain)
} catch (error) {
hasError = true
console.error('Error formatting URL:', urlString, error)
}
}
return { formattedDomains, hasError }
}