mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-23 10:00:08 +08:00
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:
parent
b9224ed311
commit
dc93e168df
51
src/renderer/src/components/ExpandableText.tsx
Normal file
51
src/renderer/src/components/ExpandableText.tsx
Normal 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)
|
||||
64
src/renderer/src/components/ModelIdWithTags.tsx
Normal file
64
src/renderer/src/components/ModelIdWithTags.tsx
Normal 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)
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
18
src/renderer/src/utils/motionVariants.ts
Normal file
18
src/renderer/src/utils/motionVariants.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user