Files
chaos-api/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx
T
QuentinHsu 2eaa943d9f perf(web): improve dialog sizing and footer layout
- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content.
- tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows.
- update channel terminology and dialog action translations across supported locales.
2026-06-06 21:49:33 +08:00

511 lines
17 KiB
TypeScript
Vendored

/*
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 <https://www.gnu.org/licenses/>.
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 { 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 { Dialog } from '@/components/dialog'
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<string[]>
existingModelsOverride?: string[]
channelName?: string | null
}
export function FetchModelsDialog({
open,
onOpenChange,
onModelsSelected,
redirectModels = [],
redirectSourceModels = [],
customFetcher,
existingModelsOverride,
channelName,
}: FetchModelsDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const activeChannel = customFetcher ? null : currentRow
const queryClient = useQueryClient()
const [isFetching, setIsFetching] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [searchKeyword, setSearchKeyword] = useState('')
// Parse existing models
const existingModels = useMemo(
() =>
existingModelsOverride ?? parseModelsString(activeChannel?.models || ''),
[existingModelsOverride, activeChannel?.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 && (activeChannel || customFetcher)) {
handleFetchModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeChannel?.id, customFetcher])
const handleFetchModels = async () => {
if (!activeChannel && !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(activeChannel!.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 (!activeChannel) return
setIsSaving(true)
try {
const modelsString = selectedModels.join(',')
const response = await updateChannel(activeChannel.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<string, string[]> = {}
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[]>
): [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 (
<Collapsible key={categoryName} defaultOpen>
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
<div className='flex items-center gap-2'>
<ChevronDown className='h-4 w-4' />
<span className='font-medium'>
{categoryName} ({categoryModels.length})
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
/ {categoryModels.length} selected
</span>
<Checkbox
checked={allSelected}
onCheckedChange={(checked) =>
toggleCategory(categoryModels, !!checked)
}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className='px-4 py-2'>
<div className='grid grid-cols-2 gap-2'>
{categoryModels.map((model) => (
<div key={model} className='flex items-center space-x-2'>
<Checkbox
id={model}
checked={selectedModels.includes(model)}
onCheckedChange={() => toggleModel(model)}
/>
<Label
htmlFor={model}
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip>
<TooltipTrigger
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
></TooltipTrigger>
<TooltipContent>
{t('From model redirect, not yet added to models list')}
</TooltipContent>
</Tooltip>
)}
</Label>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{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.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</>
)}
</Dialog>
)
}