From fc48aa43495d08d9884c9d2a31a4b744ec204948 Mon Sep 17 00:00:00 2001 From: suyao Date: Wed, 27 Aug 2025 17:46:16 +0800 Subject: [PATCH] feat(ProviderSettings): implement provider cleanup with conflict resolution - Added a new cleanup process for provider data that identifies and resolves duplicates, allowing user intervention for conflicts. - Introduced a modal for users to select which provider configurations to keep when duplicates are detected. - Updated the cleanup function to return both cleaned providers and any conflicts that require user attention. - Enhanced the UI to include a cleanup button and integrated the conflict resolution popup for better user experience. --- .../Popups/ConflictResolutionPopup.tsx | 238 ++++++++++++++++++ src/renderer/src/i18n/locales/en-us.json | 31 +++ src/renderer/src/i18n/locales/zh-cn.json | 31 +++ .../ProviderSettings/ProviderSetting.tsx | 11 - .../pages/settings/ProviderSettings/index.tsx | 118 +++++++-- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 116 +++++++++ .../src/utils/__tests__/provider.test.ts | 215 +++++++--------- src/renderer/src/utils/provider.ts | 112 +++++---- 9 files changed, 674 insertions(+), 200 deletions(-) create mode 100644 src/renderer/src/components/Popups/ConflictResolutionPopup.tsx diff --git a/src/renderer/src/components/Popups/ConflictResolutionPopup.tsx b/src/renderer/src/components/Popups/ConflictResolutionPopup.tsx new file mode 100644 index 000000000..bed8956d8 --- /dev/null +++ b/src/renderer/src/components/Popups/ConflictResolutionPopup.tsx @@ -0,0 +1,238 @@ +import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons' +import { getFancyProviderName } from '@renderer/utils' +import { ConflictInfo, ConflictResolution } from '@renderer/utils/provider' +import { Button, Card, Modal, Radio, Space, Tag, Typography } from 'antd' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const { Text, Title } = Typography + +interface Props { + conflicts: ConflictInfo[] + onResolve: (resolutions: ConflictResolution[]) => void + onCancel: () => void + visible: boolean +} + +const ConflictResolutionPopup: FC = ({ conflicts, onResolve, onCancel, visible }) => { + const { t } = useTranslation() + const [resolutions, setResolutions] = useState>({}) + const [showApiKeys, setShowApiKeys] = useState>({}) + + const handleProviderSelect = (conflictId: string, providerId: string) => { + setResolutions((prev) => ({ + ...prev, + [conflictId]: providerId + })) + } + + const toggleApiKeyVisibility = (providerKey: string) => { + setShowApiKeys((prev) => ({ + ...prev, + [providerKey]: !prev[providerKey] + })) + } + + const handleResolve = () => { + const conflictResolutions: ConflictResolution[] = Object.entries(resolutions).map( + ([conflictId, selectedProviderId]) => ({ + conflictId, + selectedProviderId + }) + ) + onResolve(conflictResolutions) + } + + const isAllResolved = conflicts.every((conflict) => resolutions[conflict.id]) + + const renderProviderCard = (provider: ConflictInfo['providers'][0], conflictId: string, isSelected: boolean) => { + const providerName = getFancyProviderName(provider) + const providerKey = `${conflictId}-${provider._tempIndex}` + const isApiKeyVisible = showApiKeys[providerKey] + + const renderApiKeyValue = () => { + if (!provider.apiKey) { + return 未设置 + } + + return ( + + {isApiKeyVisible ? provider.apiKey : '●●●●●●●●'} + { + e.stopPropagation() // 防止触发卡片选择 + toggleApiKeyVisibility(providerKey) + }}> + {isApiKeyVisible ? : } + + + ) + } + + return ( + handleProviderSelect(conflictId, provider._tempIndex!.toString())}> + + + {providerName} + {provider.enabled && ON} + + + + API Key: + {renderApiKeyValue()} + + + API Host: + {provider.apiHost || '默认'} + + + + ) + } + + return ( + + + + + }> + + {t('settings.provider.cleanup.conflict.resolution_desc')} + + {conflicts.map((conflict, index) => ( + + + {t('settings.provider.cleanup.conflict.provider_conflict', { + provider: getFancyProviderName({ name: conflict.id, id: conflict.id } as any) + })} + + + + {conflict.providers.map((provider) => + renderProviderCard(provider, conflict.id, resolutions[conflict.id] === provider._tempIndex!.toString()) + )} + + + {index < conflicts.length - 1 && } + + ))} + + + ) +} + +const ConflictContainer = styled.div` + max-height: 500px; + overflow-y: auto; +` + +const ConflictSection = styled.div` + margin-bottom: 24px; +` + +const ProvidersGrid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: 12px; +` + +const ProviderCard = styled(Card)<{ $selected: boolean }>` + cursor: pointer; + border: 2px solid ${(props) => (props.$selected ? 'var(--color-primary)' : 'var(--color-border)')}; + background: ${(props) => (props.$selected ? 'var(--color-primary-bg)' : 'var(--color-background)')}; + transition: all 0.2s ease; + + &:hover { + border-color: var(--color-primary); + } + + .ant-card-body { + padding: 12px 16px; + } +` + +const ProviderHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +` + +const ProviderName = styled.span` + font-weight: 500; + flex: 1; +` + +const ProviderDetails = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +` + +const DetailRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +const DetailLabel = styled(Text)` + min-width: 80px; + color: var(--color-text-3); + font-size: 12px; +` + +const DetailValue = styled(Text)` + font-size: 12px; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; +` + +const ApiKeyContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex: 1; +` + +const ApiKeyToggle = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; + color: var(--color-text-3); + transition: all 0.2s ease; + + &:hover { + background: var(--color-fill-tertiary); + color: var(--color-text-1); + } + + &:active { + transform: scale(0.95); + } +` + +const ConflictDivider = styled.div` + height: 1px; + background: var(--color-border); + margin: 24px 0; +` + +export default ConflictResolutionPopup diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 26fec51b6..1527735c0 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -766,6 +766,7 @@ "open": "Open", "paste": "Paste", "preview": "Preview", + "proceed": "Proceed", "prompt": "Prompt", "provider": "Provider", "reasoning_content": "Deep reasoning", @@ -3342,6 +3343,36 @@ "check": "Check", "check_all_keys": "Check All Keys", "check_multiple_keys": "Check Multiple API Keys", + "cleanup": { + "button": { + "tooltip": "Clean up duplicate and missing providers" + }, + "confirm": { + "content": "This will clean up duplicate providers and add missing system providers. Do you want to continue?", + "title": "Confirm Provider Cleanup" + }, + "conflict": { + "apply_resolution": "Apply Selection", + "both_have_apikey": "{{provider}} has multiple providers with API keys configured", + "both_have_apikey_desc": "Multiple providers with API keys detected, please select which configuration to keep", + "description": "The following conflicts were detected and need manual handling:", + "different_apihost": "{{provider}} has providers with different API hosts", + "different_apihost_desc": "Providers with different API hosts detected, please select which configuration to keep", + "multiple_enabled": "{{provider}} has multiple enabled providers", + "multiple_enabled_desc": "Multiple enabled providers detected, please select which configuration to keep", + "proceed_question": "One configuration has been automatically selected to keep. Do you want to continue?", + "provider_conflict": "{{provider}} Configuration Conflict", + "resolution_desc": "Please select which configuration to keep for each conflicting provider:", + "resolution_title": "Resolve Provider Configuration Conflicts", + "title": "Provider Configuration Conflicts", + "unknown": "{{provider}} has unknown configuration conflicts", + "unknown_desc": "Unknown configuration conflicts detected" + }, + "no_changes": "Provider configuration does not need cleanup", + "success": "Provider cleanup completed", + "success_with_conflicts": "Provider cleanup completed (conflicts automatically handled)", + "success_with_user_resolution": "Provider cleanup completed (conflicts resolved)" + }, "copilot": { "auth_failed": "Github Copilot authentication failed.", "auth_success": "GitHub Copilot authentication successful.", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 044f745d0..3b821f4c5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -766,6 +766,7 @@ "open": "打开", "paste": "粘贴", "preview": "预览", + "proceed": "继续", "prompt": "提示词", "provider": "提供商", "reasoning_content": "已深度思考", @@ -3342,6 +3343,36 @@ "check": "检测", "check_all_keys": "检测所有密钥", "check_multiple_keys": "检测多个 API 密钥", + "cleanup": { + "button": { + "tooltip": "清理重复和缺失的提供商" + }, + "confirm": { + "content": "这将清理重复的提供商并添加缺失的系统提供商,是否继续?", + "title": "确认清理提供商" + }, + "conflict": { + "apply_resolution": "应用选择", + "both_have_apikey": "{{provider}} 存在多个配置了 API 密钥的提供商", + "both_have_apikey_desc": "检测到多个配置了 API 密钥的提供商,请选择要保留的配置", + "description": "检测到以下冲突需要手动处理:", + "different_apihost": "{{provider}} 存在不同 API 地址的提供商配置", + "different_apihost_desc": "检测到不同 API 地址的提供商配置,请选择要保留的配置", + "multiple_enabled": "{{provider}} 存在多个已启用的提供商", + "multiple_enabled_desc": "检测到多个已启用的提供商,请选择要保留的配置", + "proceed_question": "已自动选择一个配置保留,是否继续?", + "provider_conflict": "{{provider}} 配置冲突", + "resolution_desc": "请为每个冲突的提供商选择要保留的配置:", + "resolution_title": "解决提供商配置冲突", + "title": "提供商配置冲突", + "unknown": "{{provider}} 存在未知配置冲突", + "unknown_desc": "检测到未知配置冲突" + }, + "no_changes": "提供商配置无需清理", + "success": "提供商清理完成", + "success_with_conflicts": "提供商清理完成(存在冲突已自动处理)", + "success_with_user_resolution": "提供商清理完成(冲突已解决)" + }, "copilot": { "auth_failed": "Github Copilot 认证失败", "auth_success": "Github Copilot 认证成功", diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 50677d81f..fbb2806f0 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -15,7 +15,6 @@ import { isSystemProvider } from '@renderer/types' import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck' import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' -import { cleanupProviders } from '@renderer/utils/provider' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { debounce, isEmpty } from 'lodash' @@ -226,16 +225,6 @@ const ProviderSetting: FC = ({ providerId }) => { ) } - // Clean up provider data on component mount - remove duplicates and add missing system providers - useEffect(() => { - const { cleanedProviders, hasChanges } = cleanupProviders(allProviders) - - if (hasChanges) { - updateProviders(cleanedProviders) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // Empty dependency array to run only on mount - useEffect(() => { if (provider.id === 'copilot') { return diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 442157546..4e6759ad9 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -2,6 +2,7 @@ import { DropResult } from '@hello-pangea/dnd' import { loggerService } from '@logger' import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList' import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons' +import ConflictResolutionPopup from '@renderer/components/Popups/ConflictResolutionPopup' import { getProviderLogo } from '@renderer/config/providers' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { getProviderLabel } from '@renderer/i18n/label' @@ -16,8 +17,9 @@ import { matchKeywordsInProvider, uuid } from '@renderer/utils' -import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' -import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react' +import { cleanupProviders, ConflictInfo, ConflictResolution } from '@renderer/utils/provider' +import { Avatar, Button, Card, Dropdown, Input, MenuProps, Modal, Tag } from 'antd' +import { Eye, EyeOff, GripVertical, PlusIcon, RefreshCcw, Search, UserPen } from 'lucide-react' import { FC, startTransition, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' @@ -31,6 +33,12 @@ const logger = loggerService.withContext('ProvidersList') const BUTTON_WRAPPER_HEIGHT = 50 +const SearchContainer = styled.div` + display: flex; + align-items: center; + width: 100%; +` + const ProvidersList: FC = () => { const [searchParams] = useSearchParams() const providers = useAllProviders() @@ -40,6 +48,9 @@ const ProvidersList: FC = () => { const [searchText, setSearchText] = useState('') const [dragging, setDragging] = useState(false) const [providerLogos, setProviderLogos] = useState>({}) + const [conflictResolutionVisible, setConflictResolutionVisible] = useState(false) + const [pendingConflicts, setPendingConflicts] = useState([]) + const [originalProviders, setOriginalProviders] = useState([]) // 存储原始providers用于冲突解决 const setSelectedProvider = useCallback( (provider: Provider) => { @@ -310,6 +321,60 @@ const ProvidersList: FC = () => { setSelectedProvider(provider) } + const onCleanupProviders = () => { + const { cleanedProviders, hasChanges, conflicts } = cleanupProviders(providers) + + if (!hasChanges) { + window.message.info(t('settings.provider.cleanup.no_changes')) + return + } + + if (conflicts.length > 0) { + // 存储原始providers和冲突信息,显示冲突解决弹窗 + setOriginalProviders(providers) + setPendingConflicts(conflicts) + setConflictResolutionVisible(true) + } else { + showCleanupConfirmDialog(cleanedProviders) + } + } + + const handleConflictResolution = (resolutions: ConflictResolution[]) => { + // 使用用户的冲突解决方案重新执行清理 + const { cleanedProviders, hasChanges } = cleanupProviders(originalProviders, resolutions) + + setConflictResolutionVisible(false) + setPendingConflicts([]) + setOriginalProviders([]) + + if (hasChanges) { + updateProviders(cleanedProviders) + window.message.success(t('settings.provider.cleanup.success_with_user_resolution')) + } else { + window.message.info(t('settings.provider.cleanup.no_changes')) + } + } + + const handleConflictCancel = () => { + setConflictResolutionVisible(false) + setPendingConflicts([]) + setOriginalProviders([]) + } + + const showCleanupConfirmDialog = (cleanedProviders: Provider[]) => { + Modal.confirm({ + title: t('settings.provider.cleanup.confirm.title'), + content: t('settings.provider.cleanup.confirm.content'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk() { + updateProviders(cleanedProviders) + window.message.success(t('settings.provider.cleanup.success')) + } + }) + } + const getDropdownMenus = (provider: Provider): MenuProps['items'] => { const noteMenu = { label: t('settings.provider.notes.title'), @@ -467,22 +532,32 @@ const ProvidersList: FC = () => { - } - onChange={(e) => setSearchText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.stopPropagation() - setSearchText('') - } - }} - allowClear - disabled={dragging} - /> + + } + onChange={(e) => setSearchText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation() + setSearchText('') + } + }} + allowClear + disabled={dragging} + /> +