diff --git a/config/routes.ts b/config/routes.ts index 537e802f..7ff3930f 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -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', diff --git a/src/components/tags-wrapper/index.less b/src/components/tags-wrapper/index.less new file mode 100644 index 00000000..49eafbbe --- /dev/null +++ b/src/components/tags-wrapper/index.less @@ -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; + } +} diff --git a/src/components/tags-wrapper/index.tsx b/src/components/tags-wrapper/index.tsx new file mode 100644 index 00000000..d55f80fd --- /dev/null +++ b/src/components/tags-wrapper/index.tsx @@ -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(null); + const observer = useRef(null); + const [hiddenIndex, setHiddenIndex] = useState(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 ( +
+ {hiddenIndex} + {React.Children.toArray(children).slice( + 0, + React.Children.toArray(children).length - hiddenIndex + )} + {hiddenIndex > 0 && ( + + + + + + )} +
+ ); +}; + +export default React.memo(TagsWrapper); diff --git a/src/locales/en-US/menu.ts b/src/locales/en-US/menu.ts index bc45b62c..5eb451bb 100644 --- a/src/locales/en-US/menu.ts +++ b/src/locales/en-US/menu.ts @@ -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', diff --git a/src/locales/zh-CN/menu.ts b/src/locales/zh-CN/menu.ts index 735195d7..cd40366f 100644 --- a/src/locales/zh-CN/menu.ts +++ b/src/locales/zh-CN/menu.ts @@ -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': '用户', diff --git a/src/pages/llmodels/catalog.tsx b/src/pages/llmodels/catalog.tsx new file mode 100644 index 00000000..a19830b4 --- /dev/null +++ b/src/pages/llmodels/catalog.tsx @@ -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({ + 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 ( + + + + + + } + > + + + {Array.from({ length: 12 }).map((_, index) => { + return ( + + handleOnDeploy(index)} + activeId={activeId} + itemId={index} + > + + ); + })} + + +
+ +
+ +
+ ); +}; + +export default Catalog; diff --git a/src/pages/llmodels/components/catalog-item.tsx b/src/pages/llmodels/components/catalog-item.tsx new file mode 100644 index 00000000..0ff0665b --- /dev/null +++ b/src/pages/llmodels/components/catalog-item.tsx @@ -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 = (props) => { + const { onDeploy, itemId, activeId } = props; + + const handleOnDeploy = () => { + onDeploy(); + }; + return ( +
+
+
+
+ +
+ + gpustack/stable-diffusion-v3-5-medium-GGUF + +
+ + 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 + +
+
+
+ + Audio + + + GGUF + + + Qwen + +
+ +
+
+ ); +}; + +export default React.memo(CatalogItem); diff --git a/src/pages/llmodels/components/deploy-builtin-modal.tsx b/src/pages/llmodels/components/deploy-builtin-modal.tsx new file mode 100644 index 00000000..88dbd34e --- /dev/null +++ b/src/pages/llmodels/components/deploy-builtin-modal.tsx @@ -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 = (props) => { + const { + title, + open, + onOk, + onCancel, + source, + action, + width = 600 + } = props || {}; + const SEARCH_SOURCE = []; + + const form = useRef({}); + const intl = useIntl(); + const [selectedModel, setSelectedModel] = useState({}); + const [collapsed, setCollapsed] = useState(false); + const [loadingModel, setLoadingModel] = useState(false); + const [isGGUF, setIsGGUF] = useState(false); + const modelFileRef = useRef(null); + const [loadfinish, setLoadfinish] = useState(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 ( + + + {title} + + + + } + 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} + > +
+ {SEARCH_SOURCE.includes(props.source) && ( + <> +
+ + + + +
+
+ + + {isGGUF && ( + + )} + + +
+ + )} + + } + > + <> + {SEARCH_SOURCE.includes(source) && ( + + {intl.formatMessage({ id: 'models.form.configurations' })} + + + )} + + + +
+
+ ); +}; + +export default memo(AddModal); diff --git a/src/pages/llmodels/components/deploy-modal.tsx b/src/pages/llmodels/components/deploy-modal.tsx index 9f49725c..0ad5f280 100644 --- a/src/pages/llmodels/components/deploy-modal.tsx +++ b/src/pages/llmodels/components/deploy-modal.tsx @@ -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 = (props) => { modelSourceMap.huggingface_value, modelSourceMap.modelscope_value ]; - // const { start } = useDriver({ - // steps, - // id: 'deploy-model' - // }); + const form = useRef({}); const intl = useIntl(); const [selectedModel, setSelectedModel] = useState({}); @@ -126,14 +122,6 @@ const AddModal: React.FC = (props) => { }; }, [open, source]); - // useEffect(() => { - // if (open && loadfinish) { - // setTimeout(() => { - // start(); - // }, 1000); - // } - // }, [loadfinish, open]); - return ( = ({ }, [deleteIds]); const sourceOptions = [ + { + label: 'Model Catalog', + value: 'model_catalog', + key: 'model_catalog', + icon: + }, { label: 'Hugging Face', value: modelSourceMap.huggingface_value, key: 'huggingface', - icon: , - onClick: (e: any) => { - setOpenDeployModal({ - show: true, - width: 'calc(100vw - 220px)', - source: modelSourceMap.huggingface_value - }); - } + icon: }, { label: 'Ollama Library', value: modelSourceMap.ollama_library_value, key: 'ollama_library', - icon: , - onClick: (e: any) => { - setOpenDeployModal(() => { - return { - show: true, - width: 600, - source: modelSourceMap.ollama_library_value - }; - }); - } + icon: }, { label: 'ModelScope', value: modelSourceMap.modelscope_value, key: 'modelscope', - icon: , - onClick: (e: any) => { - setOpenDeployModal({ - show: true, - width: 'calc(100vw - 220px)', - source: modelSourceMap.modelscope_value - }); - } + icon: }, { label: intl.formatMessage({ id: 'models.form.localPath' }), value: modelSourceMap.local_path_value, key: 'local_path', - icon: , - onClick: (e: any) => { - setOpenDeployModal(() => { - return { - show: true, - width: 600, - source: modelSourceMap.local_path_value - }; - }); - } + icon: } ]; @@ -673,6 +648,7 @@ const Models: React.FC = ({ }, [intl] ); + const renderChildren = useCallback( (list: any, parent?: any) => { return ( @@ -704,6 +680,43 @@ const Models: React.FC = ({ 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 ( <> = ({ } right={ - +