refactor: SelectModelPopup pinning (#5855)

* refactor: focus the hovered item when toggling a pinned model

* refactor: focus the selected item after loading pinned models

* refactor: update sticky group after loading pinned models

* fix: rapidly update sticky group

* refactor: defer lastscrolloffset

* refactor: rename updateOnListChange to focusOnListChange for clarity

* refactor: increaset overscan count

* refactor: use startTransition instead of deferred value

* refactor: add guard, clean up code

* refactor: simplify cleanup logic

* refactor: remove unnecessary dep on  pinnedModels

* fix: flicker on searching

* refactor: simplify tag tooltips, prevent tooltips in SelectModelPopup
This commit is contained in:
one 2025-05-12 20:43:45 +08:00 committed by GitHub
parent dd5229d5ba
commit 0a2d0ec4a8
6 changed files with 103 additions and 89 deletions

View File

@ -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<CustomTagProps> = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => {
return (
<Tooltip title={tooltip} placement="top">
const tagContent = useMemo(
() => (
<Tag $color={color} $size={size} $closable={closable}>
{icon && icon} {children}
{closable && <CloseIcon $size={size} $color={color} onClick={onClose} />}
</Tag>
),
[children, closable, color, icon, onClose, size]
)
return tooltip ? (
<Tooltip title={tooltip} placement="top" mouseEnterDelay={0.3}>
{tagContent}
</Tooltip>
) : (
tagContent
)
}

View File

@ -23,6 +23,7 @@ interface ModelTagsProps {
showToolsCalling?: boolean
size?: number
showLabel?: boolean
showTooltip?: boolean
style?: React.CSSProperties
}
@ -33,6 +34,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
showToolsCalling = true,
size = 12,
showLabel = true,
showTooltip = true,
style
}) => {
const { t } = useTranslation()
@ -73,7 +75,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
size={size}
color="#00b96b"
icon={<EyeOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.vision')}>
tooltip={showTooltip ? t('models.type.vision') : undefined}>
{shouldShowLabel ? t('models.type.vision') : ''}
</CustomTag>
)}
@ -82,7 +84,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
size={size}
color="#1677ff"
icon={<GlobalOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.websearch')}>
tooltip={showTooltip ? t('models.type.websearch') : undefined}>
{shouldShowLabel ? t('models.type.websearch') : ''}
</CustomTag>
)}
@ -91,7 +93,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
size={size}
color="#6372bd"
icon={<i className="iconfont icon-thinking" />}
tooltip={t('models.type.reasoning')}>
tooltip={showTooltip ? t('models.type.reasoning') : undefined}>
{shouldShowLabel ? t('models.type.reasoning') : ''}
</CustomTag>
)}
@ -100,19 +102,13 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
size={size}
color="#f18737"
icon={<ToolOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.function_calling')}>
tooltip={showTooltip ? t('models.type.function_calling') : undefined}>
{shouldShowLabel ? t('models.type.function_calling') : ''}
</CustomTag>
)}
{isEmbeddingModel(model) && (
<CustomTag size={size} color="#FFA500" icon={t('models.type.embedding')} tooltip={t('models.type.embedding')} />
)}
{showFree && isFreeModel(model) && (
<CustomTag size={size} color="#7cb305" icon={t('models.type.free')} tooltip={t('models.type.free')} />
)}
{isRerankModel(model) && (
<CustomTag size={size} color="#6495ED" icon={t('models.type.rerank')} tooltip={t('models.type.rerank')} />
)}
{isEmbeddingModel(model) && <CustomTag size={size} color="#FFA500" icon={t('models.type.embedding')} />}
{showFree && isFreeModel(model) && <CustomTag size={size} color="#7cb305" icon={t('models.type.free')} />}
{isRerankModel(model) && <CustomTag size={size} color="#6495ED" icon={t('models.type.rerank')} />}
</Container>
)
}

View File

@ -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 } })
}),
[]
)

View File

@ -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<Props> = ({ 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<InputRef>(null)
const listRef = useRef<FixedSizeList>(null)
@ -49,29 +58,40 @@ const PopupContainer: React.FC<Props> = ({ 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<FlatListItem | null>(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<Props> = ({ 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<Props> = ({ model, resolve }) => {
),
tags: (
<TagsContainer>
<ModelTagsWithLabel model={model} size={11} showLabel={false} />
<ModelTagsWithLabel model={model} size={11} showLabel={false} showTooltip={false} />
</TagsContainer>
),
icon: (
@ -137,7 +154,7 @@ const PopupContainer: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ model, resolve }) => {
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
{listItems.length > 0 ? (
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
<ListContainer onMouseMove={() => !isMouseOver && startTransition(() => setIsMouseOver(true))}>
{/* Sticky Group Banner它会替换第一个分组名称 */}
<StickyGroupBanner>{stickyGroup?.name}</StickyGroupBanner>
<FixedSizeList
@ -456,6 +472,8 @@ const VirtualizedRow = React.memo(
return <div style={style} />
}
const isFocused = item.key === focusedItemKey
return (
<div style={style}>
{item.type === 'group' ? (
@ -463,11 +481,11 @@ const VirtualizedRow = React.memo(
) : (
<ModelItem
className={classNames({
focused: item.key === focusedItemKey,
focused: isFocused,
selected: item.isSelected
})}
onClick={() => handleItemClick(item)}
onMouseEnter={() => setFocusedItemKey(item.key)}>
onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}>
<ModelItemLeft>
{item.icon}
{item.name}

View File

@ -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
}

View File

@ -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[] } }