diff --git a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx index cca720d9..6d7ce554 100644 --- a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -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 = {} - - 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({ } - > + /> {t('From model redirect, not yet added to models list')} @@ -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:')} {activeChannel.name} + + ) + } else if (channelName) { + dialogDescription = ( + <> + {t('Channel:')} {channelName} + + ) + } + + let defaultTab = 'existing' + if (newModels.length > 0) { + defaultTab = 'new' + } else if (removedModels.length > 0) { + defaultTab = 'removed' + } + + let dialogBody: ReactNode + if (!activeChannel && !customFetcher) { + dialogBody = ( +
+ {t('No channel selected')} +
+ ) + } else if (isFetching) { + dialogBody = ( +
+ +
+ ) + } else if (fetchedModels.length === 0 && removedModels.length === 0) { + dialogBody = ( +
+

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

+ +
+ ) + } else { + dialogBody = ( +
+ {/* Search Bar */} +
+ + setSearchKeyword(e.target.value)} + className='pl-9' + /> +
+ + {/* Tabs for New vs Existing vs Removed */} + + 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 })} +
+
+ ) + } + return ( - {t('Channel:')} {activeChannel.name} - - ) : channelName ? ( - <> - {t('Channel:')} {channelName} - - ) : ( - 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 ? ( -
- {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 })} -
-
- - )} + {dialogBody}
) } diff --git a/web/src/features/channels/lib/index.ts b/web/src/features/channels/lib/index.ts index 43eb7773..8c18151c 100644 --- a/web/src/features/channels/lib/index.ts +++ b/web/src/features/channels/lib/index.ts @@ -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' diff --git a/web/src/features/channels/lib/model-categories.ts b/web/src/features/channels/lib/model-categories.ts new file mode 100644 index 00000000..6d49cf65 --- /dev/null +++ b/web/src/features/channels/lib/model-categories.ts @@ -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 . + +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 { + const categories: Record = {} + + for (const model of models) { + const category = getModelCategory(model) + categories[category] ??= [] + categories[category].push(model) + } + + return categories +}