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:
one 2025-08-27 00:04:52 +08:00 committed by GitHub
parent 7a0da13676
commit ddc5f46e9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 154 additions and 26 deletions

View File

@ -32,7 +32,7 @@ vi.mock('@hello-pangea/dnd', () => ({
})) }))
vi.mock('@tanstack/react-virtual', () => ({ vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }) => ({ useVirtualizer: ({ count, getScrollElement }) => ({
getVirtualItems: () => getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({ Array.from({ length: count }, (_, index) => ({
index, index,
@ -41,7 +41,13 @@ vi.mock('@tanstack/react-virtual', () => ({
size: 50 size: 50
})), })),
getTotalSize: () => count * 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)
}) })
})) }))

View File

@ -1,3 +1,7 @@
export { default as DraggableList } from './list' export { default as DraggableList } from './list'
export { useDraggableReorder } from './useDraggableReorder' export { useDraggableReorder } from './useDraggableReorder'
export { default as DraggableVirtualList } from './virtual-list' export {
default as DraggableVirtualList,
type DraggableVirtualListProps,
type DraggableVirtualListRef
} from './virtual-list'

View File

@ -10,8 +10,19 @@ import {
} from '@hello-pangea/dnd' } from '@hello-pangea/dnd'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { droppableReorder } from '@renderer/utils' import { droppableReorder } from '@renderer/utils'
import { useVirtualizer } from '@tanstack/react-virtual' import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useRef } from 'react' 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 * Props DraggableVirtualList
@ -31,8 +42,8 @@ import { type Key, memo, useCallback, useRef } from 'react'
* @property {React.ReactNode} [header] * @property {React.ReactNode} [header]
* @property {(item: T, index: number) => React.ReactNode} children * @property {(item: T, index: number) => React.ReactNode} children
*/ */
interface DraggableVirtualListProps<T> { export interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<DraggableVirtualListRef>
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
scrollerStyle?: React.CSSProperties scrollerStyle?: React.CSSProperties
@ -100,9 +111,23 @@ function DraggableVirtualList<T>({
overscan 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 ( return (
<div <div
ref={ref}
className={`${className} draggable-virtual-list`} className={`${className} draggable-virtual-list`}
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}> style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}> <DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>

View File

@ -8,8 +8,9 @@ import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
import { Model, Provider } from '@renderer/types' import { Model, Provider } from '@renderer/types'
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' 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 { first, sortBy } from 'lodash'
import { SettingsIcon } from 'lucide-react'
import React, { import React, {
startTransition, startTransition,
useCallback, useCallback,
@ -150,6 +151,22 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
key: `provider-${p.id}`, key: `provider-${p.id}`,
type: 'group', type: 'group',
name: getFancyProviderName(p), 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 isSelected: false
}) })
@ -159,7 +176,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 获取可选择的模型项(过滤掉分组标题) // 获取可选择的模型项(过滤掉分组标题)
const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[] const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[]
return { listItems: items, modelItems } return { listItems: items, modelItems }
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels]) }, [pinnedModels, modelFilter, searchText.length, providers, createModelItem, t, getFilteredModels, resolve])
const listHeight = useMemo(() => { const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
@ -307,7 +324,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
(item: FlatListItem) => { (item: FlatListItem) => {
const isFocused = item.key === focusedItemKey const isFocused = item.key === focusedItemKey
if (item.type === 'group') { if (item.type === 'group') {
return <GroupItem>{item.name}</GroupItem> return (
<GroupItem>
{item.name}
{item.actions}
</GroupItem>
)
} }
return ( return (
<ModelItem <ModelItem
@ -397,11 +419,12 @@ const ListContainer = styled.div`
const GroupItem = styled.div` const GroupItem = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
position: relative; position: relative;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: normal;
height: ${ITEM_HEIGHT}px; height: ${ITEM_HEIGHT}px;
padding: 5px 10px 5px 18px; padding: 5px 12px 5px 18px;
color: var(--color-text-3); color: var(--color-text-3);
z-index: 1; z-index: 1;
background: var(--modal-background); background: var(--modal-background);

View File

@ -1,20 +1,46 @@
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { ReactNode } from 'react' import { ReactNode } from 'react'
// 列表项类型,组名也作为列表项 /**
export type ListItemType = 'group' | 'model' *
*/
// 滚动触发来源类型
export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none' export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
// 扁平化列表项接口 /**
export interface FlatListItem { *
*/
export type ListItemType = 'group' | 'model'
/**
*
*/
export type FlatListBaseItem = {
key: string key: string
type: ListItemType type: ListItemType
icon?: ReactNode
name: ReactNode name: ReactNode
tags?: ReactNode icon?: ReactNode
model?: Model
isPinned?: boolean
isSelected?: boolean 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

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "Hide Sidebar", "hide_sidebar": "Hide Sidebar",
"show_sidebar": "Show Sidebar" "show_sidebar": "Show Sidebar"
}, },
"navigate": {
"provider_settings": "Go to provider settings"
},
"notification": { "notification": {
"assistant": "Assistant Response", "assistant": "Assistant Response",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "サイドバーを非表示", "hide_sidebar": "サイドバーを非表示",
"show_sidebar": "サイドバーを表示" "show_sidebar": "サイドバーを表示"
}, },
"navigate": {
"provider_settings": "プロバイダー設定に移動"
},
"notification": { "notification": {
"assistant": "助手回應", "assistant": "助手回應",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "Скрыть боковую панель", "hide_sidebar": "Скрыть боковую панель",
"show_sidebar": "Показать боковую панель" "show_sidebar": "Показать боковую панель"
}, },
"navigate": {
"provider_settings": "Перейти к настройкам поставщика"
},
"notification": { "notification": {
"assistant": "Ответ ассистента", "assistant": "Ответ ассистента",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "隐藏侧边栏", "hide_sidebar": "隐藏侧边栏",
"show_sidebar": "显示侧边栏" "show_sidebar": "显示侧边栏"
}, },
"navigate": {
"provider_settings": "跳转到服务商设置界面"
},
"notification": { "notification": {
"assistant": "助手响应", "assistant": "助手响应",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "隱藏側邊欄", "hide_sidebar": "隱藏側邊欄",
"show_sidebar": "顯示側邊欄" "show_sidebar": "顯示側邊欄"
}, },
"navigate": {
"provider_settings": "跳轉到服務商設置界面"
},
"notification": { "notification": {
"assistant": "助手回應", "assistant": "助手回應",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "Απόκρυψη πλάγιας μπάρας", "hide_sidebar": "Απόκρυψη πλάγιας μπάρας",
"show_sidebar": "Εμφάνιση πλάγιας μπάρας" "show_sidebar": "Εμφάνιση πλάγιας μπάρας"
}, },
"navigate": {
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
},
"notification": { "notification": {
"assistant": "Απάντηση Βοηθού", "assistant": "Απάντηση Βοηθού",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "Ocultar barra lateral", "hide_sidebar": "Ocultar barra lateral",
"show_sidebar": "Mostrar barra lateral" "show_sidebar": "Mostrar barra lateral"
}, },
"navigate": {
"provider_settings": "Ir a la configuración del proveedor"
},
"notification": { "notification": {
"assistant": "Respuesta del asistente", "assistant": "Respuesta del asistente",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "Cacher la barre latérale", "hide_sidebar": "Cacher la barre latérale",
"show_sidebar": "Afficher la barre latérale" "show_sidebar": "Afficher la barre latérale"
}, },
"navigate": {
"provider_settings": "Aller aux paramètres du fournisseur"
},
"notification": { "notification": {
"assistant": "Réponse de l'assistant", "assistant": "Réponse de l'assistant",
"knowledge": { "knowledge": {

View File

@ -1567,6 +1567,9 @@
"hide_sidebar": "Ocultar barra lateral", "hide_sidebar": "Ocultar barra lateral",
"show_sidebar": "Mostrar barra lateral" "show_sidebar": "Mostrar barra lateral"
}, },
"navigate": {
"provider_settings": "Ir para as configurações do provedor"
},
"notification": { "notification": {
"assistant": "Resposta do assistente", "assistant": "Resposta do assistente",
"knowledge": { "knowledge": {

View File

@ -1,9 +1,14 @@
import { DropResult } from '@hello-pangea/dnd' import { DropResult } from '@hello-pangea/dnd'
import { loggerService } from '@logger' 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 { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { getProviderLabel } from '@renderer/i18n/label' import { getProviderLabel } from '@renderer/i18n/label'
import ImageStorage from '@renderer/services/ImageStorage' import ImageStorage from '@renderer/services/ImageStorage'
import { isSystemProvider, Provider, ProviderType } from '@renderer/types' import { isSystemProvider, Provider, ProviderType } from '@renderer/types'
@ -18,7 +23,7 @@ import {
} from '@renderer/utils' } from '@renderer/utils'
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
@ -35,11 +40,13 @@ const ProvidersList: FC = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const providers = useAllProviders() const providers = useAllProviders()
const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders() const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders()
const { setTimeoutTimer } = useTimer()
const [selectedProvider, _setSelectedProvider] = useState<Provider>(providers[0]) const [selectedProvider, _setSelectedProvider] = useState<Provider>(providers[0])
const { t } = useTranslation() const { t } = useTranslation()
const [searchText, setSearchText] = useState<string>('') const [searchText, setSearchText] = useState<string>('')
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({}) const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
const listRef = useRef<DraggableVirtualListRef>(null)
const setSelectedProvider = useCallback( const setSelectedProvider = useCallback(
(provider: Provider) => { (provider: Provider) => {
@ -75,11 +82,20 @@ const ProvidersList: FC = () => {
const provider = providers.find((p) => p.id === providerId) const provider = providers.find((p) => p.id === providerId)
if (provider) { if (provider) {
setSelectedProvider(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 { } else {
setSelectedProvider(providers[0]) setSelectedProvider(providers[0])
} }
} }
}, [providers, searchParams, setSelectedProvider]) }, [providers, searchParams, setSelectedProvider, setTimeoutTimer])
// Handle provider add key from URL schema // Handle provider add key from URL schema
useEffect(() => { useEffect(() => {
@ -485,6 +501,7 @@ const ProvidersList: FC = () => {
/> />
</AddButtonWrapper> </AddButtonWrapper>
<DraggableVirtualList <DraggableVirtualList
ref={listRef}
list={filteredProviders} list={filteredProviders}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}