Compare commits

...

3 Commits

Author SHA1 Message Date
suyao
fc48aa4349 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.
2025-08-27 17:46:16 +08:00
suyao
773d8dd4c3 refactor(ProviderSettings): streamline provider cleanup logic
- Removed inline cleanup function and utilized a dedicated utility to manage provider data.
- Enhanced the cleanup process to return both cleaned providers and a change flag for better state management.
- Simplified the useEffect hook for improved readability and maintainability.
2025-08-27 14:33:53 +08:00
suyao
e7a1a43856 feat(ProviderSettings): enhance provider data management on mount
- Implemented a cleanup process for provider data to remove duplicates and ensure all system providers are included.
- Added logic to identify and eliminate duplicate providers based on their IDs.
- Integrated missing system providers into the list, ensuring comprehensive provider management upon component mount.
2025-08-27 00:19:46 +08:00
8 changed files with 791 additions and 19 deletions

View File

@@ -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<Props> = ({ conflicts, onResolve, onCancel, visible }) => {
const { t } = useTranslation()
const [resolutions, setResolutions] = useState<Record<string, string>>({})
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({})
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 <DetailValue></DetailValue>
}
return (
<ApiKeyContainer>
<DetailValue>{isApiKeyVisible ? provider.apiKey : '●●●●●●●●'}</DetailValue>
<ApiKeyToggle
onClick={(e) => {
e.stopPropagation() // 防止触发卡片选择
toggleApiKeyVisibility(providerKey)
}}>
{isApiKeyVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
</ApiKeyToggle>
</ApiKeyContainer>
)
}
return (
<ProviderCard
key={provider._tempIndex}
size="small"
$selected={isSelected}
onClick={() => handleProviderSelect(conflictId, provider._tempIndex!.toString())}>
<ProviderHeader>
<Radio checked={isSelected} />
<ProviderName>{providerName}</ProviderName>
{provider.enabled && <Tag color="green">ON</Tag>}
</ProviderHeader>
<ProviderDetails>
<DetailRow>
<DetailLabel>API Key:</DetailLabel>
{renderApiKeyValue()}
</DetailRow>
<DetailRow>
<DetailLabel>API Host:</DetailLabel>
<DetailValue>{provider.apiHost || '默认'}</DetailValue>
</DetailRow>
</ProviderDetails>
</ProviderCard>
)
}
return (
<Modal
title={t('settings.provider.cleanup.conflict.resolution_title')}
open={visible}
onCancel={onCancel}
width={600}
footer={
<Space>
<Button onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="primary" onClick={handleResolve} disabled={!isAllResolved}>
{t('settings.provider.cleanup.conflict.apply_resolution')}
</Button>
</Space>
}>
<ConflictContainer>
<Text type="secondary">{t('settings.provider.cleanup.conflict.resolution_desc')}</Text>
{conflicts.map((conflict, index) => (
<ConflictSection key={conflict.id}>
<Title level={5}>
{t('settings.provider.cleanup.conflict.provider_conflict', {
provider: getFancyProviderName({ name: conflict.id, id: conflict.id } as any)
})}
</Title>
<ProvidersGrid>
{conflict.providers.map((provider) =>
renderProviderCard(provider, conflict.id, resolutions[conflict.id] === provider._tempIndex!.toString())
)}
</ProvidersGrid>
{index < conflicts.length - 1 && <ConflictDivider />}
</ConflictSection>
))}
</ConflictContainer>
</Modal>
)
}
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

View File

@@ -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.",

View File

@@ -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 认证成功",

View File

@@ -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<string>('')
const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
const [conflictResolutionVisible, setConflictResolutionVisible] = useState(false)
const [pendingConflicts, setPendingConflicts] = useState<ConflictInfo[]>([])
const [originalProviders, setOriginalProviders] = useState<Provider[]>([]) // 存储原始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,11 +532,12 @@ const ProvidersList: FC = () => {
<Container className="selectable">
<ProviderListContainer>
<AddButtonWrapper>
<SearchContainer>
<Input
type="text"
placeholder={t('settings.provider.search')}
value={searchText}
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35 }}
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35, flex: 1 }}
suffix={<Search size={14} />}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
@@ -483,6 +549,15 @@ const ProvidersList: FC = () => {
allowClear
disabled={dragging}
/>
<Button
type="text"
icon={<RefreshCcw size={14} />}
onClick={onCleanupProviders}
disabled={dragging}
title={t('settings.provider.cleanup.button.tooltip')}
style={{ marginLeft: 5 }}
/>
</SearchContainer>
</AddButtonWrapper>
<DraggableVirtualList
list={filteredProviders}
@@ -530,6 +605,13 @@ const ProvidersList: FC = () => {
</AddButtonWrapper>
</ProviderListContainer>
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
<ConflictResolutionPopup
conflicts={pendingConflicts}
onResolve={handleConflictResolution}
onCancel={handleConflictCancel}
visible={conflictResolutionVisible}
/>
</Container>
)
}

View File

@@ -64,7 +64,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 137,
version: 138,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -2188,6 +2188,122 @@ const migrateConfig = {
logger.error('migrate 137 error', error as Error)
return state
}
},
'138': (state: RootState) => {
try {
// 测试用迁移创建重复的系统providers和删除一些providers来测试UI
logger.info('Test migration 138: Creating duplicate providers for UI testing')
// 1. 删除一些现有的providers模拟用户删除或系统更新
const providersToRemove = ['ocoolai', 'burncloud']
state.llm.providers = state.llm.providers.filter((p) => !providersToRemove.includes(p.id))
// 2. 创建重复的OpenAI providers不同配置
const openaiDuplicate1: Provider = {
id: 'openai',
name: 'OpenAI Account 1',
type: 'openai-response',
apiKey: 'sk-test-key-account-1',
apiHost: 'https://api.openai.com',
models: [],
isSystem: true,
enabled: false,
serviceTier: 'auto'
}
const openaiDuplicate2: Provider = {
id: 'openai',
name: 'OpenAI Account 2',
type: 'openai-response',
apiKey: 'sk-test-key-account-2',
apiHost: 'https://api.openai.com',
models: [],
isSystem: true,
enabled: true,
serviceTier: 'priority'
}
// 3. 创建重复的Anthropic providers不同apiHost
const anthropicDuplicate1: Provider = {
id: 'anthropic',
name: 'Anthropic',
type: 'anthropic',
apiKey: 'ak-test-key',
apiHost: 'https://api.anthropic.com/',
models: [],
isSystem: true,
enabled: false
}
const anthropicDuplicate2: Provider = {
id: 'anthropic',
name: 'Anthropic Proxy',
type: 'anthropic',
apiKey: '',
apiHost: 'https://proxy.anthropic.com/',
models: [],
isSystem: true,
enabled: false
}
// 4. 创建重复的DeepSeek providers多个enabled
const deepseekDuplicate1: Provider = {
id: 'deepseek',
name: 'DeepSeek',
type: 'openai',
apiKey: '',
apiHost: 'https://api.deepseek.com',
models: [],
isSystem: true,
enabled: true
}
const deepseekDuplicate2: Provider = {
id: 'deepseek',
name: 'DeepSeek Custom',
type: 'openai',
apiKey: '',
apiHost: 'https://api.deepseek.com',
models: [],
isSystem: true,
enabled: true
}
const vertexaiConflict: Provider = {
id: 'vertexai',
name: 'VertexAI Project 2',
type: 'vertexai',
apiKey: 'vertex-project-2-key',
apiHost: 'https://aiplatform.googleapis.com',
models: [],
isSystem: true,
isVertex: true,
enabled: true
}
// 5. 添加重复providers到列表中在现有providers之后
state.llm.providers.push(
openaiDuplicate1,
openaiDuplicate2,
anthropicDuplicate1,
anthropicDuplicate2,
deepseekDuplicate1,
deepseekDuplicate2,
vertexaiConflict
)
logger.info(`Test migration 138 completed. Total providers: ${state.llm.providers.length}`)
logger.info('Duplicate providers created for UI testing:', {
openai: state.llm.providers.filter((p) => p.id === 'openai').length,
anthropic: state.llm.providers.filter((p) => p.id === 'anthropic').length,
deepseek: state.llm.providers.filter((p) => p.id === 'deepseek').length
})
return state
} catch (error) {
logger.error('migrate 138 error', error as Error)
return state
}
}
}

View File

@@ -0,0 +1,169 @@
import { Provider } from '@renderer/types'
import { describe, expect, it } from 'vitest'
import { cleanupProviders, ConflictResolution } from '../provider'
// Mock system providers for testing
const mockSystemProvider1: Provider = {
id: 'openai',
name: 'OpenAI',
type: 'openai',
apiKey: '',
apiHost: 'https://api.openai.com',
models: [],
isSystem: true,
enabled: false
}
const mockSystemProvider2: Provider = {
id: 'anthropic',
name: 'Anthropic',
type: 'anthropic',
apiKey: '',
apiHost: 'https://api.anthropic.com',
models: [],
isSystem: true,
enabled: false
}
const mockCustomProvider: Provider = {
id: 'custom-1',
name: 'Custom Provider',
type: 'openai',
apiKey: 'custom-key',
apiHost: 'https://api.custom.com',
models: [],
isSystem: false,
enabled: true
}
describe('cleanupProviders', () => {
it('should return original providers when no duplicates or missing providers', () => {
const providers = [mockSystemProvider1, mockSystemProvider2]
const result = cleanupProviders(providers)
expect(result.cleanedProviders).toHaveLength(2)
expect(result.cleanedProviders).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'openai' }), expect.objectContaining({ id: 'anthropic' })])
)
expect(result.hasChanges).toBe(false)
expect(result.conflicts).toHaveLength(0)
})
it('should show duplicates as conflicts', () => {
const duplicate = { ...mockSystemProvider1, apiKey: 'test-key' }
const providers = [mockSystemProvider1, duplicate]
const result = cleanupProviders(providers)
// Should have conflict for duplicates
expect(result.conflicts).toHaveLength(1)
expect(result.conflicts[0].id).toBe('openai')
expect(result.conflicts[0].providers).toHaveLength(2)
// Missing provider should be added
expect(result.cleanedProviders).toHaveLength(1) // anthropic (missing)
const anthropicProvider = result.cleanedProviders.find((p) => p.id === 'anthropic')
expect(anthropicProvider?.apiKey).toBe('')
expect(result.hasChanges).toBe(true)
})
it('should add missing system providers', () => {
const providers = [mockSystemProvider1]
const result = cleanupProviders(providers)
expect(result.cleanedProviders).toHaveLength(2)
expect(result.cleanedProviders.find((p) => p.id === 'anthropic')).toBeDefined()
expect(result.hasChanges).toBe(true)
})
it('should preserve custom providers', () => {
const providers = [mockSystemProvider1, mockCustomProvider]
const result = cleanupProviders(providers)
expect(result.cleanedProviders).toHaveLength(3) // openai + custom + anthropic (added)
expect(result.cleanedProviders.find((p) => p.id === 'custom-1')).toEqual(mockCustomProvider)
expect(result.hasChanges).toBe(true)
})
it('should handle non-system provider duplicates', () => {
const customDuplicate = { ...mockCustomProvider, name: 'Custom Provider Duplicate' }
const providers = [mockSystemProvider1, mockCustomProvider, customDuplicate]
const result = cleanupProviders(providers)
// Non-system duplicates should keep first occurrence
expect(result.cleanedProviders).toHaveLength(3) // openai + custom (first) + anthropic (added)
const customProvider = result.cleanedProviders.find((p) => p.id === 'custom-1')
expect(customProvider?.name).toBe('Custom Provider')
expect(result.hasChanges).toBe(true)
expect(result.conflicts).toHaveLength(0)
})
it('should return hasChanges false when no changes needed', () => {
const providers = [mockSystemProvider1, mockSystemProvider2]
const result = cleanupProviders(providers)
expect(result.hasChanges).toBe(false)
})
it('should show all types of duplicates as conflicts', () => {
const duplicate1 = { ...mockSystemProvider1, apiKey: 'key1' }
const duplicate2 = { ...mockSystemProvider1, apiKey: 'key2' }
const providers = [duplicate1, duplicate2]
const result = cleanupProviders(providers)
expect(result.conflicts).toHaveLength(1)
expect(result.conflicts[0].id).toBe('openai')
expect(result.conflicts[0].providers).toHaveLength(2)
expect(result.hasChanges).toBe(true)
// Conflicted providers are replaced by default system providers, plus missing providers are added
expect(result.cleanedProviders).toHaveLength(1) // anthropic (missing)
const anthropicProvider = result.cleanedProviders.find((p) => p.id === 'anthropic')
expect(anthropicProvider?.apiKey).toBe('') // Should be default system provider
})
it('should resolve conflict when user provides resolution', () => {
const duplicate1 = { ...mockSystemProvider1, apiKey: 'key1' }
const duplicate2 = { ...mockSystemProvider1, apiKey: 'key2' }
const providers = [duplicate1, duplicate2]
const resolutions: ConflictResolution[] = [
{
conflictId: 'openai',
selectedProviderId: '1' // Select second provider (index 1)
}
]
const result = cleanupProviders(providers, resolutions)
expect(result.conflicts).toHaveLength(0)
expect(result.cleanedProviders).toHaveLength(2) // selected openai + anthropic (missing)
const openaiProvider = result.cleanedProviders.find((p) => p.id === 'openai')
expect(openaiProvider?.apiKey).toBe('key2') // Should be the selected one
})
it('should handle no conflicts scenario', () => {
const providers = [mockSystemProvider1]
const result = cleanupProviders(providers)
expect(result.conflicts).toHaveLength(0)
expect(result.cleanedProviders).toHaveLength(2) // openai + anthropic (missing)
expect(result.hasChanges).toBe(true)
})
it('should return empty conflicts array for single providers', () => {
const providers = [mockSystemProvider1, mockSystemProvider2]
const result = cleanupProviders(providers)
expect(result.conflicts).toHaveLength(0)
})
})

View File

@@ -0,0 +1,105 @@
import { SYSTEM_PROVIDERS_CONFIG } from '@renderer/config/providers'
import { isSystemProvider, Provider, SystemProviderId, SystemProviderIds } from '@renderer/types'
export type ConflictInfo = {
id: string
providers: (Provider & { _tempIndex?: number })[]
}
export type ConflictResolution = {
conflictId: string
selectedProviderId: string // 临时ID用于标识用户选择的provider
}
/**
* Clean up provider data by removing duplicates and adding missing system providers
*
* For duplicate system providers, all duplicates are presented to the user for manual selection.
* No automatic priority rules are applied - the user chooses which provider to keep.
*
* @param providers - Array of providers to clean up
* @param conflictResolutions - Optional user resolutions for conflicts
* @returns Object containing cleaned providers, whether changes were made, and conflicts that need user attention
*/
export function cleanupProviders(
providers: Provider[],
conflictResolutions: ConflictResolution[] = []
): {
cleanedProviders: Provider[]
hasChanges: boolean
conflicts: ConflictInfo[]
} {
const systemProviderIds = Object.keys(SystemProviderIds) as SystemProviderId[]
const cleanedProviders: Provider[] = []
const conflicts: ConflictInfo[] = []
let hasChanges = false
// Group providers by ID to detect duplicates
const providerGroups = new Map<string, Provider[]>()
providers.forEach((p, index) => {
if (!providerGroups.has(p.id)) {
providerGroups.set(p.id, [])
}
// Add a temporary index to help identify providers during conflict resolution
const providerWithIndex = { ...p, _tempIndex: index }
providerGroups.get(p.id)!.push(providerWithIndex as Provider & { _tempIndex: number })
})
// Process each group
providerGroups.forEach((group, id) => {
if (group.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _tempIndex: _, ...cleanProvider } = group[0] as Provider & { _tempIndex: number }
cleanedProviders.push(cleanProvider)
return
}
hasChanges = true
// Handle duplicates
if (!isSystemProvider(group[0])) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _tempIndex: _, ...cleanProvider } = group[0] as Provider & { _tempIndex: number }
cleanedProviders.push(cleanProvider)
return
}
const userResolution = conflictResolutions.find((r) => r.conflictId === id)
if (userResolution) {
const selectedProvider = group.find((p) => (p as any)._tempIndex.toString() === userResolution.selectedProviderId)
if (selectedProvider) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _tempIndex: _, ...cleanProvider } = selectedProvider as Provider & { _tempIndex: number }
cleanedProviders.push(cleanProvider)
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _tempIndex: _, ...cleanProvider } = group[0] as Provider & { _tempIndex: number }
cleanedProviders.push(cleanProvider)
}
} else {
const conflictProviders = group.map((p) => p as Provider & { _tempIndex: number })
conflicts.push({
id,
providers: conflictProviders
})
return
}
})
// Add missing system providers
const existingProviderIds = cleanedProviders.map((p) => p.id)
const missingSystemProviderIds = systemProviderIds.filter((id) => !existingProviderIds.includes(id))
missingSystemProviderIds.forEach((id: SystemProviderId) => {
const systemProvider = SYSTEM_PROVIDERS_CONFIG[id]
cleanedProviders.push({ ...systemProvider } as Provider)
hasChanges = true
})
return {
cleanedProviders,
hasChanges,
conflicts
}
}