diff --git a/src/assets/images/huggingface.png b/src/assets/images/huggingface.png new file mode 100644 index 00000000..5f738193 Binary files /dev/null and b/src/assets/images/huggingface.png differ diff --git a/src/assets/images/ollama.png b/src/assets/images/ollama.png new file mode 100644 index 00000000..5d776ddb Binary files /dev/null and b/src/assets/images/ollama.png differ diff --git a/src/assets/styles/common.less b/src/assets/styles/common.less index 7aa04dd4..58e4f4ca 100644 --- a/src/assets/styles/common.less +++ b/src/assets/styles/common.less @@ -14,6 +14,10 @@ margin-left: 8px; } +.m-l-20 { + margin-left: 20px; +} + .m-r-10 { margin-right: 10px; } @@ -63,6 +67,10 @@ flex-direction: column; } +.relative { + position: relative; +} + .font-size-12 { font-size: var(--font-size-base); } diff --git a/src/components/icon-font/index.tsx b/src/components/icon-font/index.tsx index 0cb33cb0..7d62d72e 100644 --- a/src/components/icon-font/index.tsx +++ b/src/components/icon-font/index.tsx @@ -1,7 +1,7 @@ import { createFromIconfontCN } from '@ant-design/icons'; const IconFont = createFromIconfontCN({ - scriptUrl: '//at.alicdn.com/t/c/font_4613488_cpb5yo1pk77.js' + scriptUrl: '//at.alicdn.com/t/c/font_4613488_rsdrbzw4fyd.js' }); export default IconFont; diff --git a/src/components/radio-buttons/index.less b/src/components/radio-buttons/index.less new file mode 100644 index 00000000..7ef31b91 --- /dev/null +++ b/src/components/radio-buttons/index.less @@ -0,0 +1,22 @@ +.radio-button-wrap { + .item { + display: flex; + justify-content: center; + align-items: center; + font-size: var(--font-size-base); + padding: 2px; + border-radius: var(--border-radius-base); + height: 40px; + width: 40px; + border: 1px solid var(--ant-color-border); + cursor: pointer; + + &.active { + background-color: var(--ant-color-fill-secondary); + } + + &:hover { + background-color: var(--ant-color-fill-secondary); + } + } +} diff --git a/src/components/radio-buttons/index.tsx b/src/components/radio-buttons/index.tsx new file mode 100644 index 00000000..ea98e4e4 --- /dev/null +++ b/src/components/radio-buttons/index.tsx @@ -0,0 +1,29 @@ +import { Space } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import './index.less'; + +interface RadioButtonsProps { + options: { value: any; label: React.ReactNode }[]; + value: string; + gap?: number; + onChange: (value: string) => void; +} +const RadioButtons: React.FC = (props) => { + const { options, value, onChange, gap = 12 } = props; + return ( + + {options.map((option) => ( + onChange(option.value)} + className={classNames('item', { active: value === option.value })} + > + {option.label} + + ))} + + ); +}; + +export default RadioButtons; diff --git a/src/components/seal-form/seal-input.tsx b/src/components/seal-form/seal-input.tsx index 3f0b4814..b3633281 100644 --- a/src/components/seal-form/seal-input.tsx +++ b/src/components/seal-form/seal-input.tsx @@ -81,6 +81,7 @@ const SealInput: React.FC = (props) => { > = (props) => { @@ -41,17 +50,21 @@ const AddModal: React.FC = (props) => { const [fileOptions, setFileOptions] = useState< { label: string; value: string }[] >([]); + const [ollamaTags, setOllamaTags] = useState([]); const initFormValue = () => { if (action === PageAction.CREATE && open) { form.setFieldsValue({ - source: 'huggingface', + source: modelSourceMap.huggingface_value, replicas: 1 }); } if (action === PageAction.EDIT && open) { + const list = _.split(props.data?.ollama_library_model_name, ':'); form.setFieldsValue({ - ...props.data + ...props.data, + ollama_library_model_name: _.get(list, '0'), + tag: _.get(list, '1') }); } }; @@ -60,8 +73,6 @@ const AddModal: React.FC = (props) => { initFormValue(); }, [open]); - const handleInputRepoChange = (value: string) => {}; - const fileNamLabel = (item: any) => { return ( @@ -149,15 +160,14 @@ const AddModal: React.FC = (props) => { showSearch onBlur={handleRepoOnBlur} onSelect={handleRepoOnBlur} - onChange={handleInputRepoChange} onSearch={debounceSearch} options={repoOptions} - disabled={action === PageAction.EDIT} addAfter={ } + disabled={true} description={intl.formatMessage({ id: 'models.form.repoid.desc' })} > @@ -236,14 +246,26 @@ const AddModal: React.FC = (props) => { } ]} > - + > */} + + + + ); @@ -251,27 +273,36 @@ const AddModal: React.FC = (props) => { const renderFieldsBySource = () => { switch (modelSource) { - case 'huggingface': + case modelSourceMap.huggingface_value: return renderHuggingfaceFields(); - case 'ollama_library': + case modelSourceMap.ollama_library_value: return renderOllamaModelFields(); - case 's3': + case modelSourceMap.s3_value: return renderS3Fields(); default: return null; } }; - const handleSourceChange = (value: string) => { - console.log('source change', value); - }; + const handleOnSelectModel = useCallback((item: any) => { + const repo = item.name; + setOllamaTags(_.map(item.tags, (tag: string) => _.toLower(tag))); + if (form.getFieldValue('source') === modelSourceMap.huggingface_value) { + form.setFieldValue('huggingface_repo_id', repo); + handleFetchModelFiles(repo); + } else { + form.setFieldValue('ollama_library_model_name', repo); + } + }, []); + + const handleSourceChange = useCallback((value: string) => { + form.setFieldValue('source', value); + }, []); + const handleSumit = () => { form.submit(); }; - const handleOnFinish = (values: FormData) => { - console.log('onFinish', values); - onOk(values); - }; + return ( = (props) => { onOk={handleSumit} onCancel={onCancel} destroyOnClose={true} - closeIcon={false} + closeIcon={true} maskClosable={false} keyboard={false} width={600} @@ -289,6 +320,13 @@ const AddModal: React.FC = (props) => { } > + {action === PageAction.CREATE && ( + + )}
name="name" @@ -324,16 +362,18 @@ const AddModal: React.FC = (props) => { ) } ]} + noStyle={action === PageAction.CREATE} > - + {action === PageAction.EDIT && ( + + )} {renderFieldsBySource()} diff --git a/src/pages/llmodels/components/hf-model-item.tsx b/src/pages/llmodels/components/hf-model-item.tsx new file mode 100644 index 00000000..ac66fdd8 --- /dev/null +++ b/src/pages/llmodels/components/hf-model-item.tsx @@ -0,0 +1,75 @@ +import { formatNumber } from '@/utils'; +import { + DownloadOutlined, + FolderOutlined, + HeartOutlined +} from '@ant-design/icons'; +import { Space, Tag } from 'antd'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import _ from 'lodash'; +import { modelSourceMap } from '../config'; +import '../style/hf-model-item.less'; + +interface HFModelItemProps { + title: string; + downloads: number; + likes: number; + lastModified: string; + active: boolean; + source?: string; + tags?: string[]; +} + +const HFModelItem: React.FC = (props) => { + return ( +
+
+ + {props.title} +
+
+ {props.source === modelSourceMap.huggingface_value ? ( + + + {dayjs().to( + dayjs(dayjs(props.lastModified).format('YYYY-MM-DD HH:mm:ss')) + )} + + + + {props.likes} + + + + {formatNumber(props.downloads)} + + + ) : ( + + {_.map(props.tags, (tag: string) => { + return ( + + + {tag} + + + ); + })} + + )} +
+
+ ); +}; + +export default HFModelItem; diff --git a/src/pages/llmodels/components/search-input.tsx b/src/pages/llmodels/components/search-input.tsx new file mode 100644 index 00000000..5694d169 --- /dev/null +++ b/src/pages/llmodels/components/search-input.tsx @@ -0,0 +1,173 @@ +import IconFont from '@/components/icon-font'; +import RadioButtons from '@/components/radio-buttons'; +import { SearchOutlined } from '@ant-design/icons'; +import { Input } from 'antd'; +import _ from 'lodash'; +import React, { useRef, useState } from 'react'; +import { queryHuggingfaceModels } from '../apis'; +import { modelSourceMap, ollamaModelOptions } from '../config'; +import SearchStyle from '../style/search-result.less'; +import SearchResult from './search-result'; + +interface SearchInputProps { + modelSource: string; + onSourceChange: (source: string) => void; + onSelectModel: (model: any) => void; +} + +const sourceList = [ + { + label: ( + + ), + value: 'huggingface', + key: 'huggingface' + }, + { + label: , + value: 'ollama_library', + key: 'ollama_library' + } +]; + +const SearchInput: React.FC = (props) => { + const { modelSource, onSourceChange, onSelectModel } = props; + const [showSearch, setShowSearch] = useState(false); + const [repoOptions, setRepoOptions] = useState([]); + const [loading, setLoading] = useState(false); + const cacheRepoOptions = useRef([]); + const axiosTokenRef = useRef(null); + + const handleOnSearchRepo = async (text: string) => { + axiosTokenRef.current?.abort?.(); + axiosTokenRef.current = new AbortController(); + if (loading) return; + try { + setLoading(true); + cacheRepoOptions.current = []; + const params = { + search: { + query: text, + tags: ['gguf'] + } + }; + const models = await queryHuggingfaceModels(params, { + signal: axiosTokenRef.current.signal + }); + const list = _.map(models || [], (item: any) => { + return { + ...item, + value: item.name, + label: item.name + }; + }); + const sortedList = _.sortBy( + list, + (item: any) => item.downloads + ).reverse(); + cacheRepoOptions.current = sortedList; + setRepoOptions(sortedList); + } catch (error) { + setRepoOptions([]); + cacheRepoOptions.current = []; + } finally { + setLoading(false); + } + }; + + const handlerSearchModels = async (e: any) => { + const text = e.target.value; + handleOnSearchRepo(text); + }; + + const handleOnFocus = () => { + setShowSearch(true); + if ( + !repoOptions.length && + !cacheRepoOptions.current.length && + modelSource === modelSourceMap.huggingface_value + ) { + handleOnSearchRepo(''); + } + if (modelSourceMap.ollama_library_value === modelSource) { + setRepoOptions(ollamaModelOptions); + cacheRepoOptions.current = ollamaModelOptions; + } + }; + + const handleOnSelectModel = (item: any) => { + onSelectModel(item); + setShowSearch(false); + }; + + const handleOnBlur = () => { + setTimeout(() => { + setShowSearch(false); + }, 200); + }; + + const handleFilterModels = (e: any) => { + const text = e.target.value; + const list = _.filter(cacheRepoOptions.current, (item: any) => { + return item.name.includes(text); + }); + setRepoOptions(list); + console.log('handleFilterModels', text); + }; + + const debounceFilter = _.debounce((e: any) => { + handleFilterModels(e); + }, 300); + + const handleSourceChange = (source: string) => { + axiosTokenRef.current?.abort?.(); + onSourceChange(source); + setRepoOptions([]); + cacheRepoOptions.current = []; + }; + + return ( + <> +
+ + handleOnBlur()} + className="m-l-20" + placeholder={ + modelSource === 'huggingface' + ? 'Search models from hugging face ' + : '' + } + prefix={ + + } + > +
+ {showSearch && ( + + )} +
+ + ); +}; + +export default React.memo(SearchInput); diff --git a/src/pages/llmodels/components/search-result.tsx b/src/pages/llmodels/components/search-result.tsx new file mode 100644 index 00000000..51324ba4 --- /dev/null +++ b/src/pages/llmodels/components/search-result.tsx @@ -0,0 +1,64 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { Col, Empty, Row, Spin } from 'antd'; +import React from 'react'; +import '../style/search-result.less'; +import HFModelItem from './hf-model-item'; + +interface SearchResultProps { + resultList: any[]; + onSelect?: (item: any) => void; + current?: string; + source?: string; + style?: React.CSSProperties; + loading?: boolean; +} + +const SearchResult: React.FC = (props) => { + const { resultList, onSelect, source } = props; + + const handleSelect = (e: any, item: any) => { + e.stopPropagation(); + onSelect?.(item); + }; + return ( +
+ + {resultList.length ? ( + + {resultList.map((item, index) => ( + +
handleSelect(e, item)}> + +
+ + ))} +
+ ) : ( + !props.loading && ( + + } + description="No models found" + /> + ) + )} +
+
+ ); +}; + +export default React.memo(SearchResult); diff --git a/src/pages/llmodels/components/table-list.tsx b/src/pages/llmodels/components/table-list.tsx index 7bbe0c6d..c5d82b17 100644 --- a/src/pages/llmodels/components/table-list.tsx +++ b/src/pages/llmodels/components/table-list.tsx @@ -140,6 +140,11 @@ const Models: React.FC = ({ const handleModalOk = useCallback( async (data: FormData) => { try { + console.log('data:', data); + + if (data.source === modelSourceMap.ollama_library_value) { + data.ollama_library_model_name = `${data.ollama_library_model_name}:${data.tag}`; + } if (action === PageAction.CREATE) { await createModel({ data }); } diff --git a/src/pages/llmodels/config/index.ts b/src/pages/llmodels/config/index.ts index 824191b5..10c020dd 100644 --- a/src/pages/llmodels/config/index.ts +++ b/src/pages/llmodels/config/index.ts @@ -2,14 +2,45 @@ import { StatusMaps } from '@/config'; import { EditOutlined } from '@ant-design/icons'; export const ollamaModelOptions = [ - { label: 'llama3.1', value: 'llama3.1' }, - { label: 'llama3', value: 'llama3' }, - { label: 'gemma2', value: 'gemma2' }, - { label: 'mistral', value: 'mistral' }, - { label: 'qwen2', value: 'qwen2' }, - { label: 'phi3', value: 'phi3' }, - { label: 'codellama', value: 'codellama' }, - { label: 'deepseek-coder', value: 'deepseek-coder' } + { + label: 'llama3.1', + value: 'llama3.1', + name: 'llama3.1', + tags: ['Tools', '8B', '70B', '405B'] + }, + { label: 'llama3', value: 'llama3', name: 'llama3', tags: ['8B', '70B'] }, + { label: 'gemma2', value: 'gemma2', name: 'gemma2', tags: ['9B', '27B'] }, + { + label: 'mistral', + value: 'mistral', + name: 'mistral', + tags: ['Tools', '7B'] + }, + { + label: 'llava', + value: 'llava', + name: 'llava', + tags: ['Vision', '7B', '13B', '34B'] + }, + { + label: 'qwen2', + value: 'qwen2', + name: 'qwen2', + tags: ['0.5B', '1.5B', '7B', '72B'] + }, + { label: 'phi3', value: 'phi3', name: 'phi3', tags: ['3B', '14B'] }, + { + label: 'codellama', + value: 'codellama', + name: 'codellama', + tags: ['Code', '7B', '13B', '34B', '70B'] + }, + { + label: 'deepseek-coder', + value: 'deepseek-coder', + name: 'deepseek-coder', + tags: ['Code', '1B', '7B', '33B'] + } ]; export const modelSourceMap: Record = { diff --git a/src/pages/llmodels/style/hf-model-item.less b/src/pages/llmodels/style/hf-model-item.less new file mode 100644 index 00000000..1dbabcea --- /dev/null +++ b/src/pages/llmodels/style/hf-model-item.less @@ -0,0 +1,26 @@ +.hf-model-item { + height: 80px; + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--ant-color-border); + border-radius: var(--border-radius-base); + padding: 12px; + + &:hover { + background-color: var(--color-fill-sider); + } + + &.active { + background-color: var(--color-fill-sider); + } + + .title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-normal); + } + + .info { + color: var(--ant-color-text-tertiary); + } +} diff --git a/src/pages/llmodels/style/search-result.less b/src/pages/llmodels/style/search-result.less new file mode 100644 index 00000000..44dd261c --- /dev/null +++ b/src/pages/llmodels/style/search-result.less @@ -0,0 +1,26 @@ +.search-result-wrap { + background-color: rgba(255, 255, 255, 100%); + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 123px; + padding: 16px; + border-radius: 0 0 var(--border-radius-base) var(--border-radius-base); + box-shadow: inset 0 -10px 10px rgba(5, 5, 5, 10%); + z-index: 100; + overflow-y: auto; +} + +.search-bar { + display: flex; + position: absolute; + z-index: 100; + left: 0; + right: 0; + padding: 20px; + background: #fff; + border-bottom: 1px solid var(--ant-color-split); + box-shadow: 0 1px 2px rgba(5, 5, 5, 5%); + padding-top: 0; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 840fec4a..10e70250 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -155,3 +155,16 @@ export const platformCall = () => { isWin: isWin() }; }; + +export const formatNumber = (num: number) => { + if (!num) { + return '0'; + } + if (num >= 1000000) { + return (num / 1000000).toFixed(2) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(2) + 'k'; + } else { + return num.toString(); + } +};