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 { CheckOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Collapse, message as antdMessage, Tooltip } from 'antd' import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { Lightbulb } from 'lucide-react' import { Lightbulb } from 'lucide-react'
import { motion } from 'motion/react' import { motion } from 'motion/react'
@ -10,27 +11,6 @@ import styled from 'styled-components'
import Markdown from '../../Markdown/Markdown' 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 { interface Props {
block: ThinkingMessageBlock block: ThinkingMessageBlock
} }
@ -116,7 +96,7 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
<motion.span <motion.span
style={{ height: '18px' }} style={{ height: '18px' }}
variants={lightbulbVariants} variants={lightbulbVariants}
animate={isThinking ? 'thinking' : 'idle'} animate={isThinking ? 'active' : 'idle'}
initial="idle"> initial="idle">
<Lightbulb size={18} /> <Lightbulb size={18} />
</motion.span> </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 CustomCollapse from '@renderer/components/CustomCollapse'
import CustomTag from '@renderer/components/CustomTag' 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 { import {
getModelLogo, getModelLogo,
groupQwenModels, groupQwenModels,
@ -18,11 +19,12 @@ import FileItem from '@renderer/pages/files/FileItem'
import { fetchModels } from '@renderer/services/ApiService' import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types' import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils' 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 Input from 'antd/es/input/Input'
import { groupBy, isEmpty, uniqBy } from 'lodash' import { groupBy, isEmpty, uniqBy } from 'lodash'
import { debounce } from 'lodash'
import { Search } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -47,7 +49,28 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const [listModels, setListModels] = useState<Model[]>([]) const [listModels, setListModels] = useState<Model[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [searchText, setSearchText] = useState('') 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 { t, i18n } = useTranslation()
const searchInputRef = useRef<any>(null) const searchInputRef = useRef<any>(null)
@ -56,14 +79,14 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const list = allModels.filter((model) => { const list = allModels.filter((model) => {
if ( if (
searchText && filterSearchText &&
!model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) && !model.id.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase()) &&
!model.name?.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) !model.name?.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase())
) { ) {
return false return false
} }
switch (filterType) { switch (actualFilterType) {
case 'reasoning': case 'reasoning':
return isReasoningModel(model) return isReasoningModel(model)
case 'vision': case 'vision':
@ -133,9 +156,10 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
})) }))
.filter((model) => !isEmpty(model.name)) .filter((model) => !isEmpty(model.name))
) )
setLoading(false)
} catch (error) { } catch (error) {
setLoading(false) console.error('Failed to fetch models', error)
} finally {
setTimeout(() => setLoading(false), 300)
} }
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -145,7 +169,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
if (open && searchInputRef.current) { if (open && searchInputRef.current) {
setTimeout(() => { setTimeout(() => {
searchInputRef.current?.focus() searchInputRef.current?.focus()
}, 100) }, 350)
} }
}, [open]) }, [open])
@ -157,7 +181,6 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
{i18n.language.startsWith('zh') ? '' : ' '} {i18n.language.startsWith('zh') ? '' : ' '}
{t('common.models')} {t('common.models')}
</ModelHeaderTitle> </ModelHeaderTitle>
{loading && <LoadingOutlined size={20} />}
</Flex> </Flex>
) )
} }
@ -170,6 +193,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
title={ title={
isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed') isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed')
} }
mouseEnterDelay={0.5}
placement="top"> placement="top">
<Button <Button
type={isAllFilteredInProvider ? 'default' : 'primary'} 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.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`) : t(`settings.models.manage.add_whole_group`)
} }
mouseEnterDelay={0.5}
placement="top"> placement="top">
<Button <Button
type="text" type="text"
@ -242,13 +267,19 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
ref={searchInputRef} ref={searchInputRef}
placeholder={t('settings.provider.search_placeholder')} placeholder={t('settings.provider.search_placeholder')}
allowClear 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()} {renderTopTools()}
</TopToolsWrapper> </TopToolsWrapper>
<Tabs <Tabs
size={i18n.language.startsWith('zh') ? 'middle' : 'small'} size={i18n.language.startsWith('zh') ? 'middle' : 'small'}
defaultActiveKey="all" defaultActiveKey="all"
activeKey={optimisticFilterType}
items={[ items={[
{ label: t('models.all'), key: 'all' }, { label: t('models.all'), key: 'all' },
{ label: t('models.type.reasoning'), key: 'reasoning' }, { label: t('models.type.reasoning'), key: 'reasoning' },
@ -259,92 +290,90 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
{ label: t('models.type.rerank'), key: 'rerank' }, { label: t('models.type.rerank'), key: 'rerank' },
{ label: t('models.type.function_calling'), key: 'function_calling' } { label: t('models.type.function_calling'), key: 'function_calling' }
]} ]}
onChange={(key) => setFilterType(key)} onChange={(key) => {
setOptimisticFilterTypeFn(key)
startFilterTypeTransition(() => {
setActualFilterType(key)
})
}}
/> />
</SearchContainer> </SearchContainer>
<ListContainer> <ListContainer>
{Object.keys(modelGroups).map((group, i) => { {loading || isFilterTypePending || isSearchPending ? (
return ( <Flex justify="center" align="center" style={{ height: '70%' }}>
<CustomCollapse <Spin size="large" />
key={i} </Flex>
defaultActiveKey={['1']} ) : (
styles={{ body: { padding: '0 10px' } }} Object.keys(modelGroups).map((group, i) => {
label={ return (
<Flex align="center" gap={10}> <CustomCollapse
<span style={{ fontWeight: 600 }}>{group}</span> key={i}
<CustomTag color="#02B96B" size={10}> defaultActiveKey={['1']}
{modelGroups[group].length} styles={{ body: { padding: '0 10px' } }}
</CustomTag> label={
</Flex> <Flex align="center" gap={10}>
} <span style={{ fontWeight: 600 }}>{group}</span>
extra={renderGroupTools(group)}> <CustomTag color="#02B96B" size={10}>
<FlexColumn style={{ margin: '10px 0' }}> {modelGroups[group].length}
{modelGroups[group].map((model) => ( </CustomTag>
<FileItem </Flex>
style={{ }
backgroundColor: isModelInProvider(provider, model.id) extra={renderGroupTools(group)}>
? 'rgba(0, 126, 0, 0.06)' <FlexColumn style={{ margin: '10px 0' }}>
: 'rgba(255, 255, 255, 0.04)', {modelGroups[group].map((model) => (
border: 'none', <ModelListItem
boxShadow: 'none' key={model.id}
}} model={model}
key={model.id} provider={provider}
fileInfo={{ onAddModel={onAddModel}
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>, onRemoveModel={onRemoveModel}
name: ( />
<ListItemName> ))}
<Tooltip </FlexColumn>
styles={{ </CustomCollapse>
root: { )
width: 'auto', })
maxWidth: '500px' )}
} {!(loading || isFilterTypePending || isSearchPending) && isEmpty(list) && (
}} <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />
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>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
)
})}
{isEmpty(list) && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />}
</ListContainer> </ListContainer>
</Modal> </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` const SearchContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -382,17 +411,6 @@ const FlexColumn = styled.div`
margin-top: 16px; 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` const ModelHeaderTitle = styled.div`
color: var(--color-text); color: var(--color-text);
font-size: 18px; font-size: 18px;

View File

@ -8,7 +8,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse' import CustomCollapse from '@renderer/components/CustomCollapse'
import { HStack } from '@renderer/components/Layout' 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 { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers' import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant' import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
@ -36,12 +36,6 @@ const STATUS_COLORS = {
warning: '#faad14' warning: '#faad14'
} }
interface ModelListProps {
providerId: string
modelStatuses?: ModelStatus[]
searchText?: string
}
export interface ModelStatus { export interface ModelStatus {
model: Model model: Model
status?: ModelCheckStatus status?: ModelCheckStatus
@ -121,7 +115,7 @@ function useModelStatusRendering() {
if (modelStatus.checking) { if (modelStatus.checking) {
return ( return (
<StatusIndicator type="checking"> <StatusIndicator $type="checking">
<LoadingOutlined spin /> <LoadingOutlined spin />
</StatusIndicator> </StatusIndicator>
) )
@ -150,8 +144,8 @@ function useModelStatusRendering() {
} }
return ( return (
<Tooltip title={renderKeyCheckResultTooltip(modelStatus)}> <Tooltip title={renderKeyCheckResultTooltip(modelStatus)} mouseEnterDelay={0.5}>
<StatusIndicator type={statusType}>{icon}</StatusIndicator> <StatusIndicator $type={statusType}>{icon}</StatusIndicator>
</Tooltip> </Tooltip>
) )
} }
@ -167,6 +161,15 @@ function useModelStatusRendering() {
return { renderStatusIndicator, renderLatencyText } return { renderStatusIndicator, renderLatencyText }
} }
interface ModelListProps {
providerId: string
modelStatuses?: ModelStatus[]
searchText?: string
}
/**
* Model list component
*/
const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], searchText = '' }) => { const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], searchText = '' }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { provider, updateProvider, models, removeModel } = useProvider(providerId) const { provider, updateProvider, models, removeModel } = useProvider(providerId)
@ -252,16 +255,12 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
</Flex> </Flex>
} }
extra={ extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}> <Tooltip title={t('settings.models.manage.remove_whole_group')} mouseEnterDelay={0.5}>
<Button <Button
type="text" type="text"
className="toolbar-item" className="toolbar-item"
icon={<MinusOutlined />} icon={<MinusOutlined />}
onClick={() => onClick={() => modelGroups[group].forEach((model) => removeModel(model))}
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/> />
</Tooltip> </Tooltip>
}> }>
@ -276,25 +275,14 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
<Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}> <Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}>
{model?.name?.[0]?.toUpperCase()} {model?.name?.[0]?.toUpperCase()}
</Avatar> </Avatar>
<ListItemName> <ModelIdWithTags
<Tooltip model={model}
styles={{ style={{
root: { flex: 1,
width: 'auto', width: 0,
maxWidth: '500px' 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> </HStack>
<Flex gap={4} align="center"> <Flex gap={4} align="center">
{renderLatencyText(modelStatus)} {renderLatencyText(modelStatus)}
@ -378,38 +366,13 @@ const ListItem = styled.div`
line-height: 1; line-height: 1;
` `
const ListItemName = styled.div` const StatusIndicator = styled.div<{ $type: string }>`
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 }>`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 14px;
color: ${(props) => { color: ${(props) => {
switch (props.type) { switch (props.$type) {
case 'success': case 'success':
return STATUS_COLORS.success return STATUS_COLORS.success
case 'error': 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 { 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' import { useTranslation } from 'react-i18next'
interface ModelListSearchBarProps { interface ModelListSearchBarProps {
@ -15,49 +16,76 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
const { t } = useTranslation() const { t } = useTranslation()
const [searchVisible, setSearchVisible] = useState(false) const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
const handleTextChange = (text: string) => { const handleTextChange = useCallback(
setSearchText(text) (text: string) => {
onSearch(text) setSearchText(text)
} onSearch(text)
},
[onSearch]
)
const handleClear = () => { const handleClear = useCallback(() => {
setSearchText('') setSearchText('')
setSearchVisible(false) setSearchVisible(false)
onSearch('') onSearch('')
} }, [onSearch])
return searchVisible ? ( useEffect(() => {
<Input if (searchVisible && inputRef.current) {
type="text" inputRef.current.focus()
placeholder={t('models.search')} }
size="small" }, [searchVisible])
style={{ width: '160px' }}
suffix={<Search size={14} />} return (
onChange={(e) => handleTextChange(e.target.value)} <div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
onKeyDown={(e) => { <motion.div
if (e.key === 'Escape') { initial="collapsed"
handleTextChange('') animate={searchVisible ? 'expanded' : 'collapsed'}
if (!searchText) setSearchVisible(false) variants={{
} expanded: { maxWidth: 360, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
}} collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
onBlur={() => { }}
if (!searchText) setSearchVisible(false) style={{ overflow: 'hidden', flex: 1 }}>
}} <Input
autoFocus ref={inputRef}
allowClear type="text"
onClear={handleClear} placeholder={t('models.search')}
/> size="small"
) : ( suffix={<Search size={14} />}
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}> value={searchText}
<Search autoFocus
size={14} allowClear
color="var(--color-icon)" onChange={(e) => handleTextChange(e.target.value)}
onClick={() => setSearchVisible(true)} onKeyDown={(e) => {
if (e.key === 'Escape') {
handleTextChange('')
if (!searchText) setSearchVisible(false)
}
}}
onBlur={() => {
if (!searchText) setSearchVisible(false)
}}
onClear={handleClear}
style={{ width: '100%' }}
/>
</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' }} style={{ cursor: 'pointer' }}
/> onClick={() => setSearchVisible(true)}>
</Tooltip> <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 { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api' import { formatApiHost } from '@renderer/utils/api'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link' import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash' import { debounce, isEmpty } from 'lodash'
import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { motion } from 'motion/react'
import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -411,9 +413,15 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<StreamlineGoodHealthAndWellBeing />}
onClick={onHealthCheck} onClick={onHealthCheck}
loading={isHealthChecking} icon={
<motion.span
variants={lightbulbVariants}
animate={isHealthChecking ? 'active' : 'idle'}
initial="idle">
<StreamlineGoodHealthAndWellBeing />
</motion.span>
}
/> />
</Tooltip> </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'
}
}
}