feat(channels): refine fetched model categorization (#6632)

* feat(channels): refine fetched model categorization

* fix: channel category

* fix: hy3 category
This commit is contained in:
Seefs
2026-08-07 13:34:56 +08:00
committed by GitHub
parent 0cd9dc85e3
commit c9bc038649
3 changed files with 338 additions and 177 deletions
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -41,17 +41,16 @@ import {
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
categorizeModels,
categorizeModelsWithRedirect,
channelsQueryKeys,
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))
)
return [...new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))]
}
type FetchModelsDialogProps = {
@@ -140,8 +139,8 @@ export function FetchModelsDialog({
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
const response = await fetchUpstreamModels(activeChannel!.id)
} else if (activeChannel) {
const response = await fetchUpstreamModels(activeChannel.id)
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
setFetchedModels(list)
@@ -202,45 +201,6 @@ export function FetchModelsDialog({
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
@@ -249,18 +209,30 @@ export function FetchModelsDialog({
)
}, [fetchedModels, searchKeyword])
// Helper to check if a model is considered "existing" (in selected or redirect)
const isExistingModel = (model: string) =>
classificationSet.has(normalizeModelName(model))
const {
newModels,
existingFilteredModels,
newModelsByCategory,
existingModelsByCategory,
} = useMemo(() => {
const newModels: string[] = []
const existingFilteredModels: string[] = []
// Separate new and existing models
const newModels = filteredModels.filter((m) => !isExistingModel(m))
const existingFilteredModels = filteredModels.filter((m) =>
isExistingModel(m)
)
for (const model of filteredModels) {
if (classificationSet.has(normalizeModelName(model))) {
existingFilteredModels.push(model)
} else {
newModels.push(model)
}
}
const newModelsByCategory = categorizeModels(newModels)
const existingModelsByCategory = categorizeModels(existingFilteredModels)
return {
newModels,
existingFilteredModels,
newModelsByCategory: categorizeModels(newModels),
existingModelsByCategory: categorizeModels(existingFilteredModels),
}
}, [classificationSet, filteredModels])
// 厂商分类按 a-z 排序,Other 放最后,便于查找
const getSortedCategoryEntries = (
@@ -345,7 +317,7 @@ export function FetchModelsDialog({
<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>
@@ -365,24 +337,143 @@ export function FetchModelsDialog({
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
let dialogDescription: ReactNode = t('Fetch available models from upstream')
if (activeChannel) {
dialogDescription = (
<>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
)
} else if (channelName) {
dialogDescription = (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
)
}
let defaultTab = 'existing'
if (newModels.length > 0) {
defaultTab = 'new'
} else if (removedModels.length > 0) {
defaultTab = 'removed'
}
let dialogBody: ReactNode
if (!activeChannel && !customFetcher) {
dialogBody = (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
)
} else if (isFetching) {
dialogBody = (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
)
} else if (fetchedModels.length === 0 && removedModels.length === 0) {
dialogBody = (
<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>
)
} else {
dialogBody = (
<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={defaultTab}
>
<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>
)
}
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')
)
}
description={dialogDescription}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
@@ -400,113 +491,7 @@ export function FetchModelsDialog({
) : 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>
</>
)}
{dialogBody}
</Dialog>
)
}
+1
View File
@@ -26,3 +26,4 @@ export * from './channel-type-config'
export * from './channel-utils'
export * from './multi-key-utils'
export * from './model-mapping-validation'
export * from './model-categories'
@@ -0,0 +1,175 @@
/*
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
*/
type ModelCategoryRule = {
name: string
keywords?: readonly string[]
pattern?: RegExp
}
// Rules are ordered so platform-specific IDs such as Perplexity's Sonar and
// NVIDIA's Nemotron take precedence over the base Llama/Mixtral family name.
const MODEL_CATEGORY_RULES: readonly ModelCategoryRule[] = [
{ name: 'Perplexity', keywords: ['perplexity', 'sonar-'] },
{ name: 'NVIDIA', keywords: ['nvidia/', 'nvidia.', 'nemotron'] },
{
name: 'OpenAI',
keywords: [
'openai/',
'openai.',
'gpt-',
'chatgpt-',
'codex-',
'dall-e-',
'whisper-',
'tts-',
'omni-moderation-',
'text-moderation-',
'text-embedding-ada-',
'text-embedding-3-',
'text-ada-',
'text-babbage-',
'text-curie-',
'davinci-',
'babbage-',
'computer-use-preview',
'sora',
],
pattern: /(?:^|[/.:])o(?:1|3|4)(?=$|[-.:])/,
},
{ name: 'Anthropic', keywords: ['anthropic', 'claude'] },
{
name: 'Gemini',
keywords: [
'gemini',
'gemma',
'learnlm',
'imagen',
'veo',
'nano-banana',
'palm-',
],
pattern: /(?:^|[/.:])aqa$/,
},
{ name: 'xAI', keywords: ['x-ai/', 'xai/', 'xai-', 'grok'] },
{ name: 'DeepSeek', keywords: ['deepseek'] },
{
name: 'Qwen',
keywords: ['qwen', 'qwq-', 'qvq-', 'tongyi', 'gte-'],
pattern: /(?:^|[/.:])(?:text-embedding-v\d+|gui-plus|z-image)(?:$|[-_.:])/,
},
{ name: 'Wan', pattern: /(?:^|[/.:])wan(?:x?\d|[-_])/ },
{ name: 'Moonshot', keywords: ['moonshot', 'kimi-'] },
{
name: 'MiniMax',
keywords: ['minimax', 'abab', 'hailuo'],
pattern: /^(?:t2v|i2v|s2v)-01(?:-|$)/,
},
{
name: 'Doubao',
keywords: ['doubao', 'volcengine', 'seedance', 'seedream', 'seed-1-'],
},
{
name: 'Zhipu',
keywords: ['zhipu', 'zai-org', 'thudm', 'chatglm', 'cogview', 'cogvideo'],
pattern: /(?:^|[/._-])glm(?=$|[-._])/,
},
{ name: 'Baidu', keywords: ['baidu', 'wenxin', 'ernie'] },
{ name: 'Yi', keywords: ['01-ai/'], pattern: /(?:^|[/.:])yi(?=$|[-_])/ },
{ name: 'iFlytek', keywords: ['iflytek', 'sparkdesk'] },
{
name: 'Tencent',
keywords: ['tencent', 'hunyuan'],
pattern: /(?:^|[/.:])hy\d*(?=$|[-_.:])/,
},
{ name: 'Baichuan', keywords: ['baichuan'] },
{ name: 'InternLM', keywords: ['internlm'] },
{ name: 'StepFun', keywords: ['stepfun', 'step-'] },
{ name: 'MiMo', keywords: ['xiaomi', 'mimo-'] },
{
name: 'Mistral',
keywords: [
'mistral',
'mixtral',
'codestral',
'ministral',
'pixtral',
'magistral',
],
},
{ name: 'Meta', keywords: ['meta-llama', 'llama-', 'llama2', 'llama3'] },
{
name: 'Cohere',
keywords: ['cohere', 'command-', 'c4ai-aya', 'aya-'],
pattern: /(?:^|[/.:])command$/,
},
{ name: 'Jina', keywords: ['jinaai', 'jina-'] },
{ name: 'BAAI', keywords: ['baai/', 'bge-'] },
{ name: 'Black Forest Labs', keywords: ['black-forest-labs', 'flux.'] },
{
name: 'Microsoft',
keywords: ['microsoft/'],
pattern: /(?:^|[/.:])phi(?=$|[-._])/,
},
{
name: 'Amazon',
keywords: ['amazon/', 'amazon.', 'nova-', 'titan-'],
},
{ name: 'AI21 Labs', keywords: ['ai21', 'jamba'] },
{
name: 'Stability AI',
keywords: ['stabilityai', 'stable-diffusion', 'stable-image', 'sdxl-'],
},
{ name: 'Nous Research', keywords: ['nousresearch', 'hermes-'] },
{ name: '360 AI', keywords: ['360gpt', '360zhinao'] },
{ name: 'Midjourney', keywords: ['midjourney', 'mj_', 'mj-', 'swap_face'] },
{ name: 'Kling', keywords: ['kling'] },
{ name: 'Vidu', keywords: ['vidu'] },
{ name: 'Suno', keywords: ['suno'] },
{ name: 'Jimeng', keywords: ['jimeng'] },
]
export function getModelCategory(modelName: string): string {
const normalizedName = modelName.trim().toLowerCase()
for (const rule of MODEL_CATEGORY_RULES) {
if (
rule.keywords?.some((keyword) => normalizedName.includes(keyword)) ||
rule.pattern?.test(normalizedName)
) {
return rule.name
}
}
return 'Other'
}
export function categorizeModels(
models: readonly string[]
): Record<string, string[]> {
const categories: Record<string, string[]> = {}
for (const model of models) {
const category = getModelCategory(model)
categories[category] ??= []
categories[category].push(model)
}
return categories
}