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