mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
Revert "feat: Update API Key Management Interface (#3444)"
This reverts commit 31b3ce1049.
This commit is contained in:
parent
9fe5fb9a91
commit
3df5aeb3c3
@ -1605,7 +1605,6 @@
|
||||
"models.check.model_status_summary": "{{provider}}: {{summary}}",
|
||||
"models.check.no_api_keys": "No API keys found, please add API keys first.",
|
||||
"models.check.passed": "Passed",
|
||||
"models.check.checking": "Checking",
|
||||
"models.check.select_api_key": "Select the API key to use:",
|
||||
"models.check.single": "Single",
|
||||
"models.check.start": "Start",
|
||||
@ -1654,6 +1653,7 @@
|
||||
"api.url.tip": "Ending with / ignores v1, ending with # forces use of input address",
|
||||
"api_host": "API Host",
|
||||
"api_key": "API Key",
|
||||
"api_key.tip": "Multiple keys separated by commas",
|
||||
"api_version": "API Version",
|
||||
"basic_auth": "HTTP authentication",
|
||||
"basic_auth.tip": "Applicable to instances deployed remotely (see the documentation). Currently, only the Basic scheme (RFC 7617) is supported.",
|
||||
@ -1708,9 +1708,9 @@
|
||||
"get_api_key": "Get API Key",
|
||||
"is_not_support_array_content": "Enable compatible mode",
|
||||
"no_models_for_check": "No models available for checking (e.g. chat models)",
|
||||
"not_checked": "Not Checked",
|
||||
"remove_duplicate_keys": "Remove Duplicate Keys",
|
||||
"remove_invalid_keys": "Remove Invalid Keys",
|
||||
"invalid_key": "Invalid API key",
|
||||
"search": "Search Providers...",
|
||||
"search_placeholder": "Search model id or name",
|
||||
"title": "Model Provider",
|
||||
@ -1739,10 +1739,7 @@
|
||||
},
|
||||
"documentation": "View official documentation for more configuration details:",
|
||||
"learn_more": "Learn More"
|
||||
},
|
||||
"enter_new_api_key": "Enter new API key",
|
||||
"key_already_exists": "API key already exists",
|
||||
"check_tooltip.latency": "Latency"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
|
||||
@ -1599,7 +1599,6 @@
|
||||
"models.check.model_status_summary": "{{provider}}: {{summary}}",
|
||||
"models.check.no_api_keys": "APIキーが見つかりません。まずAPIキーを追加してください。",
|
||||
"models.check.passed": "成功",
|
||||
"models.check.checking": "チェック中",
|
||||
"models.check.select_api_key": "使用するAPIキーを選択:",
|
||||
"models.check.single": "単一",
|
||||
"models.check.start": "開始",
|
||||
@ -1642,6 +1641,7 @@
|
||||
"api.url.tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します",
|
||||
"api_host": "APIホスト",
|
||||
"api_key": "APIキー",
|
||||
"api_key.tip": "複数のキーはカンマで区切ります",
|
||||
"api_version": "APIバージョン",
|
||||
"basic_auth": "HTTP 認証",
|
||||
"basic_auth.tip": "サーバー展開によるインスタンスに適用されます(ドキュメントを参照)。現在はBasicスキーム(RFC7617)のみをサポートしています。",
|
||||
@ -1693,9 +1693,9 @@
|
||||
"get_api_key": "APIキーを取得",
|
||||
"is_not_support_array_content": "互換モードを有効にする",
|
||||
"no_models_for_check": "チェックするモデルがありません(例:会話モデル)",
|
||||
"not_checked": "未チェック",
|
||||
"remove_duplicate_keys": "重複キーを削除",
|
||||
"remove_invalid_keys": "無効なキーを削除",
|
||||
"invalid_key": "無効なAPIキー",
|
||||
"search": "プロバイダーを検索...",
|
||||
"search_placeholder": "モデルIDまたは名前を検索",
|
||||
"title": "モデルプロバイダー",
|
||||
@ -1727,10 +1727,7 @@
|
||||
},
|
||||
"documentation": "詳細な設定については、公式ドキュメントを参照してください:",
|
||||
"learn_more": "詳細を確認"
|
||||
},
|
||||
"enter_new_api_key": "新しいAPIキーを入力",
|
||||
"key_already_exists": "APIキーはすでに存在します",
|
||||
"check_tooltip.latency": "遅延"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
|
||||
@ -1599,7 +1599,6 @@
|
||||
"models.check.model_status_summary": "{{provider}}: {{summary}}",
|
||||
"models.check.no_api_keys": "API ключи не найдены, пожалуйста, добавьте API ключи.",
|
||||
"models.check.passed": "Прошло",
|
||||
"models.check.checking": "Проверка",
|
||||
"models.check.select_api_key": "Выберите API ключ для использования:",
|
||||
"models.check.single": "Один",
|
||||
"models.check.start": "Начать",
|
||||
@ -1642,6 +1641,7 @@
|
||||
"api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес",
|
||||
"api_host": "Хост API",
|
||||
"api_key": "Ключ API",
|
||||
"api_key.tip": "Несколько ключей, разделенных запятыми",
|
||||
"api_version": "Версия API",
|
||||
"basic_auth": "HTTP аутентификация",
|
||||
"basic_auth.tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).",
|
||||
@ -1693,9 +1693,9 @@
|
||||
"get_api_key": "Получить ключ API",
|
||||
"is_not_support_array_content": "Включить совместимый режим",
|
||||
"no_models_for_check": "Нет моделей для проверки (например, диалоговые модели)",
|
||||
"not_checked": "Не проверено",
|
||||
"remove_duplicate_keys": "Удалить дубликаты ключей",
|
||||
"remove_invalid_keys": "Удалить недействительные ключи",
|
||||
"invalid_key": "Недействительный ключ API",
|
||||
"search": "Поиск поставщиков...",
|
||||
"search_placeholder": "Поиск по ID или имени модели",
|
||||
"title": "Провайдеры моделей",
|
||||
@ -1727,10 +1727,7 @@
|
||||
},
|
||||
"documentation": "Смотрите официальную документацию для получения более подробной информации о конфигурации:",
|
||||
"learn_more": "Узнать больше"
|
||||
},
|
||||
"enter_new_api_key": "Введите новый API ключ",
|
||||
"key_already_exists": "API ключ уже существует",
|
||||
"check_tooltip.latency": "Задержка"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
|
||||
@ -1605,7 +1605,6 @@
|
||||
"models.check.model_status_summary": "{{provider}}: {{summary}}",
|
||||
"models.check.no_api_keys": "未找到API密钥,请先添加API密钥",
|
||||
"models.check.passed": "通过",
|
||||
"models.check.checking": "检测中",
|
||||
"models.check.select_api_key": "选择要使用的API密钥:",
|
||||
"models.check.single": "单个",
|
||||
"models.check.start": "开始",
|
||||
@ -1654,6 +1653,7 @@
|
||||
"api.url.tip": "/结尾忽略v1版本,#结尾强制使用输入地址",
|
||||
"api_host": "API 地址",
|
||||
"api_key": "API 密钥",
|
||||
"api_key.tip": "多个密钥使用逗号分隔",
|
||||
"api_version": "API 版本",
|
||||
"basic_auth": "HTTP 认证",
|
||||
"basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案(RFC7617)",
|
||||
@ -1708,9 +1708,9 @@
|
||||
"get_api_key": "点击这里获取密钥",
|
||||
"is_not_support_array_content": "开启兼容模式",
|
||||
"no_models_for_check": "没有可以被检测的模型(例如对话模型)",
|
||||
"not_checked": "未检测",
|
||||
"remove_duplicate_keys": "移除重复密钥",
|
||||
"remove_invalid_keys": "删除无效密钥",
|
||||
"invalid_key": "无效的 API 密钥",
|
||||
"search": "搜索模型平台...",
|
||||
"search_placeholder": "搜索模型 ID 或名称",
|
||||
"title": "模型服务",
|
||||
@ -1739,10 +1739,7 @@
|
||||
},
|
||||
"documentation": "查看官方文档了解更多配置详情:",
|
||||
"learn_more": "了解更多"
|
||||
},
|
||||
"enter_new_api_key": "输入新的API密钥",
|
||||
"key_already_exists": "API密钥已存在",
|
||||
"check_tooltip.latency": "耗时"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
|
||||
@ -1602,7 +1602,6 @@
|
||||
"models.check.model_status_summary": "{{provider}}: {{summary}}",
|
||||
"models.check.no_api_keys": "未找到API密鑰,請先添加API密鑰",
|
||||
"models.check.passed": "通過",
|
||||
"models.check.checking": "檢查中",
|
||||
"models.check.select_api_key": "選擇要使用的API密鑰:",
|
||||
"models.check.single": "單個",
|
||||
"models.check.start": "開始",
|
||||
@ -1645,6 +1644,7 @@
|
||||
"api.url.tip": "/結尾忽略 v1 版本,#結尾強制使用輸入位址",
|
||||
"api_host": "API 主機地址",
|
||||
"api_key": "API 金鑰",
|
||||
"api_key.tip": "多個金鑰使用逗號分隔",
|
||||
"api_version": "API 版本",
|
||||
"basic_auth": "HTTP 認證",
|
||||
"basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案(RFC7617)",
|
||||
@ -1696,9 +1696,9 @@
|
||||
"get_api_key": "點選這裡取得金鑰",
|
||||
"is_not_support_array_content": "開啟相容模式",
|
||||
"no_models_for_check": "沒有可以被檢查的模型(例如對話模型)",
|
||||
"not_checked": "未檢查",
|
||||
"remove_duplicate_keys": "移除重複金鑰",
|
||||
"remove_invalid_keys": "刪除無效金鑰",
|
||||
"invalid_key": "無效的 API 金鑰",
|
||||
"search": "搜尋模型平臺...",
|
||||
"search_placeholder": "搜尋模型 ID 或名稱",
|
||||
"title": "模型提供者",
|
||||
@ -1730,10 +1730,7 @@
|
||||
},
|
||||
"documentation": "檢視官方文件以取得更多設定詳細資訊:",
|
||||
"learn_more": "瞭解更多"
|
||||
},
|
||||
"enter_new_api_key": "輸入新的API密鑰",
|
||||
"key_already_exists": "API密鑰已存在",
|
||||
"check_tooltip.latency": "耗時"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
|
||||
@ -0,0 +1,231 @@
|
||||
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutlined } from '@ant-design/icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { checkApi } from '@renderer/services/ApiService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Model, Provider, WebSearchProvider } from '@renderer/types'
|
||||
import { maskApiKey } from '@renderer/utils/api'
|
||||
import { Button, List, Modal, Space, Spin, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ShowParams {
|
||||
title: string
|
||||
provider: Provider | WebSearchProvider
|
||||
model?: Model
|
||||
apiKeys: string[]
|
||||
type: 'provider' | 'websearch'
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
interface KeyStatus {
|
||||
key: string
|
||||
isValid?: boolean
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<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;
|
||||
`
|
||||
@ -1,638 +0,0 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
LoadingOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined
|
||||
} from '@ant-design/icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Model, Provider, WebSearchProvider } from '@renderer/types'
|
||||
import { maskApiKey, splitApiKeyString } from '@renderer/utils/api'
|
||||
import { Button, Card, Flex, Input, List, Space, Spin, Tooltip, Typography } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
|
||||
interface Props {
|
||||
provider: Provider | WebSearchProvider
|
||||
apiKeys: string
|
||||
onChange: (keys: string) => void
|
||||
type?: 'provider' | 'websearch'
|
||||
}
|
||||
|
||||
interface KeyStatus {
|
||||
key: string
|
||||
isValid?: boolean
|
||||
checking?: boolean
|
||||
error?: string
|
||||
model?: Model
|
||||
latency?: number
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
success: '#52c41a',
|
||||
error: '#ff4d4f'
|
||||
}
|
||||
|
||||
const formatAndConvertKeysToArray = (apiKeys: string): KeyStatus[] => {
|
||||
const formattedApiKeys = formatApiKeys(apiKeys)
|
||||
if (formattedApiKeys.includes(',')) {
|
||||
const keys = splitApiKeyString(formattedApiKeys)
|
||||
const uniqueKeys = new Set(keys)
|
||||
return Array.from(uniqueKeys).map((key) => ({ key }))
|
||||
} else {
|
||||
return formattedApiKeys ? [{ key: formattedApiKeys }] : []
|
||||
}
|
||||
}
|
||||
|
||||
const ApiKeyList: FC<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,11 +1,14 @@
|
||||
import { CheckOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory'
|
||||
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
||||
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isRerankModel } from '@renderer/config/models'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { Provider } from '@renderer/types'
|
||||
@ -13,7 +16,7 @@ import { formatApiHost, splitApiKeyString } from '@renderer/utils/api'
|
||||
import { lightbulbVariants } from '@renderer/utils/motionVariants'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react'
|
||||
@ -28,7 +31,7 @@ import {
|
||||
SettingSubtitle,
|
||||
SettingTitle
|
||||
} from '..'
|
||||
import ApiKeyList from './ApiKeyList'
|
||||
import ApiCheckPopup from './ApiCheckPopup'
|
||||
import DMXAPISettings from './DMXAPISettings'
|
||||
import GithubCopilotSettings from './GithubCopilotSettings'
|
||||
import GPUStackSettings from './GPUStackSettings'
|
||||
@ -38,6 +41,7 @@ import ModelList, { ModelStatus } from './ModelList'
|
||||
import ModelListSearchBar from './ModelListSearchBar'
|
||||
import ProviderOAuth from './ProviderOAuth'
|
||||
import ProviderSettingsPopup from './ProviderSettingsPopup'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
import VertexAISettings from './VertexAISettings'
|
||||
|
||||
interface Props {
|
||||
@ -51,11 +55,14 @@ const ProviderSetting: FC<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'
|
||||
|
||||
@ -69,6 +76,14 @@ 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]
|
||||
@ -84,6 +99,12 @@ 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 })
|
||||
@ -92,11 +113,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApiKeyChange = (newApiKey: string) => {
|
||||
setApiKey(newApiKey)
|
||||
updateProvider({ ...provider, apiKey: newApiKey })
|
||||
}
|
||||
|
||||
const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion })
|
||||
|
||||
const onHealthCheck = async () => {
|
||||
@ -176,6 +192,75 @@ const ProviderSetting: FC<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 })
|
||||
@ -244,6 +329,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
provider={provider}
|
||||
setApiKey={(v) => {
|
||||
setApiKey(v)
|
||||
setInputValue(v)
|
||||
updateProvider({ ...provider, apiKey: v })
|
||||
}}
|
||||
/>
|
||||
@ -252,14 +338,35 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
{isDmxapi && <DMXAPISettings provider={provider} setApiKey={setApiKey} />}
|
||||
{provider.id !== 'vertexai' && (
|
||||
<>
|
||||
<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" />
|
||||
<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>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: '10px' }}>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{!isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
@ -267,6 +374,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{!isDmxapi && (
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import { ExportOutlined } from '@ant-design/icons'
|
||||
import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
|
||||
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { formatApiKeys } from '@renderer/services/ApiService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { WebSearchProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Divider, Flex, Form, Input, Tooltip } from 'antd'
|
||||
import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { Info } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..'
|
||||
import ApiKeyList from '../ProviderSettings/ApiKeyList'
|
||||
import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..'
|
||||
import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup'
|
||||
|
||||
interface Props {
|
||||
provider: WebSearchProvider
|
||||
@ -22,16 +24,19 @@ const WebSearchProviderSetting: FC<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 handleApiKeyChange = (newApiKey: string) => {
|
||||
setApiKey(newApiKey)
|
||||
updateProvider({ ...provider, apiKey: newApiKey })
|
||||
const onUpdateApiKey = () => {
|
||||
if (apiKey !== provider.apiKey) {
|
||||
updateProvider({ ...provider, apiKey })
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateApiHost = () => {
|
||||
@ -66,6 +71,65 @@ const WebSearchProviderSetting: FC<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 ?? '')
|
||||
@ -90,14 +154,30 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
{hasObjectKey(provider, 'apiKey') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{hasObjectKey(provider, 'apiHost') && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user