diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index c6f4f79a78..d41a9ffd60 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -11,6 +11,7 @@ interface CustomCollapseProps { defaultActiveKey?: string[] activeKey?: string[] collapsible?: 'header' | 'icon' | 'disabled' + onChange?: (activeKeys: string | string[]) => void style?: React.CSSProperties styles?: { header?: React.CSSProperties @@ -26,6 +27,7 @@ const CustomCollapse: FC = ({ defaultActiveKey = ['1'], activeKey, collapsible = undefined, + onChange, style, styles }) => { @@ -78,7 +80,10 @@ const CustomCollapse: FC = ({ activeKey={activeKey} destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} - onChange={setActiveKeys} + onChange={(keys) => { + setActiveKeys(keys) + onChange?.(keys) + }} expandIcon={({ isActive }) => ( void +} + +const PopupContainer: React.FC = ({ provider, model, resolve }) => { + const handleUpdateModel = (updatedModel: Model) => { + resolve(updatedModel) + } + + const handleClose = () => { + resolve(undefined) // Resolve with no data on close + } + + return ( + + ) +} + +const TopViewKey = 'EditModelPopup' + +export default class EditModelPopup { + static hide() { + TopView.hide(TopViewKey) + } + + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/ModelList/ManageModelsList.tsx b/src/renderer/src/components/ModelList/ManageModelsList.tsx new file mode 100644 index 0000000000..bede6b5b74 --- /dev/null +++ b/src/renderer/src/components/ModelList/ManageModelsList.tsx @@ -0,0 +1,281 @@ +import { MinusOutlined, PlusOutlined } from '@ant-design/icons' +import CustomTag from '@renderer/components/CustomTag' +import ExpandableText from '@renderer/components/ExpandableText' +import ModelIdWithTags from '@renderer/components/ModelIdWithTags' +import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup' +import { getModelLogo } from '@renderer/config/models' +import FileItem from '@renderer/pages/files/FileItem' +import { Model, Provider } from '@renderer/types' +import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' +import { Button, Flex, Tooltip } from 'antd' +import { Avatar } from 'antd' +import { ChevronRight } from 'lucide-react' +import React, { memo, useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { isModelInProvider, isValidNewApiModel } from './utils' + +// 列表项类型定义 +interface GroupRowData { + type: 'group' + groupName: string + models: Model[] +} + +interface ModelRowData { + type: 'model' + model: Model +} + +type RowData = GroupRowData | ModelRowData + +interface ManageModelsListProps { + modelGroups: Record + provider: Provider + onAddModel: (model: Model) => void + onRemoveModel: (model: Model) => void +} + +const ManageModelsList: React.FC = ({ modelGroups, provider, onAddModel, onRemoveModel }) => { + const { t } = useTranslation() + const scrollerRef = useRef(null) + const activeStickyIndexRef = useRef(0) + const [collapsedGroups, setCollapsedGroups] = useState(new Set()) + + const handleGroupToggle = useCallback((groupName: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev) + if (newSet.has(groupName)) { + newSet.delete(groupName) // 如果已折叠,则展开 + } else { + newSet.add(groupName) // 如果已展开,则折叠 + } + return newSet + }) + }, []) + + // 将分组数据扁平化为单一列表,过滤掉空组 + const flatRows = useMemo(() => { + const rows: RowData[] = [] + + Object.entries(modelGroups).forEach(([groupName, models]) => { + if (models.length > 0) { + // 只添加非空组 + rows.push({ type: 'group', groupName, models }) + if (!collapsedGroups.has(groupName)) { + models.forEach((model) => { + rows.push({ type: 'model', model }) + }) + } + } + }) + + return rows + }, [modelGroups, collapsedGroups]) + + // 找到所有组 header 的索引 + const stickyIndexes = useMemo(() => { + return flatRows.map((row, index) => (row.type === 'group' ? index : -1)).filter((index) => index !== -1) + }, [flatRows]) + + const isSticky = useCallback((index: number) => stickyIndexes.includes(index), [stickyIndexes]) + + const isActiveSticky = useCallback((index: number) => activeStickyIndexRef.current === index, []) + + // 自定义 range extractor 用于 sticky header + const rangeExtractor = useCallback( + (range: any) => { + activeStickyIndexRef.current = [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0 + const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)]) + return [...next].sort((a, b) => a - b) + }, + [stickyIndexes] + ) + + const virtualizer = useVirtualizer({ + count: flatRows.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 42, + rangeExtractor, + overscan: 5 + }) + + const renderGroupTools = useCallback( + (models: Model[]) => { + const isAllInProvider = models.every((model) => isModelInProvider(provider, model.id)) + + const handleGroupAction = () => { + if (isAllInProvider) { + // 移除整组 + models.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel) + } else { + // 添加整组 + const wouldAddModels = models.filter((model) => !isModelInProvider(provider, model.id)) + + if (provider.id === 'new-api') { + if (wouldAddModels.every(isValidNewApiModel)) { + wouldAddModels.forEach(onAddModel) + } else { + NewApiBatchAddModelPopup.show({ + title: t('settings.models.add.batch_add_models'), + batchModels: wouldAddModels, + provider + }) + } + } else { + wouldAddModels.forEach(onAddModel) + } + } + } + + return ( + + - {models.map((model) => ( - setEditingModel(null)} - key={model.id} - /> - ))} ) } diff --git a/src/renderer/src/components/ModelList/ModelListGroup.tsx b/src/renderer/src/components/ModelList/ModelListGroup.tsx index b4a84ce77d..edf15e8f8a 100644 --- a/src/renderer/src/components/ModelList/ModelListGroup.tsx +++ b/src/renderer/src/components/ModelList/ModelListGroup.tsx @@ -2,8 +2,9 @@ import { MinusOutlined } from '@ant-design/icons' import CustomCollapse from '@renderer/components/CustomCollapse' import { Model } from '@renderer/types' import { ModelWithStatus } from '@renderer/types/healthCheck' +import { useVirtualizer } from '@tanstack/react-virtual' import { Button, Flex, Tooltip } from 'antd' -import React, { memo } from 'react' +import React, { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -31,11 +32,35 @@ const ModelListGroup: React.FC = ({ onRemoveGroup }) => { const { t } = useTranslation() + const scrollerRef = useRef(null) + const [isExpanded, setIsExpanded] = useState(defaultOpen) + + const virtualizer = useVirtualizer({ + count: models.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 52, + overscan: 5 + }) + + const virtualItems = virtualizer.getVirtualItems() + + // 监听折叠面板状态变化,确保虚拟列表在展开时正确渲染 + useEffect(() => { + if (isExpanded && scrollerRef.current) { + requestAnimationFrame(() => virtualizer.measure()) + } + }, [isExpanded, virtualizer]) + + const handleCollapseChange = (activeKeys: string[] | string) => { + const isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys + setIsExpanded(isNowExpanded) + } return ( {groupName} @@ -52,18 +77,45 @@ const ModelListGroup: React.FC = ({ /> }> - - {models.map((model) => ( - status.model.id === model.id)} - onEdit={onEditModel} - onRemove={onRemoveModel} - disabled={disabled} - /> - ))} - + +
+
+ {virtualItems.map((virtualItem) => { + const model = models[virtualItem.index] + return ( +
+ status.model.id === model.id)} + onEdit={onEditModel} + onRemove={onRemoveModel} + disabled={disabled} + /> +
+ ) + })} +
+
+
) @@ -79,6 +131,17 @@ const CustomCollapseWrapper = styled.div` &:hover .toolbar-item { opacity: 1; } + + /* 移除 collapse 的 padding,转而在 scroller 内部调整 */ + .ant-collapse-content-box { + padding: 0 !important; + } +` + +const ScrollContainer = styled.div` + overflow-y: auto; + max-height: 390px; + padding: 4px 16px; ` export default memo(ModelListGroup) diff --git a/src/renderer/src/components/ModelList/index.ts b/src/renderer/src/components/ModelList/index.ts index db47dd39d3..937c8f6617 100644 --- a/src/renderer/src/components/ModelList/index.ts +++ b/src/renderer/src/components/ModelList/index.ts @@ -1,6 +1,7 @@ export { default as AddModelPopup } from './AddModelPopup' -export { default as EditModelsPopup } from './EditModelsPopup' +export { default as EditModelPopup } from './EditModelPopup' export { default as HealthCheckPopup } from './HealthCheckPopup' +export { default as ManageModelsPopup } from './ManageModelsPopup' export { default as ModelEditContent } from './ModelEditContent' export { default as ModelList } from './ModelList' export { default as NewApiAddModelPopup } from './NewApiAddModelPopup' diff --git a/src/renderer/src/components/ModelList/utils.ts b/src/renderer/src/components/ModelList/utils.ts new file mode 100644 index 0000000000..3ced576219 --- /dev/null +++ b/src/renderer/src/components/ModelList/utils.ts @@ -0,0 +1,10 @@ +import { Model, Provider } from '@renderer/types' + +// Check if the model exists in the provider's model list +export const isModelInProvider = (provider: Provider, modelId: string): boolean => { + return provider.models.some((m) => m.id === modelId) +} + +export const isValidNewApiModel = (model: Model): boolean => { + return !!(model.supported_endpoint_types && model.supported_endpoint_types.length > 0) +} diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 351c5a58f2..711f503c86 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -19,7 +19,7 @@ import { } from '@renderer/utils' import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' import { Eye, EyeOff, Search, UserPen } from 'lucide-react' -import { FC, useEffect, useState } from 'react' +import { FC, startTransition, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' import styled from 'styled-components' @@ -34,12 +34,19 @@ const ProvidersList: FC = () => { const [searchParams] = useSearchParams() const providers = useAllProviders() const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders() - const [selectedProvider, setSelectedProvider] = useState(providers[0]) + const [selectedProvider, _setSelectedProvider] = useState(providers[0]) const { t } = useTranslation() const [searchText, setSearchText] = useState('') const [dragging, setDragging] = useState(false) const [providerLogos, setProviderLogos] = useState>({}) + const setSelectedProvider = useCallback( + (provider: Provider) => { + startTransition(() => _setSelectedProvider(provider)) + }, + [_setSelectedProvider] + ) + useEffect(() => { const loadAllLogos = async () => { const logos: Record = {} @@ -71,7 +78,7 @@ const ProvidersList: FC = () => { setSelectedProvider(providers[0]) } } - }, [providers, searchParams]) + }, [providers, searchParams, setSelectedProvider]) // Handle provider add key from URL schema useEffect(() => {