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:
one 2025-08-03 21:40:59 +08:00 committed by GitHub
parent a4854a883b
commit f9365dfa14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 131 additions and 122 deletions

View File

@ -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')

View File

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

View File

@ -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>
)
}

View File

@ -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') {

View File

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

View File

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

View File

@ -2991,6 +2991,7 @@
"label": "リストにモデルを追加"
},
"add_whole_group": "グループ全体を追加",
"refetch_list": "モデルリストを再取得",
"remove_listed": "リストからモデルを削除",
"remove_model": "モデルを削除",
"remove_whole_group": "グループ全体を削除"

View File

@ -2991,6 +2991,7 @@
"label": "Добавить в список"
},
"add_whole_group": "Добавить всю группу",
"refetch_list": "Повторное получение списка моделей",
"remove_listed": "Удалить из списка",
"remove_model": "Удалить модель",
"remove_whole_group": "Удалить всю группу"

View File

@ -2991,6 +2991,7 @@
"label": "添加列表中的模型"
},
"add_whole_group": "添加整个分组",
"refetch_list": "重新获取模型列表",
"remove_listed": "移除列表中的模型",
"remove_model": "移除模型",
"remove_whole_group": "移除整个分组"

View File

@ -2991,6 +2991,7 @@
"label": "新增列表中的模型"
},
"add_whole_group": "新增整個分組",
"refetch_list": "重新獲取模型列表",
"remove_listed": "移除列表中的模型",
"remove_model": "移除模型",
"remove_whole_group": "移除整個分組"