mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-23 18:10:26 +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 { 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>
|
||||||
|
|||||||
@ -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,11 +290,21 @@ 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 ? (
|
||||||
|
<Flex justify="center" align="center" style={{ height: '70%' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
Object.keys(modelGroups).map((group, i) => {
|
||||||
return (
|
return (
|
||||||
<CustomCollapse
|
<CustomCollapse
|
||||||
key={i}
|
key={i}
|
||||||
@ -280,71 +321,59 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
|||||||
extra={renderGroupTools(group)}>
|
extra={renderGroupTools(group)}>
|
||||||
<FlexColumn style={{ margin: '10px 0' }}>
|
<FlexColumn style={{ margin: '10px 0' }}>
|
||||||
{modelGroups[group].map((model) => (
|
{modelGroups[group].map((model) => (
|
||||||
<FileItem
|
<ModelListItem
|
||||||
style={{
|
|
||||||
backgroundColor: isModelInProvider(provider, model.id)
|
|
||||||
? 'rgba(0, 126, 0, 0.06)'
|
|
||||||
: 'rgba(255, 255, 255, 0.04)',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: 'none'
|
|
||||||
}}
|
|
||||||
key={model.id}
|
key={model.id}
|
||||||
fileInfo={{
|
model={model}
|
||||||
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
|
provider={provider}
|
||||||
name: (
|
onAddModel={onAddModel}
|
||||||
<ListItemName>
|
onRemoveModel={onRemoveModel}
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</FlexColumn>
|
</FlexColumn>
|
||||||
</CustomCollapse>
|
</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>
|
</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;
|
||||||
|
|||||||
@ -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':
|
||||||
|
|||||||
@ -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,25 +16,47 @@ 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(
|
||||||
|
(text: string) => {
|
||||||
setSearchText(text)
|
setSearchText(text)
|
||||||
onSearch(text)
|
onSearch(text)
|
||||||
}
|
},
|
||||||
|
[onSearch]
|
||||||
|
)
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = useCallback(() => {
|
||||||
setSearchText('')
|
setSearchText('')
|
||||||
setSearchVisible(false)
|
setSearchVisible(false)
|
||||||
onSearch('')
|
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
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('models.search')}
|
placeholder={t('models.search')}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: '160px' }}
|
|
||||||
suffix={<Search size={14} />}
|
suffix={<Search size={14} />}
|
||||||
|
value={searchText}
|
||||||
|
autoFocus
|
||||||
|
allowClear
|
||||||
onChange={(e) => handleTextChange(e.target.value)}
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@ -44,20 +67,25 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
|
|||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (!searchText) setSearchVisible(false)
|
if (!searchText) setSearchVisible(false)
|
||||||
}}
|
}}
|
||||||
autoFocus
|
|
||||||
allowClear
|
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
</motion.div>
|
||||||
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}>
|
<motion.div
|
||||||
<Search
|
initial="visible"
|
||||||
size={14}
|
animate={searchVisible ? 'hidden' : 'visible'}
|
||||||
color="var(--color-icon)"
|
variants={{
|
||||||
onClick={() => setSearchVisible(true)}
|
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 title={t('models.search')} mouseEnterDelay={0.5}>
|
||||||
|
<Search size={14} color="var(--color-icon)" />
|
||||||
</Tooltip>
|
</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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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