diff --git a/src/renderer/src/components/CustomTag.tsx b/src/renderer/src/components/CustomTag.tsx index 76334ae6cb..c875ba01a4 100644 --- a/src/renderer/src/components/CustomTag.tsx +++ b/src/renderer/src/components/CustomTag.tsx @@ -1,6 +1,6 @@ import { CloseOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' -import { FC, memo } from 'react' +import { FC, memo, useMemo } from 'react' import styled from 'styled-components' interface CustomTagProps { @@ -14,13 +14,22 @@ interface CustomTagProps { } const CustomTag: FC = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { - return ( - + const tagContent = useMemo( + () => ( {icon && icon} {children} {closable && } + ), + [children, closable, color, icon, onClose, size] + ) + + return tooltip ? ( + + {tagContent} + ) : ( + tagContent ) } diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 9e5feb45b0..86a04dd454 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -23,6 +23,7 @@ interface ModelTagsProps { showToolsCalling?: boolean size?: number showLabel?: boolean + showTooltip?: boolean style?: React.CSSProperties } @@ -33,6 +34,7 @@ const ModelTagsWithLabel: FC = ({ showToolsCalling = true, size = 12, showLabel = true, + showTooltip = true, style }) => { const { t } = useTranslation() @@ -73,7 +75,7 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#00b96b" icon={} - tooltip={t('models.type.vision')}> + tooltip={showTooltip ? t('models.type.vision') : undefined}> {shouldShowLabel ? t('models.type.vision') : ''} )} @@ -82,7 +84,7 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#1677ff" icon={} - tooltip={t('models.type.websearch')}> + tooltip={showTooltip ? t('models.type.websearch') : undefined}> {shouldShowLabel ? t('models.type.websearch') : ''} )} @@ -91,7 +93,7 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#6372bd" icon={} - tooltip={t('models.type.reasoning')}> + tooltip={showTooltip ? t('models.type.reasoning') : undefined}> {shouldShowLabel ? t('models.type.reasoning') : ''} )} @@ -100,19 +102,13 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#f18737" icon={} - tooltip={t('models.type.function_calling')}> + tooltip={showTooltip ? t('models.type.function_calling') : undefined}> {shouldShowLabel ? t('models.type.function_calling') : ''} )} - {isEmbeddingModel(model) && ( - - )} - {showFree && isFreeModel(model) && ( - - )} - {isRerankModel(model) && ( - - )} + {isEmbeddingModel(model) && } + {showFree && isFreeModel(model) && } + {isRerankModel(model) && } ) } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts index 93441acb21..4a8206df69 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts @@ -21,9 +21,8 @@ export function useScrollState() { focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) => dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }), searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }), - updateOnListChange: (modelItems: FlatListItem[]) => - dispatch({ type: 'UPDATE_ON_LIST_CHANGE', payload: { modelItems } }), - initScroll: () => dispatch({ type: 'INIT_SCROLL' }) + focusOnListChange: (modelItems: FlatListItem[]) => + dispatch({ type: 'FOCUS_ON_LIST_CHANGE', payload: { modelItems } }) }), [] ) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index febf768189..558b254ae0 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -11,7 +11,16 @@ import { classNames } from '@renderer/utils/style' import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd' import { first, sortBy } from 'lodash' import { Search } from 'lucide-react' -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { + startTransition, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import React from 'react' import { useTranslation } from 'react-i18next' import { FixedSizeList } from 'react-window' @@ -34,7 +43,7 @@ interface Props extends PopupParams { const PopupContainer: React.FC = ({ model, resolve }) => { const { t } = useTranslation() const { providers } = useProviders() - const { pinnedModels, togglePinnedModel, loading: loadingPinnedModels } = usePinnedModels() + const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() const [open, setOpen] = useState(true) const inputRef = useRef(null) const listRef = useRef(null) @@ -49,29 +58,40 @@ const PopupContainer: React.FC = ({ model, resolve }) => { focusedItemKey, scrollTrigger, lastScrollOffset, - stickyGroup: _stickyGroup, + stickyGroup, isMouseOver, - setFocusedItemKey, + setFocusedItemKey: _setFocusedItemKey, setScrollTrigger, - setLastScrollOffset, - setStickyGroup, + setLastScrollOffset: _setLastScrollOffset, + setStickyGroup: _setStickyGroup, setIsMouseOver, focusNextItem, focusPage, searchChanged, - updateOnListChange, - initScroll + focusOnListChange } = useScrollState() - const stickyGroup = useDeferredValue(_stickyGroup) const firstGroupRef = useRef(null) - const togglePin = useCallback( - async (modelId: string) => { - await togglePinnedModel(modelId) - setScrollTrigger('none') // pin操作不触发滚动 + const setFocusedItemKey = useCallback( + (key: string) => { + startTransition(() => _setFocusedItemKey(key)) }, - [togglePinnedModel, setScrollTrigger] + [_setFocusedItemKey] + ) + + const setLastScrollOffset = useCallback( + (offset: number) => { + startTransition(() => _setLastScrollOffset(offset)) + }, + [_setLastScrollOffset] + ) + + const setStickyGroup = useCallback( + (group: FlatListItem | null) => { + startTransition(() => _setStickyGroup(group)) + }, + [_setStickyGroup] ) // 根据输入的文本筛选模型 @@ -89,14 +109,11 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const lowerFullName = fullName.toLowerCase() return keywords.every((keyword) => lowerFullName.includes(keyword)) }) - } else { - // 如果不是搜索状态,过滤掉已固定的模型 - models = models.filter((m) => !pinnedModels.includes(getModelUniqId(m))) } return sortBy(models, ['group', 'name']) }, - [searchText, t, pinnedModels] + [searchText, t] ) // 创建模型列表项 @@ -116,7 +133,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { ), tags: ( - + ), icon: ( @@ -137,7 +154,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const items: FlatListItem[] = [] // 添加置顶模型分组(仅在无搜索文本时) - if (pinnedModels.length > 0 && searchText.length === 0) { + if (searchText.length === 0 && pinnedModels.length > 0) { const pinnedItems = providers.flatMap((p) => p.models.filter((m) => pinnedModels.includes(getModelUniqId(m))).map((m) => createModelItem(m, p, true)) ) @@ -158,7 +175,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 添加常规模型分组 providers.forEach((p) => { const filteredModels = getFilteredModels(p).filter( - (m) => !pinnedModels.includes(getModelUniqId(m)) || searchText.length > 0 + (m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m)) ) if (filteredModels.length === 0) return @@ -198,48 +215,53 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const updateStickyGroup = useCallback( (scrollOffset?: number) => { if (listItems.length === 0) { - setStickyGroup(null) + stickyGroup && setStickyGroup(null) return } + let newStickyGroup: FlatListItem | null = null + // 基于滚动位置计算当前可见的第一个项的索引 const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) // 从该索引向前查找最近的分组标题 for (let i = estimatedIndex - 1; i >= 0; i--) { if (i < listItems.length && listItems[i]?.type === 'group') { - setStickyGroup(listItems[i]) - return + newStickyGroup = listItems[i] + break } } // 找不到则使用第一个分组标题 - setStickyGroup(firstGroupRef.current) - }, - [listItems, lastScrollOffset, setStickyGroup] - ) + if (!newStickyGroup) newStickyGroup = firstGroupRef.current - // 在listItems变化时更新sticky group - useEffect(() => { - updateStickyGroup() - }, [listItems, updateStickyGroup]) + if (stickyGroup?.key !== newStickyGroup?.key) { + setStickyGroup(newStickyGroup) + } + }, + [listItems, lastScrollOffset, setStickyGroup, stickyGroup] + ) // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 const handleScroll = useCallback( ({ scrollOffset }) => { setLastScrollOffset(scrollOffset) - updateStickyGroup(scrollOffset) }, - [updateStickyGroup, setLastScrollOffset] + [setLastScrollOffset] ) - // 在列表项更新时,更新焦点项 + // 列表项更新时,更新焦点 useEffect(() => { - updateOnListChange(modelItems) - }, [modelItems, updateOnListChange]) + if (!loading) focusOnListChange(modelItems) + }, [modelItems, focusOnListChange, loading]) + + // 列表项更新时,更新sticky分组 + useEffect(() => { + if (!loading) updateStickyGroup() + }, [modelItems, updateStickyGroup, loading]) // 滚动到聚焦项 - useEffect(() => { + useLayoutEffect(() => { if (scrollTrigger === 'none' || !focusedItemKey) return const index = listItems.findIndex((item) => item.key === focusedItemKey) @@ -302,23 +324,12 @@ const PopupContainer: React.FC = ({ model, resolve }) => { break case 'Escape': e.preventDefault() - setScrollTrigger('none') setOpen(false) resolve(undefined) break } }, - [ - focusedItemKey, - modelItems, - handleItemClick, - open, - resolve, - setIsMouseOver, - focusNextItem, - focusPage, - setScrollTrigger - ] + [focusedItemKey, modelItems, handleItemClick, open, resolve, setIsMouseOver, focusNextItem, focusPage] ) useEffect(() => { @@ -327,11 +338,10 @@ const PopupContainer: React.FC = ({ model, resolve }) => { }, [handleKeyDown]) const onCancel = useCallback(() => { - setScrollTrigger('initial') setOpen(false) - }, [setScrollTrigger]) + }, []) - const onClose = useCallback(async () => { + const onAfterClose = useCallback(async () => { setScrollTrigger('initial') resolve(undefined) SelectModelPopup.hide() @@ -339,10 +349,16 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 初始化焦点和滚动位置 useEffect(() => { - if (!open || loadingPinnedModels) return + if (!open) return setTimeout(() => inputRef.current?.focus(), 0) - initScroll() - }, [open, initScroll, loadingPinnedModels]) + }, [open]) + + const togglePin = useCallback( + async (modelId: string) => { + await togglePinnedModel(modelId) + }, + [togglePinnedModel] + ) const RowData = useMemo( (): VirtualizedRowData => ({ @@ -365,7 +381,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { centered open={open} onCancel={onCancel} - afterClose={onClose} + afterClose={onAfterClose} width={600} transitionName="animation-move-down" styles={{ @@ -408,7 +424,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { {listItems.length > 0 ? ( - !isMouseOver && setIsMouseOver(true)}> + !isMouseOver && startTransition(() => setIsMouseOver(true))}> {/* Sticky Group Banner,它会替换第一个分组名称 */} {stickyGroup?.name} } + const isFocused = item.key === focusedItemKey + return (
{item.type === 'group' ? ( @@ -463,11 +481,11 @@ const VirtualizedRow = React.memo( ) : ( handleItemClick(item)} - onMouseEnter={() => setFocusedItemKey(item.key)}> + onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> {item.icon} {item.name} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts index 45e3390ea8..974fc5b509 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts @@ -72,7 +72,7 @@ export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollS scrollTrigger: action.payload.searchText ? 'search' : 'initial' } - case 'UPDATE_ON_LIST_CHANGE': { + case 'FOCUS_ON_LIST_CHANGE': { const { modelItems } = action.payload // 在列表变化时尝试聚焦一个模型: @@ -96,13 +96,6 @@ export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollS } } - case 'INIT_SCROLL': - return { - ...state, - scrollTrigger: 'initial', - lastScrollOffset: 0 - } - default: return state } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 41ec04c583..745e9688bb 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -38,5 +38,4 @@ export type ScrollAction = | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } } | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } } | { type: 'SEARCH_CHANGED'; payload: { searchText: string } } - | { type: 'UPDATE_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } - | { type: 'INIT_SCROLL'; payload?: void } + | { type: 'FOCUS_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } }