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",
|
"open": "Open",
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
|
"proceed": "Proceed",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"reasoning_content": "Deep reasoning",
|
"reasoning_content": "Deep reasoning",
|
||||||
@@ -3342,6 +3343,36 @@
|
|||||||
"check": "Check",
|
"check": "Check",
|
||||||
"check_all_keys": "Check All Keys",
|
"check_all_keys": "Check All Keys",
|
||||||
"check_multiple_keys": "Check Multiple API 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": {
|
"copilot": {
|
||||||
"auth_failed": "Github Copilot authentication failed.",
|
"auth_failed": "Github Copilot authentication failed.",
|
||||||
"auth_success": "GitHub Copilot authentication successful.",
|
"auth_success": "GitHub Copilot authentication successful.",
|
||||||
|
|||||||
@@ -766,6 +766,7 @@
|
|||||||
"open": "打开",
|
"open": "打开",
|
||||||
"paste": "粘贴",
|
"paste": "粘贴",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
|
"proceed": "继续",
|
||||||
"prompt": "提示词",
|
"prompt": "提示词",
|
||||||
"provider": "提供商",
|
"provider": "提供商",
|
||||||
"reasoning_content": "已深度思考",
|
"reasoning_content": "已深度思考",
|
||||||
@@ -3342,6 +3343,36 @@
|
|||||||
"check": "检测",
|
"check": "检测",
|
||||||
"check_all_keys": "检测所有密钥",
|
"check_all_keys": "检测所有密钥",
|
||||||
"check_multiple_keys": "检测多个 API 密钥",
|
"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": {
|
"copilot": {
|
||||||
"auth_failed": "Github Copilot 认证失败",
|
"auth_failed": "Github Copilot 认证失败",
|
||||||
"auth_success": "Github Copilot 认证成功",
|
"auth_success": "Github Copilot 认证成功",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DropResult } from '@hello-pangea/dnd'
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList'
|
import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList'
|
||||||
import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
|
import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
|
||||||
|
import ConflictResolutionPopup from '@renderer/components/Popups/ConflictResolutionPopup'
|
||||||
import { getProviderLogo } from '@renderer/config/providers'
|
import { getProviderLogo } from '@renderer/config/providers'
|
||||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
import { getProviderLabel } from '@renderer/i18n/label'
|
||||||
@@ -16,8 +17,9 @@ import {
|
|||||||
matchKeywordsInProvider,
|
matchKeywordsInProvider,
|
||||||
uuid
|
uuid
|
||||||
} from '@renderer/utils'
|
} from '@renderer/utils'
|
||||||
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
|
import { cleanupProviders, ConflictInfo, ConflictResolution } from '@renderer/utils/provider'
|
||||||
import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
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 { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
@@ -31,6 +33,12 @@ const logger = loggerService.withContext('ProvidersList')
|
|||||||
|
|
||||||
const BUTTON_WRAPPER_HEIGHT = 50
|
const BUTTON_WRAPPER_HEIGHT = 50
|
||||||
|
|
||||||
|
const SearchContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
const ProvidersList: FC = () => {
|
const ProvidersList: FC = () => {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const providers = useAllProviders()
|
const providers = useAllProviders()
|
||||||
@@ -40,6 +48,9 @@ const ProvidersList: FC = () => {
|
|||||||
const [searchText, setSearchText] = useState<string>('')
|
const [searchText, setSearchText] = useState<string>('')
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
|
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(
|
const setSelectedProvider = useCallback(
|
||||||
(provider: Provider) => {
|
(provider: Provider) => {
|
||||||
@@ -310,6 +321,60 @@ const ProvidersList: FC = () => {
|
|||||||
setSelectedProvider(provider)
|
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 getDropdownMenus = (provider: Provider): MenuProps['items'] => {
|
||||||
const noteMenu = {
|
const noteMenu = {
|
||||||
label: t('settings.provider.notes.title'),
|
label: t('settings.provider.notes.title'),
|
||||||
@@ -467,11 +532,12 @@ const ProvidersList: FC = () => {
|
|||||||
<Container className="selectable">
|
<Container className="selectable">
|
||||||
<ProviderListContainer>
|
<ProviderListContainer>
|
||||||
<AddButtonWrapper>
|
<AddButtonWrapper>
|
||||||
|
<SearchContainer>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('settings.provider.search')}
|
placeholder={t('settings.provider.search')}
|
||||||
value={searchText}
|
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} />}
|
suffix={<Search size={14} />}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -483,6 +549,15 @@ const ProvidersList: FC = () => {
|
|||||||
allowClear
|
allowClear
|
||||||
disabled={dragging}
|
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>
|
</AddButtonWrapper>
|
||||||
<DraggableVirtualList
|
<DraggableVirtualList
|
||||||
list={filteredProviders}
|
list={filteredProviders}
|
||||||
@@ -530,6 +605,13 @@ const ProvidersList: FC = () => {
|
|||||||
</AddButtonWrapper>
|
</AddButtonWrapper>
|
||||||
</ProviderListContainer>
|
</ProviderListContainer>
|
||||||
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
|
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
|
||||||
|
|
||||||
|
<ConflictResolutionPopup
|
||||||
|
conflicts={pendingConflicts}
|
||||||
|
onResolve={handleConflictResolution}
|
||||||
|
onCancel={handleConflictCancel}
|
||||||
|
visible={conflictResolutionVisible}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const persistedReducer = persistReducer(
|
|||||||
{
|
{
|
||||||
key: 'cherry-studio',
|
key: 'cherry-studio',
|
||||||
storage,
|
storage,
|
||||||
version: 137,
|
version: 138,
|
||||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||||
migrate
|
migrate
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2188,6 +2188,122 @@ const migrateConfig = {
|
|||||||
logger.error('migrate 137 error', error as Error)
|
logger.error('migrate 137 error', error as Error)
|
||||||
return state
|
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