Revert "feat: Update API Key Management Interface (#3444)"

This reverts commit 31b3ce1049.
This commit is contained in:
kangfenmao 2025-06-25 13:10:46 +08:00
parent 9fe5fb9a91
commit 3df5aeb3c3
9 changed files with 464 additions and 698 deletions

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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;
`

View File

@ -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

View File

@ -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 && (

View File

@ -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') && (