mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-14 06:43:14 +08:00
feat: goto provider settings from models popup (#9573)
* feat: goto provider settings from models popup * refactor: improve paddings * refactor: update types * refactor: update types * doc: update comments * refactor: more comments * refactor: scroll to the selected provider on navigation * test: update mocks
This commit is contained in:
parent
7a0da13676
commit
ddc5f46e9b
@ -32,7 +32,7 @@ vi.mock('@hello-pangea/dnd', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count }) => ({
|
||||
useVirtualizer: ({ count, getScrollElement }) => ({
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
@ -41,7 +41,13 @@ vi.mock('@tanstack/react-virtual', () => ({
|
||||
size: 50
|
||||
})),
|
||||
getTotalSize: () => count * 50,
|
||||
measureElement: vi.fn()
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
scrollToOffset: vi.fn(),
|
||||
scrollElement: getScrollElement(),
|
||||
measure: vi.fn(),
|
||||
resizeItem: vi.fn(),
|
||||
getVirtualIndexes: () => Array.from({ length: count }, (_, i) => i)
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
export { default as DraggableList } from './list'
|
||||
export { useDraggableReorder } from './useDraggableReorder'
|
||||
export { default as DraggableVirtualList } from './virtual-list'
|
||||
export {
|
||||
default as DraggableVirtualList,
|
||||
type DraggableVirtualListProps,
|
||||
type DraggableVirtualListRef
|
||||
} from './virtual-list'
|
||||
|
||||
@ -10,8 +10,19 @@ import {
|
||||
} from '@hello-pangea/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type Key, memo, useCallback, useRef } from 'react'
|
||||
import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
|
||||
import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
export interface DraggableVirtualListRef {
|
||||
measure: () => void
|
||||
scrollElement: () => HTMLDivElement | null
|
||||
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
|
||||
scrollToIndex: (index: number, options?: ScrollToOptions) => void
|
||||
resizeItem: (index: number, size: number) => void
|
||||
getTotalSize: () => number
|
||||
getVirtualItems: () => VirtualItem[]
|
||||
getVirtualIndexes: () => number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 泛型 Props,用于配置 DraggableVirtualList。
|
||||
@ -31,8 +42,8 @@ import { type Key, memo, useCallback, useRef } from 'react'
|
||||
* @property {React.ReactNode} [header] 列表头部内容
|
||||
* @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数
|
||||
*/
|
||||
interface DraggableVirtualListProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
export interface DraggableVirtualListProps<T> {
|
||||
ref?: React.Ref<DraggableVirtualListRef>
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
scrollerStyle?: React.CSSProperties
|
||||
@ -100,9 +111,23 @@ function DraggableVirtualList<T>({
|
||||
overscan
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
measure: () => virtualizer.measure(),
|
||||
scrollElement: () => virtualizer.scrollElement,
|
||||
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
|
||||
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
|
||||
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
|
||||
getTotalSize: () => virtualizer.getTotalSize(),
|
||||
getVirtualItems: () => virtualizer.getVirtualItems(),
|
||||
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
|
||||
}),
|
||||
[virtualizer]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${className} draggable-virtual-list`}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
|
||||
@ -8,8 +8,9 @@ import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
|
||||
import { Avatar, Divider, Empty, Modal } from 'antd'
|
||||
import { Avatar, Button, Divider, Empty, Modal, Tooltip } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import React, {
|
||||
startTransition,
|
||||
useCallback,
|
||||
@ -150,6 +151,22 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
key: `provider-${p.id}`,
|
||||
type: 'group',
|
||||
name: getFancyProviderName(p),
|
||||
actions: (
|
||||
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<SettingsIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
resolve(undefined)
|
||||
window.navigate(`/settings/provider?id=${p.id}`)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
@ -159,7 +176,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
// 获取可选择的模型项(过滤掉分组标题)
|
||||
const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[]
|
||||
return { listItems: items, modelItems }
|
||||
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels])
|
||||
}, [pinnedModels, modelFilter, searchText.length, providers, createModelItem, t, getFilteredModels, resolve])
|
||||
|
||||
const listHeight = useMemo(() => {
|
||||
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
|
||||
@ -307,7 +324,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
(item: FlatListItem) => {
|
||||
const isFocused = item.key === focusedItemKey
|
||||
if (item.type === 'group') {
|
||||
return <GroupItem>{item.name}</GroupItem>
|
||||
return (
|
||||
<GroupItem>
|
||||
{item.name}
|
||||
{item.actions}
|
||||
</GroupItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ModelItem
|
||||
@ -397,11 +419,12 @@ const ListContainer = styled.div`
|
||||
const GroupItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: normal;
|
||||
height: ${ITEM_HEIGHT}px;
|
||||
padding: 5px 10px 5px 18px;
|
||||
padding: 5px 12px 5px 18px;
|
||||
color: var(--color-text-3);
|
||||
z-index: 1;
|
||||
background: var(--modal-background);
|
||||
|
||||
@ -1,20 +1,46 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// 列表项类型,组名也作为列表项
|
||||
export type ListItemType = 'group' | 'model'
|
||||
|
||||
// 滚动触发来源类型
|
||||
/**
|
||||
* 滚动触发来源类型
|
||||
*/
|
||||
export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
|
||||
|
||||
// 扁平化列表项接口
|
||||
export interface FlatListItem {
|
||||
/**
|
||||
* 列表项分类,组名也作为列表项
|
||||
*/
|
||||
export type ListItemType = 'group' | 'model'
|
||||
|
||||
/**
|
||||
* 扁平化列表项基础类型
|
||||
*/
|
||||
export type FlatListBaseItem = {
|
||||
key: string
|
||||
type: ListItemType
|
||||
icon?: ReactNode
|
||||
name: ReactNode
|
||||
tags?: ReactNode
|
||||
model?: Model
|
||||
isPinned?: boolean
|
||||
icon?: ReactNode
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型分组列表项
|
||||
*/
|
||||
export type FlatListGroup = FlatListBaseItem & {
|
||||
type: 'group'
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型列表项
|
||||
*/
|
||||
export type FlatListModel = FlatListBaseItem & {
|
||||
type: 'model'
|
||||
model: Model
|
||||
tags?: ReactNode
|
||||
isPinned?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 扁平化列表项
|
||||
*/
|
||||
export type FlatListItem = FlatListGroup | FlatListModel
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "Hide Sidebar",
|
||||
"show_sidebar": "Show Sidebar"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Go to provider settings"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Assistant Response",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "サイドバーを非表示",
|
||||
"show_sidebar": "サイドバーを表示"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "プロバイダー設定に移動"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "Скрыть боковую панель",
|
||||
"show_sidebar": "Показать боковую панель"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Перейти к настройкам поставщика"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Ответ ассистента",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "隐藏侧边栏",
|
||||
"show_sidebar": "显示侧边栏"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "跳转到服务商设置界面"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手响应",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "隱藏側邊欄",
|
||||
"show_sidebar": "顯示側邊欄"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "跳轉到服務商設置界面"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "Απόκρυψη πλάγιας μπάρας",
|
||||
"show_sidebar": "Εμφάνιση πλάγιας μπάρας"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Απάντηση Βοηθού",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "Ocultar barra lateral",
|
||||
"show_sidebar": "Mostrar barra lateral"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Ir a la configuración del proveedor"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Respuesta del asistente",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "Cacher la barre latérale",
|
||||
"show_sidebar": "Afficher la barre latérale"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Aller aux paramètres du fournisseur"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Réponse de l'assistant",
|
||||
"knowledge": {
|
||||
|
||||
@ -1567,6 +1567,9 @@
|
||||
"hide_sidebar": "Ocultar barra lateral",
|
||||
"show_sidebar": "Mostrar barra lateral"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Ir para as configurações do provedor"
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Resposta do assistente",
|
||||
"knowledge": {
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { DropResult } from '@hello-pangea/dnd'
|
||||
import { loggerService } from '@logger'
|
||||
import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList'
|
||||
import {
|
||||
DraggableVirtualList,
|
||||
type DraggableVirtualListRef,
|
||||
useDraggableReorder
|
||||
} from '@renderer/components/DraggableList'
|
||||
import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { isSystemProvider, Provider, ProviderType } from '@renderer/types'
|
||||
@ -18,7 +23,7 @@ import {
|
||||
} from '@renderer/utils'
|
||||
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { FC, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
@ -35,11 +40,13 @@ const ProvidersList: FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const providers = useAllProviders()
|
||||
const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders()
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const [selectedProvider, _setSelectedProvider] = useState<Provider>(providers[0])
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
|
||||
const listRef = useRef<DraggableVirtualListRef>(null)
|
||||
|
||||
const setSelectedProvider = useCallback(
|
||||
(provider: Provider) => {
|
||||
@ -75,11 +82,20 @@ const ProvidersList: FC = () => {
|
||||
const provider = providers.find((p) => p.id === providerId)
|
||||
if (provider) {
|
||||
setSelectedProvider(provider)
|
||||
// 滚动到选中的 provider
|
||||
const index = providers.findIndex((p) => p.id === providerId)
|
||||
if (index >= 0) {
|
||||
setTimeoutTimer(
|
||||
'scroll-to-selected-provider',
|
||||
() => listRef.current?.scrollToIndex(index, { align: 'center' }),
|
||||
100
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setSelectedProvider(providers[0])
|
||||
}
|
||||
}
|
||||
}, [providers, searchParams, setSelectedProvider])
|
||||
}, [providers, searchParams, setSelectedProvider, setTimeoutTimer])
|
||||
|
||||
// Handle provider add key from URL schema
|
||||
useEffect(() => {
|
||||
@ -485,6 +501,7 @@ const ProvidersList: FC = () => {
|
||||
/>
|
||||
</AddButtonWrapper>
|
||||
<DraggableVirtualList
|
||||
ref={listRef}
|
||||
list={filteredProviders}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user