diff --git a/src/renderer/src/components/ExpandableText.tsx b/src/renderer/src/components/ExpandableText.tsx new file mode 100644 index 0000000000..5df32bb9c6 --- /dev/null +++ b/src/renderer/src/components/ExpandableText.tsx @@ -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 | null }) => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + const toggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) + + const button = useMemo(() => { + return ( + + ) + }, [isExpanded, t, toggleExpand]) + + return ( + + {text} + {button} + + ) +} + +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) diff --git a/src/renderer/src/components/ModelIdWithTags.tsx b/src/renderer/src/components/ModelIdWithTags.tsx new file mode 100644 index 0000000000..cfd109d0aa --- /dev/null +++ b/src/renderer/src/components/ModelIdWithTags.tsx @@ -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 | null }) => { + return ( + + + {model.id} + + } + mouseEnterDelay={0.5} + placement="top"> + {model.name} + + + + ) +} + +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) diff --git a/src/renderer/src/components/ModelTags.tsx b/src/renderer/src/components/ModelTags.tsx deleted file mode 100644 index 4c683bff58..0000000000 --- a/src/renderer/src/components/ModelTags.tsx +++ /dev/null @@ -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 = ({ model, showFree = true, showReasoning = true, showToolsCalling = true }) => { - const { t } = useTranslation() - return ( - - {isVisionModel(model) && } - {isWebSearchModel(model) && } - {showReasoning && isReasoningModel(model) && } - {showToolsCalling && isFunctionCallingModel(model) && } - {isEmbeddingModel(model) && {t('models.type.embedding')}} - {showFree && isFreeModel(model) && {t('models.type.free')}} - {isRerankModel(model) && {t('models.type.rerank')}} - - ) -} - -const Container = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 2px; -` - -export default ModelTags diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index f920000c2f..212ee53ee9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -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 = ({ block }) => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx index 1c5d189bd3..4ff54fa55a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx @@ -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 = ({ provider: _provider, resolve }) => { const [listModels, setListModels] = useState([]) const [loading, setLoading] = useState(false) const [searchText, setSearchText] = useState('') - const [filterType, setFilterType] = useState('all') + const [filterSearchText, setFilterSearchText] = useState('') + const debouncedSetFilterText = useMemo( + () => + debounce((value: string) => { + startSearchTransition(() => { + setFilterSearchText(value) + }) + }, 300), + [] + ) + useEffect(() => { + return () => { + debouncedSetFilterText.cancel() + } + }, [debouncedSetFilterText]) + const [actualFilterType, setActualFilterType] = useState('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(null) @@ -56,14 +79,14 @@ const PopupContainer: React.FC = ({ 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 = ({ 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 = ({ provider: _provider, resolve }) => { if (open && searchInputRef.current) { setTimeout(() => { searchInputRef.current?.focus() - }, 100) + }, 350) } }, [open]) @@ -157,7 +181,6 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { {i18n.language.startsWith('zh') ? '' : ' '} {t('common.models')} - {loading && } ) } @@ -170,6 +193,7 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { title={ isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed') } + mouseEnterDelay={0.5} placement="top">