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.empty": "No models found",
"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.remove_whole_group": "Remove the whole group",
"models.topic_naming_model": "Topic Naming Model",

View File

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

View File

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

View File

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

View File

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

View File

@ -104,7 +104,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
maxLength={200}
onChange={(e) => {
form.setFieldValue('name', e.target.value)
form.setFieldValue('group', getDefaultGroupName(e.target.value))
form.setFieldValue('group', getDefaultGroupName(e.target.value, provider.id))
}}
/>
</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 { groupBy, isEmpty, uniqBy } from 'lodash'
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 styled from 'styled-components'
@ -83,39 +83,36 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}
})
const modelGroups =
provider.id === 'dashscope'
? {
...groupBy(
list.filter((model) => !model.id.startsWith('qwen')),
'group'
),
...groupQwenModels(list.filter((model) => model.id.startsWith('qwen')))
}
: groupBy(list, 'group')
const modelGroups = useMemo(
() =>
provider.id === 'dashscope'
? {
...groupBy(
list.filter((model) => !model.id.startsWith('qwen')),
'group'
),
...groupQwenModels(list.filter((model) => model.id.startsWith('qwen')))
}
: groupBy(list, 'group'),
[list, provider.id]
)
const onOk = () => {
setOpen(false)
}
const onOk = useCallback(() => setOpen(false), [])
const onCancel = () => {
setOpen(false)
}
const onCancel = useCallback(() => setOpen(false), [])
const onClose = () => {
resolve({})
}
const onClose = useCallback(() => resolve({}), [resolve])
const onAddModel = (model: Model) => {
if (isEmpty(model.name)) {
return
}
addModel(model)
}
const onAddModel = useCallback(
(model: Model) => {
if (!isEmpty(model.name)) {
addModel(model)
}
},
[addModel]
)
const onRemoveModel = (model: Model) => {
removeModel(model)
}
const onRemoveModel = useCallback((model: Model) => removeModel(model), [removeModel])
useEffect(() => {
runAsyncFunction(async () => {
@ -129,7 +126,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
// @ts-ignore name
name: model.name || model.id,
provider: _provider.id,
group: getDefaultGroupName(model.id),
group: getDefaultGroupName(model.id, _provider.id),
// @ts-ignore name
description: model?.description,
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 (
<Modal
title={<ModalHeader />}
@ -180,14 +234,17 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}}
centered>
<SearchContainer>
<Input
prefix={<Search size={14} />}
size="large"
ref={searchInputRef}
placeholder={t('settings.provider.search_placeholder')}
allowClear
onChange={(e) => setSearchText(e.target.value)}
/>
<TopToolsWrapper>
<Input
prefix={<Search size={14} />}
size="large"
ref={searchInputRef}
placeholder={t('settings.provider.search_placeholder')}
allowClear
onChange={(e) => setSearchText(e.target.value)}
/>
{renderTopTools()}
</TopToolsWrapper>
<Tabs
size={i18n.language.startsWith('zh') ? 'middle' : 'small'}
defaultActiveKey="all"
@ -206,7 +263,6 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
</SearchContainer>
<ListContainer>
{Object.keys(modelGroups).map((group, i) => {
const isAllInProvider = modelGroups[group].every((model) => isModelInProvider(provider, model.id))
return (
<CustomCollapse
key={i}
@ -220,31 +276,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
</CustomTag>
</Flex>
}
extra={
<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>
}>
extra={renderGroupTools(group)}>
<FlexColumn style={{ margin: '10px 0' }}>
{modelGroups[group].map((model) => (
<FileItem
@ -325,6 +357,12 @@ const SearchContainer = styled.div`
}
`
const TopToolsWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ListContainer = styled.div`
height: calc(100vh - 300px);
overflow-y: scroll;

View File

@ -3,7 +3,6 @@ import {
CloseCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
MinusCircleOutlined,
MinusOutlined,
PlusOutlined
} from '@ant-design/icons'
@ -244,77 +243,81 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
<>
<Flex gap={12} vertical>
{Object.keys(sortedModelGroups).map((group, i) => (
<CustomCollapse
defaultActiveKey={i <= 5 ? ['1'] : []}
key={group}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<HoveredRemoveIcon
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/>
</Tooltip>
}>
<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
<CustomCollapseWrapper key={group}>
<CustomCollapse
defaultActiveKey={i <= 5 ? ['1'] : []}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<Button
type="text"
className="toolbar-item"
icon={<MinusOutlined />}
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/>
</Tooltip>
}>
<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 (
<ListItem key={model.id}>
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
<Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
return (
<ListItem key={model.id}>
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
<Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
placement="top">
<NameSpan>{model.name}</NameSpan>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} style={{ flexShrink: 0 }} />
</ListItemName>
</HStack>
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
{renderStatusIndicator(modelStatus)}
<Button
type="text"
onClick={() => !isChecking && onEditModel(model)}
disabled={isChecking}
icon={<Bolt size={16} />}
/>
<Button
type="text"
onClick={() => !isChecking && removeModel(model)}
disabled={isChecking}
icon={<MinusOutlined />}
/>
</Flex>
</ListItem>
)
})}
</Flex>
</CustomCollapse>
placement="top">
<NameSpan>{model.name}</NameSpan>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} style={{ flexShrink: 0 }} />
</ListItemName>
</HStack>
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
{renderStatusIndicator(modelStatus)}
<Button
type="text"
onClick={() => !isChecking && onEditModel(model)}
disabled={isChecking}
icon={<Bolt size={16} />}
/>
<Button
type="text"
onClick={() => !isChecking && removeModel(model)}
disabled={isChecking}
icon={<MinusOutlined />}
/>
</Flex>
</ListItem>
)
})}
</Flex>
</CustomCollapse>
</CustomCollapseWrapper>
))}
{docsWebsite && (
<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`
display: flex;
flex-direction: row;
@ -387,24 +403,6 @@ const NameSpan = styled.span`
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 }>`
display: flex;
align-items: center;

View File

@ -115,14 +115,45 @@ describe('naming', () => {
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', () => {
// 验证从包含连字符的 ID 中提取组名
expect(getDefaultGroupName('group-subgroup-model')).toBe('group-subgroup')
})
it('should return original ID if no separators', () => {
// 验证没有分隔符时返回原始 ID
expect(getDefaultGroupName('group')).toBe('group')
it('should use first delimiters for special providers', () => {
// 这些 provider 下,'/', ' ', '-', '_', ':' 都属于第一类分隔符分割后取第0部分
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
*
* 1. 0
* 2. 'a-b-c' 'a-b'
* 3. id
*
*
* - 'gpt-3.5-turbo-16k-0613' 'GPT-3.5-Turbo'
* - 'qwen2:1.5b' 'QWEN2'
* - 'gpt-3.5-turbo-16k-0613' => 'gpt-3.5'
* - 'qwen3:32b' => 'qwen3'
* - 'Qwen/Qwen3-32b' => 'qwen'
* - 'deepseek-r1' => 'deepseek-r1'
* - 'o3' => 'o3'
*
* @param id ID
* @param provider ID
* @returns string
*/
export const getDefaultGroupName = (id: string) => {
if (id.includes('/')) {
return id.split('/')[0]
export const getDefaultGroupName = (id: string, provider?: string) => {
const str = id.toLowerCase()
// 定义分隔符
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('-')
return parts[0] + '-' + parts[1]
// 第二类分隔规则
for (const delimiter of secondDelimiters) {
if (str.includes(delimiter)) {
const parts = str.split(delimiter)
return parts.length > 1 ? parts[0] + '-' + parts[1] : parts[0]
}
}
return id
return str
}
/**