feat: catalog page

This commit is contained in:
jialin
2025-01-03 20:51:22 +08:00
parent 95058f7890
commit 39fff0e57d
11 changed files with 699 additions and 52 deletions
+9
View File
@@ -67,6 +67,15 @@ export default [
access: 'canSeeAdmin',
component: './llmodels'
},
{
name: 'modelsCatalog',
path: '/models/catalog',
key: 'modelsCatalog',
icon: 'Block',
access: 'canSeeAdmin',
hideInMenu: true,
component: './llmodels/catalog'
},
{
name: 'resources',
path: '/resources',
+16
View File
@@ -0,0 +1,16 @@
.tags-wrapper {
display: flex;
width: 100%;
overflow: hidden;
.more {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
height: 22px;
opacity: 0.7;
}
}
+82
View File
@@ -0,0 +1,82 @@
import { MoreOutlined } from '@ant-design/icons';
import { Dropdown, Tag } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import './index.less';
const TagsWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const observer = useRef<IntersectionObserver | null>(null);
const [hiddenIndex, setHiddenIndex] = useState<number>(0);
const uid = useRef(0);
const updateUid = () => {
uid.current += 1;
return uid.current;
};
const dropItems = useMemo(() => {
return React.Children.toArray(children)
.slice(hiddenIndex)
.map((child, index) => ({
label: child,
key: updateUid()
}));
}, [children, hiddenIndex]);
useEffect(() => {
if (!wrapperRef.current) return;
observer.current = new IntersectionObserver(
(entries) => {
let newHiddenIndex = 0;
entries.forEach((entry, index) => {
if (entry.intersectionRatio < 1) {
newHiddenIndex = Math.min(newHiddenIndex, index);
}
console.log(
'isIntersecting=======',
entry.intersectionRatio,
newHiddenIndex
);
});
setHiddenIndex(() => newHiddenIndex);
},
{
root: wrapperRef.current,
threshold: 0
}
);
const childrenList = wrapperRef.current.children;
Array.from(childrenList).forEach((child) => {
observer.current?.observe(child);
});
return () => {
observer.current?.disconnect();
};
}, [children]);
return (
<div className="tags-wrapper" ref={wrapperRef}>
<span>{hiddenIndex}</span>
{React.Children.toArray(children).slice(
0,
React.Children.toArray(children).length - hiddenIndex
)}
{hiddenIndex > 0 && (
<Dropdown
menu={{
items: dropItems
}}
>
<Tag className="more">
<MoreOutlined rotate={90} />
</Tag>
</Dropdown>
)}
</div>
);
};
export default React.memo(TagsWrapper);
+2
View File
@@ -8,6 +8,8 @@ export default {
'menu.playground.text2images': 'Image',
'menu.compare': 'Compare',
'menu.models': 'Models',
'menu.models.catalog': 'Model Catalog',
'menu.modelsCatalog': 'Model Catalog',
'menu.resources': 'Resources',
'menu.apikeys': 'API Keys',
'menu.users': 'Users',
+2
View File
@@ -8,6 +8,8 @@ export default {
'menu.playground.text2images': '文生图',
'menu.compare': '多模型对比',
'menu.models': '模型',
'menu.models.catalog': '模型库',
'menu.modelsCatalog': '模型库',
'menu.resources': '资源',
'menu.apikeys': 'API 密钥',
'menu.users': '用户',
+146
View File
@@ -0,0 +1,146 @@
import PageTools from '@/components/page-tools';
import { PageAction } from '@/config';
import breakpoints from '@/config/breakpoints';
import { SyncOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Button, Col, Input, Pagination, Row, Space, message } from 'antd';
import _ from 'lodash';
import ResizeObserver from 'rc-resize-observer';
import React, { useCallback, useState } from 'react';
import { createModel } from './apis';
import CatalogItem from './components/catalog-item';
import DelopyBuiltInModal from './components/deploy-builtin-modal';
import { getSourceRepoConfigValue, modelSourceMap } from './config';
import { FormData } from './config/types';
const Catalog: React.FC = () => {
const intl = useIntl();
const [span, setSpan] = React.useState(6);
const [activeId, setActiveId] = React.useState(-1);
const [openDeployModal, setOpenDeployModal] = useState<any>({
show: false,
width: 600,
source: modelSourceMap.huggingface_value
});
const handleResize = useCallback(
_.throttle((size: { width: number; height: number }) => {
const { width } = size;
if (width < breakpoints.xs) {
setSpan(24);
} else if (width < breakpoints.sm) {
setSpan(24);
} else if (width < breakpoints.md) {
setSpan(12);
} else if (width < breakpoints.lg) {
setSpan(8);
} else {
setSpan(6);
}
console.log('size:', size);
}, 100),
[]
);
const handleDeployModalCancel = () => {
setOpenDeployModal({
...openDeployModal,
show: false
});
};
const handleOnDeploy = useCallback(
(index: number) => {
setActiveId(index);
setOpenDeployModal({
show: true,
source: modelSourceMap.huggingface_value,
width: 600
});
},
[openDeployModal]
);
const handleCreateModel = useCallback(
async (data: FormData) => {
try {
console.log('data:', data, openDeployModal);
const result = getSourceRepoConfigValue(openDeployModal.source, data);
const modelData = await createModel({
data: {
...result.values,
..._.omit(data, result.omits)
}
});
setOpenDeployModal({
...openDeployModal,
show: false
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {}
},
[openDeployModal]
);
return (
<PageContainer
ghost
header={{
title: intl.formatMessage({ id: 'menu.models.catalog' }),
breadcrumb: {}
}}
extra={[]}
>
<PageTools
marginBottom={22}
left={
<Space>
<Input
placeholder={intl.formatMessage({ id: 'common.filter.name' })}
style={{ width: 300 }}
size="large"
allowClear
></Input>
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
icon={<SyncOutlined></SyncOutlined>}
></Button>
</Space>
}
></PageTools>
<ResizeObserver onResize={handleResize}>
<Row gutter={[16, 16]}>
{Array.from({ length: 12 }).map((_, index) => {
return (
<Col span={span} key={index}>
<CatalogItem
onDeploy={() => handleOnDeploy(index)}
activeId={activeId}
itemId={index}
></CatalogItem>
</Col>
);
})}
</Row>
</ResizeObserver>
<div style={{ marginBlock: '32px 16px' }}>
<Pagination align="end" defaultCurrent={1} total={50} showSizeChanger />
</div>
<DelopyBuiltInModal
open={openDeployModal.show}
action={PageAction.CREATE}
title={intl.formatMessage({ id: 'models.button.deploy' })}
source={openDeployModal.source}
width={openDeployModal.width}
onCancel={handleDeployModalCancel}
onOk={handleCreateModel}
></DelopyBuiltInModal>
</PageContainer>
);
};
export default Catalog;
@@ -0,0 +1,62 @@
import IMG from '@/assets/images/small-logo-200x200.png';
import AutoTooltip from '@/components/auto-tooltip';
import { Button, Tag, Typography } from 'antd';
import classNames from 'classnames';
import React from 'react';
import '../style/catalog-item.less';
interface CatalogItemProps {
activeId: number;
itemId: number;
onDeploy: () => void;
}
const CatalogItem: React.FC<CatalogItemProps> = (props) => {
const { onDeploy, itemId, activeId } = props;
const handleOnDeploy = () => {
onDeploy();
};
return (
<div
className={classNames('catalog-item', { active: activeId === itemId })}
>
<div className="content">
<div className="title">
<div className="img">
<img src={IMG} alt="" />
</div>
<AutoTooltip ghost style={{ flex: 1 }}>
gpustack/stable-diffusion-v3-5-medium-GGUF
</AutoTooltip>
</div>
<Typography.Paragraph className="desc" ellipsis={{ rows: 2 }}>
this is description this is description this is description this is
this is description this isthis is description this isthis is
description this is this is description this isthis is description
this isthis is description this isthis is description this isthis is
description this isthis is description this isthis is description this
is this is description this isthis is description this isthis is
description this isthis is description this is
</Typography.Paragraph>
</div>
<div className="item-footer">
<div className="tags">
<Tag color="blue" className="tag-item">
Audio
</Tag>
<Tag color="purple" className="tag-item">
GGUF
</Tag>
<Tag color="orange" className="tag-item">
Qwen
</Tag>
</div>
<Button size="small" type="primary" onClick={handleOnDeploy}>
Deploy
</Button>
</div>
</div>
);
};
export default React.memo(CatalogItem);
@@ -0,0 +1,232 @@
import ModalFooter from '@/components/modal-footer';
import { PageActionType } from '@/config/types';
import { CloseOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Drawer } from 'antd';
import { debounce } from 'lodash';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
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;
action: PageActionType;
open: boolean;
data?: ListItem;
source: string;
width?: string | number;
onOk: (values: FormData) => void;
onCancel: () => void;
};
const steps = [
{
element: '#filterGGUF',
popover: {
title: '筛选模型',
description: 'Select a model from the list'
}
},
{
element: '#backend-field',
popover: {
title: '选择推理后端',
description: 'Select a model from the list'
}
}
];
const AddModal: React.FC<AddModalProps> = (props) => {
const {
title,
open,
onOk,
onCancel,
source,
action,
width = 600
} = props || {};
const SEARCH_SOURCE = [];
const form = useRef<any>({});
const intl = useIntl();
const [selectedModel, setSelectedModel] = useState<any>({});
const [collapsed, setCollapsed] = useState<boolean>(false);
const [loadingModel, setLoadingModel] = useState<boolean>(false);
const [isGGUF, setIsGGUF] = useState<boolean>(false);
const modelFileRef = useRef<any>(null);
const [loadfinish, setLoadfinish] = useState<boolean>(false);
const handleSelectModelFile = useCallback((item: any) => {
form.current?.setFieldValue?.('file_name', item.fakeName);
setLoadfinish(true);
}, []);
const handleOnSelectModel = (item: any) => {
setSelectedModel(item);
};
const handleSumit = () => {
form.current?.submit?.();
};
const debounceFetchModelFiles = debounce(() => {
modelFileRef.current?.fetchModelFiles?.();
}, 300);
const handleSetIsGGUF = (flag: boolean) => {
setIsGGUF(flag);
if (flag) {
debounceFetchModelFiles();
}
};
const handleBackendChange = (backend: string) => {
if (backend === backendOptionsMap.vllm) {
setIsGGUF(false);
}
if (backend === backendOptionsMap.llamaBox) {
setIsGGUF(true);
}
};
const handleCancel = useCallback(() => {
onCancel?.();
}, [onCancel]);
useEffect(() => {
handleSelectModelFile({ fakeName: '' });
}, [selectedModel]);
useEffect(() => {
if (!open) {
setIsGGUF(false);
form.current?.setFieldValue?.('backend', backendOptionsMap.vllm);
} else if (source === modelSourceMap.ollama_library_value) {
form.current?.setFieldValue?.('backend', backendOptionsMap.llamaBox);
setIsGGUF(true);
}
return () => {
setSelectedModel({});
};
}, [open, source]);
return (
<Drawer
title={
<div className="flex-between flex-center">
<span
style={{
color: 'var(--ant-color-text)',
fontWeight: 'var(--font-weight-medium)',
fontSize: 'var(--font-size-middle)'
}}
>
{title}
</span>
<Button type="text" size="small" onClick={handleCancel}>
<CloseOutlined></CloseOutlined>
</Button>
</div>
}
open={open}
onClose={handleCancel}
destroyOnClose={true}
closeIcon={false}
maskClosable={false}
keyboard={false}
styles={{
body: {
height: 'calc(100vh - 57px)',
padding: '16px 0',
overflowX: 'hidden'
},
content: {
borderRadius: '6px 0 0 6px'
}
}}
width={width}
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
onCancel={handleCancel}
onOk={handleSumit}
style={{
padding: '16px 24px',
display: 'flex',
justifyContent: 'flex-end'
}}
></ModalFooter>
}
>
<>
{SEARCH_SOURCE.includes(source) && (
<TitleWrapper>
{intl.formatMessage({ id: 'models.form.configurations' })}
<span style={{ display: 'flex', height: 24 }}></span>
</TitleWrapper>
)}
<DataForm
source={source}
action={action}
selectedModel={selectedModel}
onOk={onOk}
ref={form}
isGGUF={isGGUF}
onBackendChange={handleBackendChange}
></DataForm>
</>
</ColumnWrapper>
</div>
</Drawer>
);
};
export default memo(AddModal);
+2 -14
View File
@@ -6,7 +6,7 @@ import { Button, Drawer } from 'antd';
import { debounce } from 'lodash';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { backendOptionsMap, modelSourceMap } from '../config';
import { FormData, ListItem } from '../config/types';
import { FormData } from '../config/types';
import ColumnWrapper from './column-wrapper';
import DataForm from './data-form';
import HFModelFile from './hf-model-file';
@@ -19,7 +19,6 @@ type AddModalProps = {
title: string;
action: PageActionType;
open: boolean;
data?: ListItem;
source: string;
width?: string | number;
onOk: (values: FormData) => void;
@@ -57,10 +56,7 @@ const AddModal: React.FC<AddModalProps> = (props) => {
modelSourceMap.huggingface_value,
modelSourceMap.modelscope_value
];
// const { start } = useDriver({
// steps,
// id: 'deploy-model'
// });
const form = useRef<any>({});
const intl = useIntl();
const [selectedModel, setSelectedModel] = useState<any>({});
@@ -126,14 +122,6 @@ const AddModal: React.FC<AddModalProps> = (props) => {
};
}, [open, source]);
// useEffect(() => {
// if (open && loadfinish) {
// setTimeout(() => {
// start();
// }, 1000);
// }
// }, [loadfinish, open]);
return (
<Drawer
title={
+56 -38
View File
@@ -22,6 +22,7 @@ import {
DownOutlined,
EditOutlined,
ExperimentOutlined,
GoldOutlined,
PictureOutlined,
SyncOutlined,
WechatWorkOutlined
@@ -221,61 +222,35 @@ 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,
key: 'huggingface',
icon: <IconFont type="icon-huggingface"></IconFont>,
onClick: (e: any) => {
setOpenDeployModal({
show: true,
width: 'calc(100vw - 220px)',
source: modelSourceMap.huggingface_value
});
}
icon: <IconFont type="icon-huggingface"></IconFont>
},
{
label: 'Ollama Library',
value: modelSourceMap.ollama_library_value,
key: 'ollama_library',
icon: <IconFont type="icon-ollama"></IconFont>,
onClick: (e: any) => {
setOpenDeployModal(() => {
return {
show: true,
width: 600,
source: modelSourceMap.ollama_library_value
};
});
}
icon: <IconFont type="icon-ollama"></IconFont>
},
{
label: 'ModelScope',
value: modelSourceMap.modelscope_value,
key: 'modelscope',
icon: <IconFont type="icon-tu2"></IconFont>,
onClick: (e: any) => {
setOpenDeployModal({
show: true,
width: 'calc(100vw - 220px)',
source: modelSourceMap.modelscope_value
});
}
icon: <IconFont type="icon-tu2"></IconFont>
},
{
label: intl.formatMessage({ id: 'models.form.localPath' }),
value: modelSourceMap.local_path_value,
key: 'local_path',
icon: <IconFont type="icon-hard-disk"></IconFont>,
onClick: (e: any) => {
setOpenDeployModal(() => {
return {
show: true,
width: 600,
source: modelSourceMap.local_path_value
};
});
}
icon: <IconFont type="icon-hard-disk"></IconFont>
}
];
@@ -673,6 +648,7 @@ const Models: React.FC<ModelsProps> = ({
},
[intl]
);
const renderChildren = useCallback(
(list: any, parent?: any) => {
return (
@@ -704,6 +680,43 @@ const Models: React.FC<ModelsProps> = ({
return '';
}, []);
const handleClickDropdown = (item: any) => {
if (item.key === 'huggingface') {
setOpenDeployModal({
show: true,
width: 'calc(100vw - 220px)',
source: modelSourceMap.huggingface_value
});
}
if (item.key === 'model_catalog') {
navigate('/models/catalog');
}
if (item.key === 'ollama_library') {
setOpenDeployModal({
show: true,
width: 600,
source: modelSourceMap.ollama_library_value
});
}
if (item.key === 'modelscope') {
setOpenDeployModal({
show: true,
width: 'calc(100vw - 220px)',
source: modelSourceMap.modelscope_value
});
}
if (item.key === 'local_path') {
setOpenDeployModal({
show: true,
width: 600,
source: modelSourceMap.local_path_value
});
}
};
return (
<>
<PageContainer
@@ -734,7 +747,13 @@ const Models: React.FC<ModelsProps> = ({
}
right={
<Space size={20}>
<Dropdown menu={{ items: sourceOptions }} placement="bottomRight">
<Dropdown
menu={{
items: sourceOptions,
onClick: handleClickDropdown
}}
placement="bottomRight"
>
<Button
icon={<DownOutlined></DownOutlined>}
type="primary"
@@ -883,7 +902,6 @@ const Models: React.FC<ModelsProps> = ({
open={openDeployModal.show}
action={PageAction.CREATE}
title={intl.formatMessage({ id: 'models.button.deploy' })}
data={currentData}
source={openDeployModal.source}
width={openDeployModal.width}
onCancel={handleDeployModalCancel}
@@ -0,0 +1,90 @@
.catalog-item {
height: 146px;
overflow: hidden;
display: flex;
padding: 12px 14px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border: 1px solid var(--ant-color-border);
border-radius: var(--border-radius-base);
cursor: pointer;
width: 100%;
.img {
width: 32px;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
img {
height: 32px;
width: 32px;
object-fit: contain;
}
}
.content {
display: flex;
flex-direction: column;
justify-content: flex-start;
flex: 1;
width: 100%;
// height: 100%;
}
&:hover {
background-color: var(--color-fill-sider);
}
&.active {
background-color: var(--color-fill-sider);
}
.title {
font-size: var(--font-size-middle);
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
margin-bottom: 10px;
.name {
flex: 1;
}
}
.desc {
font-size: var(--font-size-base);
color: var(--ant-color-text-secondary);
width: 100%;
overflow: hidden;
}
.item-footer {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.tags {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.tag-item {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
height: 22px;
opacity: 0.7;
}
}