diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c760f49378..fa1f493d80 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1605,7 +1605,6 @@ "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "No API keys found, please add API keys first.", "models.check.passed": "Passed", - "models.check.checking": "Checking", "models.check.select_api_key": "Select the API key to use:", "models.check.single": "Single", "models.check.start": "Start", @@ -1654,6 +1653,7 @@ "api.url.tip": "Ending with / ignores v1, ending with # forces use of input address", "api_host": "API Host", "api_key": "API Key", + "api_key.tip": "Multiple keys separated by commas", "api_version": "API Version", "basic_auth": "HTTP authentication", "basic_auth.tip": "Applicable to instances deployed remotely (see the documentation). Currently, only the Basic scheme (RFC 7617) is supported.", @@ -1708,9 +1708,9 @@ "get_api_key": "Get API Key", "is_not_support_array_content": "Enable compatible mode", "no_models_for_check": "No models available for checking (e.g. chat models)", + "not_checked": "Not Checked", "remove_duplicate_keys": "Remove Duplicate Keys", "remove_invalid_keys": "Remove Invalid Keys", - "invalid_key": "Invalid API key", "search": "Search Providers...", "search_placeholder": "Search model id or name", "title": "Model Provider", @@ -1739,10 +1739,7 @@ }, "documentation": "View official documentation for more configuration details:", "learn_more": "Learn More" - }, - "enter_new_api_key": "Enter new API key", - "key_already_exists": "API key already exists", - "check_tooltip.latency": "Latency" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 3899742f9a..ffa579d563 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1599,7 +1599,6 @@ "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "APIキーが見つかりません。まずAPIキーを追加してください。", "models.check.passed": "成功", - "models.check.checking": "チェック中", "models.check.select_api_key": "使用するAPIキーを選択:", "models.check.single": "単一", "models.check.start": "開始", @@ -1642,6 +1641,7 @@ "api.url.tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します", "api_host": "APIホスト", "api_key": "APIキー", + "api_key.tip": "複数のキーはカンマで区切ります", "api_version": "APIバージョン", "basic_auth": "HTTP 認証", "basic_auth.tip": "サーバー展開によるインスタンスに適用されます(ドキュメントを参照)。現在はBasicスキーム(RFC7617)のみをサポートしています。", @@ -1693,9 +1693,9 @@ "get_api_key": "APIキーを取得", "is_not_support_array_content": "互換モードを有効にする", "no_models_for_check": "チェックするモデルがありません(例:会話モデル)", + "not_checked": "未チェック", "remove_duplicate_keys": "重複キーを削除", "remove_invalid_keys": "無効なキーを削除", - "invalid_key": "無効なAPIキー", "search": "プロバイダーを検索...", "search_placeholder": "モデルIDまたは名前を検索", "title": "モデルプロバイダー", @@ -1727,10 +1727,7 @@ }, "documentation": "詳細な設定については、公式ドキュメントを参照してください:", "learn_more": "詳細を確認" - }, - "enter_new_api_key": "新しいAPIキーを入力", - "key_already_exists": "APIキーはすでに存在します", - "check_tooltip.latency": "遅延" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 619f931c8f..a713da42ee 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1599,7 +1599,6 @@ "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "API ключи не найдены, пожалуйста, добавьте API ключи.", "models.check.passed": "Прошло", - "models.check.checking": "Проверка", "models.check.select_api_key": "Выберите API ключ для использования:", "models.check.single": "Один", "models.check.start": "Начать", @@ -1642,6 +1641,7 @@ "api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес", "api_host": "Хост API", "api_key": "Ключ API", + "api_key.tip": "Несколько ключей, разделенных запятыми", "api_version": "Версия API", "basic_auth": "HTTP аутентификация", "basic_auth.tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).", @@ -1693,9 +1693,9 @@ "get_api_key": "Получить ключ API", "is_not_support_array_content": "Включить совместимый режим", "no_models_for_check": "Нет моделей для проверки (например, диалоговые модели)", + "not_checked": "Не проверено", "remove_duplicate_keys": "Удалить дубликаты ключей", "remove_invalid_keys": "Удалить недействительные ключи", - "invalid_key": "Недействительный ключ API", "search": "Поиск поставщиков...", "search_placeholder": "Поиск по ID или имени модели", "title": "Провайдеры моделей", @@ -1727,10 +1727,7 @@ }, "documentation": "Смотрите официальную документацию для получения более подробной информации о конфигурации:", "learn_more": "Узнать больше" - }, - "enter_new_api_key": "Введите новый API ключ", - "key_already_exists": "API ключ уже существует", - "check_tooltip.latency": "Задержка" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0b5c446313..c16089db5e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1605,7 +1605,6 @@ "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "未找到API密钥,请先添加API密钥", "models.check.passed": "通过", - "models.check.checking": "检测中", "models.check.select_api_key": "选择要使用的API密钥:", "models.check.single": "单个", "models.check.start": "开始", @@ -1654,6 +1653,7 @@ "api.url.tip": "/结尾忽略v1版本,#结尾强制使用输入地址", "api_host": "API 地址", "api_key": "API 密钥", + "api_key.tip": "多个密钥使用逗号分隔", "api_version": "API 版本", "basic_auth": "HTTP 认证", "basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案(RFC7617)", @@ -1708,9 +1708,9 @@ "get_api_key": "点击这里获取密钥", "is_not_support_array_content": "开启兼容模式", "no_models_for_check": "没有可以被检测的模型(例如对话模型)", + "not_checked": "未检测", "remove_duplicate_keys": "移除重复密钥", "remove_invalid_keys": "删除无效密钥", - "invalid_key": "无效的 API 密钥", "search": "搜索模型平台...", "search_placeholder": "搜索模型 ID 或名称", "title": "模型服务", @@ -1739,10 +1739,7 @@ }, "documentation": "查看官方文档了解更多配置详情:", "learn_more": "了解更多" - }, - "enter_new_api_key": "输入新的API密钥", - "key_already_exists": "API密钥已存在", - "check_tooltip.latency": "耗时" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6d95afee24..72be4f02e6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1602,7 +1602,6 @@ "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "未找到API密鑰,請先添加API密鑰", "models.check.passed": "通過", - "models.check.checking": "檢查中", "models.check.select_api_key": "選擇要使用的API密鑰:", "models.check.single": "單個", "models.check.start": "開始", @@ -1645,6 +1644,7 @@ "api.url.tip": "/結尾忽略 v1 版本,#結尾強制使用輸入位址", "api_host": "API 主機地址", "api_key": "API 金鑰", + "api_key.tip": "多個金鑰使用逗號分隔", "api_version": "API 版本", "basic_auth": "HTTP 認證", "basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案(RFC7617)", @@ -1696,9 +1696,9 @@ "get_api_key": "點選這裡取得金鑰", "is_not_support_array_content": "開啟相容模式", "no_models_for_check": "沒有可以被檢查的模型(例如對話模型)", + "not_checked": "未檢查", "remove_duplicate_keys": "移除重複金鑰", "remove_invalid_keys": "刪除無效金鑰", - "invalid_key": "無效的 API 金鑰", "search": "搜尋模型平臺...", "search_placeholder": "搜尋模型 ID 或名稱", "title": "模型提供者", @@ -1730,10 +1730,7 @@ }, "documentation": "檢視官方文件以取得更多設定詳細資訊:", "learn_more": "瞭解更多" - }, - "enter_new_api_key": "輸入新的API密鑰", - "key_already_exists": "API密鑰已存在", - "check_tooltip.latency": "耗時" + } }, "proxy": { "mode": { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx new file mode 100644 index 0000000000..afd6ba576e --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx @@ -0,0 +1,231 @@ +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutlined } from '@ant-design/icons' +import Scrollbar from '@renderer/components/Scrollbar' +import { TopView } from '@renderer/components/TopView' +import { checkApi } from '@renderer/services/ApiService' +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' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ShowParams { + title: string + provider: Provider | WebSearchProvider + model?: Model + apiKeys: string[] + type: 'provider' | 'websearch' +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +interface KeyStatus { + key: string + isValid?: boolean + checking?: boolean +} + +const PopupContainer: React.FC = ({ title, provider, model, apiKeys, type, resolve }) => { + const [open, setOpen] = useState(true) + const [keyStatuses, setKeyStatuses] = useState(() => { + const uniqueKeys = new Set(apiKeys) + return Array.from(uniqueKeys).map((key) => ({ key })) + }) + const { t } = useTranslation() + const [isChecking, setIsChecking] = useState(false) + const [isCheckingSingle, setIsCheckingSingle] = useState(false) + + const checkAllKeys = async () => { + setIsChecking(true) + const newStatuses = [...keyStatuses] + + try { + // 使用Promise.all并行处理所有API验证请求 + const checkPromises = newStatuses.map(async (status, i) => { + // 先更新当前密钥为检查中状态 + setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status))) + + try { + let valid = false + if (type === 'provider' && model) { + await checkApi({ ...(provider as Provider), apiKey: status.key }, model) + valid = true + } else { + const result = await WebSearchService.checkSearch({ + ...(provider as WebSearchProvider), + apiKey: status.key + }) + valid = result.valid + } + + // 更新验证结果 + setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: valid } : s))) + + return { index: i, valid } + } catch (error: unknown) { + // 处理错误情况 + setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: false } : s))) + return { index: i, valid: false } + } + }) + + // 等待所有请求完成 + await Promise.all(checkPromises) + } finally { + setIsChecking(false) + } + } + + const checkSingleKey = async (keyIndex: number) => { + if (isChecking || keyStatuses[keyIndex].checking) { + return + } + + setIsCheckingSingle(true) + setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) + + try { + let valid = false + if (type === 'provider' && model) { + await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) + valid = true + } 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)) + ) + } catch (error: unknown) { + setKeyStatuses((prev) => + prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: false } : status)) + ) + } finally { + setIsCheckingSingle(false) + } + } + + const removeInvalidKeys = () => { + setKeyStatuses((prev) => prev.filter((status) => status.isValid !== false)) + } + + const removeKey = (keyIndex: number) => { + setKeyStatuses((prev) => prev.filter((_, idx) => idx !== keyIndex)) + } + + const onOk = () => { + const allKeys = keyStatuses.map((status) => status.key) + resolve({ validKeys: allKeys }) + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + return ( + + + + + + + + + + }> + + ( + + + {maskApiKey(status.key)} + + {status.checking && ( + + } /> + + )} + {status.isValid === true && !status.checking && } + {status.isValid === false && !status.checking && } + {status.isValid === undefined && !status.checking && ( + {t('settings.provider.not_checked')} + )} + + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1 + }} + /> + + + + )} + /> + + + ) +} + +export default class ApiCheckPopup { + static topviewId = 0 + static hide() { + TopView.hide('ApiCheckPopup') + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + 'ApiCheckPopup' + ) + }) + } +} + +const RemoveIcon = styled(MinusCircleOutlined)` + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--color-error); + cursor: pointer; + transition: all 0.2s ease-in-out; +` diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx deleted file mode 100644 index def4ba061a..0000000000 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx +++ /dev/null @@ -1,638 +0,0 @@ -import { - CheckCircleFilled, - CloseCircleFilled, - CloseCircleOutlined, - DeleteOutlined, - EditOutlined, - LoadingOutlined, - MinusCircleOutlined, - PlusOutlined -} from '@ant-design/icons' -import Scrollbar from '@renderer/components/Scrollbar' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { checkApi, formatApiKeys } from '@renderer/services/ApiService' -import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import WebSearchService from '@renderer/services/WebSearchService' -import { Model, Provider, WebSearchProvider } from '@renderer/types' -import { maskApiKey, splitApiKeyString } from '@renderer/utils/api' -import { Button, Card, Flex, Input, List, Space, Spin, Tooltip, Typography } from 'antd' -import { isEmpty } from 'lodash' -import { FC, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -import SelectProviderModelPopup from './SelectProviderModelPopup' - -interface Props { - provider: Provider | WebSearchProvider - apiKeys: string - onChange: (keys: string) => void - type?: 'provider' | 'websearch' -} - -interface KeyStatus { - key: string - isValid?: boolean - checking?: boolean - error?: string - model?: Model - latency?: number -} - -const STATUS_COLORS = { - success: '#52c41a', - error: '#ff4d4f' -} - -const formatAndConvertKeysToArray = (apiKeys: string): KeyStatus[] => { - const formattedApiKeys = formatApiKeys(apiKeys) - if (formattedApiKeys.includes(',')) { - const keys = splitApiKeyString(formattedApiKeys) - const uniqueKeys = new Set(keys) - return Array.from(uniqueKeys).map((key) => ({ key })) - } else { - return formattedApiKeys ? [{ key: formattedApiKeys }] : [] - } -} - -const ApiKeyList: FC = ({ provider, apiKeys, onChange, type = 'provider' }) => { - const [keyStatuses, setKeyStatuses] = useState(() => formatAndConvertKeysToArray(apiKeys)) - const [isAddingNew, setIsAddingNew] = useState(false) - const [newApiKey, setNewApiKey] = useState('') - const [isCancelingNewKey, setIsCancelingNewKey] = useState(false) - const newInputRef = useRef(null) - const [editingIndex, setEditingIndex] = useState(null) - const [editValue, setEditValue] = useState('') - const editInputRef = useRef(null) - const { t } = useTranslation() - const [isChecking, setIsChecking] = useState(false) - const [isCheckingSingle, setIsCheckingSingle] = useState(false) - const [confirmDeleteIndex, setConfirmDeleteIndex] = useState(null) - const isCopilot = provider.id === 'copilot' - - useEffect(() => { - if (isAddingNew && newInputRef.current) { - newInputRef.current.focus() - } - }, [isAddingNew]) - - useEffect(() => { - const newKeyStatuses = formatAndConvertKeysToArray(apiKeys) - - setKeyStatuses((currentStatuses) => { - const newKeys = newKeyStatuses.map((k) => k.key) - const currentKeys = currentStatuses.map((k) => k.key) - - // If the keys are the same, no need to update, prevents re-render loops. - if (newKeys.join(',') === currentKeys.join(',')) { - return currentStatuses - } - - // Merge new keys with existing statuses to preserve them. - const statusesMap = new Map(currentStatuses.map((s) => [s.key, s])) - return newKeyStatuses.map((k) => statusesMap.get(k.key) || k) - }) - }, [apiKeys]) - - useEffect(() => { - if (editingIndex !== null && editInputRef.current) { - editInputRef.current.focus() - } - }, [editingIndex]) - - const handleAddNewKey = () => { - setIsCancelingNewKey(false) - setIsAddingNew(true) - setNewApiKey('') - } - - const handleSaveNewKey = () => { - if (isCancelingNewKey) { - setIsCancelingNewKey(false) - return - } - - if (newApiKey.trim()) { - // Check if the key already exists - const keyExists = keyStatuses.some((status) => status.key === newApiKey.trim()) - - if (keyExists) { - window.message.error({ - key: 'duplicate-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.key_already_exists') - }) - return - } - - if (newApiKey.includes(',')) { - window.message.error({ - key: 'invalid-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.invalid_key') - }) - return - } - - const updatedKeyStatuses = [...keyStatuses, { key: newApiKey.trim() }] - setKeyStatuses(updatedKeyStatuses) - // Update parent component with new keys - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - // Add a small delay before resetting to prevent immediate re-triggering - setTimeout(() => { - setIsAddingNew(false) - setNewApiKey('') - }, 100) - } - - const handleCancelNewKey = () => { - setIsCancelingNewKey(true) - setIsAddingNew(false) - setNewApiKey('') - } - - const getModelForCheck = async (selectedModel?: Model): Promise => { - if (type !== 'provider') return null - - const modelsToCheck = (provider as Provider).models.filter( - (model) => !isEmbeddingModel(model) && !isRerankModel(model) - ) - - if (isEmpty(modelsToCheck)) { - window.message.error({ - key: 'no-models', - style: { marginTop: '3vh' }, - duration: 5, - content: t('settings.provider.no_models_for_check') - }) - return null - } - - try { - return ( - selectedModel || - (await SelectProviderModelPopup.show({ - provider: provider as Provider - })) - ) - } catch (err) { - // User canceled the popup - return null - } - } - - const checkSingleKey = async (keyIndex: number, selectedModel?: Model, isCheckingAll: boolean = false) => { - if (isChecking || keyStatuses[keyIndex].checking) { - return - } - - try { - let latency: number - let model: Model | undefined - - if (type === 'provider') { - const selectedModelForCheck = await getModelForCheck(selectedModel) - if (!selectedModelForCheck) { - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false } : status)) - ) - setIsCheckingSingle(false) - return - } - model = selectedModelForCheck - - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - const startTime = Date.now() - await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) - latency = Date.now() - startTime - } else { - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - const startTime = Date.now() - await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: keyStatuses[keyIndex].key - }) - latency = Date.now() - startTime - } - - // Only show notification when checking a single key - if (!isCheckingAll) { - window.message.success({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 2, - content: t('message.api.connection.success') - }) - } - - setKeyStatuses((prev) => - prev.map((status, idx) => - idx === keyIndex - ? { - ...status, - checking: false, - isValid: true, - model: selectedModel || model, - latency - } - : status - ) - ) - } catch (error: any) { - // Only show notification when checking a single key - if (!isCheckingAll) { - const errorMessage = error?.message ? ' ' + error.message : '' - window.message.error({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 8, - content: t('message.api.connection.failed') + errorMessage - }) - } - - setKeyStatuses((prev) => - prev.map((status, idx) => - idx === keyIndex - ? { - ...status, - checking: false, - isValid: false, - error: error instanceof Error ? error.message : String(error) - } - : status - ) - ) - } finally { - setIsCheckingSingle(false) - } - } - - const checkAllKeys = async () => { - setIsChecking(true) - - try { - let selectedModel - if (type === 'provider') { - selectedModel = await getModelForCheck() - if (!selectedModel) { - return - } - } - - await Promise.all(keyStatuses.map((_, index) => checkSingleKey(index, selectedModel, true))) - } finally { - setIsChecking(false) - } - } - - const removeInvalidKeys = () => { - const updatedKeyStatuses = keyStatuses.filter((status) => status.isValid !== false) - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - const removeKey = (keyIndex: number) => { - if (confirmDeleteIndex === keyIndex) { - // Second click - actually remove the key - const updatedKeyStatuses = keyStatuses.filter((_, idx) => idx !== keyIndex) - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - setConfirmDeleteIndex(null) - } else { - // First click - show confirmation state - setConfirmDeleteIndex(keyIndex) - // Auto-reset after 3 seconds - setTimeout(() => { - setConfirmDeleteIndex(null) - }, 3000) - } - } - - const renderKeyCheckResultTooltip = (status: KeyStatus) => { - if (status.checking) { - return t('settings.models.check.checking') - } - - const statusTitle = status.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed') - const statusColor = status.isValid ? STATUS_COLORS.success : STATUS_COLORS.error - - return ( -
- {statusTitle} - {type === 'provider' && status.model && ( -
- {t('common.model')}: {status.model.name} -
- )} - {status.latency && status.isValid && ( -
- {t('settings.provider.check_tooltip.latency')}: {(status.latency / 1000).toFixed(2)}s -
- )} - {status.error &&
{status.error}
} -
- ) - } - - const shouldAutoFocus = () => { - if (type === 'provider') { - return (provider as Provider).enabled && apiKeys === '' && !isProviderSupportAuth(provider as Provider) - } else if (type === 'websearch') { - return apiKeys === '' - } - return false - } - - const handleEditKey = (index: number) => { - setEditingIndex(index) - setEditValue(keyStatuses[index].key) - } - - const handleSaveEdit = () => { - if (editingIndex === null) return - - if (editValue.trim()) { - const keyExists = keyStatuses.some((status, idx) => idx !== editingIndex && status.key === editValue.trim()) - - if (keyExists) { - window.message.error({ - key: 'duplicate-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.key_already_exists') - }) - return - } - - if (editValue.includes(',')) { - window.message.error({ - key: 'invalid-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.invalid_key') - }) - return - } - - const updatedKeyStatuses = [...keyStatuses] - updatedKeyStatuses[editingIndex] = { - ...updatedKeyStatuses[editingIndex], - key: editValue.trim(), - isValid: undefined - } - - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - // Add a small delay before resetting to prevent immediate re-triggering - setTimeout(() => { - setEditingIndex(null) - setEditValue('') - }, 100) - } - - const handleCancelEdit = () => { - setEditingIndex(null) - setEditValue('') - } - - return ( - <> - - {keyStatuses.length === 0 && !isAddingNew ? ( - - {t('error.no_api_key')} - - ) : ( - <> - {keyStatuses.length > 0 && ( - - ( - - - - {editingIndex === index ? ( - setEditValue(e.target.value)} - onBlur={handleSaveEdit} - onPressEnter={handleSaveEdit} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault() - handleCancelEdit() - } - }} - style={{ width: '100%', fontSize: '14px' }} - spellCheck={false} - type="password" - /> - ) : ( - {maskApiKey(status.key)} - )} - - - {editingIndex === index ? ( - - ) : ( - <> - - {status.checking && ( - - } /> - - )} - {status.isValid === true && !status.checking && ( - - )} - {status.isValid === false && !status.checking && ( - - )} - - - {!isCopilot && ( - <> - !isChecking && !isCheckingSingle && handleEditKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px' - }} - title={t('common.edit')} - /> - {confirmDeleteIndex === index ? ( - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px', - color: 'var(--color-error)' - }} - title={t('common.delete')} - /> - ) : ( - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px', - color: 'var(--color-error)' - }} - title={t('common.delete')} - /> - )} - - )} - - )} - - - - )} - /> - - )} - {isAddingNew && ( - - - setNewApiKey(e.target.value)} - placeholder={t('settings.provider.enter_new_api_key')} - style={{ width: '60%', fontSize: '14px' }} - onPressEnter={handleSaveNewKey} - onBlur={handleSaveNewKey} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault() - handleCancelNewKey() - } - }} - spellCheck={false} - type="password" - /> - - - - - - )} - - )} - - - - {!isCopilot && ( - <> - - - - {keyStatuses.length > 1 && ( - - - - - )} - - )} - - - ) -} - -// Styled components for the list items -const ApiKeyListItem = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 0; - margin: 0; -` - -const ApiKeyContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; -` - -const ApiKeyActions = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - - @keyframes pulse { - 0% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 1; - } - } -` - -export default ApiKeyList diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index f708b28679..74a414d81c 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,11 +1,14 @@ +import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import { HStack } from '@renderer/components/Layout' -import { isRerankModel } from '@renderer/config/models' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_CONFIG } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' +import i18n from '@renderer/i18n' +import { checkApi, formatApiKeys } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' @@ -13,7 +16,7 @@ import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' -import { isEmpty } from 'lodash' +import { debounce, isEmpty } from 'lodash' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' @@ -28,7 +31,7 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiKeyList from './ApiKeyList' +import ApiCheckPopup from './ApiCheckPopup' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -38,6 +41,7 @@ import ModelList, { ModelStatus } from './ModelList' import ModelListSearchBar from './ModelListSearchBar' import ProviderOAuth from './ProviderOAuth' import ProviderSettingsPopup from './ProviderSettingsPopup' +import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { @@ -51,11 +55,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [apiKey, setApiKey] = useState(provider.apiKey) const [apiHost, setApiHost] = useState(provider.apiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) + const [apiValid, setApiValid] = useState(false) + const [apiChecking, setApiChecking] = useState(false) const [modelSearchText, setModelSearchText] = useState('') const deferredModelSearchText = useDeferredValue(modelSearchText) const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() + const [inputValue, setInputValue] = useState(apiKey) const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -69,6 +76,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedSetApiKey = useCallback( + debounce((value) => { + setApiKey(formatApiKeys(value)) + }, 100), + [] + ) + const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -84,6 +99,12 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) + const onUpdateApiKey = () => { + if (apiKey !== provider.apiKey) { + updateProvider({ ...provider, apiKey }) + } + } + const onUpdateApiHost = () => { if (apiHost.trim()) { updateProvider({ ...provider, apiHost }) @@ -92,11 +113,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } } - const handleApiKeyChange = (newApiKey: string) => { - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) const onHealthCheck = async () => { @@ -176,6 +192,75 @@ const ProviderSetting: FC = ({ provider: _provider }) => { setIsHealthChecking(false) } + const onCheckApi = async () => { + const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) + + if (isEmpty(modelsToCheck)) { + window.message.error({ + key: 'no-models', + style: { marginTop: '3vh' }, + duration: 5, + content: t('settings.provider.no_models_for_check') + }) + return + } + + const model = await SelectProviderModelPopup.show({ provider }) + + if (!model) { + window.message.error({ content: i18n.t('message.error.enter.model'), key: 'api-check' }) + return + } + + if (apiKey.includes(',')) { + const keys = splitApiKeyString(apiKey) + + const result = await ApiCheckPopup.show({ + title: t('settings.provider.check_multiple_keys'), + provider: { ...provider, apiHost }, + model, + apiKeys: keys, + type: 'provider' + }) + + if (result?.validKeys) { + const newApiKey = result.validKeys.join(',') + setInputValue(newApiKey) + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) + } + } else { + setApiChecking(true) + + try { + await checkApi({ ...provider, apiKey, apiHost }, model) + + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: i18n.t('message.api.connection.success') + }) + + setApiValid(true) + setTimeout(() => setApiValid(false), 3000) + } catch (error: any) { + const errorMessage = error?.message ? ' ' + error.message : '' + + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: i18n.t('message.api.connection.failed') + errorMessage + }) + + setApiValid(false) + } finally { + setApiChecking(false) + } + } + } + const onReset = () => { setApiHost(configedApiHost) updateProvider({ ...provider, apiHost: configedApiHost }) @@ -244,6 +329,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { provider={provider} setApiKey={(v) => { setApiKey(v) + setInputValue(v) updateProvider({ ...provider, apiKey: v }) }} /> @@ -252,14 +338,35 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {isDmxapi && } {provider.id !== 'vertexai' && ( <> - - - {t('settings.provider.api_key')} - - - + {t('settings.provider.api_key')} + + { + setInputValue(e.target.value) + debouncedSetApiKey(e.target.value) + }} + onBlur={() => { + const formattedValue = formatApiKeys(inputValue) + setInputValue(formattedValue) + setApiKey(formattedValue) + onUpdateApiKey() + }} + spellCheck={false} + autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)} + disabled={provider.id === 'copilot'} + /> + + {apiKeyWebsite && ( - + {!isDmxapi && ( @@ -267,6 +374,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} + {t('settings.provider.api_key.tip')} )} {!isDmxapi && ( diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 97d01d4f5e..44a3f01714 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,17 +1,19 @@ -import { ExportOutlined } from '@ant-design/icons' +import { CheckOutlined, ExportOutlined, 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' -import { Divider, Flex, Form, Input, Tooltip } from 'antd' +import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { Info } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' -import ApiKeyList from '../ProviderSettings/ApiKeyList' +import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' +import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' interface Props { provider: WebSearchProvider @@ -22,16 +24,19 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { 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 handleApiKeyChange = (newApiKey: string) => { - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) + const onUpdateApiKey = () => { + if (apiKey !== provider.apiKey) { + updateProvider({ ...provider, apiKey }) + } } const onUpdateApiHost = () => { @@ -66,6 +71,65 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { } } + async function checkSearch() { + if (!provider) { + window.message.error({ + content: t('settings.websearch.no_provider_selected'), + duration: 3, + icon: , + key: 'no-provider-selected' + }) + 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) + + 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 + }) + + setApiValid(valid) + } catch (err) { + console.error('Check search error:', err) + setApiValid(false) + window.message.error({ + key: 'check-search-error', + style: { marginTop: '3vh' }, + duration: 8, + content: t('settings.websearch.check_failed') + }) + } finally { + setApiChecking(false) + setTimeout(() => setApiValid(false), 2500) + } + } + useEffect(() => { setApiKey(provider.apiKey ?? '') setApiHost(provider.apiHost ?? '') @@ -90,14 +154,30 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {hasObjectKey(provider, 'apiKey') && ( <> {t('settings.provider.api_key')} - - {apiKeyWebsite && ( - - - {t('settings.websearch.get_api_key')} - - - )} + + setApiKey(formatApiKeys(e.target.value))} + onBlur={onUpdateApiKey} + spellCheck={false} + type="password" + autoFocus={apiKey === ''} + /> + + + + + {t('settings.websearch.get_api_key')} + + {t('settings.provider.api_key.tip')} + )} {hasObjectKey(provider, 'apiHost') && (