refactor(ModelList): improve model list style and grouping (#5674)

* refactor(ModelList): make the group removing button consistent with model removing button

* refactor(ModelList): improve auto-grouping, reduce the number of groups

* refactor(EditModelsPopup): extract model group tools

* refactor(naming): add special grouping rules for some providers

* feat(ModelList): add a button to add/remove all the listed models

* refactor(naming): update auto grouping

* refactor: update auto grouping for dmxapi

* revert: remove ungrouped
This commit is contained in:
one 2025-05-08 23:20:29 +08:00 committed by GitHub
parent 4c3c863c7d
commit 83cea0750d
10 changed files with 267 additions and 167 deletions

View File

@ -1389,6 +1389,8 @@
"models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used", "models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used",
"models.empty": "No models found", "models.empty": "No models found",
"models.enable_topic_naming": "Topic Auto Naming", "models.enable_topic_naming": "Topic Auto Naming",
"models.manage.add_listed": "Add models to the list",
"models.manage.remove_listed": "Remove models from the list",
"models.manage.add_whole_group": "Add the whole group", "models.manage.add_whole_group": "Add the whole group",
"models.manage.remove_whole_group": "Remove the whole group", "models.manage.remove_whole_group": "Remove the whole group",
"models.topic_naming_model": "Topic Naming Model", "models.topic_naming_model": "Topic Naming Model",

View File

@ -1387,6 +1387,8 @@
"models.default_assistant_model_description": "新しいアシスタントを作成する際に使用されるモデル。アシスタントがモデルを設定していない場合、このモデルが使用されます", "models.default_assistant_model_description": "新しいアシスタントを作成する際に使用されるモデル。アシスタントがモデルを設定していない場合、このモデルが使用されます",
"models.empty": "モデルが見つかりません", "models.empty": "モデルが見つかりません",
"models.enable_topic_naming": "トピックの自動命名", "models.enable_topic_naming": "トピックの自動命名",
"models.manage.add_listed": "リストにモデルを追加",
"models.manage.remove_listed": "リストからモデルを削除",
"models.manage.add_whole_group": "グループ全体を追加", "models.manage.add_whole_group": "グループ全体を追加",
"models.manage.remove_whole_group": "グループ全体を削除", "models.manage.remove_whole_group": "グループ全体を削除",
"models.topic_naming_model": "トピック命名モデル", "models.topic_naming_model": "トピック命名モデル",

View File

@ -1387,6 +1387,8 @@
"models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель", "models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель",
"models.empty": "Модели не найдены", "models.empty": "Модели не найдены",
"models.enable_topic_naming": "Автоматическое переименование топика", "models.enable_topic_naming": "Автоматическое переименование топика",
"models.manage.add_listed": "Добавить в список",
"models.manage.remove_listed": "Удалить из списка",
"models.manage.add_whole_group": "Добавить всю группу", "models.manage.add_whole_group": "Добавить всю группу",
"models.manage.remove_whole_group": "Удалить всю группу", "models.manage.remove_whole_group": "Удалить всю группу",
"models.topic_naming_model": "Модель именования топика", "models.topic_naming_model": "Модель именования топика",

View File

@ -1389,6 +1389,8 @@
"models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型", "models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型",
"models.empty": "没有模型", "models.empty": "没有模型",
"models.enable_topic_naming": "话题自动重命名", "models.enable_topic_naming": "话题自动重命名",
"models.manage.add_listed": "添加列表中的模型",
"models.manage.remove_listed": "移除列表中的模型",
"models.manage.add_whole_group": "添加整个分组", "models.manage.add_whole_group": "添加整个分组",
"models.manage.remove_whole_group": "移除整个分组", "models.manage.remove_whole_group": "移除整个分组",
"models.topic_naming_model": "话题命名模型", "models.topic_naming_model": "话题命名模型",

View File

@ -1388,6 +1388,8 @@
"models.default_assistant_model_description": "建立新助手時使用的模型,如果助手未設定模型,則使用此模型", "models.default_assistant_model_description": "建立新助手時使用的模型,如果助手未設定模型,則使用此模型",
"models.empty": "找不到模型", "models.empty": "找不到模型",
"models.enable_topic_naming": "話題自動重新命名", "models.enable_topic_naming": "話題自動重新命名",
"models.manage.add_listed": "添加列表中的模型",
"models.manage.remove_listed": "移除列表中的模型",
"models.manage.add_whole_group": "新增整個分組", "models.manage.add_whole_group": "新增整個分組",
"models.manage.remove_whole_group": "移除整個分組", "models.manage.remove_whole_group": "移除整個分組",
"models.topic_naming_model": "話題命名模型", "models.topic_naming_model": "話題命名模型",

View File

@ -104,7 +104,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
maxLength={200} maxLength={200}
onChange={(e) => { onChange={(e) => {
form.setFieldValue('name', e.target.value) form.setFieldValue('name', e.target.value)
form.setFieldValue('group', getDefaultGroupName(e.target.value)) form.setFieldValue('group', getDefaultGroupName(e.target.value, provider.id))
}} }}
/> />
</Form.Item> </Form.Item>

View File

@ -22,7 +22,7 @@ import { Avatar, Button, Empty, Flex, Modal, Tabs, Tooltip, Typography } from 'a
import Input from 'antd/es/input/Input' import Input from 'antd/es/input/Input'
import { groupBy, isEmpty, uniqBy } from 'lodash' import { groupBy, isEmpty, uniqBy } from 'lodash'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -83,39 +83,36 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
} }
}) })
const modelGroups = const modelGroups = useMemo(
provider.id === 'dashscope' () =>
? { provider.id === 'dashscope'
...groupBy( ? {
list.filter((model) => !model.id.startsWith('qwen')), ...groupBy(
'group' list.filter((model) => !model.id.startsWith('qwen')),
), 'group'
...groupQwenModels(list.filter((model) => model.id.startsWith('qwen'))) ),
} ...groupQwenModels(list.filter((model) => model.id.startsWith('qwen')))
: groupBy(list, 'group') }
: groupBy(list, 'group'),
[list, provider.id]
)
const onOk = () => { const onOk = useCallback(() => setOpen(false), [])
setOpen(false)
}
const onCancel = () => { const onCancel = useCallback(() => setOpen(false), [])
setOpen(false)
}
const onClose = () => { const onClose = useCallback(() => resolve({}), [resolve])
resolve({})
}
const onAddModel = (model: Model) => { const onAddModel = useCallback(
if (isEmpty(model.name)) { (model: Model) => {
return if (!isEmpty(model.name)) {
} addModel(model)
addModel(model) }
} },
[addModel]
)
const onRemoveModel = (model: Model) => { const onRemoveModel = useCallback((model: Model) => removeModel(model), [removeModel])
removeModel(model)
}
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
@ -129,7 +126,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
// @ts-ignore name // @ts-ignore name
name: model.name || model.id, name: model.name || model.id,
provider: _provider.id, provider: _provider.id,
group: getDefaultGroupName(model.id), group: getDefaultGroupName(model.id, _provider.id),
// @ts-ignore name // @ts-ignore name
description: model?.description, description: model?.description,
owned_by: model?.owned_by owned_by: model?.owned_by
@ -165,6 +162,63 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
) )
} }
const renderTopTools = useCallback(() => {
const isAllFilteredInProvider = list.length > 0 && list.every((model) => isModelInProvider(provider, model.id))
return (
<Tooltip
destroyTooltipOnHide
title={
isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed')
}
placement="top">
<Button
type={isAllFilteredInProvider ? 'default' : 'primary'}
icon={isAllFilteredInProvider ? <MinusOutlined /> : <PlusOutlined />}
size="large"
onClick={(e) => {
e.stopPropagation()
if (isAllFilteredInProvider) {
list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel)
} else {
list.filter((model) => !isModelInProvider(provider, model.id)).forEach(onAddModel)
}
}}
disabled={list.length === 0}
/>
</Tooltip>
)
}, [list, provider, onAddModel, onRemoveModel, t])
const renderGroupTools = useCallback(
(group: string) => {
const isAllInProvider = modelGroups[group].every((model) => isModelInProvider(provider, model.id))
return (
<Tooltip
destroyTooltipOnHide
title={
isAllInProvider
? t(`settings.models.manage.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`)
}
placement="top">
<Button
type="text"
icon={isAllInProvider ? <MinusOutlined /> : <PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
if (isAllInProvider) {
modelGroups[group].filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel)
} else {
modelGroups[group].filter((model) => !isModelInProvider(provider, model.id)).forEach(onAddModel)
}
}}
/>
</Tooltip>
)
},
[modelGroups, provider, onRemoveModel, onAddModel, t]
)
return ( return (
<Modal <Modal
title={<ModalHeader />} title={<ModalHeader />}
@ -180,14 +234,17 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}} }}
centered> centered>
<SearchContainer> <SearchContainer>
<Input <TopToolsWrapper>
prefix={<Search size={14} />} <Input
size="large" prefix={<Search size={14} />}
ref={searchInputRef} size="large"
placeholder={t('settings.provider.search_placeholder')} ref={searchInputRef}
allowClear placeholder={t('settings.provider.search_placeholder')}
onChange={(e) => setSearchText(e.target.value)} allowClear
/> onChange={(e) => setSearchText(e.target.value)}
/>
{renderTopTools()}
</TopToolsWrapper>
<Tabs <Tabs
size={i18n.language.startsWith('zh') ? 'middle' : 'small'} size={i18n.language.startsWith('zh') ? 'middle' : 'small'}
defaultActiveKey="all" defaultActiveKey="all"
@ -206,7 +263,6 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
</SearchContainer> </SearchContainer>
<ListContainer> <ListContainer>
{Object.keys(modelGroups).map((group, i) => { {Object.keys(modelGroups).map((group, i) => {
const isAllInProvider = modelGroups[group].every((model) => isModelInProvider(provider, model.id))
return ( return (
<CustomCollapse <CustomCollapse
key={i} key={i}
@ -220,31 +276,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
</CustomTag> </CustomTag>
</Flex> </Flex>
} }
extra={ extra={renderGroupTools(group)}>
<Tooltip
destroyTooltipOnHide
title={
isAllInProvider
? t(`settings.models.manage.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`)
}
placement="top">
<Button
type="text"
icon={isAllInProvider ? <MinusOutlined /> : <PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
if (isAllInProvider) {
modelGroups[group]
.filter((model) => isModelInProvider(provider, model.id))
.forEach(onRemoveModel)
} else {
modelGroups[group].filter((model) => !isModelInProvider(provider, model.id)).forEach(onAddModel)
}
}}
/>
</Tooltip>
}>
<FlexColumn style={{ margin: '10px 0' }}> <FlexColumn style={{ margin: '10px 0' }}>
{modelGroups[group].map((model) => ( {modelGroups[group].map((model) => (
<FileItem <FileItem
@ -325,6 +357,12 @@ const SearchContainer = styled.div`
} }
` `
const TopToolsWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ListContainer = styled.div` const ListContainer = styled.div`
height: calc(100vh - 300px); height: calc(100vh - 300px);
overflow-y: scroll; overflow-y: scroll;

View File

@ -3,7 +3,6 @@ import {
CloseCircleFilled, CloseCircleFilled,
ExclamationCircleFilled, ExclamationCircleFilled,
LoadingOutlined, LoadingOutlined,
MinusCircleOutlined,
MinusOutlined, MinusOutlined,
PlusOutlined PlusOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
@ -244,77 +243,81 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
<> <>
<Flex gap={12} vertical> <Flex gap={12} vertical>
{Object.keys(sortedModelGroups).map((group, i) => ( {Object.keys(sortedModelGroups).map((group, i) => (
<CustomCollapse <CustomCollapseWrapper key={group}>
defaultActiveKey={i <= 5 ? ['1'] : []} <CustomCollapse
key={group} defaultActiveKey={i <= 5 ? ['1'] : []}
label={ label={
<Flex align="center" gap={10}> <Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span> <span style={{ fontWeight: 600 }}>{group}</span>
</Flex> </Flex>
} }
extra={ extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}> <Tooltip title={t('settings.models.manage.remove_whole_group')}>
<HoveredRemoveIcon <Button
onClick={() => type="text"
modelGroups[group] className="toolbar-item"
.filter((model) => provider.models.some((m) => m.id === model.id)) icon={<MinusOutlined />}
.forEach((model) => removeModel(model)) onClick={() =>
} modelGroups[group]
/> .filter((model) => provider.models.some((m) => m.id === model.id))
</Tooltip> .forEach((model) => removeModel(model))
}> }
<Flex gap={10} vertical style={{ marginTop: 10 }}> />
{sortedModelGroups[group].map((model) => { </Tooltip>
const modelStatus = modelStatuses.find((status) => status.model.id === model.id) }>
const isChecking = modelStatus?.checking === true <Flex gap={10} vertical style={{ marginTop: 10 }}>
{sortedModelGroups[group].map((model) => {
const modelStatus = modelStatuses.find((status) => status.model.id === model.id)
const isChecking = modelStatus?.checking === true
return ( return (
<ListItem key={model.id}> <ListItem key={model.id}>
<HStack alignItems="center" gap={10} style={{ flex: 1 }}> <HStack alignItems="center" gap={10} style={{ flex: 1 }}>
<Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}> <Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}>
{model?.name?.[0]?.toUpperCase()} {model?.name?.[0]?.toUpperCase()}
</Avatar> </Avatar>
<ListItemName> <ListItemName>
<Tooltip <Tooltip
styles={{ styles={{
root: { root: {
width: 'auto', width: 'auto',
maxWidth: '500px' maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
} }
}} placement="top">
destroyTooltipOnHide <NameSpan>{model.name}</NameSpan>
title={ </Tooltip>
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}> <ModelTagsWithLabel model={model} size={11} style={{ flexShrink: 0 }} />
{model.id} </ListItemName>
</Typography.Text> </HStack>
} <Flex gap={4} align="center">
placement="top"> {renderLatencyText(modelStatus)}
<NameSpan>{model.name}</NameSpan> {renderStatusIndicator(modelStatus)}
</Tooltip> <Button
<ModelTagsWithLabel model={model} size={11} style={{ flexShrink: 0 }} /> type="text"
</ListItemName> onClick={() => !isChecking && onEditModel(model)}
</HStack> disabled={isChecking}
<Flex gap={4} align="center"> icon={<Bolt size={16} />}
{renderLatencyText(modelStatus)} />
{renderStatusIndicator(modelStatus)} <Button
<Button type="text"
type="text" onClick={() => !isChecking && removeModel(model)}
onClick={() => !isChecking && onEditModel(model)} disabled={isChecking}
disabled={isChecking} icon={<MinusOutlined />}
icon={<Bolt size={16} />} />
/> </Flex>
<Button </ListItem>
type="text" )
onClick={() => !isChecking && removeModel(model)} })}
disabled={isChecking} </Flex>
icon={<MinusOutlined />} </CustomCollapse>
/> </CustomCollapseWrapper>
</Flex>
</ListItem>
)
})}
</Flex>
</CustomCollapse>
))} ))}
{docsWebsite && ( {docsWebsite && (
<SettingHelpTextRow> <SettingHelpTextRow>
@ -352,6 +355,19 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
) )
} }
const CustomCollapseWrapper = styled.div`
.toolbar-item {
margin-top: 2px;
transform: translateZ(0);
will-change: opacity;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .toolbar-item {
opacity: 1;
}
`
const ListItem = styled.div` const ListItem = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -387,24 +403,6 @@ const NameSpan = styled.span`
font-size: 14px; font-size: 14px;
` `
const RemoveIcon = styled(MinusCircleOutlined)`
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--color-error);
cursor: pointer;
transition: all 0.2s ease-in-out;
`
const HoveredRemoveIcon = styled(RemoveIcon)`
opacity: 0;
margin-top: 2px;
&:hover {
opacity: 1;
}
`
const StatusIndicator = styled.div<{ type: string }>` const StatusIndicator = styled.div<{ type: string }>`
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -115,14 +115,45 @@ describe('naming', () => {
expect(getDefaultGroupName('group:model')).toBe('group') expect(getDefaultGroupName('group:model')).toBe('group')
}) })
it('should extract group name from ID with space', () => {
// 验证从包含空格的 ID 中提取组名
expect(getDefaultGroupName('foo bar')).toBe('foo')
})
it('should extract group name from ID with hyphen', () => { it('should extract group name from ID with hyphen', () => {
// 验证从包含连字符的 ID 中提取组名 // 验证从包含连字符的 ID 中提取组名
expect(getDefaultGroupName('group-subgroup-model')).toBe('group-subgroup') expect(getDefaultGroupName('group-subgroup-model')).toBe('group-subgroup')
}) })
it('should return original ID if no separators', () => { it('should use first delimiters for special providers', () => {
// 验证没有分隔符时返回原始 ID // 这些 provider 下,'/', ' ', '-', '_', ':' 都属于第一类分隔符分割后取第0部分
expect(getDefaultGroupName('group')).toBe('group') const specialProviders = ['aihubmix', 'silicon', 'ocoolai', 'o3', 'dmxapi']
specialProviders.forEach((provider) => {
expect(getDefaultGroupName('Qwen/Qwen3-32B', provider)).toBe('qwen')
expect(getDefaultGroupName('gpt-4.1-mini', provider)).toBe('gpt')
expect(getDefaultGroupName('gpt-4.1', provider)).toBe('gpt')
expect(getDefaultGroupName('gpt_4.1', provider)).toBe('gpt')
expect(getDefaultGroupName('DeepSeek Chat', provider)).toBe('deepseek')
expect(getDefaultGroupName('foo:bar', provider)).toBe('foo')
})
})
it('should use first and second delimiters for default providers', () => {
// 默认情况下,'/', ' ', ':' 属于第一类分隔符,'-' '_' 属于第二类
expect(getDefaultGroupName('Qwen/Qwen3-32B', 'foobar')).toBe('qwen')
expect(getDefaultGroupName('gpt-4.1-mini', 'foobar')).toBe('gpt-4.1')
expect(getDefaultGroupName('gpt-4.1', 'foobar')).toBe('gpt-4.1')
expect(getDefaultGroupName('DeepSeek Chat', 'foobar')).toBe('deepseek')
expect(getDefaultGroupName('foo:bar', 'foobar')).toBe('foo')
})
it('should fallback to id if no delimiters', () => {
// 没有分隔符时返回 id
const specialProviders = ['aihubmix', 'silicon', 'ocoolai', 'o3', 'dmxapi']
specialProviders.forEach((provider) => {
expect(getDefaultGroupName('o3', provider)).toBe('o3')
})
expect(getDefaultGroupName('o3', 'openai')).toBe('o3')
}) })
}) })

View File

@ -1,26 +1,49 @@
/** /**
* ID * ID
*
* 1. 0
* 2. 'a-b-c' 'a-b'
* 3. id
*
* *
* - 'gpt-3.5-turbo-16k-0613' 'GPT-3.5-Turbo' * - 'gpt-3.5-turbo-16k-0613' => 'gpt-3.5'
* - 'qwen2:1.5b' 'QWEN2' * - 'qwen3:32b' => 'qwen3'
* - 'Qwen/Qwen3-32b' => 'qwen'
* - 'deepseek-r1' => 'deepseek-r1'
* - 'o3' => 'o3'
*
* @param id ID * @param id ID
* @param provider ID
* @returns string * @returns string
*/ */
export const getDefaultGroupName = (id: string) => { export const getDefaultGroupName = (id: string, provider?: string) => {
if (id.includes('/')) { const str = id.toLowerCase()
return id.split('/')[0]
// 定义分隔符
let firstDelimiters = ['/', ' ', ':']
let secondDelimiters = ['-', '_']
if (provider && ['aihubmix', 'silicon', 'ocoolai', 'o3', 'dmxapi'].includes(provider.toLowerCase())) {
firstDelimiters = ['/', ' ', '-', '_', ':']
secondDelimiters = []
} }
if (id.includes(':')) { // 第一类分隔规则
return id.split(':')[0] for (const delimiter of firstDelimiters) {
if (str.includes(delimiter)) {
return str.split(delimiter)[0]
}
} }
if (id.includes('-')) { // 第二类分隔规则
const parts = id.split('-') for (const delimiter of secondDelimiters) {
return parts[0] + '-' + parts[1] if (str.includes(delimiter)) {
const parts = str.split(delimiter)
return parts.length > 1 ? parts[0] + '-' + parts[1] : parts[0]
}
} }
return id return str
} }
/** /**