/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useState, useEffect, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, Search, Info, ChevronDown } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { fetchUpstreamModels, updateChannel } from '../../api' import { channelsQueryKeys, categorizeModelsWithRedirect, normalizeModelName, parseModelsString, } from '../../lib' import { useChannels } from '../channels-provider' function normalizeModelNameList(models: readonly string[]): string[] { return Array.from( new Set(models.map((m) => normalizeModelName(m)).filter(Boolean)) ) } type FetchModelsDialogProps = { open: boolean onOpenChange: (open: boolean) => void onModelsSelected?: (models: string[]) => void redirectModels?: string[] redirectSourceModels?: string[] customFetcher?: () => Promise existingModelsOverride?: string[] } export function FetchModelsDialog({ open, onOpenChange, onModelsSelected, redirectModels = [], redirectSourceModels = [], customFetcher, existingModelsOverride, }: FetchModelsDialogProps) { const { t } = useTranslation() const { currentRow } = useChannels() const queryClient = useQueryClient() const [isFetching, setIsFetching] = useState(false) const [isSaving, setIsSaving] = useState(false) const [fetchedModels, setFetchedModels] = useState([]) const [selectedModels, setSelectedModels] = useState([]) const [searchKeyword, setSearchKeyword] = useState('') // Parse existing models const existingModels = useMemo( () => existingModelsOverride ?? parseModelsString(currentRow?.models || ''), [existingModelsOverride, currentRow?.models] ) // Categorize models with redirect models const modelCategories = useMemo( () => categorizeModelsWithRedirect(existingModels, redirectModels), [existingModels, redirectModels] ) const { classificationSet, redirectOnlySet } = modelCategories const fetchedModelSet = useMemo( () => new Set(normalizeModelNameList(fetchedModels)), [fetchedModels] ) // Source keys in model_mapping are aliases, not real upstream IDs, so we // must skip them when computing "removed upstream" entries to avoid false // positives. const redirectSourceKeysSet = useMemo( () => new Set(normalizeModelNameList(redirectSourceModels)), [redirectSourceModels] ) const removedModels = useMemo(() => { const kw = searchKeyword.toLowerCase().trim() return normalizeModelNameList(selectedModels).filter((model) => { if (fetchedModelSet.has(model)) return false if (redirectSourceKeysSet.has(model)) return false if (!kw) return true return model.toLowerCase().includes(kw) }) }, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels]) useEffect(() => { if (open && (currentRow || customFetcher)) { handleFetchModels() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, currentRow?.id, customFetcher]) const handleFetchModels = async () => { if (!currentRow && !customFetcher) return setIsFetching(true) try { if (customFetcher) { const list = await customFetcher() setFetchedModels(list) setSelectedModels(existingModels) toast.success(t('Fetched {{count}} models', { count: list.length })) } else { const response = await fetchUpstreamModels(currentRow!.id) if (response.success) { const list = Array.isArray(response.data) ? response.data : [] setFetchedModels(list) setSelectedModels(existingModels) toast.success(t('Fetched {{count}} models', { count: list.length })) } else { toast.error(response.message || t('Failed to fetch models')) setFetchedModels([]) } } } catch (error: unknown) { toast.error( error instanceof Error ? error.message : t('Failed to fetch models') ) setFetchedModels([]) } finally { setIsFetching(false) } } const handleSave = async () => { // If onModelsSelected callback is provided, use it (form filling mode) if (onModelsSelected) { onModelsSelected(selectedModels) toast.success(t('Models filled to form')) onOpenChange(false) return } // Otherwise, directly save to API (standalone mode) if (!currentRow) return setIsSaving(true) try { const modelsString = selectedModels.join(',') const response = await updateChannel(currentRow.id, { models: modelsString, }) if (response.success) { toast.success(t('Models updated successfully')) queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onOpenChange(false) } else { toast.error(response.message || t('Failed to update models')) } } catch (error: unknown) { toast.error( error instanceof Error ? error.message : t('Failed to update models') ) } finally { setIsSaving(false) } } const handleClose = () => { setFetchedModels([]) setSelectedModels([]) setSearchKeyword('') onOpenChange(false) } // Categorize models by common prefixes const categorizeModels = (models: string[]) => { const categories: Record = {} models.forEach((model) => { let category = 'Other' // Determine category based on model name if ( model.toLowerCase().includes('gpt') || model.toLowerCase().includes('o1') || model.toLowerCase().includes('o3') ) { category = 'OpenAI' } else if (model.toLowerCase().includes('claude')) { category = 'Anthropic' } else if (model.toLowerCase().includes('gemini')) { category = 'Gemini' } else if (model.toLowerCase().includes('qwen')) { category = 'Qwen' } else if (model.toLowerCase().includes('deepseek')) { category = 'DeepSeek' } else if (model.toLowerCase().includes('glm')) { category = 'Zhipu' } else if (model.toLowerCase().includes('llama')) { category = 'Meta' } else if (model.toLowerCase().includes('mistral')) { category = 'Mistral' } if (!categories[category]) { categories[category] = [] } categories[category].push(model) }) return categories } // Filter models by search const filteredModels = useMemo(() => { if (!searchKeyword) return fetchedModels return fetchedModels.filter((model) => model.toLowerCase().includes(searchKeyword.toLowerCase()) ) }, [fetchedModels, searchKeyword]) // Helper to check if a model is considered "existing" (in selected or redirect) const isExistingModel = (model: string) => classificationSet.has(normalizeModelName(model)) // Separate new and existing models const newModels = filteredModels.filter((m) => !isExistingModel(m)) const existingFilteredModels = filteredModels.filter((m) => isExistingModel(m) ) const newModelsByCategory = categorizeModels(newModels) const existingModelsByCategory = categorizeModels(existingFilteredModels) // 厂商分类按 a-z 排序,Other 放最后,便于查找 const getSortedCategoryEntries = ( categories: Record ): [string, string[]][] => Object.entries(categories).sort(([a], [b]) => { if (a === 'Other') return 1 if (b === 'Other') return -1 return a.localeCompare(b, undefined, { sensitivity: 'base' }) }) const toggleModel = (model: string) => { setSelectedModels((prev) => prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model] ) } const toggleCategory = (categoryModels: string[], isChecked: boolean) => { setSelectedModels((prev) => { if (isChecked) { const newSelected = [...prev] categoryModels.forEach((model) => { if (!newSelected.includes(model)) { newSelected.push(model) } }) return newSelected } else { return prev.filter((m) => !categoryModels.includes(m)) } }) } const isCategorySelected = (categoryModels: string[]) => { return categoryModels.every((m) => selectedModels.includes(m)) } const renderModelCategory = ( categoryName: string, categoryModels: string[] ) => { const allSelected = isCategorySelected(categoryModels) return (
{categoryName} ({categoryModels.length})
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '} / {categoryModels.length} selected toggleCategory(categoryModels, !!checked) } onClick={(e) => e.stopPropagation()} />
{categoryModels.map((model) => (
toggleModel(model)} />
))}
) } return ( {t('Fetch Models')} {currentRow ? ( <> {t('Fetch available models for:')}{' '} {currentRow.name} ) : ( t('Fetch available models from upstream') )} {!currentRow && !customFetcher ? (
{t('No channel selected')}
) : isFetching ? (
) : fetchedModels.length === 0 && removedModels.length === 0 ? (

{t('No models fetched yet.')}

) : ( <>
{/* Search Bar */}
setSearchKeyword(e.target.value)} className='pl-9' />
{/* Tabs for New vs Existing vs Removed */} 0 ? 'new' : removedModels.length > 0 ? 'removed' : 'existing' } > 0 ? 'grid-cols-3' : 'grid-cols-2'}`} > {t('New Models ({{count}})', { count: newModels.length })} {t('Existing Models ({{count}})', { count: existingFilteredModels.length, })} {removedModels.length > 0 && ( {t('Removed Models ({{count}})', { count: removedModels.length, })} )} {getSortedCategoryEntries(newModelsByCategory).map( ([category, models]) => renderModelCategory(category, models) )} {getSortedCategoryEntries(existingModelsByCategory).map( ([category, models]) => renderModelCategory(category, models) )} {removedModels.length > 0 && (

{t( 'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.' )}

{renderModelCategory(t('Removed'), removedModels)}
)}
{/* Selection Summary */}
{t('{{n}} model(s) selected', { n: selectedModels.length })}
)}
) }