chore: catalog api

This commit is contained in:
jialin
2025-01-03 20:51:22 +08:00
parent 81d96fe3a9
commit d9319f5734
13 changed files with 313 additions and 294 deletions
+10 -11
View File
@@ -59,22 +59,21 @@ export default [
}
]
},
{
name: 'modelCatalog',
path: '/models/catalog',
key: 'modelsCatalog',
icon: 'icon-catalog',
access: 'canSeeAdmin',
component: './llmodels/catalog'
},
{
name: 'models',
path: '/models',
path: '/models/list',
key: 'models',
icon: 'Block',
access: 'canSeeAdmin',
component: './llmodels'
},
{
name: 'modelsCatalog',
path: '/models/catalog',
key: 'modelsCatalog',
icon: 'Block',
access: 'canSeeAdmin',
hideInMenu: true,
component: './llmodels/catalog'
component: './llmodels/index'
},
{
name: 'resources',
+1 -1
View File
@@ -2,7 +2,7 @@ import { createFromIconfontCN } from '@ant-design/icons';
// import './iconfont/iconfont.js';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_4613488_bslq45omhet.js'
scriptUrl: '//at.alicdn.com/t/c/font_4613488_trmdczqx9z.js'
});
export default IconFont;
+3 -1
View File
@@ -8,8 +8,10 @@ export default {
'menu.playground.text2images': 'Image',
'menu.compare': 'Compare',
'menu.models': 'Models',
'menu.models.modelList': 'Deploy & Manage',
'menu.models.modelCatalog': 'Catalog',
'menu.models.catalog': 'Model Catalog',
'menu.modelsCatalog': 'Model Catalog',
'menu.modelCatalog': 'Catalog',
'menu.resources': 'Resources',
'menu.apikeys': 'API Keys',
'menu.users': 'Users',
+3 -1
View File
@@ -8,8 +8,10 @@ export default {
'menu.playground.text2images': '文生图',
'menu.compare': '多模型对比',
'menu.models': '模型',
'menu.models.modelList': '部署与管理',
'menu.models.modelCatalog': '模型库',
'menu.modelCatalog': '模型库',
'menu.models.catalog': '模型库',
'menu.modelsCatalog': '模型库',
'menu.resources': '资源',
'menu.apikeys': 'API 密钥',
'menu.users': '用户',
+20
View File
@@ -3,6 +3,7 @@ import { PipelineType } from '@huggingface/tasks';
import { request } from '@umijs/max';
import qs from 'query-string';
import {
CatalogItem,
FormData,
GPUListItem,
ListItem,
@@ -314,3 +315,22 @@ export async function downloadModelScopeModelfile(
}
return res.json();
}
// ===================== catalog =====================
export async function queryCatalogList(
params: Global.SearchParams,
options?: any
) {
return request<Global.PageResponse<CatalogItem>>(`/model-sets`, {
methos: 'GET',
...options,
params
});
}
export async function queryCatalogItemSpec(id: number) {
return request(`/model-sets/${id}/specs`, {
method: 'GET'
});
}
+85 -47
View File
@@ -8,48 +8,68 @@ import { Button, Col, Input, Pagination, Row, Space, message } from 'antd';
import _ from 'lodash';
import ResizeObserver from 'rc-resize-observer';
import React, { useCallback, useEffect, useState } from 'react';
import { createModel, queryHuggingfaceModels } from './apis';
import { createModel, queryCatalogList } from './apis';
import CatalogItem from './components/catalog-item';
import DelopyBuiltInModal from './components/deploy-builtin-modal';
import { getSourceRepoConfigValue, modelSourceMap } from './config';
import testData from './config/test';
import { FormData } from './config/types';
import { CatalogItem as CatalogItemType, FormData } from './config/types';
const Catalog: React.FC = () => {
const intl = useIntl();
const [span, setSpan] = React.useState(8);
const [activeId, setActiveId] = React.useState(-1);
const [dataSource, setDataSource] = useState<{
dataList: CatalogItemType[];
loading: boolean;
total: number;
}>({
dataList: [],
loading: false,
total: 0
});
const [queryParams, setQueryParams] = useState({
page: 1,
perPage: 9,
search: ''
});
const [openDeployModal, setOpenDeployModal] = useState<any>({
show: false,
width: 600,
source: modelSourceMap.huggingface_value
});
const [dataList, setDataList] = useState<any[]>([]);
const getModelsFromHuggingface = useCallback(async (sort: string) => {
console.log('getModelsFromHuggingface:', sort);
const fetchData = useCallback(async () => {
setDataSource((pre) => {
pre.loading = true;
return { ...pre };
});
try {
const params = {
search: {
query: 'gpustack',
tags: ['gguf']
}
..._.pickBy(queryParams, (val: any) => !!val)
};
const data = await queryHuggingfaceModels(params);
let list = _.map(data || [], (item: any) => {
return {
...item,
value: item.name,
label: item.name
};
const res = await queryCatalogList(params);
setDataSource({
dataList: res.items,
loading: false,
total: res.pagination.total
});
setDataList(list);
} catch (error) {
console.log('error:', error);
return [];
setDataSource({
dataList: [],
loading: false,
total: dataSource.total
});
console.log('error', error);
}
}, []);
}, [queryParams]);
const handleDeployModalCancel = () => {
setOpenDeployModal({
...openDeployModal,
show: false
});
};
const handleResize = useCallback(
_.throttle((size: { width: number; height: number }) => {
@@ -65,29 +85,18 @@ const Catalog: React.FC = () => {
} else {
setSpan(8);
}
console.log('size:', size);
}, 100),
[]
);
const handleDeployModalCancel = () => {
const handleOnDeploy = useCallback((item: CatalogItemType) => {
setActiveId(item.id);
setOpenDeployModal({
...openDeployModal,
show: false
show: true,
source: modelSourceMap.huggingface_value,
width: 600
});
};
const handleOnDeploy = useCallback(
(index: number) => {
setActiveId(index);
setOpenDeployModal({
show: true,
source: modelSourceMap.huggingface_value,
width: 600
});
},
[openDeployModal]
);
}, []);
const handleCreateModel = useCallback(
async (data: FormData) => {
@@ -112,14 +121,34 @@ const Catalog: React.FC = () => {
[openDeployModal]
);
const handleOnPageChange = useCallback((page: number, pageSize?: number) => {
setQueryParams({
...queryParams,
page,
perPage: pageSize || 10
});
}, []);
const handleSearch = (e: any) => {
fetchData();
};
const handleNameChange = (e: any) => {
setQueryParams({
...queryParams,
search: e.target.value
});
};
useEffect(() => {
getModelsFromHuggingface('desc');
}, [getModelsFromHuggingface]);
fetchData();
}, [queryParams]);
return (
<PageContainer
ghost
header={{
title: intl.formatMessage({ id: 'menu.models.catalog' }),
title: intl.formatMessage({ id: 'menu.modelCatalog' }),
breadcrumb: {}
}}
extra={[]}
@@ -133,25 +162,26 @@ const Catalog: React.FC = () => {
style={{ width: 300 }}
size="large"
allowClear
onChange={handleNameChange}
></Input>
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
icon={<SyncOutlined></SyncOutlined>}
onClick={handleSearch}
></Button>
</Space>
}
></PageTools>
<ResizeObserver onResize={handleResize}>
<Row gutter={[16, 16]}>
{testData.map((item, index) => {
{dataSource.dataList.map((item: CatalogItemType, index) => {
return (
<Col span={span} key={index}>
<Col span={span} key={item.id}>
<CatalogItem
onDeploy={() => handleOnDeploy(index)}
onClick={handleOnDeploy}
activeId={activeId}
data={item}
itemId={index}
></CatalogItem>
</Col>
);
@@ -159,7 +189,15 @@ const Catalog: React.FC = () => {
</Row>
</ResizeObserver>
<div style={{ marginBlock: '32px 16px' }}>
<Pagination align="end" defaultCurrent={1} total={50} showSizeChanger />
<Pagination
hideOnSinglePage={queryParams.perPage === 9}
align="end"
defaultCurrent={1}
total={dataSource.total}
pageSize={queryParams.perPage}
showSizeChanger
onChange={handleOnPageChange}
/>
</div>
<DelopyBuiltInModal
open={openDeployModal.show}
+54 -27
View File
@@ -1,31 +1,49 @@
import IMG from '@/assets/images/small-logo-200x200.png';
import AutoTooltip from '@/components/auto-tooltip';
import { Tag, Typography } from 'antd';
import IconFont from '@/components/icon-font';
import { ClockCircleOutlined } from '@ant-design/icons';
import { Divider, Tag, Typography } from 'antd';
import classNames from 'classnames';
import React from 'react';
import _ from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { CatalogItem as CatalogItemType } from '../config/types';
import '../style/catalog-item.less';
const COLORS = ['blue', 'purple', 'orange'];
interface CatalogItemProps {
activeId: number;
itemId: number;
data: any;
onDeploy: () => void;
data: CatalogItemType;
onClick: (data: CatalogItemType) => void;
}
const CatalogItem: React.FC<CatalogItemProps> = (props) => {
const { onDeploy, itemId, activeId, data } = props;
const { onClick, activeId, data } = props;
const handleOnDeploy = () => {
onDeploy();
const handleOnClick = useCallback(() => {
onClick(data);
}, [data, onClick]);
const icon = useMemo(() => {
const home = data.home?.replace(/\/$/, '');
const icon = data.icon?.replace(/^\//, '');
if (icon) {
return `${home}/${icon}`;
}
return IMG;
}, [data]);
const handleOnError = (e: any) => {
e.target.src = IMG;
};
return (
<div
className={classNames('catalog-item', { active: activeId === itemId })}
onClick={handleOnClick}
className={classNames('catalog-item', { active: activeId === data.id })}
>
<div className="content">
<div className="title">
<div className="img">
<img src={IMG} alt="" />
<img src={icon} alt="" onError={handleOnError} />
</div>
<AutoTooltip ghost style={{ flex: 1 }}>
{data.name}
@@ -36,34 +54,43 @@ const CatalogItem: React.FC<CatalogItemProps> = (props) => {
</Typography.Paragraph>
</div>
<div className="item-footer">
<div className="update-time">
<span className="flex-center">
<ClockCircleOutlined
className="m-r-5"
style={{ color: 'var(--ant-color-text-secondary)' }}
/>
{data.release_date}
</span>
<span className="flex-center">
<IconFont type="icon-justice" className="m-r-5"></IconFont>
{_.map(data.licenses, (license: string, index: number) => {
return (
<>
<span key={license}>{license}</span>
{index !== data.licenses.length - 1 && (
<Divider type="vertical" />
)}
</>
);
})}
</span>
</div>
<div className="tags">
{data.tags.map((sItem, i) => {
{data.categories.map((sItem, i) => {
return (
<Tag key={sItem} className="tag-item" color={COLORS[i]}>
{sItem}
</Tag>
);
})}
{/* <Tag color="blue" className="tag-item">
Audio
</Tag>
<Tag color="purple" className="tag-item">
GGUF
</Tag>
<Tag color="orange" className="tag-item">
Qwen
</Tag> */}
{/* <span className="dot"></span>
<Tag className="tag-item">Audio</Tag>
<Tag className="tag-item">GGUF</Tag>
<Tag className="tag-item">Qwen</Tag> */}
{data.size?.length > 0 && (
{data.sizes?.length > 0 && (
<>
<span className="dot"></span>
{data.size.map((sItem, i) => {
{data.sizes.map((sItem, i) => {
return (
<Tag key={sItem} className="tag-item">
{sItem}
{sItem}B
</Tag>
);
})}
+105 -56
View File
@@ -35,6 +35,11 @@ interface DataFormProps {
action: PageActionType;
selectedModel: any;
isGGUF: boolean;
sizeOptions?: Global.BaseOption<number>[];
quantizationOptions?: Global.BaseOption<string>[];
sourceDisable?: boolean;
byBuiltIn?: boolean;
backendOptions?: Global.BaseOption<string>[];
onOk: (values: FormData) => void;
onBackendChange?: (value: string) => void;
}
@@ -69,7 +74,14 @@ const sourceOptions = [
];
const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
const { action, isGGUF, onOk } = props;
const {
action,
isGGUF,
sourceDisable = true,
backendOptions,
byBuiltIn,
onOk
} = props;
const [form] = Form.useForm();
const intl = useIntl();
const [gpuOptions, setGpuOptions] = useState<
@@ -259,34 +271,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
);
};
const renderS3Fields = () => {
return (
<>
<Form.Item<FormData>
name="s3_address"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.input'
},
{ name: intl.formatMessage({ id: 'models.form.s3address' }) }
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'models.form.s3address'
})}
required
></SealInput.Input>
</Form.Item>
</>
);
};
const renderOllamaModelFields = () => {
return (
<>
@@ -364,6 +348,71 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
);
};
const handleSizeChange = (val: any) => {
form.setFieldsValue({
quantization: ''
});
};
const renderFieldsFromCatalog = useMemo(() => {
if (!byBuiltIn) {
return null;
}
return (
<>
<Form.Item<FormData>
name="size"
key="size"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.select'
},
{ name: 'size' }
)
}
]}
>
<SealAutoComplete
filterOption
onChange={handleSizeChange}
defaultActiveFirstOption
disabled={false}
options={props.sizeOptions}
label="Size"
required
></SealAutoComplete>
</Form.Item>
<Form.Item<FormData>
name="quantization"
key="quantization"
rules={[
{
required: true,
message: intl.formatMessage(
{
id: 'common.form.rule.select'
},
{ name: 'quantization' }
)
}
]}
>
<SealAutoComplete
filterOption
defaultActiveFirstOption
disabled={false}
options={props.quantizationOptions}
label="Quantization"
required
></SealAutoComplete>
</Form.Item>
</>
);
}, [props.sizeOptions, props.quantizationOptions, byBuiltIn]);
const renderFieldsBySource = useMemo(() => {
if (SEARCH_SOURCE.includes(props.source)) {
return renderHuggingfaceFields();
@@ -373,9 +422,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
return renderOllamaModelFields();
}
if (props.source === modelSourceMap.s3_value) {
return renderS3Fields();
}
if (props.source === modelSourceMap.local_path_value) {
return renderLocalPathFields();
}
@@ -477,7 +523,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
>
{
<SealSelect
disabled={true}
disabled={sourceDisable}
label={intl.formatMessage({
id: 'models.form.source'
})}
@@ -531,35 +577,38 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
</div>
</div>
}
options={[
{
label: `llama-box`,
value: backendOptionsMap.llamaBox,
disabled:
props.source === modelSourceMap.local_path_value
? false
: !isGGUF
},
{
label: 'vLLM',
value: backendOptionsMap.vllm,
disabled:
props.source === modelSourceMap.local_path_value
? false
: isGGUF
},
{
label: 'vox-box',
value: backendOptionsMap.voxBox,
disabled: props.source === modelSourceMap.ollama_library_value
}
]}
options={
backendOptions ?? [
{
label: `llama-box`,
value: backendOptionsMap.llamaBox,
disabled:
props.source === modelSourceMap.local_path_value
? false
: !isGGUF
},
{
label: 'vLLM',
value: backendOptionsMap.vllm,
disabled:
props.source === modelSourceMap.local_path_value
? false
: isGGUF
},
{
label: 'vox-box',
value: backendOptionsMap.voxBox,
disabled: props.source === modelSourceMap.ollama_library_value
}
]
}
disabled={
action === PageAction.EDIT &&
props.source !== modelSourceMap.local_path_value
}
></SealSelect>
</Form.Item>
{renderFieldsFromCatalog}
<Form.Item<FormData> name="description">
<SealInput.TextArea
label={intl.formatMessage({
@@ -9,11 +9,6 @@ import { backendOptionsMap, modelSourceMap } from '../config';
import { FormData, ListItem } from '../config/types';
import ColumnWrapper from './column-wrapper';
import DataForm from './data-form';
import HFModelFile from './hf-model-file';
import ModelCard from './model-card';
import SearchModel from './search-model';
import Separator from './separator';
import TitleWrapper from './title-wrapper';
type AddModalProps = {
title: string;
@@ -158,41 +153,6 @@ const AddModal: React.FC<AddModalProps> = (props) => {
footer={false}
>
<div style={{ display: 'flex', height: '100%' }}>
{SEARCH_SOURCE.includes(props.source) && (
<>
<div style={{ display: 'flex', flex: 1 }}>
<ColumnWrapper>
<SearchModel
modelSource={props.source}
onSelectModel={handleOnSelectModel}
setLoadingModel={setLoadingModel}
></SearchModel>
</ColumnWrapper>
<Separator></Separator>
</div>
<div style={{ display: 'flex', flex: 1 }}>
<ColumnWrapper>
<ModelCard
selectedModel={selectedModel}
onCollapse={setCollapsed}
collapsed={collapsed}
modelSource={props.source}
setIsGGUF={handleSetIsGGUF}
></ModelCard>
{isGGUF && (
<HFModelFile
ref={modelFileRef}
selectedModel={selectedModel}
modelSource={props.source}
onSelectFile={handleSelectModelFile}
collapsed={collapsed}
></HFModelFile>
)}
</ColumnWrapper>
<Separator></Separator>
</div>
</>
)}
<ColumnWrapper
footer={
<ModalFooter
@@ -207,12 +167,6 @@ const AddModal: React.FC<AddModalProps> = (props) => {
}
>
<>
{SEARCH_SOURCE.includes(source) && (
<TitleWrapper>
{intl.formatMessage({ id: 'models.form.configurations' })}
<span style={{ display: 'flex', height: 24 }}></span>
</TitleWrapper>
)}
<DataForm
source={source}
action={action}
+2 -11
View File
@@ -22,7 +22,6 @@ import {
DownOutlined,
EditOutlined,
ExperimentOutlined,
GoldOutlined,
PictureOutlined,
SyncOutlined,
WechatWorkOutlined
@@ -222,12 +221,6 @@ const Models: React.FC<ModelsProps> = ({
}, [deleteIds]);
const sourceOptions = [
{
label: 'Model Catalog',
value: 'model_catalog',
key: 'model_catalog',
icon: <GoldOutlined />
},
{
label: 'Hugging Face',
value: modelSourceMap.huggingface_value,
@@ -688,9 +681,6 @@ const Models: React.FC<ModelsProps> = ({
source: modelSourceMap.huggingface_value
});
}
if (item.key === 'model_catalog') {
navigate('/models/catalog');
}
if (item.key === 'ollama_library') {
setOpenDeployModal({
@@ -722,7 +712,8 @@ const Models: React.FC<ModelsProps> = ({
<PageContainer
ghost
header={{
title: intl.formatMessage({ id: 'models.title' })
title: intl.formatMessage({ id: 'models.title' }),
breadcrumb: {}
}}
extra={[]}
>
-84
View File
@@ -1,84 +0,0 @@
export default [
{
name: 'gpustack/bge-m3-GGUF',
tags: ['Embedding', 'GGUF', 'bert'],
description:
'It can simultaneously perform the three common retrieval functionalities of embedding model: dense retrieval, multi-vector retrieval, and sparse retrieval.',
size: ['567M']
},
{
name: 'gpustack/stable-diffusion-v3-5-medium-GGUF',
tags: ['Text-to-Image', 'GGUF'],
description:
'Stable Diffusion 3.5 Medium is a Multimodal Diffusion Transformer with improvements (MMDiT-X) text-to-image model that features improved performance in image quality, typography, complex prompt understanding, and resource-efficiency.',
size: ['8B']
},
{
name: 'gpustack/stable-diffusion-v3-5-large-GGUF',
tags: ['Text-to-Image', 'GGUF'],
description:
'Stable Diffusion 3.5 Large is a Multimodal Diffusion Transformer (MMDiT) text-to-image model that features improved performance in image quality, typography, complex prompt understanding, and resource-efficiency',
size: ['14B']
},
{
name: 'gpustack/jina-reranker-v1-tiny-en-GGUF',
tags: ['Rerank', 'GGUF', 'jina-bert-v2'],
description:
'his model is designed for blazing-fast reranking while maintaining competitive performance.',
size: ['Q8_0', 'Q4_0']
},
{
name: 'gpustack/stable-diffusion-v1-5-inpainting-GGUF',
tags: ['Text-to-Image', 'GGUF'],
description:
'Stable Diffusion Inpainting is a latent text-to-image diffusion model capable of generating photo-realistic images given any text input, with the extra capability of inpainting the pictures by using a mask',
size: ['1B', 'Q8_0', 'Q4_0']
},
{
name: 'gpustack/Llama-3.2-3B-Instruct-GGUF',
tags: ['Text-to-Text', 'GGUF', 'llama'],
description:
'The Llama 3.2 collection of multilingual large language models (LLMs) is a collection of pretrained and instruction-tuned generative models in 1B and 3B sizes (text in/text out).',
size: ['1B', 'Q8_0', 'Q4_0']
},
{
name: 'gpustack/faster-whisper-large-v1',
description:
'This model can be used in CTranslate2 or projects based on CTranslate2 such as faster-whisper.',
tags: ['Speech-to-Text'],
size: []
},
{
name: 'gpustack/faster-distil-whisper-medium.en',
description:
'This model can be used in CTranslate2 or projects based on CTranslate2 such as faster-whisper.',
tags: ['Speech-to-Text']
},
{
name: 'gpustack/CosyVoice-300M',
description:
'This model is a lightweight, fast, and efficient text-to-speech model that can be used in various applications.',
tags: ['Text-to-Speech']
},
{
name: 'gpustack/FLUX.1-mini-GGUF',
tags: ['Text-to-Image', 'GGUF'],
description:
'A 3.2B MMDiT distilled from Flux-dev for efficient text-to-image generation',
size: ['8B', 'Q8_0', 'Q4_0', 'FP16']
},
{
name: 'gpustack/stable-diffusion-v3-5-large-turbo-GGUF',
tags: ['Text-to-Image', 'GGUF'],
description:
'Stable Diffusion 3.5 Large Turbo is a Multimodal Diffusion Transformer (MMDiT) text-to-image model with Adversarial Diffusion Distillation (ADD)',
size: ['14B', 'Q8_0', 'Q4_0']
},
{
name: 'gpustack/FLUX.1-lite-GGUF',
description:
'This version uses 7 GB less RAM and runs 23% faster while maintaining the same precision (bfloat16) as the original model',
tags: ['Text-to-Image', 'GGUF'],
size: ['14B', 'Q8_0', 'Q4_0']
}
];
+15
View File
@@ -31,6 +31,8 @@ export interface ListItem {
export interface FormData {
backend?: string;
size?: number;
quantization?: number;
categories?: string[];
backend_parameters?: string[];
backend_version?: string;
@@ -118,3 +120,16 @@ export interface GPUListItem {
worker_name: string;
worker_ip: string;
}
export interface CatalogItem {
name: string;
id: number;
description: string;
home: string;
icon: string;
categories: string[];
capabilities: string[];
sizes: number[];
licenses: string[];
release_date: string;
}
+15 -9
View File
@@ -1,5 +1,5 @@
.catalog-item {
height: 160px;
height: 180px;
overflow: hidden;
display: flex;
padding: 16px 20px;
@@ -31,7 +31,6 @@
justify-content: flex-start;
flex: 1;
width: 100%;
// height: 100%;
}
&:hover {
@@ -48,8 +47,8 @@
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
margin-bottom: 16px;
gap: 12px;
margin-bottom: 12px;
.name {
flex: 1;
@@ -61,23 +60,30 @@
color: var(--ant-color-text-secondary);
width: 100%;
overflow: hidden;
margin-bottom: 16px;
margin-bottom: 12px;
}
.item-footer {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
}
.update-time {
color: var(--ant-color-text-tertiary);
display: flex;
align-items: center;
gap: 16px;
}
.dot {
width: 4px;
height: 4px;
width: 3px;
height: 3px;
border-radius: 50%;
background-color: var(--ant-color-text-tertiary);
background-color: var(--ant-color-text-quaternary);
margin-right: 8px;
}