mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
feat: Update API Key Management Interface (#3444)
* feat: enhance API key management in ApiCheckPopup: allow users to add new API key - Enhanced ApiCheckPopup component to allow users to add new API key, including validation for duplicate entries and improved user feedback. * feat: update localization strings and refactor API key management components - Added "Invalid API key" message to localization files for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese. - Refactored API key management by replacing the ApiCheckPopup with a new ApiKeyList component, enhancing user experience and modularity in handling API keys across provider settings. * refactor: update OAuthButton and ApiKeyList components for improved UI and localization - Commented out the translation key in OAuthButton for future use. - Removed unnecessary localization strings related to API key tips across multiple languages. - Enhanced ApiKeyList component with styled components for better layout and user interaction. - Updated ProviderSetting and WebSearchProviderSetting to streamline API key management UI. * refactor: streamline ApiKeyList component and update localization strings - Removed the "Check Multiple API Keys" translation key from English, Japanese, Russian, Simplified Chinese, and Traditional Chinese localization files. - Updated ApiKeyList component to eliminate the model prop, enhancing its simplicity and usability. - Improved error handling in API key validation by integrating model selection directly within the check process. * feat: add latency tooltip to API key validation in ApiKeyList component and update localization strings - Introduced a latency tooltip in the ApiKeyList component to display the time taken for API key validation. - Updated localization files for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese to include the new latency tooltip string. * refactor: remove unused imports in WebSearchProviderSetting component * refactor: improve error handling and latency tracking in ApiKeyList component - Enhanced error handling during model selection to prevent failures when the user cancels the popup. - Introduced latency tracking for API key validation, ensuring accurate measurement of response times. - Streamlined the code for better readability and maintainability. * refactor: improve styling in ApiKeyList component for better UI consistency - Updated padding styles for error messages and list items in the ApiKeyList component to enhance visual clarity and user experience. - Adjusted Card component properties to ensure consistent styling across the interface. * refactor: extract key formatting logic into a separate function in ApiKeyList component - Created a new function `formatAndConvertKeysToArray` to handle the formatting and conversion of API keys into an array of unique key objects. - Updated the state initialization and effect hook in the ApiKeyList component to utilize the new function, improving code readability and maintainability. * refactor: conditionally render API key section for non-copilot providers - Updated the ProviderSetting component to conditionally display the API key section only for providers other than 'copilot', improving the user interface and experience. - Maintained existing functionality for API key management while enhancing code readability. * refactor: enhance ApiKeyList component for copilot provider handling - Introduced a new condition to manage the rendering and functionality of buttons in the ApiKeyList component based on the provider type, specifically for 'copilot'. - Updated the ProviderSetting component to ensure the API key section is consistently displayed for all providers, improving overall user experience and code clarity. * fix model type error * feat(ApiKeyList): exclude rerank models from being checked for API key validation after #3969 is merged * refactor(ApiKeyList): conditionally render check and remove buttons based on key statuses * refactor(ApiKeyList): using Promise.all for improved performance after #4066 is merged * refactor(ProviderSettings): update API key display and tooltip integration for improved layout and accessibility * fix(ApiKeyList): prevent notifications from showing when checking multiple API keys * feat(ApiKeyList): enhance API key handling with improved key formatting and auto-focus logic for add button * refactor: clean up WebSearchProviderSetting component * refactor(ApiKeyList): replace icon buttons with styled components for save and cancel actions * refactor: API key list UI and remove unused components Simplified the API key list UI by removing custom styled components for status and actions, replacing them with Ant Design icons and buttons. Improved the key checking logic and removed the tooltip for key check results. Also removed an unused help text in ProviderSetting. * refactor: add edit functionality to API key list Introduces the ability to edit existing API keys in the ApiKeyList component. Removes custom save/cancel icon buttons in favor of standard input blur/enter and icon actions. Also adjusts styling for help text in ProviderSetting. * refactor(ApiKeyList): enhance key status display with tooltips and color coding * feat(i18n): add "checking" status message in multiple languages * feat(ApiKeyList): enhance API key management with confirmation for deletion and improved state handling - Added confirmation for deleting API keys, allowing users to confirm before removal. - Introduced a cancel state for adding new keys to improve user experience. - Enhanced key status updates to prevent unnecessary re-renders. - Improved UI interactions with better handling of edit and cancel actions. - Added escape key functionality for canceling edits and new key entries. * fix(ApiKeyList): adjust layout of API key list for improved spacing and alignment - Updated the Flex component to justify content between elements, enhancing the visual layout of the API key list. - Minor style adjustment to maintain consistency in the user interface. * fix(ApiKeyList): refine padding for API key list items to enhance visual consistency - Adjusted padding for API key list text and items to improve overall layout and alignment. - Ensured consistent spacing across different states of the API key list. --------- Co-authored-by: Pleasurecruise <3196812536@qq.com> Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
parent
f69ea8648c
commit
31b3ce1049
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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<Props> = ({ title, provider, model, apiKeys, type, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [keyStatuses, setKeyStatuses] = useState<KeyStatus[]>(() => {
|
||||
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 (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
maskClosable={false}
|
||||
footer={
|
||||
<Space style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button key="remove" danger onClick={removeInvalidKeys} disabled={isChecking || isCheckingSingle}>
|
||||
{t('settings.provider.remove_invalid_keys')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button key="check" type="primary" ghost onClick={checkAllKeys} disabled={isChecking || isCheckingSingle}>
|
||||
{t('settings.provider.check_all_keys')}
|
||||
</Button>
|
||||
<Button key="save" type="primary" onClick={onOk}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}>
|
||||
<Scrollbar style={{ maxHeight: '70vh', overflowX: 'hidden' }}>
|
||||
<List
|
||||
dataSource={keyStatuses}
|
||||
renderItem={(status, index) => (
|
||||
<List.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text copyable={{ text: status.key }}>{maskApiKey(status.key)}</Typography.Text>
|
||||
<Space>
|
||||
{status.checking && (
|
||||
<Space>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />
|
||||
</Space>
|
||||
)}
|
||||
{status.isValid === true && !status.checking && <CheckCircleFilled style={{ color: '#52c41a' }} />}
|
||||
{status.isValid === false && !status.checking && <CloseCircleFilled style={{ color: '#ff4d4f' }} />}
|
||||
{status.isValid === undefined && !status.checking && (
|
||||
<span>{t('settings.provider.not_checked')}</span>
|
||||
)}
|
||||
<Button size="small" onClick={() => checkSingleKey(index)} disabled={isChecking || isCheckingSingle}>
|
||||
{t('settings.provider.check')}
|
||||
</Button>
|
||||
<RemoveIcon
|
||||
onClick={() => !isChecking && !isCheckingSingle && removeKey(index)}
|
||||
style={{
|
||||
cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer',
|
||||
opacity: isChecking || isCheckingSingle ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Scrollbar>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class ApiCheckPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('ApiCheckPopup')
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
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;
|
||||
`
|
||||
638
src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx
Normal file
638
src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx
Normal file
@ -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<Props> = ({ provider, apiKeys, onChange, type = 'provider' }) => {
|
||||
const [keyStatuses, setKeyStatuses] = useState<KeyStatus[]>(() => formatAndConvertKeysToArray(apiKeys))
|
||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||
const [newApiKey, setNewApiKey] = useState('')
|
||||
const [isCancelingNewKey, setIsCancelingNewKey] = useState(false)
|
||||
const newInputRef = useRef<any>(null)
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const editInputRef = useRef<any>(null)
|
||||
const { t } = useTranslation()
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [isCheckingSingle, setIsCheckingSingle] = useState(false)
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(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<Model | null> => {
|
||||
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 (
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px', wordWrap: 'break-word' }}>
|
||||
<strong style={{ color: statusColor }}>{statusTitle}</strong>
|
||||
{type === 'provider' && status.model && (
|
||||
<div style={{ marginTop: 5 }}>
|
||||
{t('common.model')}: {status.model.name}
|
||||
</div>
|
||||
)}
|
||||
{status.latency && status.isValid && (
|
||||
<div style={{ marginTop: 5 }}>
|
||||
{t('settings.provider.check_tooltip.latency')}: {(status.latency / 1000).toFixed(2)}s
|
||||
</div>
|
||||
)}
|
||||
{status.error && <div style={{ marginTop: 5 }}>{status.error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
style={{ marginBottom: '10px', border: '0.5px solid var(--color-border)' }}>
|
||||
{keyStatuses.length === 0 && !isAddingNew ? (
|
||||
<Typography.Text type="secondary" style={{ padding: '4px 11px', display: 'block' }}>
|
||||
{t('error.no_api_key')}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
{keyStatuses.length > 0 && (
|
||||
<Scrollbar style={{ maxHeight: '50vh', overflowX: 'hidden' }}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={keyStatuses}
|
||||
renderItem={(status, index) => (
|
||||
<List.Item style={{ padding: '4px 11px' }}>
|
||||
<ApiKeyListItem>
|
||||
<ApiKeyContainer>
|
||||
{editingIndex === index ? (
|
||||
<Input.Password
|
||||
ref={editInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text copyable={{ text: status.key }}>{maskApiKey(status.key)}</Typography.Text>
|
||||
)}
|
||||
</ApiKeyContainer>
|
||||
<ApiKeyActions>
|
||||
{editingIndex === index ? (
|
||||
<CloseCircleOutlined
|
||||
onClick={handleCancelEdit}
|
||||
title={t('common.cancel')}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: 'var(--color-error)'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={renderKeyCheckResultTooltip(status)}>
|
||||
{status.checking && (
|
||||
<Space>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />
|
||||
</Space>
|
||||
)}
|
||||
{status.isValid === true && !status.checking && (
|
||||
<CheckCircleFilled style={{ color: STATUS_COLORS.success }} />
|
||||
)}
|
||||
{status.isValid === false && !status.checking && (
|
||||
<CloseCircleFilled style={{ color: STATUS_COLORS.error }} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => checkSingleKey(index)}
|
||||
disabled={isChecking || isCheckingSingle || isCopilot}>
|
||||
{t('settings.provider.check')}
|
||||
</Button>
|
||||
{!isCopilot && (
|
||||
<>
|
||||
<EditOutlined
|
||||
onClick={() => !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 ? (
|
||||
<DeleteOutlined
|
||||
onClick={() => !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')}
|
||||
/>
|
||||
) : (
|
||||
<MinusCircleOutlined
|
||||
onClick={() => !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')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ApiKeyActions>
|
||||
</ApiKeyListItem>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Scrollbar>
|
||||
)}
|
||||
{isAddingNew && (
|
||||
<List.Item style={{ padding: '4px 11px' }}>
|
||||
<ApiKeyListItem>
|
||||
<Input.Password
|
||||
ref={newInputRef}
|
||||
value={newApiKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<ApiKeyActions>
|
||||
<CloseCircleOutlined
|
||||
onMouseDown={handleCancelNewKey}
|
||||
title={t('common.cancel')}
|
||||
style={{
|
||||
cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer',
|
||||
opacity: isChecking || isCheckingSingle ? 0.5 : 1,
|
||||
fontSize: '16px',
|
||||
color: 'var(--color-error)'
|
||||
}}
|
||||
/>
|
||||
</ApiKeyActions>
|
||||
</ApiKeyListItem>
|
||||
</List.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Flex gap={10} justify="space-between" style={{ marginTop: '8px' }}>
|
||||
{!isCopilot && (
|
||||
<>
|
||||
<Space>
|
||||
<Button
|
||||
key="add"
|
||||
type="primary"
|
||||
onClick={editingIndex !== null ? handleSaveEdit : isAddingNew ? handleSaveNewKey : handleAddNewKey}
|
||||
icon={<PlusOutlined />}
|
||||
autoFocus={shouldAutoFocus()}>
|
||||
{editingIndex !== null || isAddingNew ? t('common.save') : t('common.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
{keyStatuses.length > 1 && (
|
||||
<Space>
|
||||
<Button key="check" type="default" onClick={checkAllKeys} disabled={isChecking || isCheckingSingle}>
|
||||
{t('settings.provider.check_all_keys')}
|
||||
</Button>
|
||||
<Button
|
||||
key="remove"
|
||||
type="default"
|
||||
danger
|
||||
onClick={removeInvalidKeys}
|
||||
disabled={isChecking || isCheckingSingle}>
|
||||
{t('settings.provider.remove_invalid_keys')}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -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<Props> = ({ 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<Props> = ({ provider: _provider }) => {
|
||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ provider: _provider }) => {
|
||||
provider={provider}
|
||||
setApiKey={(v) => {
|
||||
setApiKey(v)
|
||||
setInputValue(v)
|
||||
updateProvider({ ...provider, apiKey: v })
|
||||
}}
|
||||
/>
|
||||
@ -338,35 +252,14 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
{isDmxapi && <DMXAPISettings provider={provider} setApiKey={setApiKey} />}
|
||||
{provider.id !== 'vertexai' && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input.Password
|
||||
value={inputValue}
|
||||
placeholder={t('settings.provider.api_key')}
|
||||
onChange={(e) => {
|
||||
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'}
|
||||
/>
|
||||
<Button
|
||||
type={apiValid ? 'primary' : 'default'}
|
||||
ghost={apiValid}
|
||||
onClick={onCheckApi}
|
||||
disabled={!apiHost || apiChecking}>
|
||||
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<SettingSubtitle style={{ marginBottom: 5 }}>
|
||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
</Space>
|
||||
</SettingSubtitle>
|
||||
<ApiKeyList provider={provider} apiKeys={apiKey} onChange={handleApiKeyChange} type="provider" />
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: '10px' }}>
|
||||
<HStack>
|
||||
{!isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
@ -374,7 +267,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{!isDmxapi && (
|
||||
|
||||
@ -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<Props> = ({ 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<Props> = ({ provider: _provider }) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSearch() {
|
||||
if (!provider) {
|
||||
window.message.error({
|
||||
content: t('settings.websearch.no_provider_selected'),
|
||||
duration: 3,
|
||||
icon: <Info size={18} />,
|
||||
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<Props> = ({ provider: _provider }) => {
|
||||
{hasObjectKey(provider, 'apiKey') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<Flex gap={8}>
|
||||
<Input.Password
|
||||
value={apiKey}
|
||||
placeholder={t('settings.provider.api_key')}
|
||||
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
|
||||
onBlur={onUpdateApiKey}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
autoFocus={apiKey === ''}
|
||||
/>
|
||||
<Button
|
||||
ghost={apiValid}
|
||||
type={apiValid ? 'primary' : 'default'}
|
||||
onClick={checkSearch}
|
||||
disabled={apiChecking}>
|
||||
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.websearch.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
<ApiKeyList provider={provider} apiKeys={apiKey} onChange={handleApiKeyChange} type="websearch" />
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.websearch.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasObjectKey(provider, 'apiHost') && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user