mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
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:
parent
dd5229d5ba
commit
0a2d0ec4a8
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 } })
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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[] } }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user