diff --git a/src/components/seal-form/auto-complete-cascader.tsx b/src/components/seal-form/auto-complete-cascader.tsx deleted file mode 100644 index 9d1f6d39..00000000 --- a/src/components/seal-form/auto-complete-cascader.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Cascader, Input, Popover } from 'antd'; -import React, { useEffect, useRef, useState } from 'react'; - -const { Panel } = Cascader; - -const options = [ - { - value: 'fruit', - label: 'Fruit', - children: [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' } - ] - }, - { - value: 'vegetable', - label: 'Vegetable', - children: [ - { value: 'carrot', label: 'Carrot' }, - { value: 'potato', label: 'Potato' } - ] - } -]; - -const CustomCascader: React.FC = () => { - const [value, setValue] = useState(''); - const [visible, setVisible] = useState(false); - const [inputWidth, setInputWidth] = useState(0); - const inputRef = useRef(null); - - useEffect(() => { - if (inputRef.current) { - setInputWidth(inputRef.current.offsetWidth); - } - }, []); - - const handleCascaderChange = (selectedValues: string[]) => { - setValue(selectedValues.join(' / ')); - setVisible(false); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setValue(e.target.value); - }; - - return ( - - - - } - arrow={false} - placement="bottomLeft" - trigger="click" - open={visible} - onOpenChange={setVisible} - > - setVisible(true)} - /> - - ); -}; - -export default CustomCascader; diff --git a/src/components/seal-form/config/index.ts b/src/components/seal-form/config/index.ts new file mode 100644 index 00000000..9c6c8848 --- /dev/null +++ b/src/components/seal-form/config/index.ts @@ -0,0 +1,7 @@ +const WRAPHEIGHT = 54; +const INPUTHEIGHT = 32; +const BORDERRADIUS = 4; +const BGCOLOR = 'transparent'; +const INPUT_INNER_PADDING = 14; + +export { BGCOLOR, BORDERRADIUS, INPUTHEIGHT, INPUT_INNER_PADDING, WRAPHEIGHT }; diff --git a/src/components/seal-form/input-number.tsx b/src/components/seal-form/input-number.tsx index 9f687666..b7baae20 100644 --- a/src/components/seal-form/input-number.tsx +++ b/src/components/seal-form/input-number.tsx @@ -1,9 +1,10 @@ import { isNotEmptyValue } from '@/utils/index'; import type { InputNumberProps } from 'antd'; import { Form, InputNumber } from 'antd'; -import { useEffect, useRef, useState } from 'react'; -import Wrapper from './components/wrapper'; +import React, { useEffect, useRef, useState } from 'react'; import { SealFormItemProps } from './types'; +import Wrapper from './wrapper'; +import InputWrapper from './wrapper/input'; const SealInputNumber: React.FC = ( props @@ -60,26 +61,28 @@ const SealInputNumber: React.FC = ( }; return ( - - - + + + + + ); }; diff --git a/src/components/seal-form/password.tsx b/src/components/seal-form/password.tsx index 3f9b376c..14f9fc0b 100644 --- a/src/components/seal-form/password.tsx +++ b/src/components/seal-form/password.tsx @@ -1,8 +1,9 @@ import type { InputProps } from 'antd'; import { Form, Input } from 'antd'; -import { useEffect, useRef, useState } from 'react'; -import Wrapper from './components/wrapper'; +import React, { useEffect, useRef, useState } from 'react'; import { SealFormItemProps } from './types'; +import Wrapper from './wrapper'; +import InputWrapper from './wrapper/input'; const SealPassword: React.FC = (props) => { const { @@ -55,26 +56,28 @@ const SealPassword: React.FC = (props) => { }; return ( - - - + + + + + ); }; diff --git a/src/components/seal-form/seal-input.tsx b/src/components/seal-form/seal-input.tsx index fbcc62a3..d28fc3aa 100644 --- a/src/components/seal-form/seal-input.tsx +++ b/src/components/seal-form/seal-input.tsx @@ -1,12 +1,13 @@ import type { InputProps } from 'antd'; import { Form, Input } from 'antd'; -import { useEffect, useRef, useState } from 'react'; -import Wrapper from './components/wrapper'; +import React, { useEffect, useRef, useState } from 'react'; import SealInputNumber from './input-number'; import SealInputSearch from './input-search'; import SealPassword from './password'; import SealTextArea from './seal-textarea'; import { SealFormItemProps } from './types'; +import Wrapper from './wrapper'; +import InputWrapper from './wrapper/input'; const SealInput: React.FC = (props) => { const { @@ -72,29 +73,31 @@ const SealInput: React.FC = (props) => { }; return ( - - - + + + + + ); }; diff --git a/src/components/seal-form/seal-select.tsx b/src/components/seal-form/seal-select.tsx index 4e883af5..532c391f 100644 --- a/src/components/seal-form/seal-select.tsx +++ b/src/components/seal-form/seal-select.tsx @@ -3,9 +3,10 @@ import { useIntl } from '@umijs/max'; import type { SelectProps } from 'antd'; import { Form, Select } from 'antd'; import { cloneDeep } from 'lodash'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import Wrapper from './components/wrapper'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { SealFormItemProps } from './types'; +import Wrapper from './wrapper'; +import SelectWrapper from './wrapper/select'; const SealSelect: React.FC = (props) => { const { @@ -81,28 +82,30 @@ const SealSelect: React.FC = (props) => { }; return ( - - - + + + ); }; diff --git a/src/components/seal-form/seal-slider.tsx b/src/components/seal-form/seal-slider.tsx index 418e9dc1..2a38b3cf 100644 --- a/src/components/seal-form/seal-slider.tsx +++ b/src/components/seal-form/seal-slider.tsx @@ -2,8 +2,8 @@ import { INPUT_WIDTH } from '@/constants'; import { Form, InputNumber, Slider, type SliderSingleProps } from 'antd'; import React from 'react'; import LabelInfo from './components/label-info'; -import FieldWrapper from './field-wrapper'; -import SliderStyles from './styles/slider.less'; +import Wrapper from './wrapper'; +import SliderWrapper from './wrapper/slider'; interface SealSliderProps extends SliderSingleProps { required?: boolean; @@ -51,7 +51,7 @@ const SealSlider: React.FC = (props) => { const renderLabel = React.useMemo(() => { return ( @@ -74,25 +74,27 @@ const SealSlider: React.FC = (props) => { ); }, [label, labelWidth, description, value, max, min, step, defaultValue]); return ( - - - + + + + + ); }; diff --git a/src/components/seal-form/seal-textarea.tsx b/src/components/seal-form/seal-textarea.tsx index e12b4d0f..d4316636 100644 --- a/src/components/seal-form/seal-textarea.tsx +++ b/src/components/seal-form/seal-textarea.tsx @@ -1,8 +1,9 @@ import { Form, Input } from 'antd'; import type { TextAreaProps } from 'antd/es/input/TextArea'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import Wrapper from './components/wrapper'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { SealFormItemProps } from './types'; +import Wrapper from './wrapper'; +import InputWrapper from './wrapper/input'; const SealTextArea: React.FC = (props) => { const { @@ -78,30 +79,32 @@ const SealTextArea: React.FC = (props) => { ); return ( - - handleChange(e)} - > - + + + handleChange(e)} + > + + ); }; diff --git a/src/components/seal-form/wrapper/index.tsx b/src/components/seal-form/wrapper/index.tsx new file mode 100644 index 00000000..eb1b2d90 --- /dev/null +++ b/src/components/seal-form/wrapper/index.tsx @@ -0,0 +1,229 @@ +import classNames from 'classnames'; +import React, { FC } from 'react'; +import styled from 'styled-components'; +import LabelInfo from '../components/label-info'; +import { INPUT_INNER_PADDING } from '../config'; + +interface WrapperProps { + children: React.ReactNode; + label?: React.ReactNode; + noWrapperStyle?: boolean; + isFocus?: boolean; + classList?: string; + status?: string; // error | success | warning + required?: boolean; + description?: React.ReactNode; + className?: string; + disabled?: boolean; + extra?: React.ReactNode; + addAfter?: React.ReactNode; + variant?: string; + hasPrefix?: boolean; + labelExtra?: React.ReactNode; + onClick?: () => void; +} + +// wrapper box +const WrapperBox = styled.div` + position: relative; + display: flex; + justify-content: flex-start; + align-items: center; + height: 54px; + border-width: var(--ant-line-width); + border-style: var(--ant-line-type); + border-color: var(--ant-color-border); + border-radius: var(--border-radius-base); + background-color: var(--color-white-1); + &.borderless { + border: none; + box-shadow: none; + } + &.filled { + border: none; + box-shadow: none; + } + &.borderless:focus-within { + border: none; + box-shadow: none; + } + &:hover { + border-color: var(--ant-input-hover-border-color); + transition: all 0.2s ease; + } + &:focus-within { + border-color: var(--ant-input-active-border-color); + box-shadow: var(--ant-input-active-shadow); + outline: 0; + background-color: var(--ant-input-active-bg); + } + &.validate-status-error:not(.seal-select-wrapper) { + border-width: var(--ant-line-width); + border-style: var(--ant-line-type); + border-color: var(--ant-color-error); + + &:hover { + border-color: var(--ant-color-error-border-hover); + } + + &:focus-within { + border-color: var(--ant-color-error); + } + } + &.seal-input-wrapper-disabled { + background-color: var(--ant-color-bg-container-disabled); + cursor: not-allowed; + + &:hover { + border-color: var(--ant-color-border); + + .ant-input-search-button { + border-color: var(--ant-color-border) !important; + color: var(--ant-color-text-description) !important; + } + } + } +`; + +// wrapper +const InnerWrapper = styled.div.attrs<{ + noWrapperStyle?: boolean; + nolabel?: boolean; +}>((props) => ({ + className: classNames({ + __wrapper__: true, + 'no-wrapper-style': props.noWrapperStyle, + 'no-label': props.nolabel + }) +}))` + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1; + padding-block: 20px 0; + max-width: 100%; + &:hover { + .ant-input-number-handler-wrap { + width: 34px !important; + } + } + &.no-wrapper-style { + padding-block: 0; + } + &.no-label { + padding-block: 0; + } +`; + +// label +export const Label = styled.div.attrs<{ + isFocus?: boolean; + hasPrefix?: boolean; +}>((props) => ({ + className: classNames({ + 'isfoucs-has-value': props.isFocus, + 'blur-no-value': !props.isFocus, + 'has-prefix': props.hasPrefix + }) +}))` + position: absolute; + left: ${INPUT_INNER_PADDING}px; + color: rgba(0, 0, 0, 45%); + font-size: var(--font-size-base); + line-height: 1; + pointer-events: all; + display: flex; + width: max-content; + z-index: 5; + + &.isfoucs-has-value { + top: 10px; + transition: all 0.2s var(--seal-transition-func); + } + + &.blur-no-value { + top: 21px; + transition: all 0.2s var(--seal-transition-func); + } + + &.has-prefix { + top: 10px !important; + } +`; + +// inner +const Inner = styled.div` + width: 100%; +`; + +const Extra = styled.div` + position: absolute; + right: 12px; + top: 8px; + z-index: 10; +`; + +const AddAfter = styled.div` + position: relative; + color: var(--ant-color-text-quaternary); + font-size: 14px; + padding-right: calc(var(--ant-padding-sm) - 1px); +`; + +// for wrapper + +const Wrapper: FC = ({ + children, + label, + isFocus, + status, + className, + disabled, + classList, + description, + required, + extra, + variant, + addAfter, + hasPrefix, + noWrapperStyle, + labelExtra, + onClick +}) => { + const wrapperClass = classNames( + status ? `validate-status-${status}` : '', + className, + classList, + variant, + { + 'seal-input-wrapper-addafter': addAfter, + 'seal-input-wrapper-disabled': disabled + } + ); + return ( + + + {label && ( + + )} + {extra && {extra}} + {children} + + {addAfter && {addAfter}} + + ); +}; + +export default Wrapper; diff --git a/src/components/seal-form/wrapper/input.ts b/src/components/seal-form/wrapper/input.ts new file mode 100644 index 00000000..3dcb357a --- /dev/null +++ b/src/components/seal-form/wrapper/input.ts @@ -0,0 +1,131 @@ +import styled from 'styled-components'; +import { + BGCOLOR, + BORDERRADIUS, + INPUTHEIGHT, + INPUT_INNER_PADDING, + WRAPHEIGHT +} from '../config'; + +const InputWrapper = styled.div` + .seal-input-number { + padding-right: 0; + } + .seal-input-wrapper-disabled { + background-color: var(--ant-color-bg-container-disabled); + cursor: not-allowed; + + &:hover { + border-color: var(--ant-color-border); + .ant-input-search-button { + border-color: var(--ant-color-border) !important; + color: var(--ant-color-text-description) !important; + } + } + } + .ant-input-number-input-wrap { + flex: 1; + } + .ant-input, + .ant-input-password { + flex: 1; + display: flex; + align-items: center; + border: none; + box-shadow: none; + padding-block: 5px; + padding-inline: ${INPUT_INNER_PADDING}px; + height: ${INPUTHEIGHT}px !important; + background-color: ${BGCOLOR}; + } + .ant-input.seal-textarea { + flex: 1; + } + + .ant-input-number { + position: static; + display: flex; + align-items: center; + border: none; + box-shadow: none; + padding: 0; + height: ${INPUTHEIGHT}px !important; + background-color: ${BGCOLOR}; + + &:hover .ant-input-number-handler-wrap, + &-focused .ant-input-number-handler-wrap { + width: 34px !important; + } + + &:hover { + cursor: text; + } + &.ant-input-number-disabled { + &:hover { + background-color: inherit; + } + } + } + .ant-input-outlined { + display: flex; + align-items: center; + border: none; + box-shadow: none; + padding-block: 5px; + padding-inline: ${INPUT_INNER_PADDING}px; + height: ${INPUTHEIGHT}px !important; + background-color: transparent; + } + .ant-input.ant-input-disabled { + background-color: transparent; + } + input.ant-input-number-input { + flex: 1; + height: ${INPUTHEIGHT}px !important; + padding-block: 5px; + padding-inline: ${INPUT_INNER_PADDING}px; + } + .ant-input-group { + position: static; + } + .ant-input-group-addon { + inset-inline-start: unset !important; + border-radius: 0 ${BORDERRADIUS}px ${BORDERRADIUS}px 0; + width: 30px; + background-color: transparent; + border: none; + &:hover { + background-color: transparent !important; + } + } + .ant-input-group-wrapper-disabled { + .ant-input-group-addon { + background-color: transparent; + } + .ant-input-group-addon:hover { + background-color: transparent !important; + } + } + .ant-input-search-button { + position: absolute; + top: -20px; + right: 0; + border-radius: 0 ${BORDERRADIUS}px ${BORDERRADIUS}px 0 !important; + overflow: hidden; + border: none; + height: 52px; + border-left: var(--ant-line-width) var(--ant-line-type) + var(--ant-color-border); + } + .ant-input-number-handler-wrap { + top: 0; + height: ${WRAPHEIGHT - 2}px; + } + .seal-textarea-wrapper { + height: auto; + padding-bottom: 10px; + padding-right: 10px; + } +`; + +export default InputWrapper; diff --git a/src/components/seal-form/wrapper/select.ts b/src/components/seal-form/wrapper/select.ts new file mode 100644 index 00000000..c5314d17 --- /dev/null +++ b/src/components/seal-form/wrapper/select.ts @@ -0,0 +1,190 @@ +import styled from 'styled-components'; +import { BORDERRADIUS, INPUT_INNER_PADDING, INPUTHEIGHT } from '../config'; + +const SelectWrapper = styled.div` + .seal-select-wrapper { + border: none; + box-shadow: none; + + &.dropdown-visible { + .__wrapper__ { + .ant-select-selector { + border-bottom: none !important; + border-radius: ${BORDERRADIUS}px ${BORDERRADIUS}px 0 0; + transition: all 0.2s ease; + + &::before { + content: ''; + position: absolute; + height: 1px; + margin-inline: 1px; + bottom: 0; + left: 0; + right: 0; + background-color: var(--ant-color-split); + } + } + + .ant-select-dropdown { + box-shadow: none; + border-width: var(--ant-line-width); + border-style: var(--ant-line-type); + border-color: var(--ant-input-active-border-color); + border-top: none; + } + + &:hover { + :global(.ant-select-dropdown) { + border-color: var(--ant-input-active-border-color); + } + } + + &:focus-within { + border-color: var(--ant-input-active-border-color) !important; + outline: 0; + background-color: var(--ant-input-active-bg); + } + } + } + + &:focus-within { + border: none; + box-shadow: none; + } + .__wrapper__ { + padding-block: 0; + + .label { + left: ${INPUT_INNER_PADDING + 1}px !important; + top: 11px; + + &.isfoucs-has-value { + top: 11px; + transition: all 0.2s var(--seal-transition-func); + } + + &.blur-no-value { + top: 21px; + transition: all 0.2s var(--seal-transition-func); + } + + &.has-prefix { + top: 11px !important; + } + } + &.no-label { + padding-block: 0; + + :global { + .ant-select-arrow { + top: 50%; + } + } + + .ant-select-selector { + padding-block-start: 0 !important; + } + + :global(.ant-select .ant-select-selection-search) { + top: 10px !important; + } + } + } + .ant-select-selection-overflow-item > span { + display: flex; + align-items: center; + } + .ant-select { + display: flex; + align-items: center; + height: 54px; + + .ant-select-selection-search { + top: 20px !important; + inset-inline-start: ${INPUT_INNER_PADDING}px; + } + &.ant-select-multiple.ant-cascader .ant-select-selection-search { + top: 0 !important; + } + } + + .ant-select-selector { + flex: 1; + padding-inline: ${INPUT_INNER_PADDING}px !important; + border-width: var(--ant-line-width); + border-style: var(--ant-line-type); + border-color: var(--ant-color-border); + border-radius: ${BORDERRADIUS}px; + height: 54px !important; + padding-block: 20px 0 !important; + box-shadow: none !important; + + &:hover { + border-color: var(--ant-input-hover-border-color) !important; + transition: all 0.2s ease; + } + + &:focus-within { + border-color: var(--ant-input-active-border-color) !important; + outline: 0; + background-color: var(--ant-input-active-bg); + } + } + + .ant-select-multiple.ant-select-lg { + .ant-select-selection-search { + margin-inline-start: 0 !important; + left: 0 !important; + } + } + + .ant-select-selection-item { + height: ${INPUTHEIGHT}px !important; + padding-block: 5px !important; + line-height: 22px !important; + padding-inline-end: 0 !important; + } + + .ant-select-auto-complete .ant-select-selector { + padding-inline: 0 !important; + flex: 1; + } + + .ant-select-arrow { + top: 32px; + } + + .ant-select-outlined.ant-select-disabled:not(.ant-select-customize-input) + .ant-select-selector { + background-color: transparent; + } + + .ant-select-selection-search-input { + height: ${INPUTHEIGHT}px !important; + } + + &.validate-status-error { + .ant-select-selector { + border-width: var(--ant-line-width) !important; + border-style: var(--ant-line-type) !important; + border-color: var(--ant-color-error) !important; + + &:hover { + border-color: var(--ant-color-error-border-hover) !important; + } + + &:focus-within { + border-color: var(--ant-color-error) !important; + } + } + + &.dropdown-visible { + .ant-select-dropdown { + border-color: var(--ant-color-error) !important; + } + } + } + } +`; + +export default SelectWrapper; diff --git a/src/components/seal-form/wrapper/slider.ts b/src/components/seal-form/wrapper/slider.ts new file mode 100644 index 00000000..4454de7a --- /dev/null +++ b/src/components/seal-form/wrapper/slider.ts @@ -0,0 +1,40 @@ +import styled from 'styled-components'; +import { INPUTHEIGHT, INPUT_INNER_PADDING } from '../config'; + +const SliderWrapper = styled.div` + padding-block: 0; + padding-inline: 2px; + input.ant-input-number-input { + text-align: center !important; + flex: 1; + height: ${INPUTHEIGHT}px !important; + padding-block: 5px; + padding-inline: ${INPUT_INNER_PADDING}px; + } + .slider-label { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + + .val { + color: var(--ant-color-text); + } + + .label-val { + position: absolute !important; + top: -14px; + right: -14px; + width: 80px; + border-radius: var(--border-radius-base); + text-align: center; + border: 1px solid var(--ant-color-border) !important; + + .ant-input-number-input { + text-align: center !important; + } + } + } +`; + +export default SliderWrapper; diff --git a/src/locales/en-US/resources.ts b/src/locales/en-US/resources.ts index e7070f60..d40fc446 100644 --- a/src/locales/en-US/resources.ts +++ b/src/locales/en-US/resources.ts @@ -57,8 +57,14 @@ export default { 'resources.worker.container.install': 'Container Installation(Linux Only)', 'resources.worker.cann.tips': `Set ASCEND_VISIBLE_DEVICES to the required GPU indices. For GPU0 to GPU3, use ASCEND_VISIBLE_DEVICES=0,1,2,3 or ASCEND_VISIBLE_DEVICES=0-3.`, 'resources.modelfiles.form.path': 'Storage path', - 'resources.modelfiles.modelfile': 'Model File', - 'resources.modelfiles.download': 'Download Model', + 'resources.modelfiles.modelfile': 'Model Files', + 'resources.modelfiles.download': 'Add Model File', 'resources.modelfiles.size': 'Size', - 'resources.modelfiles.selecttarget': 'Select Target' + 'resources.modelfiles.selecttarget': 'Select Target', + 'resources.modelfiles.form.localdir': 'Local Directory', + 'resources.modelfiles.form.localdir.tips': + 'The default storage directory is /var/lib/gpustack/cache.', + 'resources.modelfiles.retry.download': 'Retry Download', + 'resources.modelfiles.storagePath.holder': + 'Waiting for download to complete...' }; diff --git a/src/locales/ru-RU/resources.ts b/src/locales/ru-RU/resources.ts index b2f78d07..56c7743e 100644 --- a/src/locales/ru-RU/resources.ts +++ b/src/locales/ru-RU/resources.ts @@ -57,10 +57,16 @@ export default { 'resources.worker.container.install': 'Установка контейнером (только Linux)', 'resources.worker.cann.tips': `Укажите индексы GPU через ASCEND_VISIBLE_DEVICES=0,1,2,3 или ASCEND_VISIBLE_DEVICES=0-3.`, 'resources.modelfiles.form.path': 'Storage path', - 'resources.modelfiles.modelfile': 'Model File', - 'resources.modelfiles.download': 'Add Model', + 'resources.modelfiles.modelfile': 'Model Files', + 'resources.modelfiles.download': 'Add Model File', 'resources.modelfiles.size': 'Size', - 'resources.modelfiles.selecttarget': 'Select Target' + 'resources.modelfiles.selecttarget': 'Select Target', + 'resources.modelfiles.form.localdir': 'Local Directory', + 'resources.modelfiles.form.localdir.tips': + 'The default storage directory is /var/lib/gpustack/cache.', + 'resources.modelfiles.retry.download': 'Retry Download', + 'resources.modelfiles.storagePath.holder': + 'Waiting for download to complete...' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== @@ -69,4 +75,8 @@ export default { //3. 'resources.modelfiles.download', //4. 'resources.modelfiles.size', //5. 'resources.modelfiles.selecttarget', +//6. 'resources.modelfiles.form.localdir', +//7. 'resources.modelfiles.retry.download', +//8. 'resources.modelfiles.form.localdir.tips', +//9. 'resources.modelfiles.storagePath.holder', // ========== End of To-Do List ========== diff --git a/src/locales/zh-CN/resources.ts b/src/locales/zh-CN/resources.ts index 7a149730..a7253347 100644 --- a/src/locales/zh-CN/resources.ts +++ b/src/locales/zh-CN/resources.ts @@ -57,7 +57,12 @@ export default { '按需要挂载的 GPU index 设置 ASCEND_VISIBLE_DEVICES,如需挂载 GPU0 - GPU3,则设为 ASCEND_VISIBLE_DEVICES=0,1,2,3ASCEND_VISIBLE_DEVICES=0-3', 'resources.modelfiles.form.path': '存储路径', 'resources.modelfiles.modelfile': '模型文件', - 'resources.modelfiles.download': '添加模型', + 'resources.modelfiles.download': '添加模型文件', 'resources.modelfiles.size': '文件大小', - 'resources.modelfiles.selecttarget': '选择目标位置' + 'resources.modelfiles.selecttarget': '选择目标位置', + 'resources.modelfiles.form.localdir': '本地目录', + 'resources.modelfiles.form.localdir.tips': + '默认存储目录为 /var/lib/gpustack/cache', + 'resources.modelfiles.retry.download': '重新下载', + 'resources.modelfiles.storagePath.holder': '等待下载完成...' }; diff --git a/src/pages/llmodels/catalog.tsx b/src/pages/llmodels/catalog.tsx index a1fab4fd..93cfdce0 100644 --- a/src/pages/llmodels/catalog.tsx +++ b/src/pages/llmodels/catalog.tsx @@ -35,7 +35,7 @@ const Catalog: React.FC = () => { page: 1, perPage: 100, search: '', - categories: [] + categories: '' }); const [openDeployModal, setOpenDeployModal] = useState({ show: false, @@ -49,22 +49,20 @@ const Catalog: React.FC = () => { const categoryOptions = [...modelCategories.filter((item) => item.value)]; const filterData = useCallback( - (data: { search: string; categories: string[] }) => { + (data: { search: string; categories: string }) => { const { search, categories } = data; const dataList = cacheData.current.filter((item) => { - if (search && categories.length > 0) { + if (search && categories) { return ( _.toLower(item.name).includes(search) && - categories.some((category) => item.categories.includes(category)) + item.categories.includes(categories) ); } if (search) { return _.toLower(item.name).includes(_.toLower(search)); } - if (categories.length > 0) { - return categories.some((category) => - item.categories.includes(category) - ); + if (categories) { + return item.categories.includes(categories); } return true; }); @@ -241,7 +239,6 @@ const Catalog: React.FC = () => { placeholder={intl.formatMessage({ id: 'models.filter.category' })} style={{ width: 230 }} size="large" - mode="multiple" maxTagCount={1} onChange={handleCategoryChange} options={categoryOptions} diff --git a/src/pages/llmodels/components/data-form.tsx b/src/pages/llmodels/components/data-form.tsx index e54fcc00..911dd140 100644 --- a/src/pages/llmodels/components/data-form.tsx +++ b/src/pages/llmodels/components/data-form.tsx @@ -1,6 +1,5 @@ import IconFont from '@/components/icon-font'; import SealAutoComplete from '@/components/seal-form/auto-complete'; -import SealCascader from '@/components/seal-form/seal-cascader'; import SealInput from '@/components/seal-form/seal-input'; import SealSelect from '@/components/seal-form/seal-select'; import TooltipList from '@/components/tooltip-list'; @@ -8,7 +7,7 @@ import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import useAppUtils from '@/hooks/use-app-utils'; import { useIntl } from '@umijs/max'; -import { Form, Input, Typography } from 'antd'; +import { Form, Typography } from 'antd'; import _ from 'lodash'; import React, { forwardRef, @@ -30,11 +29,12 @@ import { ollamaModelOptions, sourceOptions } from '../config'; -import { HuggingFaceModels, ModelScopeModels } from '../config/audio-catalog'; +import { identifyModelTask } from '../config/audio-catalog'; import { FormData } from '../config/types'; import AdvanceConfig from './advance-config'; interface DataFormProps { + initialValues?: any; ref?: any; source: string; action: PageActionType; @@ -65,6 +65,7 @@ const DataForm: React.FC = forwardRef((props, ref) => { const { action, isGGUF, + initialValues, sourceDisable = true, backendOptions, sourceList, @@ -116,33 +117,15 @@ const DataForm: React.FC = forwardRef((props, ref) => { [] ); - const identifyModelTask = () => { - let data = null; - if (props.source === modelSourceMap.huggingface_value) { - data = HuggingFaceModels.find( - (item) => - `${item.org}/${item.name}`.indexOf(props.selectedModel.name) > -1 || - props.selectedModel.name?.indexOf(`${item.org}/${item.name}`) > -1 - ); - } - if (props.source === modelSourceMap.modelscope_value) { - data = ModelScopeModels.find( - (item) => - `${item.org}/${item.name}`.indexOf(props.selectedModel.name) > -1 || - props.selectedModel.name?.indexOf(`${item.org}/${item.name}`) > -1 - ); - } - if (data) { - return modelTaskMap.audio; - } - return ''; - }; const handleOnSelectModel = () => { let name = _.split(props.selectedModel.name, '/').slice(-1)[0]; const reg = /(-gguf)$/i; name = _.toLower(name).replace(reg, ''); - const modelTaskType = identifyModelTask(); + const modelTaskType = identifyModelTask( + props.source, + props.selectedModel.name + ); const modelTask = HuggingFaceTaskMap.audio.includes(props.selectedModel.task) || @@ -282,11 +265,6 @@ const DataForm: React.FC = forwardRef((props, ref) => { ); }; - const handleOnSearch = (value: string) => { - console.log('local_path', value); - form.setFieldValue('local_path', value); - }; - const renderLocalPathFields = () => { return ( <> @@ -300,14 +278,17 @@ const DataForm: React.FC = forwardRef((props, ref) => { } ]} > - {/* } - > */} - + {/* } @@ -330,7 +311,7 @@ const DataForm: React.FC = forwardRef((props, ref) => { }} /> )} - > + > */} ); @@ -523,7 +504,8 @@ const DataForm: React.FC = forwardRef((props, ref) => { cpu_offloading: true, scheduleType: 'auto', categories: null, - distributed_inference_across_workers: true + distributed_inference_across_workers: true, + ...initialValues }} > diff --git a/src/pages/llmodels/components/deploy-modal.tsx b/src/pages/llmodels/components/deploy-modal.tsx index a7128ade..a25f4dd8 100644 --- a/src/pages/llmodels/components/deploy-modal.tsx +++ b/src/pages/llmodels/components/deploy-modal.tsx @@ -4,7 +4,7 @@ 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 _, { debounce } from 'lodash'; import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { backendOptionsMap, modelSourceMap } from '../config'; import { FormData } from '../config/types'; @@ -21,9 +21,11 @@ type AddModalProps = { action: PageActionType; open: boolean; source: string; + isGGUF?: boolean; width?: string | number; gpuOptions: any[]; modelFileOptions: any[]; + initialValues?: any; onOk: (values: FormData) => void; onCancel: () => void; }; @@ -36,7 +38,8 @@ const AddModal: FC = (props) => { onCancel, source, action, - width = 600 + width = 600, + initialValues } = props || {}; const SEARCH_SOURCE = [ modelSourceMap.huggingface_value, @@ -47,7 +50,7 @@ const AddModal: FC = (props) => { const intl = useIntl(); const [selectedModel, setSelectedModel] = useState({}); const [collapsed, setCollapsed] = useState(false); - const [isGGUF, setIsGGUF] = useState(false); + const [isGGUF, setIsGGUF] = useState(props.isGGUF || false); const modelFileRef = useRef(null); const [warningStatus, setWarningStatus] = useState<{ show: boolean; @@ -128,7 +131,9 @@ const AddModal: FC = (props) => { }, [onCancel]); useEffect(() => { - handleSelectModelFile({ fakeName: '' }); + if (!_.isEmpty(selectedModel)) { + handleSelectModelFile({ fakeName: '' }); + } }, [selectedModel]); useEffect(() => { @@ -142,12 +147,17 @@ const AddModal: FC = (props) => { } else if (source === modelSourceMap.ollama_library_value) { form.current?.setFieldValue?.('backend', backendOptionsMap.llamaBox); setIsGGUF(true); + } else { + form.current?.setFieldsValue({ + ...props.initialValues + }); + setIsGGUF(props.isGGUF || false); } return () => { setSelectedModel({}); }; - }, [open, source]); + }, [open, source, props.isGGUF, props.initialValues]); return ( = (props) => { )} = ({ fileList }) => { +}> = ({ fileList, showSize = true }) => { return ( - {convertFileSize(file.size)} + {showSize && {convertFileSize(file.size)}} ); })} diff --git a/src/pages/llmodels/components/table-list.tsx b/src/pages/llmodels/components/table-list.tsx index 69971d98..f997026d 100644 --- a/src/pages/llmodels/components/table-list.tsx +++ b/src/pages/llmodels/components/table-list.tsx @@ -730,7 +730,6 @@ const Models: React.FC = ({ })} style={{ width: 230 }} size="large" - mode="multiple" maxTagCount={1} onChange={handleCategoryChange} options={modelCategories.filter((item) => item.value)} diff --git a/src/pages/llmodels/config/audio-catalog.ts b/src/pages/llmodels/config/audio-catalog.ts index 0a2645d8..19d00403 100644 --- a/src/pages/llmodels/config/audio-catalog.ts +++ b/src/pages/llmodels/config/audio-catalog.ts @@ -1,3 +1,5 @@ +import { modelSourceMap, modelTaskMap } from './index'; + export const HuggingFaceModels = [ { type: 'stt', @@ -178,3 +180,25 @@ export const ModelScopeModels = [ name: 'faster-whisper-large-v1' } ]; + +export const identifyModelTask = (source: string, modelName: string) => { + let data = null; + if (source === modelSourceMap.huggingface_value) { + data = HuggingFaceModels.find( + (item) => + `${item.org}/${item.name}`.indexOf(modelName) > -1 || + modelName?.indexOf(`${item.org}/${item.name}`) > -1 + ); + } + if (source === modelSourceMap.modelscope_value) { + data = ModelScopeModels.find( + (item) => + `${item.org}/${item.name}`.indexOf(modelName) > -1 || + modelName?.indexOf(`${item.org}/${item.name}`) > -1 + ); + } + if (data) { + return modelTaskMap.audio; + } + return ''; +}; diff --git a/src/pages/llmodels/config/button-actions.ts b/src/pages/llmodels/config/button-actions.ts index 565cbcc6..277dab09 100644 --- a/src/pages/llmodels/config/button-actions.ts +++ b/src/pages/llmodels/config/button-actions.ts @@ -189,13 +189,13 @@ export const setModelActionList = (record: any) => { }; export const modelFileActions = [ - // { - // label: 'common.button.deploy', - // key: 'deploy', - // icon: icons.ThunderboltOutlined - // }, { - label: 'common.button.retry', + label: 'common.button.deploy', + key: 'deploy', + icon: icons.ThunderboltOutlined + }, + { + label: 'resources.modelfiles.retry.download', key: 'retry', icon: icons.RetweetOutlined }, diff --git a/src/pages/llmodels/download/index.tsx b/src/pages/llmodels/download/index.tsx index a7e1b860..2bdd5c60 100644 --- a/src/pages/llmodels/download/index.tsx +++ b/src/pages/llmodels/download/index.tsx @@ -110,6 +110,12 @@ const DownloadModel: React.FC = (props) => { } else if (source === modelSourceMap.ollama_library_value) { setIsGGUF(true); } + if (open) { + form.current?.form?.setFieldValue( + 'worker_id', + workersList[0]?.value || '' + ); + } return () => { setSelectedModel({}); diff --git a/src/pages/llmodels/download/target-form.tsx b/src/pages/llmodels/download/target-form.tsx index 90a75d9b..7e67529d 100644 --- a/src/pages/llmodels/download/target-form.tsx +++ b/src/pages/llmodels/download/target-form.tsx @@ -162,14 +162,21 @@ const TargetForm: React.FC = forwardRef((props, ref) => { name="local_dir" rules={[ { - required: true, - message: getRuleMessage('input', 'resources.modelfiles.form.path') + required: false, + message: getRuleMessage( + 'input', + 'resources.modelfiles.form.localdir' + ) } ]} > )} diff --git a/src/pages/llmodels/hooks/index.ts b/src/pages/llmodels/hooks/index.ts index afdf9f84..0f0bf194 100644 --- a/src/pages/llmodels/hooks/index.ts +++ b/src/pages/llmodels/hooks/index.ts @@ -143,7 +143,29 @@ export const useGenerateModelFileOptions = () => { Object.entries(worker).filter(([key]) => workerFields.has(key)) ) })); - return result; + // extract a list from the result, and the structure is like: + // [ + // { + // label: 'worker_name/child_label', + // value: 'child_value', + // ...other child properties + // } + // ] + const childrenList = result.reduce((acc: any[], cur) => { + if (cur.children) { + const list = cur.children.map((child: any) => ({ + ...child, + label: `${cur.label}${child.label}`, + value: child.value + })); + acc.push(...list); + } + return acc; + }, []); + + return childrenList; + + // return result; }; return { diff --git a/src/pages/resources/components/model-files.tsx b/src/pages/resources/components/model-files.tsx index f7572302..87c73500 100644 --- a/src/pages/resources/components/model-files.tsx +++ b/src/pages/resources/components/model-files.tsx @@ -1,3 +1,4 @@ +import { modelsExpandKeysAtom } from '@/atoms/models'; import AutoTooltip from '@/components/auto-tooltip'; import CopyButton from '@/components/copy-button'; import DeleteModal from '@/components/delete-modal'; @@ -5,9 +6,19 @@ import DropDownActions from '@/components/drop-down-actions'; import DropdownButtons from '@/components/drop-down-buttons'; import PageTools from '@/components/page-tools'; import StatusTag from '@/components/status-tag'; +import { PageAction } from '@/config'; import useAppUtils from '@/hooks/use-app-utils'; +import useBodyScroll from '@/hooks/use-body-scroll'; import useTableFetch from '@/hooks/use-table-fetch'; -import { modelSourceMap } from '@/pages/llmodels/config'; +import { createModel } from '@/pages/llmodels/apis'; +import DeployModal from '@/pages/llmodels/components/deploy-modal'; +import FileParts from '@/pages/llmodels/components/file-parts'; +import { + backendOptionsMap, + getSourceRepoConfigValue, + modelSourceMap +} from '@/pages/llmodels/config'; +import { identifyModelTask } from '@/pages/llmodels/config/audio-catalog'; import { generateSource, modalConfig, @@ -16,11 +27,32 @@ import { } from '@/pages/llmodels/config/button-actions'; import DownloadModal from '@/pages/llmodels/download'; import { convertFileSize } from '@/utils'; -import { DeleteOutlined, DownOutlined, SyncOutlined } from '@ant-design/icons'; -import { useIntl } from '@umijs/max'; -import { Button, ConfigProvider, Empty, Input, Space, Table } from 'antd'; +import { + DeleteOutlined, + DownOutlined, + InfoCircleOutlined, + SyncOutlined +} from '@ant-design/icons'; +import { useIntl, useNavigate } from '@umijs/max'; +import { + Button, + ConfigProvider, + Empty, + Input, + Space, + Table, + Tag, + Tooltip, + message +} from 'antd'; import dayjs from 'dayjs'; +import { useAtom } from 'jotai'; +import _ from 'lodash'; import { useEffect, useState } from 'react'; +import { + useGenerateFormEditInitialValues, + useGenerateModelFileOptions +} from '../../llmodels/hooks'; import { MODEL_FILES_API, deleteModelFile, @@ -40,6 +72,9 @@ import { ListItem as WorkerListItem } from '../config/types'; +const pattern = /^(.*)-(\d+)-of-(\d+)\.(.*)$/; +const filterPattern = /^(.*)-\d+-of-\d+(\.gguf)?$/; + const getWorkerName = ( id: number, workersList: Global.BaseOption[] @@ -77,11 +112,16 @@ const InstanceStatusTag = (props: { data: ListItem }) => { }; const ModelFiles = () => { + const { getGPUList, generateFormValues } = useGenerateFormEditInitialValues(); + const { saveScrollHeight, restoreScrollHeight } = useBodyScroll(); + const [modelsExpandKeys, setModelsExpandKeys] = useAtom(modelsExpandKeysAtom); + const navigate = useNavigate(); const { dataSource, rowSelection, queryParams, modalRef, + fetchData, handleDelete, handleDeleteBatch, handlePageChange, @@ -95,7 +135,8 @@ const ModelFiles = () => { watch: true, contentForDelete: 'resources.modelfiles.modelfile' }); - + const { getModelFileList, generateModelFileOptions } = + useGenerateModelFileOptions(); const intl = useIntl(); const { showSuccess } = useAppUtils(); const [workersList, setWorkersList] = useState[]>( @@ -112,6 +153,23 @@ const ModelFiles = () => { source: modelSourceMap.huggingface_value, gpuOptions: [] }); + const [openDeployModal, setOpenDeployModal] = useState<{ + show: boolean; + width: number | string; + source: string; + gpuOptions: any[]; + modelFileOptions?: any[]; + initialValues: any; + isGGUF?: boolean; + }>({ + show: false, + width: 600, + source: modelSourceMap.local_path_value, + gpuOptions: [], + modelFileOptions: [], + initialValues: {}, + isGGUF: false + }); useEffect(() => { const fetchWorkerList = async () => { @@ -140,6 +198,84 @@ const ModelFiles = () => { fetchWorkerList(); }, []); + const extractFileName = (name: string) => { + return name.replace(filterPattern, '$1'); + }; + + const generateInitialValues = (record: ListItem) => { + const isGGUF = _.includes(record.resolved_paths?.[0], 'gguf'); + const isOllama = !!record.ollama_library_model_name; + const audioModelTag = identifyModelTask( + record.source, + record.resolved_paths?.[0] + ); + + let name = _.toLower( + _.split( + record.huggingface_repo_id || + record.ollama_library_model_name || + record.model_scope_model_id || + record.local_path, + '/' + ).pop() + ); + + return { + source: modelSourceMap.local_path_value, + local_path: record.resolved_paths?.[0], + name: extractFileName(name), + backend: + isGGUF || isOllama + ? backendOptionsMap.llamaBox + : audioModelTag + ? backendOptionsMap.voxBox + : backendOptionsMap.vllm, + isGGUF: !audioModelTag && (isGGUF || isOllama) + }; + }; + + const renderParts = (record: ListItem) => { + const parts = _.tail(record.resolved_paths); + if (!parts.length) { + return null; + } + const partsList = parts.map((item: string) => { + const match = item.match(pattern); + if (!match) { + return null; + } + return { + part: parseInt(match[2], 10), + total: parseInt(match[3], 10), + name: _.split(match[1], '/').pop() + }; + }); + return ( + } + > + + + + {partsList.length} parts + + + + ); + }; + const handleSelect = async (val: any, record: ListItem) => { try { if (val === 'delete') { @@ -150,6 +286,22 @@ const ModelFiles = () => { } else if (val === 'retry') { await retryDownloadModelFile(record.id); showSuccess(); + } else if (val === 'deploy') { + saveScrollHeight(); + const [modelFileList, gpuList] = await Promise.all([ + getModelFileList(), + getGPUList() + ]); + const dataList = generateModelFileOptions(modelFileList, workersList); + const initialValues = generateInitialValues(record); + setOpenDeployModal({ + ...openDeployModal, + modelFileOptions: dataList, + gpuOptions: gpuList, + initialValues: initialValues, + isGGUF: initialValues.isGGUF, + show: true + }); } } catch (error) { // console.log('error', error); @@ -189,43 +341,60 @@ const ModelFiles = () => { ...downloadModalStatus, show: false }); + fetchData(); showSuccess(); } catch (error) { // console.log('error', error); } }; + const setActionList = (record: ListItem) => { + return _.filter(modelFileActions, (item: { key: string }) => { + if (item.key === 'deploy') { + return record.state === ModelfileStateMap.Ready; + } + return true; + }); + }; + const handleDeployModalCancel = () => { + setOpenDeployModal({ + ...openDeployModal, + show: false + }); + restoreScrollHeight(); + }; + + const handleCreateModel = async (data: any) => { + try { + 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' })); + setModelsExpandKeys([modelData.id]); + navigate('/models/list'); + } catch (error) { + // console.log('error', error); + } + }; + const columns = [ { - title: intl.formatMessage({ id: 'resources.modelfiles.form.path' }), - dataIndex: 'resolved_paths', - width: 240, - render: (text: string, record: ListItem) => { - return ( - record.resolved_paths?.length > 0 && ( - - - {record.resolved_paths?.[0]} - - - - ) - ); - } - }, - { - title: intl.formatMessage({ id: 'resources.modelfiles.size' }), - dataIndex: 'size', - render: (text: string, record: ListItem) => { - return ( - - {convertFileSize(record.size, 1)} - - ); - } + title: intl.formatMessage({ id: 'models.form.source' }), + dataIndex: 'source', + render: (text: string, record: ListItem) => ( + + {generateSource(record)} + + ) }, { title: 'Worker', @@ -238,26 +407,63 @@ const ModelFiles = () => { ); } }, - { - title: intl.formatMessage({ id: 'models.form.source' }), - dataIndex: 'source', - render: (text: string, record: ListItem) => ( - - {generateSource(record)} - - ) - }, { title: intl.formatMessage({ id: 'common.table.status' }), dataIndex: 'state', + width: 120, render: (text: string, record: ListItem) => { return ; } }, + { + title: intl.formatMessage({ id: 'resources.modelfiles.form.path' }), + dataIndex: 'resolved_paths', + render: (text: string, record: ListItem) => { + if ( + !record.resolved_paths.length && + record.state === ModelfileStateMap.Downloading + ) { + return ( + + {intl.formatMessage({ + id: 'resources.modelfiles.storagePath.holder' + })} + + ); + } + return ( + record.resolved_paths?.length > 0 && ( + + + {record.resolved_paths?.[0]} + + + {renderParts(record)} + + ) + ); + } + }, + { + title: intl.formatMessage({ id: 'resources.modelfiles.size' }), + dataIndex: 'size', + width: 100, + render: (text: string, record: ListItem) => { + return ( + + {convertFileSize(record.size, 1)} + + ); + } + }, { title: intl.formatMessage({ id: 'common.table.createTime' }), dataIndex: 'created_at', sorter: false, + width: 180, render: (text: number) => ( {dayjs(text).format('YYYY-MM-DD HH:mm:ss')} @@ -267,9 +473,10 @@ const ModelFiles = () => { { title: intl.formatMessage({ id: 'common.table.operation' }), dataIndex: 'operation', + width: 120, render: (text: string, record: ListItem) => ( handleSelect(val, record)} > ) @@ -334,6 +541,7 @@ const ModelFiles = () => { { onOk={handleDownload} workersList={workersList} > + ); };