cherry-studio/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx
Phantom bef0180e4c
feat: web search icons (#9147)
* feat(类型): 添加WebSearchProviderIds常量并更新WebSearchProvider类型

* refactor(web-search): 重构网络搜索提供商配置和logo获取逻辑

将webSearchProviders.ts中的提供商logo获取函数移动到使用组件中
并优化提供商配置的类型定义

* feat(WebSearchButton): 添加不同搜索引擎的图标支持

为WebSearchButton组件添加多个搜索引擎的图标支持,包括Baidu、Google、Bing等

* feat(types): 添加预处理和网页搜索提供者的类型校验函数

添加 PreprocessProviderId 和 WebSearchProviderId 的类型校验函数 isPreprocessProviderId 和 isWebSearchProviderId,用于验证字符串是否为有效的提供者 ID

* refactor(types): 重命名ApiProviderUnion并添加更新函数类型

添加用于更新不同类型API提供者的函数类型,提高类型安全性

* refactor(websearch): 将搜索提供商配置提取到单独文件

将websearch store中的搜索提供商配置提取到单独的配置文件,提高代码可维护性

* refactor(PreprocessSettings): 移除未使用的 system 选项禁用逻辑

由于 system 字段实际未使用,移除相关代码以简化逻辑

* refactor(api-key-popup): 移除providerKind参数,改用providerId判断类型

* refactor(preprocessProviders): 使用类型定义优化预处理提供者配置

将 providerId 参数类型从 string 改为 PreprocessProviderId
为 PREPROCESS_PROVIDER_CONFIG 添加类型定义

* refactor(hooks): 使用PreprocessProviderId类型替换字符串类型参数

* refactor(hooks): 使用 WebSearchProviderId 类型替换字符串类型参数

将 useWebSearchProvider 钩子的 id 参数类型从 string 改为 WebSearchProviderId,提高类型安全性

* refactor(knowledge): 将providerId类型改为PreprocessProviderId

* refactor(PreprocessSettings): 移除未使用的options相关代码

清理PreprocessSettings组件中已被注释掉的options状态和相关逻辑,简化代码结构

* refactor(WebSearchProviderSetting): 将providerId类型从string改为WebSearchProviderId

* refactor(websearch): 移除WebSearchProvider类型中不必要的id字段约束

* style(WebSearchButton): 调整图标大小和样式以保持视觉一致性

* fix(ApiKeyListPopup): 修正LLM提供者判断逻辑

使用'models'属性检查替代原有逻辑,更准确地判断是否为LLM provider

* fix(ApiKeyListPopup): 修复预处理provider判断逻辑

处理mistral同时提供预处理和llm服务的情况,避免误判
2025-08-14 23:19:17 +08:00

294 lines
10 KiB
TypeScript

import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
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'
import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup'
import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProviderId } from '@renderer/types'
import { formatApiKeys, hasObjectKey } from '@renderer/utils'
import { Button, Divider, Flex, Form, Input, Space, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { Info, List } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..'
const logger = loggerService.withContext('WebSearchProviderSetting')
interface Props {
providerId: WebSearchProviderId
}
const WebSearchProviderSetting: FC<Props> = ({ providerId }) => {
const { provider, updateProvider } = useWebSearchProvider(providerId)
const { t } = useTranslation()
const [apiKey, setApiKey] = useState(provider.apiKey || '')
const [apiHost, setApiHost] = useState(provider.apiHost || '')
const [apiChecking, setApiChecking] = useState(false)
const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '')
const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '')
const [apiValid, setApiValid] = useState(false)
const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id]
const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey
const officialWebsite = webSearchProviderConfig?.websites?.official
const onUpdateApiKey = () => {
if (apiKey !== provider.apiKey) {
updateProvider({ apiKey })
}
}
const onUpdateApiHost = () => {
let trimmedHost = apiHost?.trim() || ''
if (trimmedHost.endsWith('/')) {
trimmedHost = trimmedHost.slice(0, -1)
}
if (trimmedHost !== provider.apiHost) {
updateProvider({ apiHost: trimmedHost })
} else {
setApiHost(provider.apiHost || '')
}
}
const onUpdateBasicAuthUsername = () => {
const currentValue = basicAuthUsername || ''
const savedValue = provider.basicAuthUsername || ''
if (currentValue !== savedValue) {
updateProvider({ basicAuthUsername })
} else {
setBasicAuthUsername(provider.basicAuthUsername || '')
}
}
const onUpdateBasicAuthPassword = () => {
const currentValue = basicAuthPassword || ''
const savedValue = provider.basicAuthPassword || ''
if (currentValue !== savedValue) {
updateProvider({ basicAuthPassword })
} else {
setBasicAuthPassword(provider.basicAuthPassword || '')
}
}
const openApiKeyList = async () => {
await ApiKeyListPopup.show({
providerId: provider.id,
title: `${provider.name} ${t('settings.provider.api.key.list.title')}`
})
}
async function checkSearch() {
if (!provider) {
window.message.error({
content: t('settings.no_provider_selected'),
duration: 3,
icon: <Info size={18} />,
key: 'no-provider-selected'
})
return
}
if (apiKey.includes(',')) {
await openApiKeyList()
return
}
try {
setApiChecking(true)
const { valid, error } = await WebSearchService.checkSearch(provider)
const errorMessage = error && error?.message ? ' ' + error?.message : ''
window.message[valid ? 'success' : 'error']({
key: 'api-check',
style: { marginTop: '3vh' },
duration: valid ? 2 : 8,
content: valid
? t('settings.tool.websearch.check_success')
: t('settings.tool.websearch.check_failed') + errorMessage
})
setApiValid(valid)
} catch (err) {
logger.error('Check search error:', err as Error)
setApiValid(false)
window.message.error({
key: 'check-search-error',
style: { marginTop: '3vh' },
duration: 8,
content: t('settings.tool.websearch.check_failed')
})
} finally {
setApiChecking(false)
setTimeout(() => setApiValid(false), 2500)
}
}
useEffect(() => {
setApiKey(provider.apiKey ?? '')
setApiHost(provider.apiHost ?? '')
setBasicAuthUsername(provider.basicAuthUsername ?? '')
setBasicAuthPassword(provider.basicAuthPassword ?? '')
}, [provider.apiKey, provider.apiHost, provider.basicAuthUsername, provider.basicAuthPassword])
const getWebSearchProviderLogo = (providerId: WebSearchProviderId) => {
switch (providerId) {
case 'tavily':
return TavilyLogo
case 'searxng':
return SearxngLogo
case 'exa':
return ExaLogo
case 'bocha':
return BochaLogo
default:
return undefined
}
}
return (
<>
<SettingTitle>
<Flex align="center" gap={8}>
<ProviderLogo src={getWebSearchProviderLogo(provider.id)} />
<ProviderName> {provider.name}</ProviderName>
{officialWebsite && webSearchProviderConfig?.websites && (
<Link target="_blank" href={webSearchProviderConfig.websites.official}>
<ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} />
</Link>
)}
</Flex>
</SettingTitle>
<Divider style={{ width: '100%', margin: '10px 0' }} />
{hasObjectKey(provider, 'apiKey') && (
<>
<SettingSubtitle
style={{
marginTop: 5,
marginBottom: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key.label')}
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
<Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} />
</Tooltip>
</SettingSubtitle>
<Space.Compact style={{ width: '100%' }}>
<Input.Password
value={apiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
onBlur={onUpdateApiKey}
spellCheck={false}
type="password"
autoFocus={apiKey === ''}
/>
<Button
ghost={apiValid}
type={apiValid ? 'primary' : 'default'}
onClick={checkSearch}
disabled={apiChecking}>
{apiChecking ? (
<LoadingOutlined spin />
) : apiValid ? (
<CheckOutlined />
) : (
t('settings.tool.websearch.check')
)}
</Button>
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.api_key.tip')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
</>
)}
{hasObjectKey(provider, 'apiHost') && (
<>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
{t('settings.provider.api_host')}
</SettingSubtitle>
<Flex gap={8}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
</Flex>
</>
)}
{hasObjectKey(provider, 'basicAuthUsername') && (
<>
<SettingDivider style={{ marginTop: 12, marginBottom: 12 }} />
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
{t('settings.provider.basic_auth.label')}
<Tooltip title={t('settings.provider.basic_auth.tip')} placement="right">
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
</Tooltip>
</SettingSubtitle>
<Flex>
<Form
layout="vertical"
style={{ width: '100%' }}
initialValues={{
username: basicAuthUsername,
password: basicAuthPassword
}}
onValuesChange={(changedValues) => {
// Update local state when form values change
if ('username' in changedValues) {
setBasicAuthUsername(changedValues.username || '')
}
if ('password' in changedValues) {
setBasicAuthPassword(changedValues.password || '')
}
}}>
<Form.Item label={t('settings.provider.basic_auth.user_name.label')} name="username">
<Input
placeholder={t('settings.provider.basic_auth.user_name.tip')}
onBlur={onUpdateBasicAuthUsername}
/>
</Form.Item>
<Form.Item
label={t('settings.provider.basic_auth.password.label')}
name="password"
rules={[{ required: !!basicAuthUsername, validateTrigger: ['onBlur', 'onChange'] }]}
help=""
hidden={!basicAuthUsername}>
<Input.Password
placeholder={t('settings.provider.basic_auth.password.tip')}
onBlur={onUpdateBasicAuthPassword}
disabled={!basicAuthUsername}
visibilityToggle={true}
/>
</Form.Item>
</Form>
</Flex>
</>
)}
</>
)
}
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;
`
const ProviderLogo = styled.img`
width: 20px;
height: 20px;
object-fit: contain;
`
export default WebSearchProviderSetting