refactor: deployments dir tree

This commit is contained in:
jialin
2026-03-19 12:28:18 +08:00
committed by jialin
parent 9840a48301
commit fd1a079271
44 changed files with 118 additions and 306 deletions
@@ -0,0 +1,37 @@
import { convertFileSize } from '@/utils';
import React from 'react';
import 'simplebar-react/dist/simplebar.min.css';
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
.file-part-item {
width: 180px;
display: flex;
align-items: center;
justify-content: space-between;
}
`;
const FileParts: React.FC<{
showSize?: boolean;
fileList: any[];
}> = ({ fileList, showSize = true }) => {
return (
<Wrapper>
{fileList.map((file, index) => {
return (
<div key={index} className="file-part-item">
<span>
Part {file.part} of {file.total}
</span>
{<span>{convertFileSize(file.size)}</span>}
</div>
);
})}
</Wrapper>
);
};
export default FileParts;
@@ -0,0 +1,34 @@
import { Flex, Skeleton, Space } from 'antd';
import styled from 'styled-components';
const Wrapper = styled(Flex)`
padding: 12px 14px;
border: 1px solid var(--ant-color-border);
border-radius: var(--border-radius-base);
background-color: var(--ant-color-bg-container);
`;
const FileSkeleton: React.FC<{ counts: number; itemHeight?: number }> = ({
counts = 2,
itemHeight
}) => {
return (
<Wrapper
vertical
justify={'space-between'}
style={{ height: itemHeight || 'auto' }}
>
<Skeleton paragraph={{ rows: 1, width: '100%' }} title={false}></Skeleton>
<Space>
{Array.from({ length: counts }).map((_, index) => (
<Skeleton.Node
style={{ width: 60, height: 22 }}
key={index}
></Skeleton.Node>
))}
</Space>
</Wrapper>
);
};
export default FileSkeleton;
@@ -0,0 +1,20 @@
import { WarningOutlined } from '@ant-design/icons';
import { Result } from 'antd';
import React from 'react';
const GGUFResult: React.FC = () => {
return (
<Result
status="info"
icon={
<WarningOutlined
style={{ color: 'var(--ant-color-text-quaternary)' }}
/>
}
title={false}
subTitle="GGUF model is not supported."
/>
);
};
export default GGUFResult;
@@ -0,0 +1,370 @@
import { getRequestId } from '@/atoms/models';
import BaseSelect from '@/components/seal-form/base/select';
import SimpleOverlay from '@/components/simple-overlay';
import { useIntl } from '@umijs/max';
import { Empty, Spin } from 'antd';
import _ from 'lodash';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import 'simplebar-react/dist/simplebar.min.css';
import styled from 'styled-components';
import {
queryHuggingfaceModelFiles,
queryModelScopeModelFiles
} from '../../apis';
import { modelSourceMap } from '../../config';
import '../../style/hf-model-file.less';
import TitleWrapper from '../title-wrapper';
import FileSkeleton from './file-skeleton';
import ModelFileItem from './model-file-item';
const ItemFileWrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
gap: 24px;
`;
interface HFModelFileProps {
isDownload?: boolean;
selectedModel: any;
collapsed?: boolean;
loadingModel?: boolean;
modelSource: string;
ref: any;
onSelectFile?: (
file: any,
options: { requestModelId: number; manual?: boolean }
) => void;
onSelectFileAfterEvaluate?: (file: any) => void;
}
const pattern = /^(.*)-(\d+)-of-(\d+)\.(.*)$/;
const filterReg = /\.(safetensors|gguf)$/i;
const includeReg = /\.(safetensors|gguf)$/i;
const filterRegGGUF = /\.(gguf)$/i;
const HFModelFile: React.FC<HFModelFileProps> = forwardRef((props, ref) => {
const { collapsed, modelSource, isDownload, onSelectFileAfterEvaluate } =
props;
const intl = useIntl();
const [isEvaluating, setIsEvaluating] = useState(false);
const [dataSource, setDataSource] = useState<any>({
fileList: [],
loading: false
});
const [sortType, setSortType] = useState<string>('size');
const [current, setCurrent] = useState<string>('');
const currentPathRef = useRef<string>('');
const modelFilesSortOptions = useRef<any[]>([
{
label: intl.formatMessage({ id: 'models.sort.size' }),
value: 'size'
},
{
label: intl.formatMessage({ id: 'models.sort.name' }),
value: 'name'
}
]);
const axiosTokenRef = useRef<any>(null);
const checkTokenRef = useRef<any>(null);
const timer = useRef<any>(null);
const parentRequestModelId = useRef<number>(0);
const handleSelectModelFile = (item: any, manual?: boolean) => {
props.onSelectFile?.(item, {
requestModelId: parentRequestModelId.current,
manual: manual
});
setCurrent(item.path);
currentPathRef.current = item.path;
};
const handleSelectModelFileManually = (data: any) => {
if (data.path === currentPathRef.current) {
return;
}
handleSelectModelFile(data, true);
};
const parseFilename = (filename: string) => {
const match = filename.match(pattern);
if (match) {
return {
filename: match[1],
part: parseInt(match[2], 10),
total: parseInt(match[3], 10),
extension: match[4]
};
} else {
return null;
}
};
const generateGroupByFilename = (list: any[]) => {
// general file
const generalFileList = _.filter(list, (item: any) => {
const parsed = parseFilename(item.path);
return !parsed;
});
const newGeneralFileList = _.map(generalFileList, (item: any) => {
return {
...item,
fakeName: item.path
};
});
// shard file
const shardFileList = _.filter(list, (item: any) => {
const parsed = parseFilename(item.path);
return !!parsed;
});
const newShardFileList = _.map(shardFileList, (item: any) => {
const parsed = parseFilename(item.path);
return {
...item,
...parsed
};
});
const group = _.groupBy(newShardFileList, 'filename');
const shardFileListResult = _.map(
group,
(value: any[], filename: string) => {
return {
path: filename,
fakeName: `${filename}-*.${_.get(value, '[0].extension')}`,
size: _.sumBy(value, 'size'),
parts: value
};
}
);
return [...shardFileListResult, ...newGeneralFileList];
};
const hfFileFilter = (file: any) => {
return filterRegGGUF.test(file.path) || _.includes(file.path, '.gguf');
};
const isNormalGGUFModelFile = (filename: string) => {
const file = filename?.toLowerCase() ?? '';
return file.indexOf('mmproj') === -1 && file.indexOf('imatrix') === -1;
};
// hugging face files
const getHuggingfaceFiles = async () => {
try {
const res = await queryHuggingfaceModelFiles(
{ repo: props.selectedModel.name || '' },
{
signal: axiosTokenRef.current.signal
}
);
const fileList = _.filter(res, (file: any) => {
return file.type === 'file';
});
const list = _.filter(fileList, (file: any) => {
return hfFileFilter(file) && isNormalGGUFModelFile(file.path);
});
return list;
} catch (error) {
return [];
}
};
const modelscopeFileFilter = (file: any) => {
return (
filterRegGGUF.test(file.Path) &&
file.Type === 'blob' &&
isNormalGGUFModelFile(file.Path)
);
};
// modelscope files
const getModelScopeFiles = async () => {
try {
const data = await queryModelScopeModelFiles(
{
name: props.selectedModel.name || '',
revision: props.selectedModel.revision || 'master'
},
{
signal: axiosTokenRef.current.signal
}
);
const fileList = _.filter(_.get(data, ['Data', 'Files']), (file: any) => {
return modelscopeFileFilter(file);
});
const list = _.map(fileList, (item: any) => {
return {
path: item.Path,
size: item.Size
};
});
return list;
} catch (error) {
return [];
}
};
const handleFetchModelFiles = async () => {
if (!props.selectedModel.name) {
setDataSource({ fileList: [], loading: false });
handleSelectModelFile({});
return;
}
parentRequestModelId.current = getRequestId();
checkTokenRef.current?.cancel?.();
axiosTokenRef.current?.abort?.();
axiosTokenRef.current = new AbortController();
setDataSource({ ...dataSource, loading: true });
setCurrent('');
try {
let list = [];
const currentParentRequestId = getRequestId();
if (modelSourceMap.huggingface_value === modelSource) {
list = await getHuggingfaceFiles();
} else if (modelSourceMap.modelscope_value === modelSource) {
list = await getModelScopeFiles();
}
if (currentParentRequestId !== getRequestId()) {
return;
}
const newList = generateGroupByFilename(list);
const sortList = _.sortBy(newList, (item: any) => {
return sortType === 'size' ? item.size : item.path;
});
handleSelectModelFile(sortList[0] || {});
setDataSource({ fileList: sortList, loading: false });
} catch (error) {
setDataSource({ fileList: [], loading: false });
handleSelectModelFile({});
}
};
const handleSortChange = (value: string) => {
const list = _.sortBy(dataSource.fileList, (item: any) => {
return value === 'size' ? item.size : item.path;
});
setSortType(value);
setDataSource({ ...dataSource, fileList: list });
};
const cancelRequest = () => {
axiosTokenRef.current?.abort?.();
checkTokenRef.current?.cancel?.();
if (timer.current) {
clearTimeout(timer.current);
}
};
useImperativeHandle(ref, () => ({
fetchModelFiles: handleFetchModelFiles,
cancelRequest: cancelRequest
}));
useEffect(() => {
if (!props.selectedModel.name) {
setDataSource({ fileList: [], loading: false });
}
}, [props.selectedModel?.name]);
useEffect(() => {
return () => {
cancelRequest();
};
}, []);
return (
<div className="files-wrap">
<TitleWrapper style={{ paddingInline: '24px' }}>
<span className="title">
{intl.formatMessage({ id: 'models.available.files' })} (
{dataSource.fileList.length || 0})
</span>
<BaseSelect
value={sortType}
onChange={handleSortChange}
labelRender={({ label }) => {
return (
<span>
{intl.formatMessage({ id: 'model.deploy.sort' })}: {label}
</span>
);
}}
options={modelFilesSortOptions.current}
size="middle"
style={{ width: '120px', fontWeight: 400 }}
></BaseSelect>
</TitleWrapper>
{dataSource.loading && (
<div className="spin-wrapper">
<Spin
spinning={dataSource.loading}
style={{ height: '100%', width: '100%' }}
></Spin>
</div>
)}
<SimpleOverlay height={collapsed ? 'max-content' : 'calc(100vh - 300px)'}>
<div style={{ padding: '16px 24px' }}>
{dataSource.loading ? (
<ItemFileWrapper>
{_.times(5, (index: number) => {
return <FileSkeleton key={index} counts={2}></FileSkeleton>;
})}
</ItemFileWrapper>
) : dataSource.fileList.length ? (
<ItemFileWrapper>
{_.map(dataSource.fileList, (item: any) => {
return (
<ModelFileItem
key={item.path}
data={item}
isEvaluating={isEvaluating}
active={item.path === current}
handleSelectModelFile={handleSelectModelFileManually}
></ModelFileItem>
);
})}
</ItemFileWrapper>
) : (
<Empty
styles={{
image: {
height: 'auto',
marginTop: '20px'
}
}}
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={intl.formatMessage({
id: 'models.search.nofiles'
})}
/>
)}
</div>
</SimpleOverlay>
</div>
);
});
export default HFModelFile;
@@ -0,0 +1,69 @@
import IconFont from '@/components/icon-font';
import { formatNumber } from '@/utils';
import { DownloadOutlined, HeartOutlined } from '@ant-design/icons';
import classNames from 'classnames';
import dayjs from 'dayjs';
import React from 'react';
import { modelSourceMap } from '../../config';
import { EvaluateResult } from '../../config/types';
import '../../style/hf-model-item.less';
import IncompatiableInfo from '../incompatiable-info';
interface HFModelItemProps {
title: string;
downloads: number;
likes: number;
task?: string;
updatedAt: string;
active: boolean;
source?: string;
tags?: string[];
evaluateResult?: EvaluateResult;
isEvaluating?: boolean;
}
const HFModelItem: React.FC<HFModelItemProps> = (props) => {
const { evaluateResult, isEvaluating } = props;
return (
<div
className={classNames('hf-model-item', {
active: props.active
})}
>
<div className="title">
<IconFont
type={
props.source === modelSourceMap.huggingface_value
? 'icon-huggingface1'
: 'icon-modelscope_light'
}
className="m-r-5"
style={{ color: 'var(--ant-color-text-tertiary)', fontSize: 16 }}
/>
{props.title}
</div>
<div className="info">
<div className="info-item">
<span>{dayjs().to(dayjs(props.updatedAt))}</span>
<span className="flex-center">
<HeartOutlined className="m-r-5" />
{props.likes}
</span>
<span className="flex-center">
<DownloadOutlined className="m-r-5" />
{formatNumber(props.downloads)}
</span>
</div>
{
<IncompatiableInfo
data={evaluateResult}
isEvaluating={isEvaluating}
></IncompatiableInfo>
}
</div>
</div>
);
};
export default HFModelItem;
@@ -0,0 +1,409 @@
import IconFont from '@/components/icon-font';
import MarkdownViewer from '@/components/markdown-viewer';
import SimpleOverlay from '@/components/simple-overlay';
import ThemeTag from '@/components/tags-wrapper/theme-tag';
import { GPUSTACK_API_BASE_URL } from '@/config/settings';
import useRequestToken from '@/hooks/use-request-token';
import {
DownOutlined,
FileMarkdownOutlined,
RightOutlined
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Empty, Spin, Tooltip } from 'antd';
import { some } from 'lodash';
import 'overlayscrollbars/overlayscrollbars.css';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import 'simplebar-react/dist/simplebar.min.css';
import styled from 'styled-components';
import {
downloadModelFile,
queryHuggingfaceModelDetail,
queryModelScopeModelDetail
} from '../../apis';
import { modelSourceMap } from '../../config';
import '../../style/model-card.less';
import TitleWrapper from '../title-wrapper';
const MkdTitle = styled.span`
cursor: pointer;
background-color: var(--ant-color-fill-tertiary);
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
height: 36px;
`;
const MarkDownTitle: React.FC<{
collapsed: boolean;
loading: boolean;
onCollapse: () => void;
}> = ({ collapsed, loading, onCollapse }) => {
return (
<MkdTitle onClick={onCollapse}>
<span>
<FileMarkdownOutlined className="m-r-2 text-tertiary" /> README.md
</span>
<span>
{collapsed ? (
<DownOutlined />
) : loading ? (
<Spin spinning={true} size="small"></Spin>
) : (
<RightOutlined />
)}
</span>
</MkdTitle>
);
};
const ModelCard: React.FC<{
onCollapse: (flag: boolean) => void;
setIsGGUF: (flag: boolean) => void;
isGGUF?: boolean;
selectedModel: any;
collapsed: boolean;
loadingModel?: boolean;
modelSource: string;
}> = (props) => {
const { onCollapse, setIsGGUF, collapsed, modelSource, isGGUF } = props;
const intl = useIntl();
const requestSource = useRequestToken();
const [modelData, setModelData] = useState<any>(null);
const [readmeText, setReadmeText] = useState<string | null>(null);
const requestToken = useRef<any>(null);
const axiosTokenRef = useRef<any>(null);
const loadConfigTokenRef = useRef<any>(null);
const loadConfigJsonTokenRef = useRef<any>(null);
const [loading, setLoading] = useState<boolean>(false);
const modelTags = useMemo(() => {
if (modelSource === modelSourceMap.huggingface_value) {
return modelData?.pipeline_tag ? [modelData?.pipeline_tag] : [];
}
if (modelSource === modelSourceMap.modelscope_value) {
const tasks = modelData?.Tasks || [];
return tasks.map((task: any) => task?.Name)?.filter((val: string) => val);
}
return [];
}, [modelSource, modelData]);
const modelType = useMemo(() => {
if (modelSource === modelSourceMap.huggingface_value) {
return modelData?.config?.model_type || modelData?.ModelType?.[0];
}
if (modelSource === modelSourceMap.modelscope_value) {
return modelData?.ModelType?.[0];
}
}, [modelData, modelSource]);
const loadFile = useCallback(async (repo: string, sha: string) => {
try {
axiosTokenRef.current?.abort?.();
axiosTokenRef.current = new AbortController();
const res = await downloadModelFile(
{
repo,
revision: sha,
path: 'README.md'
},
{
signal: axiosTokenRef.current.signal
}
);
return res || '';
} catch (error) {
return '';
}
}, []);
const handleOnCollapse = (readmeText: any) => {
if (!readmeText) {
onCollapse(false);
}
};
const removeMetadata = useCallback((str: string) => {
let indexes = [];
let index = str.indexOf('---');
while (index !== -1) {
indexes.push(index);
if (indexes.length >= 2) {
break;
}
index = str.indexOf('---', index + 1);
}
if (indexes.length >= 2) {
return str.slice(indexes[1] + 3);
}
return str;
}, []);
// huggingface model card data
const getHuggingfaceModelDetail = async () => {
try {
const [modelcard, readme] = await Promise.all([
queryHuggingfaceModelDetail(
{ repo: props.selectedModel.name },
{
token: requestToken.current.token
}
),
loadFile(props.selectedModel.name, 'main')
]);
setModelData(modelcard);
// remove the meta data from readme
const newReadme = removeMetadata(readme);
setReadmeText(newReadme);
handleOnCollapse(newReadme);
const isGGUF = modelcard.tags?.includes('gguf');
setIsGGUF(isGGUF || props.selectedModel?.isGGUF);
} catch (error) {
setModelData(null);
setReadmeText(null);
handleOnCollapse(null);
}
};
const getModelScopeModelDetail = async () => {
try {
const data = await queryModelScopeModelDetail(
{
name: props.selectedModel.name
},
{
token: requestToken.current.token
}
);
setModelData({
...data?.Data,
name: `${data.Data?.Path}/${data.Data?.Name}`
});
setReadmeText(data?.Data?.ReadMeContent);
handleOnCollapse(data?.Data?.ReadMeContent);
const isGGUF = some(
data?.Data?.Tags,
(tag: string) => tag?.indexOf('gguf') > -1
);
setIsGGUF(isGGUF || props.selectedModel?.isGGUF);
} catch (error) {
setModelData(null);
setReadmeText(null);
handleOnCollapse(null);
}
};
const getModelCardData = async () => {
if (!props.selectedModel?.name) {
setModelData(null);
setReadmeText(null);
handleOnCollapse(null);
return;
}
requestToken.current?.cancel?.();
requestToken.current = requestSource();
setLoading(true);
if (modelSource === modelSourceMap.huggingface_value) {
await getHuggingfaceModelDetail();
} else if (modelSource === modelSourceMap.modelscope_value) {
await getModelScopeModelDetail();
}
setLoading(false);
};
const handleCollapse = useCallback(() => {
onCollapse(!collapsed);
}, [collapsed]);
const generateModelLink = () => {
const name = modelData?.id || modelData?.name;
if (!name) {
return null;
}
if (modelSource === modelSourceMap.huggingface_value) {
return (
<Tooltip title={intl.formatMessage({ id: 'models.viewin.hf' })}>
<Button
size="small"
type="link"
target="_blank"
href={`https://huggingface.co/${modelData?.id}`}
>
<IconFont
type="icon-external-link"
className="font-size-14"
></IconFont>
</Button>
</Tooltip>
);
}
if (modelSource === modelSourceMap.modelscope_value) {
return (
<Tooltip title={intl.formatMessage({ id: 'models.viewin.modelscope' })}>
<Button
size="small"
type="link"
target="_blank"
href={`https://modelscope.cn/models/${modelData?.name}`}
>
<IconFont
type="icon-external-link"
className="font-size-14"
></IconFont>
</Button>
</Tooltip>
);
}
return null;
};
const generateModeScopeImgLink = useCallback(
(imgSrc: string) => {
if (!imgSrc) {
return '';
}
if (modelSource === modelSourceMap.modelscope_value) {
return `https://modelscope.cn/api/${GPUSTACK_API_BASE_URL}/models/${modelData?.name}/repo?Revision=${modelData?.Revision}&View=true&FilePath=${imgSrc}`;
}
if (modelSource === modelSourceMap.huggingface_value) {
return `https://huggingface.co/${modelData?.id}/resolve/main/${imgSrc}`;
}
return '';
},
[modelSource, modelData?.name, modelData?.id, modelData?.Revision]
);
useEffect(() => {
if (!props.selectedModel.name) return;
getModelCardData();
setModelData(() => ({
id: props.selectedModel.name,
name: props.selectedModel.name,
isGGUF: props.selectedModel.isGGUF
}));
}, [props.selectedModel?.name, props.selectedModel?.isGGUF]);
useEffect(() => {
return () => {
requestToken.current?.cancel?.();
axiosTokenRef.current?.abort?.();
loadConfigTokenRef.current?.abort?.();
loadConfigJsonTokenRef.current?.abort?.();
};
}, []);
return (
<>
<TitleWrapper style={{ paddingInline: 24 }}>
<div className="title">{modelData?.id || modelData?.name} </div>
{generateModelLink()}
</TitleWrapper>
<div className="card-wrapper">
{modelData ? (
<div className="model-card-wrap">
<div className="flex-center flex-wrap gap-8">
{modelType && (
<ThemeTag className="tag-item" color="gold" opacity={0.65}>
<span className="m-r-5">
{intl.formatMessage({ id: 'models.architecture' })}:
</span>
{modelType}
</ThemeTag>
)}
{isGGUF && (
<ThemeTag className="tag-item" color="magenta" opacity={0.65}>
GGUF
</ThemeTag>
)}
{!!modelTags.length &&
modelTags.map((tag: string, index: number) => {
return (
<ThemeTag
className="tag-item"
color="geekblue"
key={index}
opacity={0.65}
>
{tag}
</ThemeTag>
);
})}
</div>
{readmeText && isGGUF && (
<div
style={{
borderRadius: 4,
marginTop: 16,
overflow: 'hidden'
}}
>
<MarkDownTitle
onCollapse={handleCollapse}
collapsed={collapsed}
loading={loading}
></MarkDownTitle>
<Spin spinning={loading && collapsed} size="middle">
<SimpleOverlay
style={{
paddingTop: collapsed ? 12 : 0,
maxHeight: collapsed ? 300 : 0
}}
>
<MarkdownViewer
generateImgLink={generateModeScopeImgLink}
content={readmeText}
theme="light"
></MarkdownViewer>
</SimpleOverlay>
</Spin>
</div>
)}
</div>
) : (
<>
{!loading && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginBlock: 20 }}
></Empty>
)}
</>
)}
</div>
{!isGGUF && (
<Spin spinning={loading} size="middle">
<div style={{ minHeight: 200 }}>
{readmeText && (
<>
<TitleWrapper style={{ paddingInline: 24 }}>
<span className="title">README.md</span>
</TitleWrapper>
<div className="card-wrapper">
<MarkdownViewer
generateImgLink={generateModeScopeImgLink}
content={readmeText}
theme="light"
></MarkdownViewer>
</div>
</>
)}
</div>
</Spin>
)}
</>
);
};
export default ModelCard;
@@ -0,0 +1,109 @@
import { TooltipOverlayScroller } from '@/components/overlay-scroller';
import ThemeTag from '@/components/tags-wrapper/theme-tag';
import { convertFileSize } from '@/utils';
import { InfoCircleOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import classNames from 'classnames';
import _ from 'lodash';
import React from 'react';
import 'simplebar-react/dist/simplebar.min.css';
import { getFileType } from '../../constants/file-type';
import '../../style/hf-model-file.less';
import IncompatiableInfo from '../incompatiable-info';
import FileParts from './file-parts';
interface ModelFileItemProps {
data: Record<string, any>;
isEvaluating: boolean;
active: boolean;
handleSelectModelFile: (item: any) => void;
}
const FilePartsTag = (props: { parts: any[] }) => {
const { parts } = props;
const intl = useIntl();
if (!props.parts || !props.parts.length) {
return null;
}
return (
<TooltipOverlayScroller title={<FileParts fileList={parts}></FileParts>}>
<ThemeTag
opacity={0.7}
className="tag-item"
color="purple"
style={{
marginRight: 0
}}
>
<span style={{ opacity: 1 }}>
<InfoCircleOutlined className="m-r-5" />
{intl.formatMessage(
{ id: 'models.search.parts' },
{ n: parts.length }
)}
</span>
</ThemeTag>
</TooltipOverlayScroller>
);
};
const ModelFileItem: React.FC<ModelFileItemProps> = (props) => {
const { data: item, isEvaluating, active, handleSelectModelFile } = props;
const getModelQuantizationType = (item: any) => {
let path = item.path;
if (item?.parts?.length) {
path = `${item.path}.gguf`;
}
const quanType = getFileType(path);
if (quanType) {
return (
<ThemeTag
opacity={0.7}
className="tag-item"
color="cyan"
style={{
marginRight: 0
}}
>
{_.toUpper(quanType)}
</ThemeTag>
);
}
return null;
};
return (
<div
className={classNames('hf-model-file', {
active: active
})}
onClick={() => handleSelectModelFile(item)}
>
<div className="title">{item.path}</div>
<div className="tags flex-between">
<span className="flex-center gap-8">
<ThemeTag
opacity={0.7}
className="tag-item"
color="green"
style={{
marginRight: 0
}}
>
{convertFileSize(item.size)}
</ThemeTag>
{getModelQuantizationType(item)}
<FilePartsTag parts={item.parts}></FilePartsTag>
</span>
<IncompatiableInfo
isEvaluating={isEvaluating}
data={item.evaluateResult}
></IncompatiableInfo>
</div>
</div>
);
};
export default ModelFileItem;
@@ -0,0 +1,98 @@
import IconFont from '@/components/icon-font';
import hotkeys from '@/config/hotkeys';
import { SearchOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Input } from 'antd';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import styled from 'styled-components';
import { modelSourceMap, modelSourceValueMap } from '../../config';
const SearchInputWrapper = styled.div`
position: relative;
width: 100%;
`;
const Holder = styled.div`
pointer-events: none;
position: absolute;
top: 13px;
left: 34px;
color: var(--ant-color-text-quaternary);
font-size: var(--font-size-small);
z-index: 10;
kbd {
border: 1px solid var(--ant-color-border);
border-radius: 3px;
padding: 0 2px;
}
`;
const SearchInput: React.FC<{
modelSource: string;
onChange: (e: any) => void;
onSearch: (e: any) => void;
}> = (props) => {
const { onSearch, onChange, modelSource } = props;
const intl = useIntl();
const inputRef = useRef<any>(null);
const [isFocused, setIsFocused] = React.useState(false);
const [value, setValue] = React.useState('');
useHotkeys(hotkeys.FOCUS, (e: any) => {
e.preventDefault();
inputRef.current?.focus?.();
});
const handleOnChange = (e: any) => {
setValue(e.target.value);
onChange(e);
};
return (
<SearchInputWrapper>
<Input
ref={inputRef}
onPressEnter={onSearch}
onChange={handleOnChange}
onFocus={(e) => {
setIsFocused(true);
e.stopPropagation();
}}
onBlur={(e) => {
setIsFocused(false);
e.stopPropagation();
}}
allowClear
suffix={
<SearchOutlined
className="font-size-16"
style={{ color: 'var(--ant-color-text-placeholder)' }}
/>
}
prefix={
<IconFont
className="font-size-16"
type={
modelSource === modelSourceMap.huggingface_value
? 'icon-huggingface'
: 'icon-tu2'
}
></IconFont>
}
></Input>
{!value && !isFocused && (
<Holder
dangerouslySetInnerHTML={{
__html: intl.formatMessage(
{
id: 'model.deploy.search.placeholder'
},
{ source: modelSourceValueMap[modelSource] }
)
}}
></Holder>
)}
</SearchInputWrapper>
);
};
export default SearchInput;
@@ -0,0 +1,610 @@
import { getRequestId, setRquestId } from '@/atoms/models';
import BaseSelect from '@/components/seal-form/base/select';
import { createAxiosToken } from '@/hooks/use-chunk-request';
import ColumnWrapper from '@/pages/_components/column-wrapper';
import { useIntl } from '@umijs/max';
import { Pagination } from 'antd';
import _ from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import {
evaluationsModelSpec,
queryHuggingfaceModels,
queryModelScopeModels
} from '../../apis';
import {
ModelScopeSortType,
ModelSortType,
modelSourceMap
} from '../../config';
import { MessageStatus, WarningStausOptions } from '../../hooks';
import useCheckBackend from '../../hooks/use-check-backend';
import useRecognizeAudio from '../../hooks/use-recognize-audio';
import SearchStyle from '../../style/search-result.less';
import SearchInput from './search-input';
import SearchResult from './search-result';
const filterOptions = [
{ label: 'FP8', value: 'fp8' },
{ label: 'AWQ', value: 'awq' },
{ label: 'GPTQ', value: 'gptq' }
];
const PaginationMain = styled(Pagination)`
.ant-pagination-slash {
margin-inline: 5px;
}
`;
interface SearchInputProps {
hasLinuxWorker?: boolean;
modelSource: string;
isDownload?: boolean;
gpuOptions?: any[];
clusterId?: number;
setLoadingModel?: (flag: boolean) => void;
onSourceChange?: (source: string) => void;
onSelectModel: (model: any, manul?: boolean) => void;
onSelectModelAfterEvaluate?: (model: any, manual?: boolean) => void;
displayEvaluateStatus?: (
data: MessageStatus,
options?: WarningStausOptions
) => void;
}
const SearchModel: React.FC<SearchInputProps> = (props) => {
const intl = useIntl();
const {
modelSource,
isDownload,
gpuOptions,
clusterId,
setLoadingModel,
onSelectModel,
onSelectModelAfterEvaluate,
displayEvaluateStatus
} = props;
const { recognizeAudioModel } = useRecognizeAudio();
const { checkCurrentbackend } = useCheckBackend();
const [dataSource, setDataSource] = useState<{
dataList: any[];
loading: boolean;
networkError: boolean;
sortType: string;
filters: Record<string, any>;
}>({
dataList: [],
loading: false,
networkError: false,
sortType: ModelSortType.trendingScore,
filters: {
tag: null
}
});
const SUPPORTEDSOURCE = [
modelSourceMap.huggingface_value,
modelSourceMap.modelscope_value
];
const [isEvaluating, setIsEvaluating] = useState<boolean>(false);
const [current, setCurrent] = useState<string>('');
const currentRef = useRef<string>('');
const cacheRepoOptions = useRef<any[]>([]);
const axiosTokenRef = useRef<any>(null);
const checkTokenRef = useRef<any>(null);
const searchInputRef = useRef<any>('');
const timer = useRef<any>(null);
const requestIdRef = useRef<number>(0);
const searchRepoRequestIdRef = useRef<number>(0);
const [paginationInfo, setPaginationInfo] = useState({
page: 1,
perPage: 10,
total: 0
});
const modelFilesSortOptions = [
{
label: intl.formatMessage({ id: 'models.sort.trending' }),
value: ModelSortType.trendingScore
},
{
label: intl.formatMessage({ id: 'models.sort.likes' }),
value: ModelSortType.likes
},
{
label: intl.formatMessage({ id: 'models.sort.downloads' }),
value: ModelSortType.downloads
},
{
label: intl.formatMessage({ id: 'models.sort.updated' }),
value: ModelSortType.lastModified
}
];
const updateSearchRepoRequestId = () => {
searchRepoRequestIdRef.current += 1;
return searchRepoRequestIdRef.current;
};
const updateRequestId = () => {
requestIdRef.current += 1;
return requestIdRef.current;
};
const checkIsGGUF = (item: any) => {
const isGGUF = _.some(item.tags, (tag: string) => {
return tag.toLowerCase() === 'gguf';
});
const isGGUFFromMs = _.some(item.libraries, (tag: string) => {
return tag.toLowerCase() === 'gguf';
});
return isGGUF || isGGUFFromMs;
};
const handleOnSelectModel = (model: any, manual?: boolean) => {
const item = model || {};
// because need cancel the fetch file request when select another model, so check the empty model in the parent level handler.
if (!item.evaluated || item.isGGUF) {
onSelectModel(item, manual);
} else {
onSelectModelAfterEvaluate?.(item, manual);
}
setCurrent(item.id);
currentRef.current = item.id;
};
// huggeface
const getModelsFromHuggingface = async (query: {
sort: string;
filters?: Record<string, any>;
}) => {
const currentSearchId = setRquestId();
const task: any = searchInputRef.current ? '' : 'text-generation';
const params = {
search: {
query: searchInputRef.current || '',
sort: query.sort,
tags: query.filters?.tag ? [query.filters.tag] : [],
task: task
}
};
const data = await queryHuggingfaceModels(params, {
signal: axiosTokenRef.current.signal
});
if (getRequestId() !== currentSearchId) {
throw 'new request has been sent';
}
let list = _.map(data || [], (item: any) => {
return {
...item,
value: item.name,
label: item.name,
isGGUF: checkIsGGUF(item),
source: modelSource
};
});
return list;
};
// modelscope, only modelscope has page and perPage
const getModelsFromModelscope = async (queryParams: {
sortType: string;
page: number;
perPage?: number;
filters?: Record<string, any>;
}) => {
const currentSearchId = setRquestId();
try {
const params = {
Name: `${searchInputRef.current}`,
tags: queryParams.filters?.tag ? [queryParams.filters.tag] : [],
SortBy: ModelScopeSortType[queryParams.sortType],
PageNumber: queryParams.page,
PageSize: queryParams.perPage,
tasks: []
};
const data = await queryModelScopeModels(params, {
signal: axiosTokenRef.current.signal
});
if (getRequestId() !== currentSearchId) {
throw 'new request has been sent';
}
let list = _.map(_.get(data, 'Data.Model.Models') || [], (item: any) => {
return {
path: item.Path,
name: `${item.Path}/${item.Name}`,
downloads: item.Downloads,
id: `${item.Path}/${item.Name}`,
updatedAt: item.LastUpdatedTime * 1000,
likes: item.Stars,
value: item.Name,
label: item.Name,
revision: item.Revision,
task: item.Tasks?.map((sItem: any) => sItem.Name).join(','),
tags: item.Tags,
libraries: item.Libraries,
avatar: item.Avatar,
isGGUF: checkIsGGUF({
tags: item.Tags,
libraries: item.Libraries
}),
source: modelSource
};
});
setPaginationInfo((prev) => {
return {
...prev,
page: queryParams.page,
total: _.get(data, 'Data.Model.TotalCount', 0)
};
});
return list;
} catch (error) {
setPaginationInfo((prev) => {
return {
...prev,
page: queryParams.page
};
});
throw error;
}
};
const getEvaluateResults = async (repoList: any[]) => {
checkTokenRef.current?.cancel?.();
checkTokenRef.current = createAxiosToken();
const evaluations = await evaluationsModelSpec(
{
cluster_id: clusterId!,
model_specs: repoList
},
{
token: checkTokenRef.current?.token
}
);
return evaluations.results;
};
const handleEvaluate = async (list: any[]) => {
if (isDownload) {
return;
}
const currentRequestId = updateRequestId();
const currentSearchId = getRequestId();
try {
const repoList = list.map((item) => {
const res = recognizeAudioModel(item, modelSource);
let backendObj = {};
const backend = checkCurrentbackend({
isGGUF: item.isGGUF,
isAudio: res.isAudio,
gpuOptions: gpuOptions || []
});
if (backend) {
backendObj = {
backend: backend
};
}
return {
...backendObj,
cluster_id: clusterId,
source: modelSource,
...(modelSource === modelSourceMap.huggingface_value
? {
huggingface_repo_id: item.name
}
: {
model_scope_model_id: item.name
})
};
});
setIsEvaluating(true);
const evaluations = await getEvaluateResults(repoList);
// bind the requestId to the current request and searchId
if (
requestIdRef.current !== currentRequestId &&
currentSearchId !== getRequestId()
) {
return;
}
const resultList = list.map((item, index) => {
return {
...item,
evaluated: true,
evaluateResult: evaluations[index] || null
};
});
setIsEvaluating(false);
setDataSource((pre) => {
return {
...pre,
loading: false,
dataList: resultList
};
});
// current selected item
const currentItem = resultList.find(
(item) => item.id === currentRef.current
);
// if it is gguf, would trigger a evaluation after select a model file
if (currentItem && !currentItem.isGGUF) {
onSelectModelAfterEvaluate?.(currentItem);
}
} catch (error) {
// cancel the corrponding request
if (requestIdRef.current === currentRequestId) {
setIsEvaluating(false);
}
}
};
const getCurrentPage = (page: number) => {
const start = (page - 1) * paginationInfo.perPage;
const end = start + paginationInfo.perPage;
return cacheRepoOptions.current.slice(start, end);
};
const handleOnSearchRepo = async (params: {
sortType: string;
page: number;
perPage: number;
filters: Record<string, any>;
}) => {
if (!SUPPORTEDSOURCE.includes(modelSource)) {
return;
}
const currentSearchId = updateSearchRepoRequestId();
axiosTokenRef.current?.abort?.('cancel previous request');
axiosTokenRef.current = new AbortController();
checkTokenRef.current?.cancel?.();
if (timer.current) {
clearTimeout(timer.current);
}
try {
setDataSource((pre) => {
pre.loading = true;
return { ...pre };
});
setLoadingModel?.(true);
cacheRepoOptions.current = [];
let list: any[] = [];
if (modelSource === modelSourceMap.huggingface_value) {
const resultList = await getModelsFromHuggingface({
sort: params.sortType,
filters: params.filters
});
cacheRepoOptions.current = resultList;
// hf has no page and perPage, so we need to slice the resultList
list = getCurrentPage(params.page);
setPaginationInfo((prev) => {
return {
...prev,
page: params.page,
total: resultList.length
};
});
} else if (modelSource === modelSourceMap.modelscope_value) {
list = await getModelsFromModelscope(params);
console.log('list:', list);
cacheRepoOptions.current = list;
}
setDataSource({
dataList: list,
loading: false,
networkError: false,
sortType: params.sortType,
filters: params.filters
});
handleOnSelectModel(list[0]);
setLoadingModel?.(false);
handleEvaluate(list);
} catch (error: any) {
console.log('error:', error);
setDataSource({
dataList: [],
loading: currentSearchId !== searchRepoRequestIdRef.current,
sortType: params.sortType,
filters: params.filters,
networkError: error?.message === 'Failed to fetch'
});
setLoadingModel?.(currentSearchId !== searchRepoRequestIdRef.current);
displayEvaluateStatus?.({
show: false,
message: ''
});
handleOnSelectModel({});
cacheRepoOptions.current = [];
}
};
const handleSearchInputChange = (e: any) => {
searchInputRef.current = e.target.value;
};
const handlerSearchModels = _.debounce(
() =>
handleOnSearchRepo({
sortType: dataSource.sortType,
page: 1,
perPage: paginationInfo.perPage,
filters: dataSource.filters
}),
100
);
const handleOnOpen = () => {
if (
!dataSource.dataList.length &&
!cacheRepoOptions.current.length &&
SUPPORTEDSOURCE.includes(modelSource)
) {
handleOnSearchRepo({
sortType: dataSource.sortType,
page: 1,
perPage: paginationInfo.perPage,
filters: dataSource.filters
});
}
};
const handleSortChange = (value: string) => {
handleOnSearchRepo({
sortType: value,
page: 1,
perPage: paginationInfo.perPage,
filters: dataSource.filters
});
};
const handleFilterChange = (value: string) => {
handleOnSearchRepo({
sortType: dataSource.sortType,
page: 1,
perPage: paginationInfo.perPage,
filters: {
...dataSource.filters,
tag: value
}
});
};
const handleOnPageChange = (page: number) => {
if (modelSource === modelSourceMap.huggingface_value) {
const currentList = getCurrentPage(page);
setPaginationInfo((prev) => {
return {
...prev,
page: page
};
});
setDataSource((pre) => {
return {
...pre,
dataList: currentList
};
});
handleOnSelectModel(currentList[0]);
handleEvaluate(currentList);
} else if (modelSource === modelSourceMap.modelscope_value) {
setPaginationInfo((prev) => {
return {
...prev,
page: page
};
});
handleOnSearchRepo({
sortType: dataSource.sortType,
page: page,
perPage: paginationInfo.perPage,
filters: dataSource.filters
});
}
};
const handleSelectModelManually = (model: any) => {
if (model.id === currentRef.current) {
return;
}
setRquestId();
handleOnSelectModel(model, true);
};
const renderHFSearch = () => {
return (
<>
<SearchInput
onSearch={handlerSearchModels}
onChange={handleSearchInputChange}
modelSource={modelSource}
></SearchInput>
<div className={SearchStyle.filter}>
<span className="flex-center gap-8">
<BaseSelect
value={dataSource.sortType}
onChange={handleSortChange}
prefix={
<span>{intl.formatMessage({ id: 'model.deploy.sort' })}:</span>
}
options={modelFilesSortOptions}
size="middle"
style={{ width: '150px' }}
></BaseSelect>
<BaseSelect
allowClear
value={dataSource.filters.tag}
onChange={handleFilterChange}
options={filterOptions}
size="middle"
placeholder={intl.formatMessage({
id: 'models.form.quantization'
})}
style={{ width: 130 }}
></BaseSelect>
</span>
<PaginationMain
simple={{ readOnly: true }}
total={paginationInfo.total}
current={paginationInfo.page}
pageSize={paginationInfo.perPage}
onChange={handleOnPageChange}
showSizeChanger={false}
hideOnSinglePage={paginationInfo.total <= paginationInfo.perPage}
></PaginationMain>
</div>
</>
);
};
useEffect(() => {
handleOnOpen();
}, [modelSource]);
useEffect(() => {
return () => {
axiosTokenRef.current?.abort?.();
checkTokenRef.current?.cancel?.();
if (timer.current) {
clearTimeout(timer.current);
}
};
}, []);
return (
<div style={{ width: '100%' }}>
<div className={SearchStyle['search-bar']}>{renderHFSearch()}</div>
<ColumnWrapper
maxHeight={'calc(100vh - 210px)'}
styles={{
container: {
paddingTop: 0
}
}}
>
<SearchResult
loading={dataSource.loading}
resultList={dataSource.dataList}
networkError={dataSource.networkError}
current={current}
source={modelSource}
isEvaluating={isEvaluating}
onSelect={handleSelectModelManually}
></SearchResult>
</ColumnWrapper>
</div>
);
};
export default SearchModel;
@@ -0,0 +1,173 @@
import IconFont from '@/components/icon-font';
import { SearchOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Empty, Spin } from 'antd';
import _ from 'lodash';
import React, { useMemo } from 'react';
import 'simplebar-react/dist/simplebar.min.css';
import styled from 'styled-components';
import { modelSourceMap } from '../../config';
import '../../style/search-result.less';
import FileSkeleton from './file-skeleton';
import HFModelItem from './hf-model-item';
interface SearchResultProps {
resultList: any[];
onSelect?: (item: any) => void;
current?: string;
source?: string;
style?: React.CSSProperties;
loading?: boolean;
networkError?: boolean;
isEvaluating?: boolean;
}
const ItemFileWrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
gap: 24px;
`;
const SpinWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
bottom: 0;
display: flex;
justify-content: center;
`;
const SearchResult: React.FC<SearchResultProps> = (props) => {
const { resultList, onSelect, isEvaluating, source, networkError } = props;
const intl = useIntl();
const handleSelect = (e: any, item: any) => {
e.stopPropagation();
onSelect?.(item);
};
const handleOnEnter = (e: any, item: any) => {
e.stopPropagation();
if (e.key === 'Enter') {
onSelect?.(item);
}
};
const renderEmpty = useMemo(() => {
if (networkError) {
return (
<Empty
styles={{
image: {
height: 'auto',
marginTop: '20px'
}
}}
image={
<IconFont
type="icon-networkerror"
style={{
color: 'var(--ant-color-text-tertiary)',
fontSize: '66px'
}}
></IconFont>
}
description={
source === modelSourceMap.huggingface_value ? (
<div className="flex-column gap-5">
<span>
{intl.formatMessage({ id: 'models.search.networkerror' })}
</span>
<span>
<span>
{intl.formatMessage({ id: 'models.search.hfvisit' })}
</span>
<Button
type="link"
size="small"
href="https://huggingface.co/"
target="_blank"
>
Hugging Face
</Button>
</span>
</div>
) : null
}
/>
);
}
return (
<Empty
styles={{
image: {
height: 'auto',
marginTop: '20px'
}
}}
image={
<SearchOutlined
className="font-size-16"
style={{ color: 'var(--ant-color-text-tertiary)' }}
></SearchOutlined>
}
description={intl.formatMessage({ id: 'models.search.noresult' })}
/>
);
}, [networkError, source, intl]);
return (
<>
<div style={{ ...props.style }} className="search-result-wrap">
<Spin spinning={props.loading} size={'middle'}>
<div style={{ minHeight: 200 }}>
{resultList.length ? (
<ItemFileWrapper>
{resultList.map((item, index) => (
<div
key={item.name}
onClick={(e) => handleSelect(e, item)}
onKeyDown={(e) => handleOnEnter(e, item)}
>
<HFModelItem
source={source}
tags={item.tags}
key={index}
title={item.name}
downloads={item.downloads}
likes={item.likes}
task={item.task}
updatedAt={item.updatedAt}
evaluateResult={item.evaluateResult}
active={item.id === props.current}
isEvaluating={isEvaluating}
/>
</div>
))}
</ItemFileWrapper>
) : props.loading ? (
<ItemFileWrapper>
{_.times(10, (index: number) => {
return (
<FileSkeleton
key={index}
counts={3}
itemHeight={82}
></FileSkeleton>
);
})}
</ItemFileWrapper>
) : (
renderEmpty
)}
</div>
</Spin>
</div>
</>
);
};
export default SearchResult;