diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index 63ceb32..4ecf668 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -89,8 +89,8 @@ export function ClaudeSection({ keyField={(item) => item.apiKey} emptyTitle={t('ai_providers.claude_empty_title')} emptyDescription={t('ai_providers.claude_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index ea31771..5328eb3 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -89,8 +89,8 @@ export function CodexSection({ keyField={(item) => item.apiKey} emptyTitle={t('ai_providers.codex_empty_title')} emptyDescription={t('ai_providers.codex_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index 6f28f3e..3fc6e5d 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -89,8 +89,8 @@ export function GeminiSection({ keyField={(item) => item.apiKey} emptyTitle={t('ai_providers.gemini_empty_title')} emptyDescription={t('ai_providers.gemini_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(_, index) => onEdit(index)} + onDelete={(_, index) => onDelete(index)} actionsDisabled={actionsDisabled} getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)} renderExtraActions={(item, index) => ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 1782c72..59a9835 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -1,4 +1,4 @@ -import { Fragment, useMemo } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; @@ -18,6 +18,10 @@ import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getOpenAIProviderStats, getStatsBySource } from '../utils'; +type SortOption = 'name' | 'priority' | 'recent-success'; +type SortDirection = 'asc' | 'desc'; +type ToolbarPosition = 'top' | 'bottom'; + interface OpenAISectionProps { configs: OpenAIProviderConfig[]; keyStats: KeyStats; @@ -45,6 +49,38 @@ export function OpenAISection({ }: OpenAISectionProps) { const { t } = useTranslation(); const actionsDisabled = disableControls || loading || isSwitching; + const [sortOption, setSortOption] = useState('priority'); + const [sortDirection, setSortDirection] = useState('asc'); + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [activeDropdown, setActiveDropdown] = useState(null); + const topDropdownRef = useRef(null); + const bottomDropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const clickedTop = topDropdownRef.current?.contains(target); + const clickedBottom = bottomDropdownRef.current?.contains(target); + + if (!clickedTop && !clickedBottom) { + setActiveDropdown(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const allModelNames = useMemo(() => { + const modelSet = new Set(); + configs.forEach((provider) => { + provider.models?.forEach((model) => { + if (model.name) { + modelSet.add(model.name); + } + }); + }); + return Array.from(modelSet).sort(); + }, [configs]); const statusBarCache = useMemo(() => { const cache = new Map>(); @@ -65,6 +101,171 @@ export function OpenAISection({ return cache; }, [configs, usageDetailsBySource]); + const sortedConfigs = useMemo(() => { + const filtered = configs.filter((provider) => { + if (selectedModels.size === 0) return true; + return provider.models?.some((model) => selectedModels.has(model.name)); + }); + + const sorted = [...filtered]; + const direction = sortDirection === 'desc' ? -1 : 1; + + switch (sortOption) { + case 'name': + sorted.sort((a, b) => direction * a.name.localeCompare(b.name)); + break; + case 'priority': + sorted.sort((a, b) => { + const priorityA = a.priority ?? Number.MAX_SAFE_INTEGER; + const priorityB = b.priority ?? Number.MAX_SAFE_INTEGER; + return direction * (priorityA - priorityB) || a.name.localeCompare(b.name); + }); + break; + case 'recent-success': + sorted.sort((a, b) => { + const statsA = getOpenAIProviderStats(a.apiKeyEntries, keyStats, a.prefix); + const statsB = getOpenAIProviderStats(b.apiKeyEntries, keyStats, b.prefix); + return direction * (statsA.success - statsB.success) || a.name.localeCompare(b.name); + }); + break; + default: + break; + } + + return sorted; + }, [configs, sortOption, sortDirection, keyStats, selectedModels]); + + const getProviderKey = (item: OpenAIProviderConfig) => `${item.name}-${item.prefix ?? ''}-${item.baseUrl}`; + + const getProviderIndex = (item: OpenAIProviderConfig) => + configs.findIndex((config) => config === item); + + const toggleModelSelection = (modelName: string) => { + setSelectedModels((prev) => { + const next = new Set(prev); + if (next.has(modelName)) { + next.delete(modelName); + } else { + next.add(modelName); + } + return next; + }); + }; + + const clearAllModels = () => { + setSelectedModels(new Set()); + }; + + const toggleSortDirection = () => { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + }; + + const toggleDropdown = (position: ToolbarPosition) => { + setActiveDropdown((prev) => (prev === position ? null : position)); + }; + + const renderSortControls = () => ( +
+ + +
+ ); + + const renderToolbar = (position: ToolbarPosition) => { + const isDropdownOpen = activeDropdown === position; + const dropdownRef = position === 'top' ? topDropdownRef : bottomDropdownRef; + const wrapperClassName = + position === 'top' ? styles.cardHeaderActions : `${styles.cardHeaderActions} ${styles.cardFooterActions}`; + + return ( +
+
+
toggleDropdown(position)}> + {selectedModels.size === 0 ? ( + + {t('ai_providers.model_search_placeholder')} + + ) : ( + <> + {Array.from(selectedModels).map((name) => ( + + {name} + + + ))} + + )} + +
+ + {isDropdownOpen && ( +
+
+ + {selectedModels.size > 0 && ( + + )} +
+ {allModelNames.map((name) => ( + + ))} +
+ )} +
+ {renderSortControls()} + +
+ ); + }; + return ( <> } - extra={ - - } + extra={renderToolbar('top')} > - items={configs} + items={sortedConfigs} loading={loading} - keyField={(_, index) => `openai-provider-${index}`} + keyField={(item) => getProviderKey(item)} emptyTitle={t('ai_providers.openai_empty_title')} emptyDescription={t('ai_providers.openai_empty_desc')} - onEdit={onEdit} - onDelete={onDelete} + onEdit={(item) => { + const index = getProviderIndex(item); + if (index >= 0) { + onEdit(index); + } + }} + onDelete={(item) => { + const index = getProviderIndex(item); + if (index >= 0) { + onDelete(index); + } + }} actionsDisabled={actionsDisabled} renderContent={(item) => { const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix); @@ -195,6 +402,7 @@ export function OpenAISection({ ); }} /> +
{renderToolbar('bottom')}
); diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 9d1a1f7..4f2fada 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -8,8 +8,8 @@ interface ProviderListProps { loading: boolean; keyField: (item: T, index: number) => string; renderContent: (item: T, index: number) => ReactNode; - onEdit: (index: number) => void; - onDelete: (index: number) => void; + onEdit: (item: T, index: number) => void; + onDelete: (item: T, index: number) => void; emptyTitle: string; emptyDescription: string; deleteLabel?: string; @@ -57,7 +57,7 @@ export function ProviderList({