diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index fa1f493d80..c760f49378 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1605,6 +1605,7 @@ "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", @@ -1653,7 +1654,6 @@ "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,7 +1739,10 @@ }, "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 ffa579d563..3899742f9a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1599,6 +1599,7 @@ "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": "開始", @@ -1641,7 +1642,6 @@ "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,7 +1727,10 @@ }, "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 a713da42ee..619f931c8f 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1599,6 +1599,7 @@ "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": "Начать", @@ -1641,7 +1642,6 @@ "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,7 +1727,10 @@ }, "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 c16089db5e..0b5c446313 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1605,6 +1605,7 @@ "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": "开始", @@ -1653,7 +1654,6 @@ "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,7 +1739,10 @@ }, "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 72be4f02e6..6d95afee24 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1602,6 +1602,7 @@ "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": "開始", @@ -1644,7 +1645,6 @@ "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,7 +1730,10 @@ }, "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 deleted file mode 100644 index afd6ba576e..0000000000 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx +++ /dev/null @@ -1,231 +0,0 @@ -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 new file mode 100644 index 0000000000..def4ba061a --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx @@ -0,0 +1,638 @@ +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 74a414d81c..f708b28679 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,14 +1,11 @@ -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 { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { 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' @@ -16,7 +13,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 { debounce, isEmpty } from 'lodash' +import { isEmpty } from 'lodash' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' @@ -31,7 +28,7 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from './ApiCheckPopup' +import ApiKeyList from './ApiKeyList' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -41,7 +38,6 @@ 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 { @@ -55,14 +51,11 @@ 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' @@ -76,14 +69,6 @@ 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] @@ -99,12 +84,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - const onUpdateApiHost = () => { if (apiHost.trim()) { updateProvider({ ...provider, apiHost }) @@ -113,6 +92,11 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } } + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) + } + const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) const onHealthCheck = async () => { @@ -192,75 +176,6 @@ 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 }) @@ -329,7 +244,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { provider={provider} setApiKey={(v) => { setApiKey(v) - setInputValue(v) updateProvider({ ...provider, apiKey: v }) }} /> @@ -338,35 +252,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {isDmxapi && } {provider.id !== 'vertexai' && ( <> - {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'} - /> - - + + + {t('settings.provider.api_key')} + + + {apiKeyWebsite && ( - + {!isDmxapi && ( @@ -374,7 +267,6 @@ 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 44a3f01714..97d01d4f5e 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,19 +1,17 @@ -import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' +import { ExportOutlined } 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 { Button, Divider, Flex, Form, Input, Tooltip } from 'antd' +import { 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, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' +import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' +import ApiKeyList from '../ProviderSettings/ApiKeyList' interface Props { provider: WebSearchProvider @@ -24,19 +22,16 @@ 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 onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) } const onUpdateApiHost = () => { @@ -71,65 +66,6 @@ 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 ?? '') @@ -154,30 +90,14 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {hasObjectKey(provider, 'apiKey') && ( <> {t('settings.provider.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')} - + + {apiKeyWebsite && ( + + + {t('settings.websearch.get_api_key')} + + + )} )} {hasObjectKey(provider, 'apiHost') && (