Compare commits
3 Commits
copilot/fi
...
feat/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc48aa4349 | ||
|
|
773d8dd4c3 | ||
|
|
e7a1a43856 |
238
src/renderer/src/components/Popups/ConflictResolutionPopup.tsx
Normal file
238
src/renderer/src/components/Popups/ConflictResolutionPopup.tsx
Normal 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
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 认证成功",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 137,
|
||||
version: 138,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
src/renderer/src/utils/__tests__/provider.test.ts
Normal file
169
src/renderer/src/utils/__tests__/provider.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
105
src/renderer/src/utils/provider.ts
Normal file
105
src/renderer/src/utils/provider.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user