mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
refactor(ManageModelsPopup): better animation and feedback (#8797)
* refactor(ManageModelsPopup): pass providerId, add loadModels, rename loading state * feat: add a button to reload models * refactor: better transition for ManageModelsPopup * style: fix lint
This commit is contained in:
parent
a4854a883b
commit
f9365dfa14
@ -21,6 +21,7 @@ import {
|
||||
isSupportedThinkingTokenZhipuModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
// For Copilot token
|
||||
@ -62,7 +63,6 @@ import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatComple
|
||||
import { GenericChunk } from '../../middleware/schemas'
|
||||
import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types'
|
||||
import { OpenAIBaseClient } from './OpenAIBaseClient'
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
|
||||
const logger = loggerService.withContext('OpenAIApiClient')
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import {
|
||||
FileMetadata,
|
||||
@ -44,7 +45,6 @@ import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
import { OpenAIAPIClient } from './OpenAIApiClient'
|
||||
import { OpenAIBaseClient } from './OpenAIBaseClient'
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
|
||||
export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
OpenAI,
|
||||
|
||||
@ -18,40 +18,35 @@ import {
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { fetchModels } from '@renderer/services/ApiService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import {
|
||||
filterModelsByKeywords,
|
||||
getDefaultGroupName,
|
||||
getFancyProviderName,
|
||||
isFreeModel,
|
||||
runAsyncFunction
|
||||
} from '@renderer/utils'
|
||||
import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName, isFreeModel } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { groupBy, isEmpty, uniqBy } from 'lodash'
|
||||
import { debounce } from 'lodash'
|
||||
import { Search } from 'lucide-react'
|
||||
import { RefreshCcw, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
import ManageModelsList from './ManageModelsList'
|
||||
import { isModelInProvider, isValidNewApiModel } from './utils'
|
||||
|
||||
const logger = loggerService.withContext('ManageModelsPopup')
|
||||
|
||||
interface ShowParams {
|
||||
provider: Provider
|
||||
providerId: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { provider, models, addModel, removeModel } = useProvider(_provider.id)
|
||||
const { provider, models, addModel, removeModel } = useProvider(providerId)
|
||||
const [listModels, setListModels] = useState<Model[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [filterSearchText, setFilterSearchText] = useState('')
|
||||
const debouncedSetFilterText = useMemo(
|
||||
@ -78,9 +73,14 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const searchInputRef = useRef<any>(null)
|
||||
|
||||
const systemModels = SYSTEM_MODELS[_provider.id] || []
|
||||
const systemModels = SYSTEM_MODELS[provider.id] || []
|
||||
const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id')
|
||||
|
||||
const isLoading = useMemo(
|
||||
() => loadingModels || isFilterTypePending || isSearchPending,
|
||||
[loadingModels, isFilterTypePending, isSearchPending]
|
||||
)
|
||||
|
||||
const list = useMemo(
|
||||
() =>
|
||||
filterModelsByKeywords(filterSearchText, allModels).filter((model) => {
|
||||
@ -149,48 +149,66 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
|
||||
const onRemoveModel = useCallback((model: Model) => removeModel(model), [removeModel])
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
let mounted = true
|
||||
const onRemoveAll = useCallback(() => {
|
||||
list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel)
|
||||
}, [list, onRemoveModel, provider])
|
||||
|
||||
runAsyncFunction(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const models = await fetchModels(_provider)
|
||||
setListModels(
|
||||
models
|
||||
.map((model) => ({
|
||||
// @ts-ignore modelId
|
||||
id: model?.id || model?.name,
|
||||
// @ts-ignore name
|
||||
name: model?.display_name || model?.displayName || model?.name || model?.id,
|
||||
provider: _provider.id,
|
||||
// @ts-ignore group
|
||||
group: getDefaultGroupName(model?.id || model?.name, _provider.id),
|
||||
// @ts-ignore description
|
||||
description: model?.description || '',
|
||||
// @ts-ignore owned_by
|
||||
owned_by: model?.owned_by || '',
|
||||
// @ts-ignore supported_endpoint_types
|
||||
supported_endpoint_types: model?.supported_endpoint_types
|
||||
}))
|
||||
.filter((model) => !isEmpty(model.name))
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch models', error as Error)
|
||||
} finally {
|
||||
if (mounted) {
|
||||
timer = setTimeout(() => setLoading(false), 300)
|
||||
const onAddAll = useCallback(() => {
|
||||
const wouldAddModel = list.filter((model) => !isModelInProvider(provider, model.id))
|
||||
window.modal.confirm({
|
||||
title: t('settings.models.manage.add_listed.label'),
|
||||
content: t('settings.models.manage.add_listed.confirm'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
if (provider.id === 'new-api') {
|
||||
if (models.every(isValidNewApiModel)) {
|
||||
wouldAddModel.forEach(onAddModel)
|
||||
} else {
|
||||
NewApiBatchAddModelPopup.show({
|
||||
title: t('settings.models.add.batch_add_models'),
|
||||
batchModels: wouldAddModel,
|
||||
provider
|
||||
})
|
||||
}
|
||||
} else {
|
||||
wouldAddModel.forEach(onAddModel)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [list, models, onAddModel, provider, t])
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const loadModels = useCallback(async (provider: Provider) => {
|
||||
setLoadingModels(true)
|
||||
try {
|
||||
const models = await fetchModels(provider)
|
||||
const filteredModels = models
|
||||
.map((model) => ({
|
||||
// @ts-ignore modelId
|
||||
id: model?.id || model?.name,
|
||||
// @ts-ignore name
|
||||
name: model?.display_name || model?.displayName || model?.name || model?.id,
|
||||
provider: provider.id,
|
||||
// @ts-ignore group
|
||||
group: getDefaultGroupName(model?.id || model?.name, provider.id),
|
||||
// @ts-ignore description
|
||||
description: model?.description || '',
|
||||
// @ts-ignore owned_by
|
||||
owned_by: model?.owned_by || '',
|
||||
// @ts-ignore supported_endpoint_types
|
||||
supported_endpoint_types: model?.supported_endpoint_types
|
||||
}))
|
||||
.filter((model) => !isEmpty(model.name))
|
||||
|
||||
setListModels(filteredModels)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load models for provider ${getFancyProviderName(provider)}`, error as Error)
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadModels(provider)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
@ -222,57 +240,39 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
const renderTopTools = useCallback(() => {
|
||||
const isAllFilteredInProvider = list.length > 0 && list.every((model) => isModelInProvider(provider, model.id))
|
||||
|
||||
const onRemoveAll = () => {
|
||||
list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel)
|
||||
}
|
||||
|
||||
const onAddAll = () => {
|
||||
const wouldAddModel = list.filter((model) => !isModelInProvider(provider, model.id))
|
||||
window.modal.confirm({
|
||||
title: t('settings.models.manage.add_listed.label'),
|
||||
content: t('settings.models.manage.add_listed.confirm'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
if (provider.id === 'new-api') {
|
||||
if (models.every(isValidNewApiModel)) {
|
||||
wouldAddModel.forEach(onAddModel)
|
||||
} else {
|
||||
NewApiBatchAddModelPopup.show({
|
||||
title: t('settings.models.add.batch_add_models'),
|
||||
batchModels: wouldAddModel,
|
||||
provider
|
||||
})
|
||||
}
|
||||
} else {
|
||||
wouldAddModel.forEach(onAddModel)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
destroyTooltipOnHide
|
||||
title={
|
||||
isAllFilteredInProvider
|
||||
? t('settings.models.manage.remove_listed')
|
||||
: t('settings.models.manage.add_listed.label')
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
placement="top">
|
||||
<Button
|
||||
type="default"
|
||||
icon={isAllFilteredInProvider ? <MinusOutlined /> : <PlusOutlined />}
|
||||
size="large"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isAllFilteredInProvider ? onRemoveAll() : onAddAll()
|
||||
}}
|
||||
disabled={loading || list.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
<HStack gap={8}>
|
||||
<Tooltip
|
||||
title={
|
||||
isAllFilteredInProvider
|
||||
? t('settings.models.manage.remove_listed')
|
||||
: t('settings.models.manage.add_listed.label')
|
||||
}
|
||||
destroyTooltipOnHide
|
||||
mouseLeaveDelay={0}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={isAllFilteredInProvider ? <MinusOutlined /> : <PlusOutlined />}
|
||||
size="large"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isAllFilteredInProvider ? onRemoveAll() : onAddAll()
|
||||
}}
|
||||
disabled={loadingModels || list.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.models.manage.refetch_list')} destroyTooltipOnHide mouseLeaveDelay={0}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<RefreshCcw size={16} />}
|
||||
size="large"
|
||||
onClick={() => loadModels(provider)}
|
||||
disabled={loadingModels}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
)
|
||||
}, [list, t, loading, provider, onRemoveModel, models, onAddModel])
|
||||
}, [list, t, loadingModels, provider, onRemoveAll, onAddAll, loadModels])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -293,7 +293,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
<SearchContainer>
|
||||
<TopToolsWrapper>
|
||||
<Input
|
||||
prefix={<Search size={14} />}
|
||||
prefix={<Search size={16} style={{ marginRight: 4 }} />}
|
||||
size="large"
|
||||
ref={searchInputRef}
|
||||
placeholder={t('settings.provider.search_placeholder')}
|
||||
@ -304,6 +304,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
setSearchText(newSearchValue) // Update input field immediately
|
||||
debouncedSetFilterText(newSearchValue)
|
||||
}}
|
||||
disabled={loadingModels}
|
||||
/>
|
||||
{renderTopTools()}
|
||||
</TopToolsWrapper>
|
||||
@ -329,23 +330,26 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
}}
|
||||
/>
|
||||
</SearchContainer>
|
||||
<ListContainer>
|
||||
{loading || isFilterTypePending || isSearchPending ? (
|
||||
<Flex justify="center" align="center" style={{ height: '70%' }}>
|
||||
<Spin indicator={<SvgSpinners180Ring color="var(--color-text-2)" />} />
|
||||
</Flex>
|
||||
) : (
|
||||
<ManageModelsList
|
||||
modelGroups={modelGroups}
|
||||
provider={provider}
|
||||
onAddModel={onAddModel}
|
||||
onRemoveModel={onRemoveModel}
|
||||
/>
|
||||
)}
|
||||
{!(loading || isFilterTypePending || isSearchPending) && isEmpty(list) && (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />
|
||||
)}
|
||||
</ListContainer>
|
||||
<Spin
|
||||
spinning={isLoading}
|
||||
indicator={<SvgSpinners180Ring color="var(--color-text-2)" style={{ opacity: loadingModels ? 1 : 0 }} />}>
|
||||
<ListContainer>
|
||||
{loadingModels || isEmpty(list) ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('settings.models.empty')}
|
||||
style={{ visibility: loadingModels ? 'hidden' : 'visible' }}
|
||||
/>
|
||||
) : (
|
||||
<ManageModelsList
|
||||
modelGroups={modelGroups}
|
||||
provider={provider}
|
||||
onAddModel={onAddModel}
|
||||
onRemoveModel={onRemoveModel}
|
||||
/>
|
||||
)}
|
||||
</ListContainer>
|
||||
</Spin>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -89,8 +89,8 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
|
||||
}, [displayedModelGroups])
|
||||
|
||||
const onManageModel = useCallback(() => {
|
||||
ManageModelsPopup.show({ provider })
|
||||
}, [provider])
|
||||
ManageModelsPopup.show({ providerId: provider.id })
|
||||
}, [provider.id])
|
||||
|
||||
const onAddModel = useCallback(() => {
|
||||
if (provider.id === 'new-api') {
|
||||
|
||||
@ -15,7 +15,6 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png
|
||||
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
|
||||
import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png'
|
||||
import GiteeAIProviderLogo from '@renderer/assets/images/providers/gitee-ai.png'
|
||||
import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg'
|
||||
import GithubProviderLogo from '@renderer/assets/images/providers/github.png'
|
||||
import GoogleProviderLogo from '@renderer/assets/images/providers/google.png'
|
||||
import GPUStackProviderLogo from '@renderer/assets/images/providers/gpustack.svg'
|
||||
@ -39,6 +38,7 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
|
||||
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
|
||||
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
|
||||
import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.png'
|
||||
import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg'
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
@ -52,9 +52,9 @@ import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png
|
||||
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
|
||||
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
import { TOKENFLUX_HOST } from './constant'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
const PROVIDER_LOGO_MAP = {
|
||||
ph8: Ph8ProviderLogo,
|
||||
|
||||
@ -2991,6 +2991,7 @@
|
||||
"label": "Add models to the list"
|
||||
},
|
||||
"add_whole_group": "Add the whole group",
|
||||
"refetch_list": "Refetch model list",
|
||||
"remove_listed": "Remove models from the list",
|
||||
"remove_model": "Remove model",
|
||||
"remove_whole_group": "Remove the whole group"
|
||||
|
||||
@ -2991,6 +2991,7 @@
|
||||
"label": "リストにモデルを追加"
|
||||
},
|
||||
"add_whole_group": "グループ全体を追加",
|
||||
"refetch_list": "モデルリストを再取得",
|
||||
"remove_listed": "リストからモデルを削除",
|
||||
"remove_model": "モデルを削除",
|
||||
"remove_whole_group": "グループ全体を削除"
|
||||
|
||||
@ -2991,6 +2991,7 @@
|
||||
"label": "Добавить в список"
|
||||
},
|
||||
"add_whole_group": "Добавить всю группу",
|
||||
"refetch_list": "Повторное получение списка моделей",
|
||||
"remove_listed": "Удалить из списка",
|
||||
"remove_model": "Удалить модель",
|
||||
"remove_whole_group": "Удалить всю группу"
|
||||
|
||||
@ -2991,6 +2991,7 @@
|
||||
"label": "添加列表中的模型"
|
||||
},
|
||||
"add_whole_group": "添加整个分组",
|
||||
"refetch_list": "重新获取模型列表",
|
||||
"remove_listed": "移除列表中的模型",
|
||||
"remove_model": "移除模型",
|
||||
"remove_whole_group": "移除整个分组"
|
||||
|
||||
@ -2991,6 +2991,7 @@
|
||||
"label": "新增列表中的模型"
|
||||
},
|
||||
"add_whole_group": "新增整個分組",
|
||||
"refetch_list": "重新獲取模型列表",
|
||||
"remove_listed": "移除列表中的模型",
|
||||
"remove_model": "移除模型",
|
||||
"remove_whole_group": "移除整個分組"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user