feat: Enhance API key verification and multi-key support for web search providers (#3346)

* feat(websearch): implement API key formatting and add WebSearchApiCheckPopup for multiple keys validation

- Introduced a new WebSearchApiCheckPopup component to validate multiple API keys.
- Added formatApiKeys function to standardize API key input.
- Updated WebSearchProviderSetting to utilize the new popup for checking multiple keys.
- Enhanced error handling and user feedback for API key validation.

* feat(settings): enhance API key validation for providers and web search

- Updated ApiCheckPopup to handle both provider and web search API key validation.
- Refactored key checking logic to differentiate between provider and web search types.
- Removed the obsolete WebSearchApiCheckPopup component and integrated its functionality into ApiCheckPopup.
- Adjusted WebSearchProviderSetting to utilize the updated ApiCheckPopup for checking multiple keys.
This commit is contained in:
Xunjin ZHENG 2025-03-14 17:41:38 +08:00 committed by GitHub
parent 4c5b8ee0ee
commit aa6ecb4814
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 110 additions and 29 deletions

View File

@ -967,6 +967,7 @@
"blacklist_description": "Results from the following websites will not appear in search results", "blacklist_description": "Results from the following websites will not appear in search results",
"blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "Check", "check": "Check",
"check_success": "Verification successful",
"check_failed": "Verification failed", "check_failed": "Verification failed",
"get_api_key": "Get API Key", "get_api_key": "Get API Key",
"no_provider_selected": "Please select a search service provider before checking.", "no_provider_selected": "Please select a search service provider before checking.",

View File

@ -967,6 +967,7 @@
"blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません",
"blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "チェック", "check": "チェック",
"check_success": "検証に成功しました",
"check_failed": "検証に失敗しました", "check_failed": "検証に失敗しました",
"get_api_key": "APIキーを取得", "get_api_key": "APIキーを取得",
"no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。",

View File

@ -967,6 +967,7 @@
"blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска",
"blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "проверка", "check": "проверка",
"check_success": "Проверка успешна",
"check_failed": "Проверка не прошла", "check_failed": "Проверка не прошла",
"get_api_key": "Получить ключ API", "get_api_key": "Получить ключ API",
"no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.",

View File

@ -967,6 +967,7 @@
"blacklist_description": "在搜索结果中不会出现以下网站的结果", "blacklist_description": "在搜索结果中不会出现以下网站的结果",
"blacklist_tooltip": "请使用以下格式(换行分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "blacklist_tooltip": "请使用以下格式(换行分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "检查", "check": "检查",
"check_success": "验证成功",
"check_failed": "验证失败", "check_failed": "验证失败",
"get_api_key": "点击这里获取密钥", "get_api_key": "点击这里获取密钥",
"no_provider_selected": "请选择搜索服务商后再检查", "no_provider_selected": "请选择搜索服务商后再检查",

View File

@ -967,6 +967,7 @@
"blacklist_description": "以下網站不會出現在搜尋結果中", "blacklist_description": "以下網站不會出現在搜尋結果中",
"blacklist_tooltip": "請使用以下格式 (換行符號分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "blacklist_tooltip": "請使用以下格式 (換行符號分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"check": "檢查", "check": "檢查",
"check_success": "驗證成功",
"check_failed": "驗證失敗", "check_failed": "驗證失敗",
"get_api_key": "點選這裡取得金鑰", "get_api_key": "點選這裡取得金鑰",
"no_provider_selected": "請選擇搜尋服務商後再檢查", "no_provider_selected": "請選擇搜尋服務商後再檢查",

View File

@ -2,8 +2,8 @@ import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutli
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { checkApi } from '@renderer/services/ApiService' import { checkApi } from '@renderer/services/ApiService'
import { Model } from '@renderer/types' import WebSearchService from '@renderer/services/WebSearchService'
import { Provider } from '@renderer/types' import { Model, Provider, WebSearchProvider } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api' import { maskApiKey } from '@renderer/utils/api'
import { Button, List, Modal, Space, Spin, Typography } from 'antd' import { Button, List, Modal, Space, Spin, Typography } from 'antd'
import { useState } from 'react' import { useState } from 'react'
@ -12,9 +12,10 @@ import styled from 'styled-components'
interface ShowParams { interface ShowParams {
title: string title: string
provider: Provider provider: Provider | WebSearchProvider
model: Model model?: Model
apiKeys: string[] apiKeys: string[]
type: 'provider' | 'websearch'
} }
interface Props extends ShowParams { interface Props extends ShowParams {
@ -27,7 +28,7 @@ interface KeyStatus {
checking?: boolean checking?: boolean
} }
const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, resolve }) => { const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, type, resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [keyStatuses, setKeyStatuses] = useState<KeyStatus[]>(() => { const [keyStatuses, setKeyStatuses] = useState<KeyStatus[]>(() => {
const uniqueKeys = new Set(apiKeys) const uniqueKeys = new Set(apiKeys)
@ -45,7 +46,17 @@ const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, reso
for (let i = 0; i < newStatuses.length; i++) { for (let i = 0; i < newStatuses.length; i++) {
setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status))) setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status)))
const { valid } = await checkApi({ ...provider, apiKey: newStatuses[i].key }, model) let valid = false
if (type === 'provider' && model) {
const result = await checkApi({ ...(provider as Provider), apiKey: newStatuses[i].key }, model)
valid = result.valid
} else {
const result = await WebSearchService.checkSearch({
...(provider as WebSearchProvider),
apiKey: newStatuses[i].key
})
valid = result.valid
}
setKeyStatuses((prev) => setKeyStatuses((prev) =>
prev.map((status, idx) => (idx === i ? { ...status, checking: false, isValid: valid } : status)) prev.map((status, idx) => (idx === i ? { ...status, checking: false, isValid: valid } : status))
@ -65,7 +76,17 @@ const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, reso
setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status)))
try { try {
const { valid } = await checkApi({ ...provider, apiKey: keyStatuses[keyIndex].key }, model) let valid = false
if (type === 'provider' && model) {
const result = await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model)
valid = result.valid
} else {
const result = await WebSearchService.checkSearch({
...(provider as WebSearchProvider),
apiKey: keyStatuses[keyIndex].key
})
valid = result.valid
}
setKeyStatuses((prev) => setKeyStatuses((prev) =>
prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: valid } : status)) prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: valid } : status))

View File

@ -6,7 +6,7 @@ import { useTheme } from '@renderer/context/ThemeProvider'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { isOpenAIProvider } from '@renderer/providers/ProviderFactory' import { isOpenAIProvider } from '@renderer/providers/ProviderFactory'
import { checkApi } from '@renderer/services/ApiService' import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService' import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService' import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
@ -198,7 +198,8 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
title: t('settings.provider.check_multiple_keys'), title: t('settings.provider.check_multiple_keys'),
provider: { ...provider, apiHost }, provider: { ...provider, apiHost },
model, model,
apiKeys: keys apiKeys: keys,
type: 'provider'
}) })
if (result?.validKeys) { if (result?.validKeys) {
@ -240,10 +241,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return formatApiHost(apiHost) + 'chat/completions' return formatApiHost(apiHost) + 'chat/completions'
} }
const formatApiKeys = (value: string) => {
return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',')
}
useEffect(() => { useEffect(() => {
setApiKey(provider.apiKey) setApiKey(provider.apiKey)
setApiHost(provider.apiHost) setApiHost(provider.apiHost)

View File

@ -1,6 +1,7 @@
import { CheckOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons' import { CheckOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { formatApiKeys } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService' import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider } from '@renderer/types' import { WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils' import { hasObjectKey } from '@renderer/utils'
@ -10,7 +11,8 @@ import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..'
import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup'
interface Props { interface Props {
provider: WebSearchProvider provider: WebSearchProvider
@ -19,8 +21,8 @@ interface Props {
const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => { const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const { provider, updateProvider } = useWebSearchProvider(_provider.id) const { provider, updateProvider } = useWebSearchProvider(_provider.id)
const { t } = useTranslation() const { t } = useTranslation()
const [apiKey, setApiKey] = useState(provider.apiKey) const [apiKey, setApiKey] = useState(provider.apiKey || '')
const [apiHost, setApiHost] = useState(provider.apiHost) const [apiHost, setApiHost] = useState(provider.apiHost || '')
const [apiChecking, setApiChecking] = useState(false) const [apiChecking, setApiChecking] = useState(false)
const [apiValid, setApiValid] = useState(false) const [apiValid, setApiValid] = useState(false)
@ -57,27 +59,47 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
return return
} }
if (apiKey.includes(',')) {
const keys = apiKey
.split(',')
.map((k) => k.trim())
.filter((k) => k)
const result = await ApiCheckPopup.show({
title: t('settings.provider.check_multiple_keys'),
provider: { ...provider, apiHost },
apiKeys: keys,
type: 'websearch'
})
if (result?.validKeys) {
setApiKey(result.validKeys.join(','))
updateProvider({ ...provider, apiKey: result.validKeys.join(',') })
}
return
}
try { try {
setApiChecking(true) setApiChecking(true)
const { valid, error } = await WebSearchService.checkSearch(provider) const { valid, error } = await WebSearchService.checkSearch(provider)
setApiValid(valid) const errorMessage = error && error?.message ? ' ' + error?.message : ''
window.message[valid ? 'success' : 'error']({
if (!valid && error) { key: 'api-check',
const errorMessage = error.message ? ' ' + error.message : '' style: { marginTop: '3vh' },
window.message.error({ duration: valid ? 2 : 8,
content: errorMessage, content: valid ? t('settings.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage
duration: 4,
key: 'search-check-error'
}) })
}
setApiValid(valid)
} catch (err) { } catch (err) {
console.error('Check search error:', err) console.error('Check search error:', err)
setApiValid(false) setApiValid(false)
window.message.error({ window.message.error({
content: t('settings.websearch.check_failed'), key: 'check-search-error',
duration: 3, style: { marginTop: '3vh' },
key: 'check-search-error' duration: 8,
content: t('settings.websearch.check_failed')
}) })
} finally { } finally {
setApiChecking(false) setApiChecking(false)
@ -112,7 +134,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Input.Password <Input.Password
value={apiKey} value={apiKey}
placeholder={t('settings.provider.api_key')} placeholder={t('settings.provider.api_key')}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
onBlur={onUpdateApiKey} onBlur={onUpdateApiKey}
spellCheck={false} spellCheck={false}
type="password" type="password"
@ -130,6 +152,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingHelpLink target="_blank" href={apiKeyWebsite}> <SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.websearch.get_api_key')} {t('settings.websearch.get_api_key')}
</SettingHelpLink> </SettingHelpLink>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow> </SettingHelpTextRow>
</> </>
)} )}

View File

@ -308,3 +308,12 @@ export async function fetchModels(provider: Provider) {
return [] return []
} }
} }
/**
* Format API keys
* @param value Raw key string
* @returns Formatted key string
*/
export const formatApiKeys = (value: string) => {
return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',')
}

View File

@ -3,8 +3,34 @@ import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
export default abstract class BaseWebSearchProvider { export default abstract class BaseWebSearchProvider {
// @ts-ignore this // @ts-ignore this
private provider: WebSearchProvider private provider: WebSearchProvider
protected apiKey: string
constructor(provider: WebSearchProvider) { constructor(provider: WebSearchProvider) {
this.provider = provider this.provider = provider
this.apiKey = this.getApiKey()
} }
abstract search(query: string, maxResult: number, excludeDomains: string[]): Promise<WebSearchResponse> abstract search(query: string, maxResult: number, excludeDomains: string[]): Promise<WebSearchResponse>
public getApiKey() {
const keys = this.provider.apiKey?.split(',').map((key) => key.trim()) || []
const keyName = `web-search-provider:${this.provider.id}:last_used_key`
if (keys.length === 1) {
return keys[0]
}
const lastUsedKey = window.keyv.get(keyName)
if (!lastUsedKey) {
window.keyv.set(keyName, keys[0])
return keys[0]
}
const currentIndex = keys.indexOf(lastUsedKey)
const nextIndex = (currentIndex + 1) % keys.length
const nextKey = keys[nextIndex]
window.keyv.set(keyName, nextKey)
return nextKey
}
} }