refactor: improve model management UI, add animations to some buttons (#5932)

* feat: add motion to ModelListSearchBar

* feat: add motion to health checking button

* refactor(EditModelsPopup): show spin while fetching models

* refactor: remove redundant filtering, use transient props

* chore: remove useless component ModelTags

* refactor: extract and reuse ModelIdWithTags

* refactor(EditModelsPopup): use ExpandableText instead of expandable Typography.Paragraph

* refactor(EditModelsPopup): implement optimistic updates for filter type and loading state

* refactor: startTransition for search

* refactor(EditModelsPopup): enhance search and filter handling with optimistic updates

* refactor(EditModelsPopup): implement debounced search filter updates

---------

Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
one 2025-05-13 14:48:59 +08:00 committed by kangfenmao
parent b9224ed311
commit dc93e168df
9 changed files with 357 additions and 278 deletions

View File

@ -0,0 +1,51 @@
import { Button } from 'antd'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ExpandableTextProps {
text: string
style?: React.CSSProperties
}
const ExpandableText = ({
ref,
text,
style
}: ExpandableTextProps & { ref?: React.RefObject<HTMLParagraphElement> | null }) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const toggleExpand = useCallback(() => {
setIsExpanded((prev) => !prev)
}, [])
const button = useMemo(() => {
return (
<Button type="link" onClick={toggleExpand} style={{ alignSelf: 'flex-end' }}>
{isExpanded ? t('common.collapse') : t('common.expand')}
</Button>
)
}, [isExpanded, t, toggleExpand])
return (
<Container ref={ref} style={style} $expanded={isExpanded}>
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
{button}
</Container>
)
}
const Container = styled.div<{ $expanded?: boolean }>`
display: flex;
flex-direction: ${(props) => (props.$expanded ? 'column' : 'row')};
`
const TextContainer = styled.div<{ $expanded?: boolean }>`
overflow: hidden;
text-overflow: ${(props) => (props.$expanded ? 'unset' : 'ellipsis')};
white-space: ${(props) => (props.$expanded ? 'normal' : 'nowrap')};
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
`
export default memo(ExpandableText)

View File

@ -0,0 +1,64 @@
import { Model } from '@renderer/types'
import { Tooltip, Typography } from 'antd'
import { memo } from 'react'
import styled from 'styled-components'
import ModelTagsWithLabel from './ModelTagsWithLabel'
interface ModelIdWithTagsProps {
model: Model
fontSize?: number
style?: React.CSSProperties
}
const ModelIdWithTags = ({
ref,
model,
fontSize = 14,
style
}: ModelIdWithTagsProps & { ref?: React.RefObject<HTMLDivElement> | null }) => {
return (
<ListItemName ref={ref} $fontSize={fontSize} style={style}>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
mouseEnterDelay={0.5}
placement="top">
<NameSpan>{model.name}</NameSpan>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} style={{ flexShrink: 0 }} />
</ListItemName>
)
}
const ListItemName = styled.div<{ $fontSize?: number }>`
display: flex;
align-items: center;
flex-direction: row;
gap: 10px;
color: var(--color-text);
line-height: 1;
font-weight: 600;
font-size: ${(props) => props.$fontSize}px;
`
const NameSpan = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
font-family: 'Ubuntu';
line-height: 30px;
`
export default memo(ModelIdWithTags)

View File

@ -1,51 +0,0 @@
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { Model } from '@renderer/types'
import { isFreeModel } from '@renderer/utils'
import { Tag } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ReasoningIcon from './Icons/ReasoningIcon'
import ToolsCallingIcon from './Icons/ToolsCallingIcon'
import VisionIcon from './Icons/VisionIcon'
import WebSearchIcon from './Icons/WebSearchIcon'
interface ModelTagsProps {
model: Model
showFree?: boolean
showReasoning?: boolean
showToolsCalling?: boolean
}
const ModelTags: FC<ModelTagsProps> = ({ model, showFree = true, showReasoning = true, showToolsCalling = true }) => {
const { t } = useTranslation()
return (
<Container>
{isVisionModel(model) && <VisionIcon />}
{isWebSearchModel(model) && <WebSearchIcon />}
{showReasoning && isReasoningModel(model) && <ReasoningIcon />}
{showToolsCalling && isFunctionCallingModel(model) && <ToolsCallingIcon />}
{isEmbeddingModel(model) && <Tag color="orange">{t('models.type.embedding')}</Tag>}
{showFree && isFreeModel(model) && <Tag color="green">{t('models.type.free')}</Tag>}
{isRerankModel(model) && <Tag color="geekblue">{t('models.type.rerank')}</Tag>}
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 2px;
`
export default ModelTags

View File

@ -1,6 +1,7 @@
import { CheckOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { Lightbulb } from 'lucide-react'
import { motion } from 'motion/react'
@ -10,27 +11,6 @@ import styled from 'styled-components'
import Markdown from '../../Markdown/Markdown'
// Define variants outside the component if they don't depend on component's props/state directly
// or inside if they do (though for this case, outside is fine).
const lightbulbVariants = {
thinking: {
opacity: [1, 0.2, 1],
transition: {
duration: 1.2,
ease: 'easeInOut',
times: [0, 0.5, 1],
repeat: Infinity
}
},
idle: {
opacity: 1,
transition: {
duration: 0.3, // Smooth transition to idle state
ease: 'easeInOut'
}
}
}
interface Props {
block: ThinkingMessageBlock
}
@ -116,7 +96,7 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
<motion.span
style={{ height: '18px' }}
variants={lightbulbVariants}
animate={isThinking ? 'thinking' : 'idle'}
animate={isThinking ? 'active' : 'idle'}
initial="idle">
<Lightbulb size={18} />
</motion.span>

View File

@ -1,7 +1,8 @@
import { LoadingOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons'
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import CustomTag from '@renderer/components/CustomTag'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import ExpandableText from '@renderer/components/ExpandableText'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import {
getModelLogo,
groupQwenModels,
@ -18,11 +19,12 @@ import FileItem from '@renderer/pages/files/FileItem'
import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
import { Avatar, Button, Empty, Flex, Modal, Tabs, Tooltip, Typography } from 'antd'
import { Avatar, 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -47,7 +49,28 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const [listModels, setListModels] = useState<Model[]>([])
const [loading, setLoading] = useState(false)
const [searchText, setSearchText] = useState('')
const [filterType, setFilterType] = useState<string>('all')
const [filterSearchText, setFilterSearchText] = useState('')
const debouncedSetFilterText = useMemo(
() =>
debounce((value: string) => {
startSearchTransition(() => {
setFilterSearchText(value)
})
}, 300),
[]
)
useEffect(() => {
return () => {
debouncedSetFilterText.cancel()
}
}, [debouncedSetFilterText])
const [actualFilterType, setActualFilterType] = useState<string>('all')
const [optimisticFilterType, setOptimisticFilterTypeFn] = useOptimistic(
actualFilterType,
(_currentFilterType, newFilterType: string) => newFilterType
)
const [isSearchPending, startSearchTransition] = useTransition()
const [isFilterTypePending, startFilterTypeTransition] = useTransition()
const { t, i18n } = useTranslation()
const searchInputRef = useRef<any>(null)
@ -56,14 +79,14 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const list = allModels.filter((model) => {
if (
searchText &&
!model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) &&
!model.name?.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())
filterSearchText &&
!model.id.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase()) &&
!model.name?.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase())
) {
return false
}
switch (filterType) {
switch (actualFilterType) {
case 'reasoning':
return isReasoningModel(model)
case 'vision':
@ -133,9 +156,10 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}))
.filter((model) => !isEmpty(model.name))
)
setLoading(false)
} catch (error) {
setLoading(false)
console.error('Failed to fetch models', error)
} finally {
setTimeout(() => setLoading(false), 300)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -145,7 +169,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
if (open && searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus()
}, 100)
}, 350)
}
}, [open])
@ -157,7 +181,6 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
{i18n.language.startsWith('zh') ? '' : ' '}
{t('common.models')}
</ModelHeaderTitle>
{loading && <LoadingOutlined size={20} />}
</Flex>
)
}
@ -170,6 +193,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
title={
isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed')
}
mouseEnterDelay={0.5}
placement="top">
<Button
type={isAllFilteredInProvider ? 'default' : 'primary'}
@ -200,6 +224,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
? t(`settings.models.manage.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`)
}
mouseEnterDelay={0.5}
placement="top">
<Button
type="text"
@ -242,13 +267,19 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
ref={searchInputRef}
placeholder={t('settings.provider.search_placeholder')}
allowClear
onChange={(e) => setSearchText(e.target.value)}
value={searchText}
onChange={(e) => {
const newSearchValue = e.target.value
setSearchText(newSearchValue) // Update input field immediately
debouncedSetFilterText(newSearchValue)
}}
/>
{renderTopTools()}
</TopToolsWrapper>
<Tabs
size={i18n.language.startsWith('zh') ? 'middle' : 'small'}
defaultActiveKey="all"
activeKey={optimisticFilterType}
items={[
{ label: t('models.all'), key: 'all' },
{ label: t('models.type.reasoning'), key: 'reasoning' },
@ -259,11 +290,21 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
{ label: t('models.type.rerank'), key: 'rerank' },
{ label: t('models.type.function_calling'), key: 'function_calling' }
]}
onChange={(key) => setFilterType(key)}
onChange={(key) => {
setOptimisticFilterTypeFn(key)
startFilterTypeTransition(() => {
setActualFilterType(key)
})
}}
/>
</SearchContainer>
<ListContainer>
{Object.keys(modelGroups).map((group, i) => {
{loading || isFilterTypePending || isSearchPending ? (
<Flex justify="center" align="center" style={{ height: '70%' }}>
<Spin size="large" />
</Flex>
) : (
Object.keys(modelGroups).map((group, i) => {
return (
<CustomCollapse
key={i}
@ -280,71 +321,59 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
extra={renderGroupTools(group)}>
<FlexColumn style={{ margin: '10px 0' }}>
{modelGroups[group].map((model) => (
<FileItem
style={{
backgroundColor: isModelInProvider(provider, model.id)
? 'rgba(0, 126, 0, 0.06)'
: 'rgba(255, 255, 255, 0.04)',
border: 'none',
boxShadow: 'none'
}}
<ModelListItem
key={model.id}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: (
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
placement="top">
<span style={{ cursor: 'help' }}>{model.name}</span>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} />
</ListItemName>
),
extra: model.description && (
<div style={{ marginTop: 6 }}>
<Typography.Paragraph
type="secondary"
ellipsis={{ rows: 1, expandable: true }}
style={{ marginBottom: 0, marginTop: 5 }}>
{model.description}
</Typography.Paragraph>
</div>
),
ext: '.model',
actions: (
<div>
{isModelInProvider(provider, model.id) ? (
<Button type="text" onClick={() => onRemoveModel(model)} icon={<MinusOutlined />} />
) : (
<Button type="text" onClick={() => onAddModel(model)} icon={<PlusOutlined />} />
)}
</div>
)
}}
model={model}
provider={provider}
onAddModel={onAddModel}
onRemoveModel={onRemoveModel}
/>
))}
</FlexColumn>
</CustomCollapse>
)
})}
{isEmpty(list) && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />}
})
)}
{!(loading || isFilterTypePending || isSearchPending) && isEmpty(list) && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />
)}
</ListContainer>
</Modal>
)
}
interface ModelListItemProps {
model: Model
provider: Provider
onAddModel: (model: Model) => void
onRemoveModel: (model: Model) => void
}
const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onAddModel, onRemoveModel }) => {
const isAdded = useMemo(() => isModelInProvider(provider, model.id), [provider, model.id])
return (
<FileItem
style={{
backgroundColor: isAdded ? 'rgba(0, 126, 0, 0.06)' : 'rgba(255, 255, 255, 0.04)',
border: 'none',
boxShadow: 'none'
}}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: <ModelIdWithTags model={model} />,
extra: model.description && <ExpandableText text={model.description} />,
ext: '.model',
actions: isAdded ? (
<Button type="text" onClick={() => onRemoveModel(model)} icon={<MinusOutlined />} />
) : (
<Button type="text" onClick={() => onAddModel(model)} icon={<PlusOutlined />} />
)
}}
/>
)
})
const SearchContainer = styled.div`
display: flex;
flex-direction: column;
@ -382,17 +411,6 @@ const FlexColumn = styled.div`
margin-top: 16px;
`
const ListItemName = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
font-weight: 600;
`
const ModelHeaderTitle = styled.div`
color: var(--color-text);
font-size: 18px;

View File

@ -8,7 +8,7 @@ import {
} from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import { HStack } from '@renderer/components/Layout'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
@ -36,12 +36,6 @@ const STATUS_COLORS = {
warning: '#faad14'
}
interface ModelListProps {
providerId: string
modelStatuses?: ModelStatus[]
searchText?: string
}
export interface ModelStatus {
model: Model
status?: ModelCheckStatus
@ -121,7 +115,7 @@ function useModelStatusRendering() {
if (modelStatus.checking) {
return (
<StatusIndicator type="checking">
<StatusIndicator $type="checking">
<LoadingOutlined spin />
</StatusIndicator>
)
@ -150,8 +144,8 @@ function useModelStatusRendering() {
}
return (
<Tooltip title={renderKeyCheckResultTooltip(modelStatus)}>
<StatusIndicator type={statusType}>{icon}</StatusIndicator>
<Tooltip title={renderKeyCheckResultTooltip(modelStatus)} mouseEnterDelay={0.5}>
<StatusIndicator $type={statusType}>{icon}</StatusIndicator>
</Tooltip>
)
}
@ -167,6 +161,15 @@ function useModelStatusRendering() {
return { renderStatusIndicator, renderLatencyText }
}
interface ModelListProps {
providerId: string
modelStatuses?: ModelStatus[]
searchText?: string
}
/**
* Model list component
*/
const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], searchText = '' }) => {
const { t } = useTranslation()
const { provider, updateProvider, models, removeModel } = useProvider(providerId)
@ -252,16 +255,12 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<Tooltip title={t('settings.models.manage.remove_whole_group')} mouseEnterDelay={0.5}>
<Button
type="text"
className="toolbar-item"
icon={<MinusOutlined />}
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
onClick={() => modelGroups[group].forEach((model) => removeModel(model))}
/>
</Tooltip>
}>
@ -276,25 +275,14 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
<Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
<ModelIdWithTags
model={model}
style={{
flex: 1,
width: 0,
overflow: 'hidden'
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
placement="top">
<NameSpan>{model.name}</NameSpan>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} style={{ flexShrink: 0 }} />
</ListItemName>
/>
</HStack>
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
@ -378,38 +366,13 @@ const ListItem = styled.div`
line-height: 1;
`
const ListItemName = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
font-weight: 600;
min-width: 0;
overflow: hidden;
flex: 1;
width: 0;
`
const NameSpan = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
font-family: 'Ubuntu';
line-height: 30px;
font-size: 14px;
`
const StatusIndicator = styled.div<{ type: string }>`
const StatusIndicator = styled.div<{ $type: string }>`
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: ${(props) => {
switch (props.type) {
switch (props.$type) {
case 'success':
return STATUS_COLORS.success
case 'error':

View File

@ -1,6 +1,7 @@
import { Input, Tooltip } from 'antd'
import { Input, InputRef, Tooltip } from 'antd'
import { Search } from 'lucide-react'
import React, { useState } from 'react'
import { motion } from 'motion/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface ModelListSearchBarProps {
@ -15,25 +16,47 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
const { t } = useTranslation()
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
const handleTextChange = (text: string) => {
const handleTextChange = useCallback(
(text: string) => {
setSearchText(text)
onSearch(text)
}
},
[onSearch]
)
const handleClear = () => {
const handleClear = useCallback(() => {
setSearchText('')
setSearchVisible(false)
onSearch('')
}
}, [onSearch])
return searchVisible ? (
useEffect(() => {
if (searchVisible && inputRef.current) {
inputRef.current.focus()
}
}, [searchVisible])
return (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<motion.div
initial="collapsed"
animate={searchVisible ? 'expanded' : 'collapsed'}
variants={{
expanded: { maxWidth: 360, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
}}
style={{ overflow: 'hidden', flex: 1 }}>
<Input
ref={inputRef}
type="text"
placeholder={t('models.search')}
size="small"
style={{ width: '160px' }}
suffix={<Search size={14} />}
value={searchText}
autoFocus
allowClear
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@ -44,20 +67,25 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
onBlur={() => {
if (!searchText) setSearchVisible(false)
}}
autoFocus
allowClear
onClear={handleClear}
style={{ width: '100%' }}
/>
) : (
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}>
<Search
size={14}
color="var(--color-icon)"
onClick={() => setSearchVisible(true)}
</motion.div>
<motion.div
initial="visible"
animate={searchVisible ? 'hidden' : 'visible'}
variants={{
visible: { opacity: 1, transition: { duration: 0.1, delay: 0.3, ease: 'easeInOut' } },
hidden: { opacity: 0, transition: { duration: 0.1, ease: 'easeInOut' } }
}}
style={{ cursor: 'pointer' }}
/>
onClick={() => setSearchVisible(true)}>
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}>
<Search size={14} color="var(--color-icon)" />
</Tooltip>
</motion.div>
</div>
)
}
export default ModelListSearchBar
export default memo(ModelListSearchBar)

View File

@ -12,10 +12,12 @@ import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/Heal
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { motion } from 'motion/react'
import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -411,9 +413,15 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Button
type="text"
size="small"
icon={<StreamlineGoodHealthAndWellBeing />}
onClick={onHealthCheck}
loading={isHealthChecking}
icon={
<motion.span
variants={lightbulbVariants}
animate={isHealthChecking ? 'active' : 'idle'}
initial="idle">
<StreamlineGoodHealthAndWellBeing />
</motion.span>
}
/>
</Tooltip>
)}

View File

@ -0,0 +1,18 @@
export const lightbulbVariants = {
active: {
opacity: [1, 0.2, 1],
transition: {
duration: 1.2,
ease: 'easeInOut',
times: [0, 0.5, 1],
repeat: Infinity
}
},
idle: {
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut'
}
}
}