feat(channels): refine fetched model categorization (#6632)
* feat(channels): refine fetched model categorization * fix: channel category * fix: hy3 category
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user