perf: virtual list for quick panel and SelectModelPopup (#5594)

* perf: quick panel with virtual list

* fix: adaptive virtual list height

* fix: keep panel content if it is re-opened in a very short interval

* refactor: use modified rc-virtual-list to support overscan

* fix: virtual list item key

* refactor: remove useless styles

* refactor: support smooth scrolling for virtual list

* fix: lint error

* perf: use virtual list for SelectModelPopup

* refactor: change model name style

* fix: better auto scroll behaviour

* perf: improve memorization for SelectModelPopup

* fix: group name background

* refactor: change model item margin

* chore: update rc-virtual-list to 3.19.2

* fix: adaptive list height

* refactor: improve styles for focused or selected items

* refactor: do not show model if the assistant has not default model

* chore: migrate to custom rc-virtual-list

* refactor: improve selected item style

* refactor: improve selected item style

* fix: left margin

* refactor: simplify the indicator for selected item

* fix: prevent mouse hover for keyboard events

* chore: bump rc-virtual-list

* refactor: simulate sticky group header in SelectModelPopup

* fix: cleanup timer, add comments

* perf: improve smooth scrolling

* chore: bump to rc-virtual-list:3.19.6

* refactor: update memorization

* refactor: extract item rendering logic

* refactor: delay CustomTag tooltip for performance

* fix: disable spellcheck in model search bar

* refactor: expand/collapse model label on resizing window

* refactor: simplify filtering

* chore: update rc-virtual-list

* refactor: always render virtual list to avoid inconsistent state

* chore: update dependencies

* chore: update dependencies

* chore: update dependencies

* refactor: remove useless states

* refactor: simplify selected state

* refactor: improve keyboard events for SelectModelPopup

* revert: do not expand mode tags

* refactor: reduce animation time

* chore: update dependencies

* refactor: better names and comments

* refactor: better error handling

* refactor: simplify auto-scrolling logic

* refactor: use react-window rather than rc-virtual-list

* fix: disable auto-scroll

* fix: scroll bar style and item margin

* fix: initialize sticky banner

* refactor: distinguish auto-scrolling behaviour for different causes

* fix: keyboard navigation error
This commit is contained in:
one 2025-05-09 11:54:54 +08:00 committed by GitHub
parent 685ea0e297
commit fcc52867c4
9 changed files with 709 additions and 475 deletions

View File

@ -97,6 +97,8 @@
"opendal": "^0.47.11",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"rc-virtual-list": "^3.18.6",
"react-window": "^1.8.11",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
@ -141,6 +143,7 @@
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-window": "^1",
"@types/tinycolor2": "^1",
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "^3.9.0",
@ -179,7 +182,6 @@
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.6.1",

View File

@ -51,6 +51,8 @@
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--modal-background: #1f1f1f;
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background: #1f1f1f;
@ -123,6 +125,8 @@ body[theme-mode='light'] {
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--modal-background: var(--color-white);
--navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background: rgba(244, 244, 244);

View File

@ -1,6 +1,6 @@
import { CloseOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
import { FC } from 'react'
import { FC, memo, useEffect, useMemo, useState } from 'react'
import styled from 'styled-components'
interface CustomTagProps {
@ -14,17 +14,33 @@ interface CustomTagProps {
}
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => {
return (
<Tooltip title={tooltip} placement="top">
const [showTooltip, setShowTooltip] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setShowTooltip(true), 300)
return () => clearTimeout(timer)
}, [])
const tagContent = useMemo(
() => (
<Tag $color={color} $size={size} $closable={closable}>
{icon && icon} {children}
{closable && <CloseIcon $size={size} $color={color} onClick={onClose} />}
</Tag>
),
[children, color, closable, icon, onClose, size]
)
return tooltip && showTooltip ? (
<Tooltip title={tooltip} placement="top">
{tagContent}
</Tooltip>
) : (
tagContent
)
}
export default CustomTag
export default memo(CustomTag)
const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>`
display: inline-flex;

View File

@ -10,7 +10,7 @@ import {
import i18n from '@renderer/i18n'
import { Model } from '@renderer/types'
import { isFreeModel } from '@renderer/utils'
import { FC, useEffect, useRef, useState } from 'react'
import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -36,34 +36,35 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
style
}) => {
const { t } = useTranslation()
const [_showLabel, _setShowLabel] = useState(showLabel)
const [shouldShowLabel, setShouldShowLabel] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const resizeObserver = useRef<ResizeObserver>(null)
const resizeObserver = useRef<ResizeObserver | null>(null)
useEffect(() => {
if (!showLabel) return
const maxWidth = useMemo(() => (i18n.language.startsWith('zh') ? 300 : 350), [])
if (containerRef.current) {
const currentElement = containerRef.current
useLayoutEffect(() => {
const currentElement = containerRef.current
if (!showLabel || !currentElement) return
setShouldShowLabel(currentElement.offsetWidth >= maxWidth)
if (currentElement) {
resizeObserver.current = new ResizeObserver((entries) => {
const maxWidth = i18n.language.startsWith('zh') ? 300 : 350
for (const entry of entries) {
const { width } = entry.contentRect
_setShowLabel(width >= maxWidth)
setShouldShowLabel(width >= maxWidth)
}
})
resizeObserver.current.observe(currentElement)
return () => {
if (resizeObserver.current) {
resizeObserver.current.unobserve(currentElement)
}
}
return () => {
if (resizeObserver.current && currentElement) {
resizeObserver.current.unobserve(currentElement)
resizeObserver.current.disconnect()
resizeObserver.current = null
}
}
return undefined
}, [showLabel])
}, [maxWidth, showLabel])
return (
<Container ref={containerRef} style={style}>
@ -73,7 +74,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
color="#00b96b"
icon={<EyeOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.vision')}>
{_showLabel ? t('models.type.vision') : ''}
{shouldShowLabel ? t('models.type.vision') : ''}
</CustomTag>
)}
{isWebSearchModel(model) && (
@ -82,7 +83,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
color="#1677ff"
icon={<GlobalOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.websearch')}>
{_showLabel ? t('models.type.websearch') : ''}
{shouldShowLabel ? t('models.type.websearch') : ''}
</CustomTag>
)}
{showReasoning && isReasoningModel(model) && (
@ -91,7 +92,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
color="#6372bd"
icon={<i className="iconfont icon-thinking" />}
tooltip={t('models.type.reasoning')}>
{_showLabel ? t('models.type.reasoning') : ''}
{shouldShowLabel ? t('models.type.reasoning') : ''}
</CustomTag>
)}
{showToolsCalling && isFunctionCallingModel(model) && (
@ -100,7 +101,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
color="#f18737"
icon={<ToolOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.function_calling')}>
{_showLabel ? t('models.type.function_calling') : ''}
{shouldShowLabel ? t('models.type.function_calling') : ''}
</CustomTag>
)}
{isEmbeddingModel(model) && (
@ -128,4 +129,4 @@ const Container = styled.div`
}
`
export default ModelTagsWithLabel
export default memo(ModelTagsWithLabel)

View File

@ -5,18 +5,39 @@ import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
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, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { FixedSizeList } from 'react-window'
import styled from 'styled-components'
import { HStack } from '../Layout'
import ModelTagsWithLabel from '../ModelTagsWithLabel'
import Scrollbar from '../Scrollbar'
type MenuItem = Required<MenuProps>['items'][number]
const PAGE_SIZE = 9
const ITEM_HEIGHT = 36
// 列表项类型,组名也作为列表项
type ListItemType = 'group' | 'model'
// 滚动触发来源类型
type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
// 扁平化列表项接口
interface FlatListItem {
key: string
type: ListItemType
icon?: React.ReactNode
name: React.ReactNode
tags?: React.ReactNode
model?: Model
isPinned?: boolean
isSelected?: boolean
}
interface Props {
model?: Model
@ -27,25 +48,25 @@ interface PopupContainerProps extends Props {
}
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
const { providers } = useProviders()
const [open, setOpen] = useState(true)
const inputRef = useRef<InputRef>(null)
const listRef = useRef<FixedSizeList>(null)
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const [isMouseOver, setIsMouseOver] = useState(false)
const [pinnedModels, setPinnedModels] = useState<string[]>([])
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
const menuItemRefs = useRef<Record<string, HTMLElement | null>>({})
const [_focusedItemKey, setFocusedItemKey] = useState<string>('')
const focusedItemKey = useDeferredValue(_focusedItemKey)
const [currentStickyGroup, setCurrentStickyGroup] = useState<FlatListItem | null>(null)
const firstGroupRef = useRef<FlatListItem | null>(null)
const scrollTriggerRef = useRef<ScrollTrigger>('initial')
const setMenuItemRef = useCallback(
(key: string) => (el: HTMLElement | null) => {
if (el) {
menuItemRefs.current[key] = el
}
},
[]
)
// 当前选中的模型ID
const currentModelId = model ? getModelUniqId(model) : ''
// 加载置顶模型列表
useEffect(() => {
const loadPinnedModels = async () => {
const setting = await db.settings.get('pinned:models')
@ -60,19 +81,34 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
}
setPinnedModels(sortBy(validPinnedModels, ['group', 'name']))
setPinnedModels(sortBy(validPinnedModels))
}
try {
loadPinnedModels()
} catch (error) {
console.error('Failed to load pinned models', error)
setPinnedModels([])
}
loadPinnedModels()
}, [providers])
const togglePin = async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
const togglePin = useCallback(
async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(sortBy(newPinnedModels, ['group', 'name']))
}
try {
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(sortBy(newPinnedModels))
// Pin操作不触发滚动
scrollTriggerRef.current = 'none'
} catch (error) {
console.error('Failed to update pinned models', error)
}
},
[pinnedModels]
)
// 根据输入的文本筛选模型
const getFilteredModels = useCallback(
@ -99,267 +135,276 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
[searchText, t, pinnedModels]
)
// 递归处理菜单项为每个项添加ref
const processMenuItems = useCallback(
(items: MenuItem[]) => {
// 内部定义 renderMenuItem 函数
const renderMenuItem = (item: any) => {
return {
...item,
label: <div ref={setMenuItemRef(item.key)}>{item.label}</div>
}
}
// 创建模型列表项
const createModelItem = useCallback(
(model: Model, provider: any, isPinned: boolean): FlatListItem => {
const modelId = getModelUniqId(model)
const groupName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
return items.map((item) => {
if (item && 'children' in item && item.children) {
return {
...item,
children: (item.children as MenuItem[]).map(renderMenuItem)
}
}
return item
})
return {
key: isPinned ? `${modelId}_pinned` : modelId,
type: 'model',
name: (
<ModelName>
{model.name}
{isPinned && <span style={{ color: 'var(--color-text-3)' }}> | {groupName}</span>}
</ModelName>
),
tags: (
<TagsContainer>
<ModelTagsWithLabel model={model} size={11} showLabel={false} />
</TagsContainer>
),
icon: (
<Avatar src={getModelLogo(model.id || '')} size={24}>
{first(model.name) || 'M'}
</Avatar>
),
model,
isPinned,
isSelected: modelId === currentModelId
}
},
[setMenuItemRef]
[t, currentModelId]
)
const filteredItems: MenuItem[] = providers
.filter((p) => p.models && p.models.length > 0)
.map((p) => {
const filteredModels = getFilteredModels(p).map((m) => ({
key: getModelUniqId(m),
label: (
<ModelItem>
<ModelNameRow>
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m))
}}
isPinned={pinnedModels.includes(getModelUniqId(m))}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m?.id || '')} size={24}>
{first(m?.name)}
</Avatar>
),
onClick: () => {
resolve(m)
setOpen(false)
}
}))
// 构建扁平化列表数据
const listItems = useMemo(() => {
const items: FlatListItem[] = []
// Only return the group if it has filtered models
return filteredModels.length > 0
? {
key: p.id,
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group',
children: filteredModels
}
: null
})
.filter(Boolean) as MenuItem[] // Filter out null items
if (pinnedModels.length > 0 && searchText.length === 0) {
const pinnedItems = providers
.flatMap((p) =>
p.models
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.map((m) => ({
key: getModelUniqId(m),
model: m,
provider: p
}))
)
.map((m) => ({
key: getModelUniqId(m.model) + '_pinned',
label: (
<ModelItem>
<ModelNameRow>
<span>
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
</span>{' '}
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m.model))
}}
isPinned={true}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m.model?.id || '')} size={24}>
{first(m.model?.name)}
</Avatar>
),
onClick: () => {
resolve(m.model)
setOpen(false)
}
}))
if (pinnedItems.length > 0) {
filteredItems.unshift({
key: 'pinned',
label: t('models.pinned'),
type: 'group',
children: pinnedItems
} as MenuItem)
}
}
// 处理菜单项添加ref
const processedItems = processMenuItems(filteredItems)
const onCancel = () => {
setKeyboardSelectedId('')
setOpen(false)
}
const onClose = async () => {
setKeyboardSelectedId('')
resolve(undefined)
SelectModelPopup.hide()
}
useEffect(() => {
open && setTimeout(() => inputRef.current?.focus(), 0)
}, [open])
useEffect(() => {
if (open && model) {
setTimeout(() => {
const modelId = getModelUniqId(model)
if (menuItemRefs.current[modelId]) {
menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' })
}
}, 100) // Small delay to ensure menu is rendered
}
}, [open, model])
// 获取所有可见的模型项
const getVisibleModelItems = useCallback(() => {
const items: { key: string; model: Model }[] = []
// 如果有置顶模型且没有搜索文本,添加置顶模型
// 添加置顶模型分组(仅在无搜索文本时)
if (pinnedModels.length > 0 && searchText.length === 0) {
providers
.flatMap((p) => p.models || [])
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.forEach((m) => items.push({ key: getModelUniqId(m) + '_pinned', model: m }))
const pinnedItems = providers.flatMap((p) =>
p.models.filter((m) => pinnedModels.includes(getModelUniqId(m))).map((m) => createModelItem(m, p, true))
)
if (pinnedItems.length > 0) {
// 添加置顶分组标题
items.push({
key: 'pinned-group',
type: 'group',
name: t('models.pinned'),
isSelected: false
})
items.push(...pinnedItems)
}
}
// 添加其他过滤后的模型
// 添加常规模型分组
providers.forEach((p) => {
if (p.models) {
getFilteredModels(p).forEach((m) => {
const modelId = getModelUniqId(m)
const isPinned = pinnedModels.includes(modelId)
const filteredModels = getFilteredModels(p).filter(
(m) => !pinnedModels.includes(getModelUniqId(m)) || searchText.length > 0
)
// 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型
// 非搜索状态下,只添加非固定模型(固定模型已在上面添加)
if (searchText.length > 0 || !isPinned) {
items.push({
key: modelId,
model: m
})
}
})
}
if (filteredModels.length === 0) return
// 添加 provider 分组标题
items.push({
key: `provider-${p.id}`,
type: 'group',
name: p.isSystem ? t(`provider.${p.id}`) : p.name,
isSelected: false
})
items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModels.includes(getModelUniqId(m)))))
})
return items
}, [pinnedModels, searchText, providers, getFilteredModels])
// 添加一个useLayoutEffect来处理滚动
useLayoutEffect(() => {
if (open && keyboardSelectedId && menuItemRefs.current[keyboardSelectedId]) {
// 获取当前选中元素和容器
const selectedElement = menuItemRefs.current[keyboardSelectedId]
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const selectedRect = selectedElement.getBoundingClientRect()
const containerRect = scrollContainer.getBoundingClientRect()
// 计算元素相对于容器的位置
const currentScrollTop = scrollContainer.scrollTop
const elementTop = selectedRect.top - containerRect.top + currentScrollTop
const groupTitleHeight = 30
// 确定滚动位置
if (selectedRect.top < containerRect.top + groupTitleHeight) {
// 元素被组标题遮挡,向上滚动
scrollContainer.scrollTo({
top: elementTop - groupTitleHeight,
behavior: 'smooth'
})
} else if (selectedRect.bottom > containerRect.bottom) {
// 元素在视口下方,向下滚动
scrollContainer.scrollTo({
top: elementTop - containerRect.height + selectedRect.height,
behavior: 'smooth'
})
}
// 移除第一个分组标题,使用 sticky group banner 替代,模拟 sticky 效果
if (items.length > 0 && items[0].type === 'group') {
firstGroupRef.current = items[0]
items.shift()
} else {
firstGroupRef.current = null
}
}, [open, keyboardSelectedId])
return items
}, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem])
// 处理键盘导航
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const items = getVisibleModelItems()
if (items.length === 0) return
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const currentIndex = items.findIndex((item) => item.key === keyboardSelectedId)
let nextIndex
if (currentIndex === -1) {
nextIndex = e.key === 'ArrowDown' ? 0 : items.length - 1
} else {
nextIndex =
e.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length
}
const nextItem = items[nextIndex]
setKeyboardSelectedId(nextItem.key)
} else if (e.key === 'Enter') {
e.preventDefault() // 阻止回车的默认行为
if (keyboardSelectedId) {
const selectedItem = items.find((item) => item.key === keyboardSelectedId)
if (selectedItem) {
resolve(selectedItem.model)
setOpen(false)
}
}
}
},
[keyboardSelectedId, getVisibleModelItems, resolve, setOpen]
)
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = useMemo(() => {
return listItems.filter((item) => item.type === 'model')
}, [listItems])
// 搜索文本变化时设置滚动来源
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// 搜索文本改变时重置键盘选中状态
useEffect(() => {
setKeyboardSelectedId('')
if (searchText.trim() !== '') {
scrollTriggerRef.current = 'search'
setFocusedItemKey('')
}
}, [searchText])
const selectedKeys = keyboardSelectedId ? [keyboardSelectedId] : model ? [getModelUniqId(model)] : []
// 设置初始聚焦项以触发滚动
useEffect(() => {
if (scrollTriggerRef.current === 'initial' || scrollTriggerRef.current === 'search') {
const selectedItem = modelItems.find((item) => item.isSelected)
if (selectedItem) {
setFocusedItemKey(selectedItem.key)
} else if (scrollTriggerRef.current === 'initial' && modelItems.length > 0) {
setFocusedItemKey(modelItems[0].key)
}
// 其余情况不设置focusedItemKey
}
}, [modelItems])
// 滚动到聚焦项
useEffect(() => {
if (scrollTriggerRef.current === 'none' || !focusedItemKey) return
const index = listItems.findIndex((item) => item.key === focusedItemKey)
if (index < 0) return
// 根据触发源决定滚动对齐方式
const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center'
listRef.current?.scrollToItem(index, alignment)
console.log('focusedItemKey', focusedItemKey)
console.log('scrollToFocusedItem', index, alignment)
// 滚动后重置触发器
scrollTriggerRef.current = 'none'
}, [focusedItemKey, listItems])
const handleItemClick = useCallback(
(item: FlatListItem) => {
if (item.type === 'model') {
scrollTriggerRef.current = 'none'
resolve(item.model)
setOpen(false)
}
},
[resolve]
)
// 处理键盘导航
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!open) return
if (modelItems.length === 0) {
return
}
// 键盘操作时禁用鼠标 hover
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)
}
const getCurrentIndex = (currentKey: string) => {
const currentIndex = modelItems.findIndex((item) => item.key === currentKey)
return currentIndex < 0 ? 0 : currentIndex
}
switch (e.key) {
case 'ArrowUp':
scrollTriggerRef.current = 'keyboard'
setFocusedItemKey((prev) => {
const currentIndex = getCurrentIndex(prev)
const nextIndex = (currentIndex - 1 + modelItems.length) % modelItems.length
return modelItems[nextIndex].key
})
break
case 'ArrowDown':
scrollTriggerRef.current = 'keyboard'
setFocusedItemKey((prev) => {
const currentIndex = getCurrentIndex(prev)
const nextIndex = (currentIndex + 1) % modelItems.length
return modelItems[nextIndex].key
})
break
case 'PageUp':
scrollTriggerRef.current = 'keyboard'
setFocusedItemKey((prev) => {
const currentIndex = getCurrentIndex(prev)
const nextIndex = Math.max(currentIndex - PAGE_SIZE, 0)
return modelItems[nextIndex].key
})
break
case 'PageDown':
scrollTriggerRef.current = 'keyboard'
setFocusedItemKey((prev) => {
const currentIndex = getCurrentIndex(prev)
const nextIndex = Math.min(currentIndex + PAGE_SIZE, modelItems.length - 1)
return modelItems[nextIndex].key
})
break
case 'Enter':
if (focusedItemKey) {
const selectedItem = modelItems.find((item) => item.key === focusedItemKey)
if (selectedItem) {
handleItemClick(selectedItem)
}
}
break
case 'Escape':
e.preventDefault()
scrollTriggerRef.current = 'none'
setOpen(false)
resolve(undefined)
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [focusedItemKey, modelItems, handleItemClick, open, resolve])
const onCancel = useCallback(() => {
scrollTriggerRef.current = 'none'
setOpen(false)
}, [])
const onClose = useCallback(async () => {
scrollTriggerRef.current = 'none'
resolve(undefined)
SelectModelPopup.hide()
}, [resolve])
useEffect(() => {
if (!open) return
setTimeout(() => inputRef.current?.focus(), 0)
scrollTriggerRef.current = 'initial'
}, [open])
// 初始化sticky分组标题
useEffect(() => {
if (firstGroupRef.current) {
setCurrentStickyGroup(firstGroupRef.current)
}
}, [listItems])
const handleItemsRendered = useCallback(
({ visibleStartIndex }: { visibleStartIndex: number; visibleStopIndex: number }) => {
// 从可见区域的起始位置向前查找最近的分组标题
for (let i = visibleStartIndex - 1; i >= 0; i--) {
if (listItems[i]?.type === 'group') {
setCurrentStickyGroup(listItems[i])
return
}
}
// 找不到则使用第一个分组标题
setCurrentStickyGroup(firstGroupRef.current ?? null)
},
[listItems]
)
const RowData = useMemo(
(): VirtualizedRowData => ({
listItems,
focusedItemKey,
setFocusedItemKey,
currentStickyGroup,
handleItemClick,
togglePin
}),
[currentStickyGroup, focusedItemKey, handleItemClick, listItems, togglePin]
)
const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
}, [listItems.length])
return (
<Modal
@ -380,6 +425,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
}}
closeIcon={null}
footer={null}>
{/* 搜索框 */}
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
<Input
prefix={
@ -389,118 +435,203 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
}
ref={inputRef}
placeholder={t('models.search')}
value={searchText}
value={_searchText} // 使用 _searchText需要实时更新
onChange={(e) => setSearchText(e.target.value)}
allowClear
autoFocus
spellCheck={false}
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onKeyDown={(e) => {
// 防止上下键移动光标
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
e.preventDefault()
}
}}
/>
</HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
<Container>
{processedItems.length > 0 ? (
<StyledMenu
items={processedItems}
selectedKeys={selectedKeys}
mode="inline"
inlineIndent={6}
onSelect={({ key }) => {
setKeyboardSelectedId(key as string)
}}
/>
) : (
<EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</EmptyState>
)}
</Container>
</Scrollbar>
{listItems.length > 0 ? (
<ListContainer onMouseMove={() => setIsMouseOver(true)}>
{/* Sticky Group Banner它会替换第一个分组名称 */}
<StickyGroupBanner>{currentStickyGroup?.name}</StickyGroupBanner>
<FixedSizeList
ref={listRef}
height={listHeight}
width="100%"
itemCount={listItems.length}
itemSize={ITEM_HEIGHT}
itemData={RowData}
itemKey={(index, data) => data.listItems[index].key}
overscanCount={4}
onItemsRendered={handleItemsRendered}
style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
{VirtualizedRow}
</FixedSizeList>
</ListContainer>
) : (
<EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</EmptyState>
)}
</Modal>
)
}
const Container = styled.div`
margin-top: 10px;
interface VirtualizedRowData {
listItems: FlatListItem[]
focusedItemKey: string
setFocusedItemKey: (key: string) => void
currentStickyGroup: FlatListItem | null
handleItemClick: (item: FlatListItem) => void
togglePin: (modelId: string) => void
}
/**
*
*/
const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { listItems, focusedItemKey, setFocusedItemKey, handleItemClick, togglePin, currentStickyGroup } = data
const item = listItems[index]
if (!item) {
return <div style={style} />
}
return (
<div style={style}>
{item.type === 'group' ? (
<GroupItem $isSticky={item.key === currentStickyGroup?.key}>{item.name}</GroupItem>
) : (
<ModelItem
className={classNames({
focused: item.key === focusedItemKey,
selected: item.isSelected
})}
onClick={() => handleItemClick(item)}
onMouseEnter={() => setFocusedItemKey(item.key)}>
<ModelItemLeft>
{item.icon}
{item.name}
{item.tags}
</ModelItemLeft>
<PinIconWrapper
onClick={(e) => {
e.stopPropagation()
if (item.model) {
togglePin(getModelUniqId(item.model))
}
}}
data-pinned={item.isPinned}
$isPinned={item.isPinned}>
<PushpinOutlined />
</PinIconWrapper>
</ModelItem>
)}
</div>
)
}
)
VirtualizedRow.displayName = 'VirtualizedRow'
const ListContainer = styled.div`
position: relative;
overflow: hidden;
`
const StyledMenu = styled(Menu)`
background-color: transparent;
padding: 5px;
margin-top: -10px;
max-height: calc(60vh - 50px);
const GroupItem = styled.div<{ $isSticky?: boolean }>`
display: flex;
align-items: center;
position: relative;
font-size: 12px;
font-weight: 500;
height: ${ITEM_HEIGHT}px;
padding: 5px 10px 5px 18px;
color: var(--color-text-3);
z-index: 1;
.ant-menu-item-group-title {
position: sticky;
top: 0;
z-index: 1;
margin: 0 -5px;
padding: 5px 10px;
padding-left: 18px;
font-size: 12px;
font-weight: 500;
visibility: ${(props) => (props.$isSticky ? 'hidden' : 'visible')};
`
/* Scroll-driven animation for sticky header */
animation: background-change linear both;
animation-timeline: scroll();
animation-range: entry 0% entry 1%;
}
/* Simple animation that changes background color when sticky */
@keyframes background-change {
to {
background-color: var(--color-background);
}
}
.ant-menu-item {
height: 36px;
line-height: 36px;
&.ant-menu-item-selected {
background-color: var(--color-background-mute) !important;
color: var(--color-text-primary) !important;
}
&:not([data-menu-id^='pinned-']) {
.pin-icon {
opacity: 0;
}
&:hover {
.pin-icon {
opacity: 0.3;
}
}
}
.anticon {
min-width: auto;
}
}
const StickyGroupBanner = styled(GroupItem)`
position: sticky;
background: var(--modal-background);
`
const ModelItem = styled.div`
display: flex;
align-items: center;
font-size: 14px;
justify-content: space-between;
position: relative;
width: 100%;
font-size: 14px;
padding: 0 8px;
margin: 1px 8px;
height: ${ITEM_HEIGHT - 2}px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.1s ease;
&.focused {
background-color: var(--color-background-mute);
}
&.selected {
&::before {
content: '';
display: block;
position: absolute;
left: -1px;
top: 13%;
width: 3px;
height: 74%;
background: var(--color-primary-soft);
border-radius: 8px;
}
}
.pin-icon {
opacity: 0;
}
&:hover .pin-icon {
opacity: 0.3;
}
`
const ModelNameRow = styled.div`
const ModelItemLeft = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
overflow: hidden;
padding-right: 26px;
.anticon {
min-width: auto;
flex-shrink: 0;
}
`
const ModelName = styled.span`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin: 0 8px;
min-width: 0;
`
const TagsContainer = styled.div`
display: flex;
justify-content: flex-end;
min-width: 80px;
max-width: 180px;
overflow: hidden;
flex-shrink: 0;
`
const EmptyState = styled.div`
@ -522,19 +653,19 @@ const SearchIcon = styled.div`
margin-right: 2px;
`
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>`
const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?: boolean }>`
margin-left: auto;
padding: 0 8px;
opacity: ${(props) => (props.isPinned ? 1 : 'inherit')};
padding: 0 10px;
opacity: ${(props) => (props.$isPinned ? 1 : 'inherit')};
transition: opacity 0.2s;
position: absolute;
right: 0;
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
transform: ${(props) => (props.isPinned ? 'rotate(-45deg)' : 'none')};
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
&:hover {
opacity: 1 !important;
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
}
`
@ -542,6 +673,7 @@ export default class SelectModelPopup {
static hide() {
TopView.hide('SelectModelPopup')
}
static show(params: Props) {
return new Promise<Model | undefined>((resolve) => {
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')

View File

@ -1,4 +1,4 @@
import React, { createContext, useCallback, useMemo, useState } from 'react'
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
QuickPanelCallBackOptions,
@ -25,7 +25,14 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const clearTimer = useRef<NodeJS.Timeout | null>(null)
const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
clearTimer.current = null
}
setTitle(options.title)
setList(options.list)
setDefaultIndex(options.defaultIndex ?? 0)
@ -45,7 +52,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setIsVisible(false)
onClose?.({ symbol, action })
setTimeout(() => {
clearTimer.current = setTimeout(() => {
setList([])
setOnClose(undefined)
setBeforeAction(undefined)
@ -57,6 +64,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
[onClose, symbol]
)
useEffect(() => {
return () => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
clearTimer.current = null
}
}
}, [])
const value = useMemo(
() => ({
open,

View File

@ -7,12 +7,15 @@ import Color from 'color'
import { t } from 'i18next'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { FixedSizeList } from 'react-window'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
import { QuickPanelContext } from './provider'
import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types'
const ITEM_HEIGHT = 31
interface Props {
setInputText: React.Dispatch<React.SetStateAction<string>>
}
@ -47,19 +50,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
const bodyRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const listRef = useRef<FixedSizeList>(null)
const footerRef = useRef<HTMLDivElement>(null)
const scrollBlock = useRef<ScrollLogicalPosition>('nearest')
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 解决长按上下键时滚动太慢问题
const keyPressCount = useRef<number>(0)
const scrollBehavior = useRef<'auto' | 'smooth'>('smooth')
// 处理搜索,过滤列表
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
@ -252,17 +249,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
// 处理上下翻时滚动到选中的元素
useEffect(() => {
if (!contentRef.current) return
const selectedElement = contentRef.current.children[index] as HTMLElement
if (selectedElement) {
selectedElement.scrollIntoView({
block: scrollBlock.current,
behavior: scrollBehavior.current
})
scrollBlock.current = 'nearest'
if (index >= 0) {
listRef.current?.scrollToItem(index, 'auto')
}
}, [index])
@ -275,14 +264,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
setIsAssistiveKeyPressed(true)
}
// 处理上下翻页时,滚动太慢问题
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
keyPressCount.current++
if (keyPressCount.current > 5) {
scrollBehavior.current = 'auto'
}
}
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Escape'].includes(e.key)) {
e.preventDefault()
e.stopPropagation()
@ -297,34 +278,29 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
switch (e.key) {
case 'ArrowUp':
if (isAssistiveKeyPressed) {
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev - ctx.pageSize
if (prev === 0) return list.length - 1
return newIndex < 0 ? 0 : newIndex
})
} else {
scrollBlock.current = 'nearest'
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
}
break
case 'ArrowDown':
if (isAssistiveKeyPressed) {
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev + ctx.pageSize
if (prev + 1 === list.length) return 0
return newIndex >= list.length ? list.length - 1 : newIndex
})
} else {
scrollBlock.current = 'nearest'
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
}
break
case 'PageUp':
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev - ctx.pageSize
return newIndex < 0 ? 0 : newIndex
@ -332,7 +308,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
break
case 'PageDown':
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev + ctx.pageSize
return newIndex >= list.length ? list.length - 1 : newIndex
@ -382,9 +357,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (isMac ? !e.metaKey : !e.ctrlKey) {
setIsAssistiveKeyPressed(false)
}
keyPressCount.current = 0
scrollBehavior.current = 'smooth'
}
const handleClickOutside = (e: MouseEvent) => {
@ -421,6 +393,20 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return () => window.removeEventListener('resize', handleResize)
}, [ctx.isVisible])
const listHeight = useMemo(() => {
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
}, [ctx.pageSize, list.length])
const RowData = useMemo(
(): VirtualizedRowData => ({
list,
focusedIndex: index,
handleItemAction,
setIndex
}),
[list, index, handleItemAction, setIndex]
)
return (
<QuickPanelContainer
$pageSize={ctx.pageSize}
@ -428,40 +414,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$selectedColorHover={selectedColorHover}
className={ctx.isVisible ? 'visible' : ''}>
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
{list.map((item, i) => (
<QuickPanelItem
className={classNames({
focused: i === index,
selected: item.isSelected,
disabled: item.disabled
})}
key={i}
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}
onMouseEnter={() => setIndex(i)}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
</QuickPanelItemLeft>
<QuickPanelItemRight>
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
<QuickPanelItemSuffixIcon>
{item.suffix ? (
item.suffix
) : item.isSelected ? (
<Check />
) : (
item.isMenu && !item.disabled && <RightOutlined />
)}
</QuickPanelItemSuffixIcon>
</QuickPanelItemRight>
</QuickPanelItem>
))}
</QuickPanelContent>
<FixedSizeList
ref={listRef}
itemCount={list.length}
itemSize={ITEM_HEIGHT}
itemData={RowData}
height={listHeight}
width="100%"
overscanCount={4}
style={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{VirtualizedRow}
</FixedSizeList>
<QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}>
@ -510,6 +475,59 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
)
}
interface VirtualizedRowData {
list: QuickPanelListItem[]
focusedIndex: number
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
setIndex: (index: number) => void
}
/**
*
*/
const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { list, focusedIndex, handleItemAction, setIndex } = data
const item = list[index]
if (!item) return null
return (
<div style={style}>
<QuickPanelItem
className={classNames({
focused: index === focusedIndex,
selected: item.isSelected,
disabled: item.disabled
})}
data-id={index}
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}
onMouseEnter={() => setIndex(index)}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
</QuickPanelItemLeft>
<QuickPanelItemRight>
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
<QuickPanelItemSuffixIcon>
{item.suffix ? (
item.suffix
) : item.isSelected ? (
<Check />
) : (
item.isMenu && !item.disabled && <RightOutlined />
)}
</QuickPanelItemSuffixIcon>
</QuickPanelItemRight>
</QuickPanelItem>
</div>
)
}
)
const QuickPanelContainer = styled.div<{
$pageSize: number
$selectedColor: string
@ -533,7 +551,7 @@ const QuickPanelContainer = styled.div<{
&.visible {
pointer-events: auto;
max-height: ${(props) => props.$pageSize * 31 + 100}px;
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
}
body[theme-mode='dark'] & {
--focused-color: rgba(255, 255, 255, 0.1);
@ -561,6 +579,10 @@ const QuickPanelBody = styled.div`
background-color: rgba(40, 40, 40, 0.4);
}
}
::-webkit-scrollbar {
width: 3px;
}
`
const QuickPanelFooter = styled.div`
@ -590,30 +612,17 @@ const QuickPanelFooterTitle = styled.div`
white-space: nowrap;
`
const QuickPanelContent = styled.div<{ $pageSize: number; $isMouseOver: boolean }>`
width: 100%;
max-height: ${(props) => props.$pageSize * 31}px;
padding: 0 5px;
overflow-x: hidden;
overflow-y: auto;
pointer-events: ${(props) => (props.$isMouseOver ? 'auto' : 'none')};
&::-webkit-scrollbar {
width: 3px;
}
`
const QuickPanelItem = styled.div`
height: 30px;
display: flex;
align-items: center;
gap: 20px;
justify-content: space-between;
margin: 0 5px 1px 5px;
padding: 5px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
margin-bottom: 1px;
font-family: Ubuntu;
&.selected {
background-color: var(--selected-color);

View File

@ -8,7 +8,7 @@ import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '
import { modalConfirm } from '@renderer/utils'
import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
import { isNull } from 'lodash'
import { FC, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -160,8 +160,9 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
})
}
const onSelectModel = async () => {
const selectedModel = await SelectModelPopup.show({ model: assistant?.model })
const onSelectModel = useCallback(async () => {
const currentModel = defaultModel ? assistant?.model : undefined
const selectedModel = await SelectModelPopup.show({ model: currentModel })
if (selectedModel) {
setDefaultModel(selectedModel)
updateAssistant({
@ -170,7 +171,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
defaultModel: selectedModel
})
}
}
}, [assistant, defaultModel, updateAssistant])
useEffect(() => {
return () => updateAssistantSettings({ customParameters: customParametersRef.current })

View File

@ -374,6 +374,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0":
version: 7.27.1
resolution: "@babel/runtime@npm:7.27.1"
checksum: 10c0/530a7332f86ac5a7442250456823a930906911d895c0b743bf1852efc88a20a016ed4cd26d442d0ca40ae6d5448111e02a08dd638a4f1064b47d080e2875dc05
languageName: node
linkType: hard
"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.9.2":
version: 7.27.0
resolution: "@babel/runtime@npm:7.27.0"
@ -3951,6 +3958,15 @@ __metadata:
languageName: node
linkType: hard
"@types/react-window@npm:^1":
version: 1.8.8
resolution: "@types/react-window@npm:1.8.8"
dependencies:
"@types/react": "npm:*"
checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f
languageName: node
linkType: hard
"@types/react@npm:*, @types/react@npm:^19.0.12":
version: 19.1.2
resolution: "@types/react@npm:19.1.2"
@ -4418,6 +4434,7 @@ __metadata:
"@types/react": "npm:^19.0.12"
"@types/react-dom": "npm:^19.0.4"
"@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/react-window": "npm:^1"
"@types/tinycolor2": "npm:^1"
"@types/ws": "npm:^8"
"@vitejs/plugin-react-swc": "npm:^3.9.0"
@ -4480,7 +4497,7 @@ __metadata:
p-queue: "npm:^8.1.0"
prettier: "npm:^3.5.3"
proxy-agent: "npm:^6.5.0"
rc-virtual-list: "npm:^3.18.5"
rc-virtual-list: "npm:^3.18.6"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
react-hotkeys-hook: "npm:^4.6.1"
@ -4491,6 +4508,7 @@ __metadata:
react-router: "npm:6"
react-router-dom: "npm:6"
react-spinners: "npm:^0.14.1"
react-window: "npm:^1.8.11"
redux: "npm:^5.0.1"
redux-persist: "npm:^6.0.0"
rehype-katex: "npm:^7.0.1"
@ -11530,6 +11548,13 @@ __metadata:
languageName: node
linkType: hard
"memoize-one@npm:>=3.1.1 <6":
version: 5.2.1
resolution: "memoize-one@npm:5.2.1"
checksum: 10c0/fd22dbe9a978a2b4f30d6a491fc02fb90792432ad0dab840dc96c1734d2bd7c9cdeb6a26130ec60507eb43230559523615873168bcbe8fafab221c30b11d54c1
languageName: node
linkType: hard
"memoize-one@npm:^6.0.0":
version: 6.0.0
resolution: "memoize-one@npm:6.0.0"
@ -14487,7 +14512,7 @@ __metadata:
languageName: node
linkType: hard
"rc-virtual-list@npm:^3.14.2, rc-virtual-list@npm:^3.18.5, rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2":
"rc-virtual-list@npm:^3.14.2, rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2":
version: 3.18.5
resolution: "rc-virtual-list@npm:3.18.5"
dependencies:
@ -14502,6 +14527,21 @@ __metadata:
languageName: node
linkType: hard
"rc-virtual-list@npm:^3.18.6":
version: 3.18.6
resolution: "rc-virtual-list@npm:3.18.6"
dependencies:
"@babel/runtime": "npm:^7.20.0"
classnames: "npm:^2.2.6"
rc-resize-observer: "npm:^1.0.0"
rc-util: "npm:^5.36.0"
peerDependencies:
react: ">=16.9.0"
react-dom: ">=16.9.0"
checksum: 10c0/689f22ea64827c65d9fa32e1b6d52affcebdfb556dc27913b9d839600683aca327d2f5a1f18f11d16a2dee9029d991e69d2d3e87652c2c6d3ca804a64a8e61f9
languageName: node
linkType: hard
"rc@npm:^1.2.7":
version: 1.2.8
resolution: "rc@npm:1.2.8"
@ -14687,6 +14727,19 @@ __metadata:
languageName: node
linkType: hard
"react-window@npm:^1.8.11":
version: 1.8.11
resolution: "react-window@npm:1.8.11"
dependencies:
"@babel/runtime": "npm:^7.0.0"
memoize-one: "npm:>=3.1.1 <6"
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/5ae8da1bc5c47d8f0a428b28a600256e2db511975573e52cb65a9b27ed1a0e5b9f7b3bee5a54fb0da93956d782c24010be434be451072f46ba5a89159d2b3944
languageName: node
linkType: hard
"react@npm:^19.0.0":
version: 19.1.0
resolution: "react@npm:19.1.0"