From aa6ecb4814df190e73b6e7d357f4a445c4850cf1 Mon Sep 17 00:00:00 2001 From: Xunjin ZHENG Date: Fri, 14 Mar 2025 17:41:38 +0800 Subject: [PATCH] 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. --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../ProviderSettings/ApiCheckPopup.tsx | 35 +++++++++--- .../ProviderSettings/ProviderSetting.tsx | 9 +-- .../WebSearchProviderSetting.tsx | 55 +++++++++++++------ src/renderer/src/services/ApiService.ts | 9 +++ .../BaseWebSearchProvider.ts | 26 +++++++++ 10 files changed, 110 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e98682ad..67ae5f79 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -967,6 +967,7 @@ "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", "check": "Check", + "check_success": "Verification successful", "check_failed": "Verification failed", "get_api_key": "Get API Key", "no_provider_selected": "Please select a search service provider before checking.", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d83eac4d..896a5afa 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -967,6 +967,7 @@ "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", "blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "check": "チェック", + "check_success": "検証に成功しました", "check_failed": "検証に失敗しました", "get_api_key": "APIキーを取得", "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index cf297eb2..ee081a9d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -967,6 +967,7 @@ "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", "blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "check": "проверка", + "check_success": "Проверка успешна", "check_failed": "Проверка не прошла", "get_api_key": "Получить ключ API", "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index fe9f5ffa..60ebd166 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -967,6 +967,7 @@ "blacklist_description": "在搜索结果中不会出现以下网站的结果", "blacklist_tooltip": "请使用以下格式(换行分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "check": "检查", + "check_success": "验证成功", "check_failed": "验证失败", "get_api_key": "点击这里获取密钥", "no_provider_selected": "请选择搜索服务商后再检查", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 02553ebf..3727f7ef 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -967,6 +967,7 @@ "blacklist_description": "以下網站不會出現在搜尋結果中", "blacklist_tooltip": "請使用以下格式 (換行符號分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "check": "檢查", + "check_success": "驗證成功", "check_failed": "驗證失敗", "get_api_key": "點選這裡取得金鑰", "no_provider_selected": "請選擇搜尋服務商後再檢查", diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx index 3224efcd..ffbe5822 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx @@ -2,8 +2,8 @@ import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutli import Scrollbar from '@renderer/components/Scrollbar' import { TopView } from '@renderer/components/TopView' import { checkApi } from '@renderer/services/ApiService' -import { Model } from '@renderer/types' -import { Provider } from '@renderer/types' +import WebSearchService from '@renderer/services/WebSearchService' +import { Model, Provider, WebSearchProvider } from '@renderer/types' import { maskApiKey } from '@renderer/utils/api' import { Button, List, Modal, Space, Spin, Typography } from 'antd' import { useState } from 'react' @@ -12,9 +12,10 @@ import styled from 'styled-components' interface ShowParams { title: string - provider: Provider - model: Model + provider: Provider | WebSearchProvider + model?: Model apiKeys: string[] + type: 'provider' | 'websearch' } interface Props extends ShowParams { @@ -27,7 +28,7 @@ interface KeyStatus { checking?: boolean } -const PopupContainer: React.FC = ({ title, provider, model, apiKeys, resolve }) => { +const PopupContainer: React.FC = ({ title, provider, model, apiKeys, type, resolve }) => { const [open, setOpen] = useState(true) const [keyStatuses, setKeyStatuses] = useState(() => { const uniqueKeys = new Set(apiKeys) @@ -45,7 +46,17 @@ const PopupContainer: React.FC = ({ title, provider, model, apiKeys, reso for (let i = 0; i < newStatuses.length; i++) { 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) => prev.map((status, idx) => (idx === i ? { ...status, checking: false, isValid: valid } : status)) @@ -65,7 +76,17 @@ const PopupContainer: React.FC = ({ title, provider, model, apiKeys, reso setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) 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) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: valid } : status)) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index f61c6721..84c9a533 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -6,7 +6,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useProvider } from '@renderer/hooks/useProvider' import i18n from '@renderer/i18n' 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 { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' @@ -198,7 +198,8 @@ const ProviderSetting: FC = ({ provider: _provider }) => { title: t('settings.provider.check_multiple_keys'), provider: { ...provider, apiHost }, model, - apiKeys: keys + apiKeys: keys, + type: 'provider' }) if (result?.validKeys) { @@ -240,10 +241,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return formatApiHost(apiHost) + 'chat/completions' } - const formatApiKeys = (value: string) => { - return value.replaceAll(',', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',') - } - useEffect(() => { setApiKey(provider.apiKey) setApiHost(provider.apiHost) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 1507bcf0..eb82344a 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons' import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' +import { formatApiKeys } from '@renderer/services/ApiService' import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' @@ -10,7 +11,8 @@ import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' 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 { provider: WebSearchProvider @@ -19,8 +21,8 @@ interface Props { const WebSearchProviderSetting: FC = ({ provider: _provider }) => { const { provider, updateProvider } = useWebSearchProvider(_provider.id) const { t } = useTranslation() - const [apiKey, setApiKey] = useState(provider.apiKey) - const [apiHost, setApiHost] = useState(provider.apiHost) + const [apiKey, setApiKey] = useState(provider.apiKey || '') + const [apiHost, setApiHost] = useState(provider.apiHost || '') const [apiChecking, setApiChecking] = useState(false) const [apiValid, setApiValid] = useState(false) @@ -57,27 +59,47 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { 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 { setApiChecking(true) const { valid, error } = await WebSearchService.checkSearch(provider) - setApiValid(valid) + 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.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage + }) - if (!valid && error) { - const errorMessage = error.message ? ' ' + error.message : '' - window.message.error({ - content: errorMessage, - duration: 4, - key: 'search-check-error' - }) - } + setApiValid(valid) } catch (err) { console.error('Check search error:', err) setApiValid(false) window.message.error({ - content: t('settings.websearch.check_failed'), - duration: 3, - key: 'check-search-error' + key: 'check-search-error', + style: { marginTop: '3vh' }, + duration: 8, + content: t('settings.websearch.check_failed') }) } finally { setApiChecking(false) @@ -112,7 +134,7 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { setApiKey(e.target.value)} + onChange={(e) => setApiKey(formatApiKeys(e.target.value))} onBlur={onUpdateApiKey} spellCheck={false} type="password" @@ -130,6 +152,7 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {t('settings.websearch.get_api_key')} + {t('settings.provider.api_key.tip')} )} diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index c1004434..c1ce13c2 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -308,3 +308,12 @@ export async function fetchModels(provider: Provider) { 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', ',') +} diff --git a/src/renderer/src/webSearchProvider/BaseWebSearchProvider.ts b/src/renderer/src/webSearchProvider/BaseWebSearchProvider.ts index cfb3728f..8e1c311a 100644 --- a/src/renderer/src/webSearchProvider/BaseWebSearchProvider.ts +++ b/src/renderer/src/webSearchProvider/BaseWebSearchProvider.ts @@ -3,8 +3,34 @@ import { WebSearchProvider, WebSearchResponse } from '@renderer/types' export default abstract class BaseWebSearchProvider { // @ts-ignore this private provider: WebSearchProvider + protected apiKey: string + constructor(provider: WebSearchProvider) { this.provider = provider + this.apiKey = this.getApiKey() } + abstract search(query: string, maxResult: number, excludeDomains: string[]): Promise + + 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 + } }