diff --git a/src/pages/llmodels/apis/index.ts b/src/pages/llmodels/apis/index.ts index f5a11dc8..7e388dff 100644 --- a/src/pages/llmodels/apis/index.ts +++ b/src/pages/llmodels/apis/index.ts @@ -16,7 +16,8 @@ import { GPUListItem, ListItem, ModelInstanceFormData, - ModelInstanceListItem + ModelInstanceListItem, + ModelLoraAdapterResult } from '../config/types'; export const MODELS_API = '/models'; @@ -33,6 +34,8 @@ export const DRAFT_MODELS_API = '/draft-models'; export const CATALOG_LIST_API = '/model-sets'; +export const MODEL_LORA_ADAPTER_API = '/models/adapters'; + const setProxyUrl = (url: string) => { return `/proxy?url=${encodeURIComponent(url)}`; }; @@ -100,6 +103,21 @@ export async function queryModelDetail(id: number) { }); } +export async function queryModelLoraAdapter( + params: { + base: string; + q?: string; + limit?: number; + }, + options?: any +) { + return request(`${MODEL_LORA_ADAPTER_API}`, { + params, + cancelToken: options?.token, + method: 'GET' + }); +} + // ===================== Model Instances start ===================== export async function queryModelInstancesList( diff --git a/src/pages/llmodels/config/types.ts b/src/pages/llmodels/config/types.ts index 93b20a19..ff345b1d 100644 --- a/src/pages/llmodels/config/types.ts +++ b/src/pages/llmodels/config/types.ts @@ -48,6 +48,16 @@ export type SourceType = | 'local_path' | 'ollama_library'; +export interface LoraListItem { + lora_name: string; + lora_repo_name: string; + source: 'huggingface' | 'model_scope'; + huggingface_filename: string; + model_scope_file_path: string; + local_path: string; + path: string; + model_file_id: number; +} export interface FormData { image_name?: string; run_command?: string; @@ -66,6 +76,7 @@ export interface FormData { s3_address: string; ollama_library_model_name: string; distributed_inference_across_workers?: boolean; + lora_list: LoraListItem[]; local_path?: string; model_scope_model_id?: string; model_scope_file_path?: string; @@ -398,3 +409,10 @@ export interface InstanceRestartCount { error?: string | null; }[]; } + +export interface ModelLoraAdapterResult { + lora_list: Array<{ + lora_repo_name: string; + source: string; + }>; +} diff --git a/src/pages/llmodels/forms/advance-config.tsx b/src/pages/llmodels/forms/advance-config.tsx index 69d49441..201c903f 100644 --- a/src/pages/llmodels/forms/advance-config.tsx +++ b/src/pages/llmodels/forms/advance-config.tsx @@ -15,6 +15,7 @@ import { useFormContext } from '../config/form-context'; import { FormData } from '../config/types'; import { backendOptionsMap } from '../constants/backend-parameters'; import BackendParametersList from './backend-parameters-list'; +import ModelLoraList from './model-lora-list'; const AdvanceConfig = () => { const intl = useIntl(); @@ -88,6 +89,7 @@ const AdvanceConfig = () => { onDelete={handleDeleteEnvSelector} > + {(backend === backendOptionsMap.custom || !currentBackendOptions?.isBuiltIn) && ( diff --git a/src/pages/llmodels/forms/index.tsx b/src/pages/llmodels/forms/index.tsx index c53ce331..56841d5c 100644 --- a/src/pages/llmodels/forms/index.tsx +++ b/src/pages/llmodels/forms/index.tsx @@ -27,6 +27,7 @@ import { BackendOption, DeployFormKey, FormData, + LoraListItem, SourceType } from '../config/types'; import { backendOptionsMap } from '../constants/backend-parameters'; @@ -216,6 +217,14 @@ const DataForm: React.FC = forwardRef((props, ref) => { const handleOk = async (formdata: FormData) => { const data = _.cloneDeep(formdata); data.categories = data.categories ? [data.categories] : []; + if (data.lora_list && data.lora_list.length > 0) { + data.lora_list = data.lora_list.map((item: LoraListItem) => ({ + ...item, + huggingface_filename: data.huggingface_filename || '', + model_scope_file_path: data.model_scope_file_path || '', + local_path: data.local_path || '' + })); + } const gpuSelector = generateGPUIds(data); const allValues = { ..._.omit(data, ['scheduleType']), diff --git a/src/pages/llmodels/forms/lora-list-item.tsx b/src/pages/llmodels/forms/lora-list-item.tsx new file mode 100644 index 00000000..65dff244 --- /dev/null +++ b/src/pages/llmodels/forms/lora-list-item.tsx @@ -0,0 +1,211 @@ +import { + AutoTooltip, + Input as CInput, + Cascader as SealCascader +} from '@gpustack/core-ui'; +import _ from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; +import { modelSourceMap } from '../config'; +import useQueryModelLoraList from '../services/use-query-lora-list'; + +type LoraDataItem = { + label: string; + value: string; + lora_repo_name: string; + source: string; +}; + +interface LoraListItemProps { + item: { value: any[]; lora_name: string }; + base: string; + defaultDataList: LoraDataItem[]; + selectedRepoNames: Set; + onChange: (partial: { value?: any[]; lora_name?: string }) => void; +} + +const sourceLabel = (source: string) => { + if (source === modelSourceMap.huggingface_value) { + return modelSourceMap.huggingface; + } + if (source === modelSourceMap.modelscope_value) { + return modelSourceMap.modelScope; + } + return source; +}; + +const LoraListItem: React.FC = ({ + item, + base, + defaultDataList, + selectedRepoNames, + onChange +}) => { + const { dataList: ownSearchList, fetchData } = useQueryModelLoraList(); + const [hasSearched, setHasSearched] = useState(false); + + const itemDataList = hasSearched ? ownSearchList : defaultDataList; + + const debouncedSearch = useMemo( + () => + _.debounce((q: string) => { + fetchData({ base, q }); + }, 300), + [base] + ); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + useEffect(() => { + setHasSearched(false); + }, [base]); + + const groupedOptions = useMemo(() => { + const groups: Record< + string, + { + label: string; + value: string; + isParent: boolean; + children: any[]; + } + > = {}; + const currentRepo = item.value?.[1]; + + itemDataList.forEach((it) => { + if (!groups[it.source]) { + groups[it.source] = { + label: sourceLabel(it.source), + value: it.source, + isParent: true, + children: [] + }; + } + const isSelectedByOther = + selectedRepoNames.has(it.lora_repo_name) && + it.lora_repo_name !== currentRepo; + if (!isSelectedByOther) { + groups[it.source].children.push({ + label: it.lora_repo_name, + value: it.lora_repo_name, + source: it.source, + isParent: false + }); + } + }); + + return Object.values(groups).filter((g) => g.children.length > 0); + }, [itemDataList, selectedRepoNames, item.value]); + + const handleSearch = (q: string) => { + if (!base || !q) { + setHasSearched(false); + debouncedSearch.cancel(); + return; + } + setHasSearched(true); + debouncedSearch(q); + }; + + const handleCascaderChange = (value: any) => { + onChange({ value: value || [] }); + }; + + const handleNameChange = (e: React.ChangeEvent) => { + onChange({ lora_name: e.target.value }); + }; + + const cascaderEmpty = !item.value || item.value.length === 0; + const nameEmpty = !item.lora_name; + const cascaderStatus = + cascaderEmpty && !nameEmpty ? ('error' as const) : undefined; + const inputStatus = + nameEmpty && !cascaderEmpty ? ('error' as const) : undefined; + + const displayRender = (labels: any[]) => { + return ( + + {labels[0]} / {labels[1]} + + } + > + + {labels[0]} / {labels[1]} + + + ); + }; + + const optionNode = (option: any) => { + const { data } = option; + if (data.isParent) { + return ( + + {data.label} + + ); + } + return ( + + {data.label} + + ); + }; + + return ( +
+ triggerNode.parentNode} + > + +
+ ); +}; + +export default LoraListItem; diff --git a/src/pages/llmodels/forms/model-lora-list.tsx b/src/pages/llmodels/forms/model-lora-list.tsx new file mode 100644 index 00000000..13813715 --- /dev/null +++ b/src/pages/llmodels/forms/model-lora-list.tsx @@ -0,0 +1,116 @@ +import { MetadataList } from '@gpustack/core-ui'; +import { Form } from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { FormData, LoraListItem } from '../config/types'; +import useQueryModelLoraList from '../services/use-query-lora-list'; +import LoraItem from './lora-list-item'; + +type ItemValue = { value: any[]; lora_name: string }; + +const ModelLoraList = () => { + const form = Form.useFormInstance(); + const huggingfaceRepoId = Form.useWatch('huggingface_repo_id', form); + const modelScopeModelId = Form.useWatch('model_scope_model_id', form); + const localPath = Form.useWatch('local_path', form); + + const base = huggingfaceRepoId || modelScopeModelId || localPath || ''; + + const { dataList: defaultDataList, fetchData } = useQueryModelLoraList(); + + const [itemList, setItemList] = useState([]); + const initializedRef = useRef(false); + const prevBaseRef = useRef(''); + + useEffect(() => { + if (initializedRef.current) { + return; + } + initializedRef.current = true; + const existing = (form.getFieldValue('lora_list') || []) as LoraListItem[]; + if (existing.length > 0) { + setItemList( + existing.map((it) => ({ + value: + it.source && it.lora_repo_name + ? [it.source, it.lora_repo_name] + : [], + lora_name: it.lora_name || '' + })) + ); + } + }, []); + + useEffect(() => { + if (!base) { + prevBaseRef.current = ''; + return; + } + fetchData({ base }); + if (prevBaseRef.current && prevBaseRef.current !== base) { + form.setFieldValue('lora_list', []); + setItemList([]); + } + prevBaseRef.current = base; + }, [base]); + + const selectedRepoNames = useMemo(() => { + return new Set( + itemList.map((it) => it.value?.[1]).filter(Boolean) as string[] + ); + }, [itemList]); + + const syncFormField = (newItemList: ItemValue[]) => { + const newFormList = newItemList.map((it) => ({ + source: (it.value?.[0] || '') as 'huggingface' | 'model_scope', + lora_repo_name: it.value?.[1] || '', + lora_name: it.lora_name || '' + })); + form.setFieldValue('lora_list', newFormList); + }; + + const handleItemChange = ( + index: number, + partial: { value?: any[]; lora_name?: string } + ) => { + const newItemList = [...itemList]; + newItemList[index] = { ...newItemList[index], ...partial }; + setItemList(newItemList); + syncFormField(newItemList); + }; + + const handleAdd = () => { + const newItemList = [...itemList, { value: [], lora_name: '' }]; + setItemList(newItemList); + syncFormField(newItemList); + }; + + const handleDelete = (index: number) => { + const newItemList = itemList.filter((_, i) => i !== index); + setItemList(newItemList); + syncFormField(newItemList); + }; + + return ( + name="lora_list" trigger=""> + + {(item, index) => ( + handleItemChange(index, partial)} + /> + )} + + + ); +}; + +export default ModelLoraList; diff --git a/src/pages/llmodels/services/use-query-lora-list.ts b/src/pages/llmodels/services/use-query-lora-list.ts new file mode 100644 index 00000000..7aa955f6 --- /dev/null +++ b/src/pages/llmodels/services/use-query-lora-list.ts @@ -0,0 +1,51 @@ +import { useQueryData } from '@gpustack/core-ui'; +import { useState } from 'react'; +import { queryModelLoraAdapter } from '../apis'; +import { ModelLoraAdapterResult } from '../config/types'; + +type Parameters = { + base: string; + q?: string; + limit?: number; // default to 40 +}; + +export const useQueryModelLoraList = () => { + const { detailData, loading, fetchData, cancelRequest } = useQueryData< + ModelLoraAdapterResult, + Parameters + >({ + key: 'modelLoraList', + fetchDetail: queryModelLoraAdapter + }); + + const [dataList, setDataList] = useState< + { + label: string; + value: string; + lora_repo_name: string; + source: string; + }[] + >([]); + + const getData = (params: Parameters) => { + fetchData(params).then((result) => { + if (result) { + const formattedData = result.lora_list.map((item) => ({ + ...item, + label: item.lora_repo_name, + value: item.lora_repo_name + })); + setDataList(formattedData); + } + }); + }; + + return { + dataList, + loading, + fetchData: getData, + cancelRequest + }; +}; + +export default useQueryModelLoraList;