From a4620f8c68e97305bb730bd08ee20cd785b67584 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 6 Jul 2025 15:10:44 +0800 Subject: [PATCH] refactor(ApiKeyList): add a popup for api key list (#7491) * refactor(ApiKeyList): add a popup for api key list - ApiKeyList for key management - ApiKeyListPopup triggerred by a button - Move formatApiKeys to utils for better reuse - Simplify apikey related states in ProviderSettings for better integration with ApiKeyList - Modify `updateProvider` to accept partial updates - Update api key placeholder * fix: strict type * refactor: support websearch provider * refactor: remove ApiCheckPopup * refactor: simplify interfaces for ProviderSetting and WebSearchProviderSetting * fix: sync input api key between sub-pages, futher simplification * fix: bold title * refactor: extract status icon colors * refactor: add a status indicator to input box on error, update type definitions * refactor: further simplification, make data flow clearer * feat: support api key list for preprocess settings * refactor: better naming, less confusion --- src/renderer/src/assets/styles/color.scss | 4 + src/renderer/src/components/Icons/SVGIcon.tsx | 27 +- .../components/Popups/ApiKeyListPopup/hook.ts | 307 ++++++++++++++++++ .../Popups/ApiKeyListPopup/index.ts | 2 + .../Popups/ApiKeyListPopup/item.tsx | 213 ++++++++++++ .../Popups/ApiKeyListPopup/list.tsx | 224 +++++++++++++ .../Popups/ApiKeyListPopup/popup.tsx | 88 +++++ .../Popups/ApiKeyListPopup/types.ts | 31 ++ src/renderer/src/hooks/usePreprocess.ts | 7 +- src/renderer/src/hooks/useProvider.ts | 4 +- .../src/hooks/useWebSearchProviders.ts | 7 +- src/renderer/src/i18n/locales/en-us.json | 10 +- src/renderer/src/i18n/locales/ja-jp.json | 10 +- src/renderer/src/i18n/locales/ru-ru.json | 10 +- src/renderer/src/i18n/locales/zh-cn.json | 10 +- src/renderer/src/i18n/locales/zh-tw.json | 10 +- .../pages/knowledge/components/QuotaTag.tsx | 6 +- .../ProviderSettings/ApiCheckPopup.tsx | 231 ------------- .../ProviderSettings/DMXAPISettings.tsx | 8 +- .../GithubCopilotSettings.tsx | 15 +- .../settings/ProviderSettings/ModelList.tsx | 19 +- .../ProviderSettings/ProviderOAuth.tsx | 12 +- .../ProviderSettings/ProviderSetting.tsx | 250 +++++++------- .../SelectProviderModelPopup.tsx | 2 + .../pages/settings/ProviderSettings/index.tsx | 2 +- .../ToolSettings/OcrSettings/OcrSettings.tsx | 3 +- .../PreprocessSettings/PreprocessSettings.tsx | 38 ++- .../WebSearchProviderSetting.tsx | 69 ++-- .../ToolSettings/WebSearchSettings/index.tsx | 2 +- src/renderer/src/services/ApiService.ts | 4 - src/renderer/src/store/llm.ts | 7 +- src/renderer/src/store/preprocess.ts | 4 +- src/renderer/src/store/websearch.ts | 4 +- src/renderer/src/utils/api.ts | 10 + src/renderer/src/utils/index.ts | 1 + src/renderer/src/utils/naming.ts | 12 + 36 files changed, 1205 insertions(+), 458 deletions(-) create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/index.ts create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/types.ts delete mode 100644 src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index ce7e9cefe9..3f23425afc 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -72,6 +72,10 @@ --chat-text-user: var(--color-black); --list-item-border-radius: 20px; + + --color-status-success: #52c41a; + --color-status-error: #ff4d4f; + --color-status-warning: #faad14; } [theme-mode='light'] { diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index d58eab7ee5..988d9657c1 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -1,14 +1,25 @@ +import { lightbulbVariants } from '@renderer/utils/motionVariants' +import { motion } from 'framer-motion' import { SVGProps } from 'react' -export const StreamlineGoodHealthAndWellBeing = (props: SVGProps) => { +export const StreamlineGoodHealthAndWellBeing = ( + props: SVGProps & { + size?: number | string + isActive?: boolean + } +) => { + const { size = '1em', isActive, ...svgProps } = props + return ( - - {/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */} - - - - - + + + {/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */} + + + + + + ) } diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts new file mode 100644 index 0000000000..5bd9072d12 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts @@ -0,0 +1,307 @@ +import Logger from '@renderer/config/logger' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup' +import { checkApi } from '@renderer/services/ApiService' +import WebSearchService from '@renderer/services/WebSearchService' +import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' +import { formatErrorMessage } from '@renderer/utils/error' +import { TFunction } from 'i18next' +import { isEmpty } from 'lodash' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { ApiKeyConnectivity, ApiKeyValidity, ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types' + +interface UseApiKeysProps { + provider: ApiProviderUnion + updateProvider: (provider: Partial) => void + providerKind: ApiProviderKind +} + +/** + * API Keys 管理 hook + */ +export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKeysProps) { + const { t } = useTranslation() + + // 连通性检查的 UI 状态管理 + const [connectivityStates, setConnectivityStates] = useState>(new Map()) + + // 保存 apiKey 到 provider + const updateProviderWithKey = useCallback( + (newKeys: string[]) => { + const validKeys = newKeys.filter((k) => k.trim()) + const formattedKeyString = formatApiKeys(validKeys.join(',')) + updateProvider({ apiKey: formattedKeyString }) + }, + [updateProvider] + ) + + // 解析 keyString 为数组 + const keys = useMemo(() => { + if (!provider.apiKey) return [] + const formattedApiKeys = formatApiKeys(provider.apiKey) + const keys = splitApiKeyString(formattedApiKeys) + return Array.from(new Set(keys)) + }, [provider.apiKey]) + + // 合并基本数据和连通性状态 + const keysWithStatus = useMemo((): ApiKeyWithStatus[] => { + return keys.map((key) => { + const connectivityState = connectivityStates.get(key) || { + status: 'not_checked' as const, + checking: false, + error: undefined, + model: undefined, + latency: undefined + } + return { + key, + ...connectivityState + } + }) + }, [keys, connectivityStates]) + + // 更新单个 key 的连通性状态 + const updateConnectivityState = useCallback((key: string, state: Partial) => { + setConnectivityStates((prev) => { + const newMap = new Map(prev) + const currentState = prev.get(key) || { + status: 'not_checked' as const, + checking: false, + error: undefined, + model: undefined, + latency: undefined + } + newMap.set(key, { ...currentState, ...state }) + return newMap + }) + }, []) + + // 验证 API key 格式 + const validateApiKey = useCallback( + (key: string, existingKeys: string[] = []): ApiKeyValidity => { + const trimmedKey = key.trim() + + if (!trimmedKey) { + return { isValid: false, error: t('settings.provider.api.key.error.empty') } + } + + if (existingKeys.includes(trimmedKey)) { + return { isValid: false, error: t('settings.provider.api.key.error.duplicate') } + } + + return { isValid: true } + }, + [t] + ) + + // 添加新 key + const addKey = useCallback( + (key: string): ApiKeyValidity => { + const validation = validateApiKey(key, keys) + + if (!validation.isValid) { + return validation + } + + updateProviderWithKey([...keys, key.trim()]) + return { isValid: true } + }, + [validateApiKey, keys, updateProviderWithKey] + ) + + // 更新 key + const updateKey = useCallback( + (index: number, key: string): ApiKeyValidity => { + if (index < 0 || index >= keys.length) { + Logger.error('[ApiKeyList] invalid key index', { index }) + return { isValid: false, error: 'Invalid index' } + } + + const otherKeys = keys.filter((_, i) => i !== index) + const validation = validateApiKey(key, otherKeys) + + if (!validation.isValid) { + return validation + } + + // 清除旧 key 的连通性状态 + const oldKey = keys[index] + if (oldKey !== key.trim()) { + setConnectivityStates((prev) => { + const newMap = new Map(prev) + newMap.delete(oldKey) + return newMap + }) + } + + const newKeys = [...keys] + newKeys[index] = key.trim() + updateProviderWithKey(newKeys) + + return { isValid: true } + }, + [keys, validateApiKey, updateProviderWithKey] + ) + + // 移除 key + const removeKey = useCallback( + (index: number) => { + if (index < 0 || index >= keys.length) return + + const keyToRemove = keys[index] + const newKeys = keys.filter((_, i) => i !== index) + + // 清除对应的连通性状态 + setConnectivityStates((prev) => { + const newMap = new Map(prev) + newMap.delete(keyToRemove) + return newMap + }) + + updateProviderWithKey(newKeys) + }, + [keys, updateProviderWithKey] + ) + + // 移除连通性检查失败的 keys + const removeInvalidKeys = useCallback(() => { + const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== 'error').map((k) => k.key) + + // 清除被删除的 keys 的连通性状态 + const keysToRemove = keysWithStatus.filter((keyStatus) => keyStatus.status === 'error').map((k) => k.key) + + setConnectivityStates((prev) => { + const newMap = new Map(prev) + keysToRemove.forEach((key) => newMap.delete(key)) + return newMap + }) + + updateProviderWithKey(validKeys) + }, [keysWithStatus, updateProviderWithKey]) + + // 检查单个 key 的连通性,不负责选择和验证模型 + const runConnectivityCheck = useCallback( + async (index: number, model?: Model): Promise => { + const keyToCheck = keys[index] + const currentState = connectivityStates.get(keyToCheck) + if (currentState?.checking) return + + // 设置检查状态 + updateConnectivityState(keyToCheck, { checking: true }) + + try { + const startTime = Date.now() + if (isLlmProvider(provider, providerKind) && model) { + await checkApi({ ...provider, apiKey: keyToCheck }, model) + } else { + const result = await WebSearchService.checkSearch({ ...provider, apiKey: keyToCheck }) + if (!result.valid) throw new Error(result.error) + } + const latency = Date.now() - startTime + + // 连通性检查成功 + updateConnectivityState(keyToCheck, { + checking: false, + status: 'success', + model, + latency, + error: undefined + }) + } catch (error: any) { + // 连通性检查失败 + updateConnectivityState(keyToCheck, { + checking: false, + status: 'error', + error: formatErrorMessage(error), + model: undefined, + latency: undefined + }) + + Logger.error('[ApiKeyList] failed to validate the connectivity of the api key', error) + } + }, + [keys, connectivityStates, updateConnectivityState, provider, providerKind] + ) + + // 检查单个 key 的连通性 + const checkKeyConnectivity = useCallback( + async (index: number): Promise => { + if (!provider || index < 0 || index >= keys.length) return + + const keyToCheck = keys[index] + const currentState = connectivityStates.get(keyToCheck) + if (currentState?.checking) return + + const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined + if (model === null) return + + await runConnectivityCheck(index, model) + }, + [provider, keys, connectivityStates, providerKind, t, runConnectivityCheck] + ) + + // 检查所有 keys 的连通性 + const checkAllKeysConnectivity = useCallback(async () => { + if (!provider || keys.length === 0) return + + const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined + if (model === null) return + + await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, model))) + }, [provider, keys, providerKind, t, runConnectivityCheck]) + + // 计算是否有 key 正在检查 + const isChecking = useMemo(() => { + return Array.from(connectivityStates.values()).some((state) => state.checking) + }, [connectivityStates]) + + return { + keys: keysWithStatus, + addKey, + updateKey, + removeKey, + removeInvalidKeys, + checkKeyConnectivity, + checkAllKeysConnectivity, + isChecking + } +} + +export function isLlmProvider(obj: any, kind: ApiProviderKind): obj is Provider { + return kind === 'llm' && 'type' in obj && 'models' in obj +} + +export function isWebSearchProvider(obj: any, kind: ApiProviderKind): obj is WebSearchProvider { + return kind === 'websearch' && ('url' in obj || 'engines' in obj) +} + +export function isPreprocessProvider(obj: any, kind: ApiProviderKind): obj is PreprocessProvider { + return kind === 'doc-preprocess' && ('quota' in obj || 'options' in obj) +} + +// 获取模型用于检查 +async function getModelForCheck(provider: Provider, t: TFunction): Promise { + const modelsToCheck = 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 { + const selectedModel = await SelectProviderModelPopup.show({ provider }) + if (!selectedModel) return null + return selectedModel + } catch (error) { + Logger.error('[ApiKeyList] failed to select model', error) + return null + } +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/index.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/index.ts new file mode 100644 index 0000000000..b599832456 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/index.ts @@ -0,0 +1,2 @@ +export { default as ApiKeyListPopup } from './popup' +export * from './types' diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx new file mode 100644 index 0000000000..cf830897cb --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx @@ -0,0 +1,213 @@ +import { CheckCircleFilled, CloseCircleFilled, MinusOutlined } from '@ant-design/icons' +import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' +import { maskApiKey } from '@renderer/utils/api' +import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd' +import { Check, PenLine, X } from 'lucide-react' +import { FC, memo, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { ApiKeyValidity, ApiKeyWithStatus } from './types' + +export interface ApiKeyItemProps { + keyStatus: ApiKeyWithStatus + onUpdate: (newKey: string) => ApiKeyValidity + onRemove: () => void + onCheck: () => Promise + disabled?: boolean + showHealthCheck?: boolean + isNew?: boolean +} + +/** + * API Key 项组件 + * 支持编辑、删除、连接检查等操作 + */ +const ApiKeyItem: FC = ({ + keyStatus, + onUpdate, + onRemove, + onCheck, + disabled: _disabled = false, + showHealthCheck = true, + isNew = false +}) => { + const { t } = useTranslation() + const [isEditing, setIsEditing] = useState(isNew || !keyStatus.key.trim()) + const [editValue, setEditValue] = useState(keyStatus.key) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const inputRef = useRef(null) + + const disabled = keyStatus.checking || _disabled + const isNotChecked = keyStatus.status === 'not_checked' + const isSuccess = keyStatus.status === 'success' + const statusColor = isSuccess ? 'var(--color-status-success)' : 'var(--color-status-error)' + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + } + }, [isEditing]) + + useEffect(() => { + setHasUnsavedChanges(editValue.trim() !== keyStatus.key.trim()) + }, [editValue, keyStatus.key]) + + const handleEdit = () => { + if (disabled) return + setIsEditing(true) + setEditValue(keyStatus.key) + } + + const handleSave = () => { + const result = onUpdate(editValue) + if (!result.isValid) { + window.message.warning({ + key: 'api-key-error', + content: result.error + }) + return + } + + setIsEditing(false) + } + + const handleCancelEdit = () => { + if (isNew || !keyStatus.key.trim()) { + // 临时项取消时直接移除 + onRemove() + } else { + // 现有项取消时恢复原值 + setEditValue(keyStatus.key) + setIsEditing(false) + } + } + + const renderStatusIcon = () => { + if (keyStatus.checking || isNotChecked) return null + + const StatusIcon = isSuccess ? CheckCircleFilled : CloseCircleFilled + return + } + + const renderKeyCheckResultTooltip = () => { + if (keyStatus.checking) { + return t('settings.models.check.checking') + } + + if (isNotChecked) { + return '' + } + + const statusTitle = isSuccess ? t('settings.models.check.passed') : t('settings.models.check.failed') + + return ( +
+ {statusTitle} + {keyStatus.model && ( +
+ {t('common.model')}: {keyStatus.model.name} +
+ )} + {keyStatus.latency && isSuccess && ( +
+ {t('settings.provider.api.key.check.latency')}: {(keyStatus.latency / 1000).toFixed(2)}s +
+ )} + {keyStatus.error &&
{keyStatus.error}
} +
+ ) + } + + return ( + + {isEditing ? ( + + setEditValue(e.target.value)} + onPressEnter={handleSave} + placeholder={t('settings.provider.api.key.new_key.placeholder')} + style={{ flex: 1, fontSize: '14px', marginLeft: '-10px' }} + spellCheck={false} + disabled={disabled} + /> + + + + + + + ) +} + +interface SpecificApiKeyListProps { + providerId: string + providerKind: ApiProviderKind + showHealthCheck?: boolean +} + +export const LlmApiKeyList: FC = ({ providerId, providerKind, showHealthCheck = true }) => { + const { provider, updateProvider } = useProvider(providerId) + + return ( + + ) +} + +export const WebSearchApiKeyList: FC = ({ + providerId, + providerKind, + showHealthCheck = true +}) => { + const { provider, updateProvider } = useWebSearchProvider(providerId) + + return ( + + ) +} + +export const DocPreprocessApiKeyList: FC = ({ + providerId, + providerKind, + showHealthCheck = true +}) => { + const { provider, updateProvider } = usePreprocessProvider(providerId) + + return ( + + ) +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx new file mode 100644 index 0000000000..096e00ca58 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx @@ -0,0 +1,88 @@ +import { TopView } from '@renderer/components/TopView' +import { Modal } from 'antd' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { DocPreprocessApiKeyList, LlmApiKeyList, WebSearchApiKeyList } from './list' +import { ApiProviderKind } from './types' + +interface ShowParams { + providerId: string + providerKind: ApiProviderKind + title?: string + showHealthCheck?: boolean +} + +interface Props extends ShowParams { + resolve: (value: any) => void +} + +/** + * API Key 列表弹窗容器组件 + */ +const PopupContainer: React.FC = ({ providerId, providerKind, title, resolve, showHealthCheck = true }) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + const ListComponent = useMemo(() => { + switch (providerKind) { + case 'llm': + return LlmApiKeyList + case 'websearch': + return WebSearchApiKeyList + case 'doc-preprocess': + return DocPreprocessApiKeyList + default: + return null + } + }, [providerKind]) + + return ( + + {ListComponent && ( + + )} + + ) +} + +const TopViewKey = 'ApiKeyListPopup' + +export default class ApiKeyListPopup { + static topviewId = 0 + + static hide() { + TopView.hide(TopViewKey) + } + + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts new file mode 100644 index 0000000000..f5ed1c62d1 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts @@ -0,0 +1,31 @@ +import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' + +/** + * API Key 连通性检查的状态 + */ +export type ApiKeyConnectivity = { + status: 'success' | 'error' | 'not_checked' + checking?: boolean + error?: string + model?: Model + latency?: number +} + +/** + * API key 及其连通性检查的状态 + */ +export type ApiKeyWithStatus = { + key: string +} & ApiKeyConnectivity + +/** + * API key 格式有效性 + */ +export type ApiKeyValidity = { + isValid: boolean + error?: string +} + +export type ApiProviderUnion = Provider | WebSearchProvider | PreprocessProvider + +export type ApiProviderKind = 'llm' | 'websearch' | 'doc-preprocess' diff --git a/src/renderer/src/hooks/usePreprocess.ts b/src/renderer/src/hooks/usePreprocess.ts index 5a4c6649b5..41463227ad 100644 --- a/src/renderer/src/hooks/usePreprocess.ts +++ b/src/renderer/src/hooks/usePreprocess.ts @@ -14,10 +14,11 @@ export const usePreprocessProvider = (id: string) => { if (!provider) { throw new Error(`preprocess provider with id ${id} not found`) } - const updatePreprocessProvider = (preprocessProvider: PreprocessProvider) => { - dispatch(_updatePreprocessProvider(preprocessProvider)) + + return { + provider, + updateProvider: (updates: Partial) => dispatch(_updatePreprocessProvider({ id, ...updates })) } - return { provider, updatePreprocessProvider } } export const usePreprocessProviders = () => { diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts index 95a8a8fa0b..3f1d0223ec 100644 --- a/src/renderer/src/hooks/useProvider.ts +++ b/src/renderer/src/hooks/useProvider.ts @@ -26,7 +26,7 @@ export function useProviders() { providers: providers || {}, addProvider: (provider: Provider) => dispatch(addProvider(provider)), removeProvider: (provider: Provider) => dispatch(removeProvider(provider)), - updateProvider: (provider: Provider) => dispatch(updateProvider(provider)), + updateProvider: (updates: Partial & { id: string }) => dispatch(updateProvider(updates)), updateProviders: (providers: Provider[]) => dispatch(updateProviders(providers)) } } @@ -50,7 +50,7 @@ export function useProvider(id: string) { return { provider, models: provider?.models || [], - updateProvider: (provider: Provider) => dispatch(updateProvider(provider)), + updateProvider: (updates: Partial) => dispatch(updateProvider({ id, ...updates })), addModel: (model: Model) => dispatch(addModel({ providerId: id, model })), removeModel: (model: Model) => dispatch(removeModel({ providerId: id, model })), updateModel: (model: Model) => dispatch(updateModel({ providerId: id, model })) diff --git a/src/renderer/src/hooks/useWebSearchProviders.ts b/src/renderer/src/hooks/useWebSearchProviders.ts index f5c1dda78c..32f9238abf 100644 --- a/src/renderer/src/hooks/useWebSearchProviders.ts +++ b/src/renderer/src/hooks/useWebSearchProviders.ts @@ -58,11 +58,10 @@ export const useWebSearchProvider = (id: string) => { throw new Error(`Web search provider with id ${id} not found`) } - const updateProvider = (provider: WebSearchProvider) => { - dispatch(updateWebSearchProvider(provider)) + return { + provider, + updateProvider: (updates: Partial) => dispatch(updateWebSearchProvider({ id, ...updates })) } - - return { provider, updateProvider } } export const useBlacklist = () => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f2e9def398..d0574c2028 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -385,6 +385,7 @@ "cut": "Cut", "default": "Default", "delete": "Delete", + "delete_confirm": "Are you sure you want to delete?", "description": "Description", "docs": "Docs", "download": "Download", @@ -1707,8 +1708,14 @@ "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_key.tip": "Multiple keys separated by commas or spaces", "api_version": "API Version", + "api.key.new_key.placeholder": "Enter one or more keys", + "api.key.error.duplicate": "API key already exists", + "api.key.error.empty": "API key cannot be empty", + "api.key.check.latency": "Latency", + "api.key.list.open": "Open Management Interface", + "api.key.list.title": "API Key Management", "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.user_name": "Username", @@ -1891,7 +1898,6 @@ "check": "Check", "check_failed": "Verification failed", "check_success": "Verification successful", - "get_api_key": "Get API Key", "no_provider_selected": "Please select a search service provider before checking.", "search_max_result": "Number of search results", "search_provider": "Search service provider", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index e0776e5887..8f9d621b89 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -385,6 +385,7 @@ "cut": "切り取り", "default": "デフォルト", "delete": "削除", + "delete_confirm": "削除してもよろしいですか?", "description": "説明", "docs": "ドキュメント", "download": "ダウンロード", @@ -1689,8 +1690,14 @@ "api.url.tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します", "api_host": "APIホスト", "api_key": "APIキー", - "api_key.tip": "複数のキーはカンマで区切ります", + "api_key.tip": "複数のキーはカンマまたはスペースで区切ります", "api_version": "APIバージョン", + "api.key.new_key.placeholder": "1つ以上のキーを入力してください", + "api.key.error.duplicate": "APIキーはすでに存在します", + "api.key.error.empty": "APIキーは空にできません", + "api.key.check.latency": "遅延", + "api.key.list.open": "管理インターフェースを開く", + "api.key.list.title": "APIキー管理", "basic_auth": "HTTP 認証", "basic_auth.tip": "サーバー展開によるインスタンスに適用されます(ドキュメントを参照)。現在はBasicスキーム(RFC7617)のみをサポートしています。", "basic_auth.user_name": "ユーザー名", @@ -1846,7 +1853,6 @@ "check": "チェック", "check_failed": "検証に失敗しました", "check_success": "検証に成功しました", - "get_api_key": "APIキーを取得", "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", "search_max_result": "検索結果の数", "search_provider": "検索サービスプロバイダー", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6a835f61b7..902e2b51df 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -385,6 +385,7 @@ "cut": "Вырезать", "default": "По умолчанию", "delete": "Удалить", + "delete_confirm": "Вы уверены, что хотите удалить?", "description": "Описание", "docs": "Документы", "download": "Скачать", @@ -1689,8 +1690,14 @@ "api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес", "api_host": "Хост API", "api_key": "Ключ API", - "api_key.tip": "Несколько ключей, разделенных запятыми", + "api_key.tip": "Несколько ключей, разделенных запятыми или пробелами", "api_version": "Версия API", + "api.key.new_key.placeholder": "Введите один или несколько ключей", + "api.key.error.duplicate": "API ключ уже существует", + "api.key.error.empty": "API ключ не может быть пустым", + "api.key.check.latency": "Задержка", + "api.key.list.open": "Открыть интерфейс управления", + "api.key.list.title": "Управление ключами API", "basic_auth": "HTTP аутентификация", "basic_auth.tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).", "basic_auth.user_name": "Имя пользователя", @@ -1846,7 +1853,6 @@ "check": "проверка", "check_failed": "Проверка не прошла", "check_success": "Проверка успешна", - "get_api_key": "Получить ключ API", "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", "search_max_result": "Количество результатов поиска", "search_max_result.tooltip": "При отключенном сжатии результатов поиска, количество результатов может быть слишком большим, что приведет к исчерпанию токенов", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index db5dd86d5f..2d603470e0 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -385,6 +385,7 @@ "cut": "剪切", "default": "默认", "delete": "删除", + "delete_confirm": "确定要删除吗?", "description": "描述", "docs": "文档", "download": "下载", @@ -1707,8 +1708,14 @@ "api.url.tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址", "api_host": "API 地址", "api_key": "API 密钥", - "api_key.tip": "多个密钥使用逗号分隔", + "api_key.tip": "多个密钥使用逗号或空格分隔", "api_version": "API 版本", + "api.key.new_key.placeholder": "输入一个或多个密钥", + "api.key.error.duplicate": "API 密钥已存在", + "api.key.error.empty": "API 密钥不能为空", + "api.key.check.latency": "耗时", + "api.key.list.open": "打开管理界面", + "api.key.list.title": "API 密钥管理", "basic_auth": "HTTP 认证", "basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案(RFC7617)", "basic_auth.user_name": "用户名", @@ -1938,7 +1945,6 @@ "check_success": "验证成功", "overwrite": "覆盖服务商搜索", "overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索", - "get_api_key": "点击这里获取密钥", "no_provider_selected": "请选择搜索服务商后再检测", "search_max_result": "搜索结果个数", "search_max_result.tooltip": "未开启搜索结果压缩的情况下,数量过大可能会消耗过多 tokens", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 743814c0cd..0b8e0b7e2d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -385,6 +385,7 @@ "cut": "剪下", "default": "預設", "delete": "刪除", + "delete_confirm": "確定要刪除嗎?", "description": "描述", "docs": "文件", "download": "下載", @@ -1692,8 +1693,14 @@ "api.url.tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址", "api_host": "API 主機地址", "api_key": "API 金鑰", - "api_key.tip": "多個金鑰使用逗號分隔", + "api_key.tip": "多個金鑰使用逗號或空格分隔", "api_version": "API 版本", + "api.key.new_key.placeholder": "輸入一個或多個密鑰", + "api.key.error.duplicate": "API 密鑰已存在", + "api.key.error.empty": "API 密鑰不能為空", + "api.key.check.latency": "耗時", + "api.key.list.open": "打開管理界面", + "api.key.list.title": "API 密鑰管理", "basic_auth": "HTTP 認證", "basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案(RFC7617)", "basic_auth.user_name": "用戶", @@ -1849,7 +1856,6 @@ "check": "檢查", "check_failed": "驗證失敗", "check_success": "驗證成功", - "get_api_key": "點選這裡取得金鑰", "no_provider_selected": "請選擇搜尋服務商後再檢查", "search_max_result": "搜尋結果個數", "search_max_result.tooltip": "未開啟搜尋結果壓縮的情況下,數量過大可能會消耗過多 tokens", diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index fe088f0ac4..2f811d363c 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -12,7 +12,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> quota: _quota }) => { const { t } = useTranslation() - const { provider, updatePreprocessProvider } = usePreprocessProvider(providerId) + const { provider, updateProvider } = usePreprocessProvider(providerId) const [quota, setQuota] = useState(_quota) useEffect(() => { @@ -21,7 +21,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> // 使用用户的key时quota为无限 if (provider.apiKey) { setQuota(-9999) - updatePreprocessProvider({ ...provider, quota: -9999 }) + updateProvider({ quota: -9999 }) return } if (quota === undefined) { @@ -39,7 +39,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> } } if (_quota !== undefined) { - updatePreprocessProvider({ ...provider, quota: _quota }) + updateProvider({ quota: _quota }) return } checkQuota() diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx deleted file mode 100644 index afd6ba576e..0000000000 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutlined } from '@ant-design/icons' -import Scrollbar from '@renderer/components/Scrollbar' -import { TopView } from '@renderer/components/TopView' -import { checkApi } from '@renderer/services/ApiService' -import WebSearchService from '@renderer/services/WebSearchService' -import { Model, Provider, WebSearchProvider } from '@renderer/types' -import { maskApiKey } from '@renderer/utils/api' -import { Button, List, Modal, Space, Spin, Typography } from 'antd' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface ShowParams { - title: string - provider: Provider | WebSearchProvider - model?: Model - apiKeys: string[] - type: 'provider' | 'websearch' -} - -interface Props extends ShowParams { - resolve: (data: any) => void -} - -interface KeyStatus { - key: string - isValid?: boolean - checking?: boolean -} - -const PopupContainer: React.FC = ({ title, provider, model, apiKeys, type, resolve }) => { - const [open, setOpen] = useState(true) - const [keyStatuses, setKeyStatuses] = useState(() => { - const uniqueKeys = new Set(apiKeys) - return Array.from(uniqueKeys).map((key) => ({ key })) - }) - const { t } = useTranslation() - const [isChecking, setIsChecking] = useState(false) - const [isCheckingSingle, setIsCheckingSingle] = useState(false) - - const checkAllKeys = async () => { - setIsChecking(true) - const newStatuses = [...keyStatuses] - - try { - // 使用Promise.all并行处理所有API验证请求 - const checkPromises = newStatuses.map(async (status, i) => { - // 先更新当前密钥为检查中状态 - setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status))) - - try { - let valid = false - if (type === 'provider' && model) { - await checkApi({ ...(provider as Provider), apiKey: status.key }, model) - valid = true - } else { - const result = await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: status.key - }) - valid = result.valid - } - - // 更新验证结果 - setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: valid } : s))) - - return { index: i, valid } - } catch (error: unknown) { - // 处理错误情况 - setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: false } : s))) - return { index: i, valid: false } - } - }) - - // 等待所有请求完成 - await Promise.all(checkPromises) - } finally { - setIsChecking(false) - } - } - - const checkSingleKey = async (keyIndex: number) => { - if (isChecking || keyStatuses[keyIndex].checking) { - return - } - - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - try { - let valid = false - if (type === 'provider' && model) { - await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) - valid = true - } else { - const result = await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: keyStatuses[keyIndex].key - }) - valid = result.valid - } - - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: valid } : status)) - ) - } catch (error: unknown) { - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: false } : status)) - ) - } finally { - setIsCheckingSingle(false) - } - } - - const removeInvalidKeys = () => { - setKeyStatuses((prev) => prev.filter((status) => status.isValid !== false)) - } - - const removeKey = (keyIndex: number) => { - setKeyStatuses((prev) => prev.filter((_, idx) => idx !== keyIndex)) - } - - const onOk = () => { - const allKeys = keyStatuses.map((status) => status.key) - resolve({ validKeys: allKeys }) - setOpen(false) - } - - const onCancel = () => { - setOpen(false) - } - - const onClose = () => { - resolve({}) - } - - return ( - - - - - - - - - - }> - - ( - - - {maskApiKey(status.key)} - - {status.checking && ( - - } /> - - )} - {status.isValid === true && !status.checking && } - {status.isValid === false && !status.checking && } - {status.isValid === undefined && !status.checking && ( - {t('settings.provider.not_checked')} - )} - - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1 - }} - /> - - - - )} - /> - - - ) -} - -export default class ApiCheckPopup { - static topviewId = 0 - static hide() { - TopView.hide('ApiCheckPopup') - } - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - />, - 'ApiCheckPopup' - ) - }) - } -} - -const RemoveIcon = styled(MinusCircleOutlined)` - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - color: var(--color-error); - cursor: pointer; - transition: all 0.2s ease-in-out; -` diff --git a/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx index b9d54fd8b9..495c629429 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx @@ -2,7 +2,6 @@ import DmxapiLogo from '@renderer/assets/images/providers/dmxapi-logo.webp' import DmxapiLogoDark from '@renderer/assets/images/providers/dmxapi-logo-dark.webp' import { useTheme } from '@renderer/context/ThemeProvider' import { useProvider } from '@renderer/hooks/useProvider' -import { Provider } from '@renderer/types' import { Radio, RadioChangeEvent, Space } from 'antd' import { FC, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -11,8 +10,7 @@ import styled from 'styled-components' import { SettingSubtitle } from '..' interface DMXAPISettingsProps { - provider: Provider - setApiKey: (apiKey: string) => void + providerId: string } // DMXAPI平台选项 @@ -40,8 +38,8 @@ const PlatformOptions = [ } ] -const DMXAPISettings: FC = ({ provider: initialProvider }) => { - const { provider, updateProvider } = useProvider(initialProvider.id) +const DMXAPISettings: FC = ({ providerId }) => { + const { provider, updateProvider } = useProvider(providerId) const { theme } = useTheme() const { t } = useTranslation() diff --git a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx index f91f09848a..55bb643653 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx @@ -1,7 +1,6 @@ import { CheckCircleOutlined, CopyOutlined, ExclamationCircleOutlined } from '@ant-design/icons' import { useCopilot } from '@renderer/hooks/useCopilot' import { useProvider } from '@renderer/hooks/useProvider' -import { Provider } from '@renderer/types' import { Alert, Button, Input, message, Popconfirm, Slider, Space, Tooltip, Typography } from 'antd' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -10,8 +9,7 @@ import styled from 'styled-components' import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingTitle } from '..' interface GithubCopilotSettingsProps { - provider: Provider - setApiKey: (apiKey: string) => void + providerId: string } enum AuthStatus { @@ -20,9 +18,9 @@ enum AuthStatus { AUTHENTICATED } -const GithubCopilotSettings: FC = ({ provider: initialProvider, setApiKey }) => { +const GithubCopilotSettings: FC = ({ providerId }) => { const { t } = useTranslation() - const { provider, updateProvider } = useProvider(initialProvider.id) + const { provider, updateProvider } = useProvider(providerId) const { username, avatar, defaultHeaders, updateState, updateDefaultHeaders } = useCopilot() // 状态管理 const [authStatus, setAuthStatus] = useState(AuthStatus.NOT_STARTED) @@ -79,7 +77,6 @@ const GithubCopilotSettings: FC = ({ provider: initi setAuthStatus(AuthStatus.AUTHENTICATED) updateState({ username: login, avatar: avatar }) updateProvider({ ...provider, apiKey: token, isAuthed: true }) - setApiKey(token) message.success(t('settings.provider.copilot.auth_success')) } } catch (error) { @@ -88,7 +85,7 @@ const GithubCopilotSettings: FC = ({ provider: initi } finally { setLoading(false) } - }, [deviceCode, t, updateProvider, provider, setApiKey, updateState, defaultHeaders]) + }, [deviceCode, t, updateProvider, provider, updateState, defaultHeaders]) // 登出 const handleLogout = useCallback(async () => { @@ -97,7 +94,6 @@ const GithubCopilotSettings: FC = ({ provider: initi // 1. 保存登出状态到本地 updateProvider({ ...provider, apiKey: '', isAuthed: false }) - setApiKey('') // 3. 清除本地存储的token await window.api.copilot.logout() @@ -114,11 +110,10 @@ const GithubCopilotSettings: FC = ({ provider: initi message.error(t('settings.provider.copilot.logout_failed')) // 如果登出失败,重置登出状态 updateProvider({ ...provider, apiKey: '', isAuthed: false }) - setApiKey('') } finally { setLoading(false) } - }, [t, updateProvider, provider, setApiKey]) + }, [t, updateProvider, provider]) // 复制用户代码 const handleCopyUserCode = useCallback(() => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx index 12f1e50516..c032467a66 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx @@ -31,12 +31,6 @@ import AddModelPopup from './AddModelPopup' import EditModelsPopup from './EditModelsPopup' import ModelEditContent from './ModelEditContent' -const STATUS_COLORS = { - success: '#52c41a', - error: '#ff4d4f', - warning: '#faad14' -} - export interface ModelStatus { model: Model status?: ModelCheckStatus @@ -74,7 +68,7 @@ function useModelStatusRendering() { return (
{statusTitle} - {status.error &&
{status.error}
} + {status.error &&
{status.error}
}
) } @@ -93,7 +87,10 @@ function useModelStatusRendering() { return (
  • + style={{ + marginBottom: '5px', + color: kr.isValid ? 'var(--color-status-success)' : 'var(--color-status-error)' + }}> {maskedKey}: {kr.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed')} {kr.error && !kr.isValid && ` (${kr.error})`} {kr.latency && kr.isValid && ` (${formatLatency(kr.latency)})`} @@ -383,11 +380,11 @@ const StatusIndicator = styled.div<{ $type: string }>` color: ${(props) => { switch (props.$type) { case 'success': - return STATUS_COLORS.success + return 'var(--color-status-success)' case 'error': - return STATUS_COLORS.error + return 'var(--color-status-error)' case 'partial': - return STATUS_COLORS.warning + return 'var(--color-status-warning)' default: return 'var(--color-text)' } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx index e5d70d4fee..6cc757a450 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx @@ -5,7 +5,7 @@ import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.p import { HStack } from '@renderer/components/Layout' import OAuthButton from '@renderer/components/OAuth/OAuthButton' import { PROVIDER_CONFIG } from '@renderer/config/providers' -import { Provider } from '@renderer/types' +import { useProvider } from '@renderer/hooks/useProvider' import { providerBills, providerCharge } from '@renderer/utils/oauth' import { Button } from 'antd' import { isEmpty } from 'lodash' @@ -15,8 +15,7 @@ import { Trans, useTranslation } from 'react-i18next' import styled from 'styled-components' interface Props { - provider: Provider - setApiKey: (apiKey: string) => void + providerId: string } const PROVIDER_LOGO_MAP = { @@ -26,8 +25,13 @@ const PROVIDER_LOGO_MAP = { tokenflux: TokenFluxProviderLogo } -const ProviderOAuth: FC = ({ provider, setApiKey }) => { +const ProviderOAuth: FC = ({ providerId }) => { const { t } = useTranslation() + const { provider, updateProvider } = useProvider(providerId) + + const setApiKey = (newKey: string) => { + updateProvider({ apiKey: newKey }) + } let providerWebsite = PROVIDER_CONFIG[provider.id]?.api?.url.replace('https://', '').replace('api.', '') || provider.name diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 74a414d81c..4f693aaacb 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,25 +1,26 @@ -import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' +import { CheckOutlined, CloseCircleFilled, 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 { ApiKeyConnectivity, ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' 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 { checkApi } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import { Provider } from '@renderer/types' -import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' +import { formatApiHost, formatApiKeys, getFancyProviderName, splitApiKeyString } from '@renderer/utils' +import { formatErrorMessage } from '@renderer/utils/error' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { debounce, isEmpty } from 'lodash' -import { Settings2, SquareArrowOutUpRight } from 'lucide-react' +import { List, Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' -import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' +import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -31,7 +32,6 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from './ApiCheckPopup' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -45,24 +45,19 @@ import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { - provider: Provider + providerId: string } -const ProviderSetting: FC = ({ provider: _provider }) => { - const { provider } = useProvider(_provider.id) +const ProviderSetting: FC = ({ providerId }) => { + const { provider, updateProvider, models } = useProvider(providerId) const allProviders = useAllProviders() const { updateProviders } = useProviders() - const [apiKey, setApiKey] = useState(provider.apiKey) const [apiHost, setApiHost] = useState(provider.apiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) - const [apiValid, setApiValid] = useState(false) - const [apiChecking, setApiChecking] = useState(false) const [modelSearchText, setModelSearchText] = useState('') const deferredModelSearchText = useDeferredValue(modelSearchText) - const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() - const [inputValue, setInputValue] = useState(apiKey) const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -76,14 +71,43 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) + const fancyProviderName = getFancyProviderName(provider) + + const [localApiKey, setLocalApiKey] = useState(provider.apiKey) + const [apiKeyConnectivity, setApiKeyConnectivity] = useState({ + status: 'not_checked', + checking: false + }) + // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedSetApiKey = useCallback( + const debouncedUpdateApiKey = useCallback( debounce((value) => { - setApiKey(formatApiKeys(value)) - }, 100), + updateProvider({ apiKey: formatApiKeys(value) }) + }, 150), [] ) + // 同步 provider.apiKey 到 localApiKey + // 重置连通性检查状态 + useEffect(() => { + setLocalApiKey(provider.apiKey) + setApiKeyConnectivity({ status: 'not_checked' }) + }, [provider.apiKey]) + + // 同步 localApiKey 到 provider.apiKey(防抖) + useEffect(() => { + if (localApiKey !== provider.apiKey) { + debouncedUpdateApiKey(localApiKey) + } + + // 卸载时取消任何待执行的更新 + return () => debouncedUpdateApiKey.cancel() + }, [localApiKey, provider.apiKey, debouncedUpdateApiKey]) + + const isApiKeyConnectable = useMemo(() => { + return apiKeyConnectivity.status === 'success' + }, [apiKeyConnectivity]) + const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -99,21 +123,23 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - const onUpdateApiHost = () => { if (apiHost.trim()) { - updateProvider({ ...provider, apiHost }) + updateProvider({ apiHost }) } else { setApiHost(provider.apiHost) } } - const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) + const onUpdateApiVersion = () => updateProvider({ apiVersion }) + + const openApiKeyList = async () => { + await ApiKeyListPopup.show({ + providerId: provider.id, + providerKind: 'llm', + title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}` + }) + } const onHealthCheck = async () => { const modelsToCheck = models.filter((model) => !isRerankModel(model)) @@ -128,7 +154,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return } - const keys = splitApiKeyString(apiKey) + const keys = splitApiKeyString(provider.apiKey) // Add an empty key to enable health checks for local models. // Error messages will be shown for each model if a valid key is needed. @@ -193,6 +219,12 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } const onCheckApi = async () => { + // 如果存在多个密钥,直接打开管理窗口 + if (provider.apiKey.includes(',')) { + await openApiKeyList() + return + } + const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) if (isEmpty(modelsToCheck)) { @@ -212,58 +244,38 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return } - if (apiKey.includes(',')) { - const keys = splitApiKeyString(apiKey) + try { + setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: 'not_checked' })) + await checkApi({ ...provider, apiHost }, model) - const result = await ApiCheckPopup.show({ - title: t('settings.provider.check_multiple_keys'), - provider: { ...provider, apiHost }, - model, - apiKeys: keys, - type: 'provider' + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: i18n.t('message.api.connection.success') }) - if (result?.validKeys) { - const newApiKey = result.validKeys.join(',') - setInputValue(newApiKey) - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - } else { - setApiChecking(true) + setApiKeyConnectivity((prev) => ({ ...prev, status: 'success' })) + setTimeout(() => { + setApiKeyConnectivity((prev) => ({ ...prev, status: 'not_checked' })) + }, 3000) + } catch (error: any) { + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: i18n.t('message.api.connection.failed') + }) - 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) - } + setApiKeyConnectivity((prev) => ({ ...prev, status: 'error', error: formatErrorMessage(error) })) + } finally { + setApiKeyConnectivity((prev) => ({ ...prev, checking: false })) } } const onReset = () => { setApiHost(configedApiHost) - updateProvider({ ...provider, apiHost: configedApiHost }) + updateProvider({ apiHost: configedApiHost }) } const hostPreview = () => { @@ -276,28 +288,31 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return formatApiHost(apiHost) + 'responses' } + // API key 连通性检查状态指示器,目前仅在失败时显示 + const renderStatusIndicator = () => { + if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== 'error') { + return null + } + + return ( + {apiKeyConnectivity.error}}> + + + ) + } + useEffect(() => { if (provider.id === 'copilot') { return } - setApiKey(provider.apiKey) setApiHost(provider.apiHost) - }, [provider.apiKey, provider.apiHost, provider.id]) - - // Save apiKey to provider when unmount - useEffect(() => { - return () => { - if (apiKey.trim() && apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - }, [apiKey, provider, updateProvider]) + }, [provider.apiHost, provider.id]) return ( - {provider.isSystem ? t(`provider.${provider.id}`) : provider.name} + {fancyProviderName} {officialWebsite && ( {apiKeyWebsite && ( @@ -423,7 +441,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} {provider.id === 'lmstudio' && } {provider.id === 'gpustack' && } - {provider.id === 'copilot' && } + {provider.id === 'copilot' && } {provider.id === 'vertexai' && } @@ -461,4 +479,12 @@ const ProviderName = styled.span` margin-right: -2px; ` +const ErrorOverlay = styled.div` + max-height: 200px; + overflow-y: auto; + max-width: 300px; + word-wrap: break-word; + user-select: text; +` + export default ProviderSetting diff --git a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx index 9e5a8161b4..d6cc3e3f8f 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx @@ -52,8 +52,10 @@ const PopupContainer: React.FC = ({ provider, resolve, reject }) => { centered>