feat: lora for deployment

This commit is contained in:
jialin
2026-05-25 13:56:44 +08:00
committed by jialin
parent 6af18275c4
commit 342583f8d1
7 changed files with 426 additions and 1 deletions
+19 -1
View File
@@ -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<ModelLoraAdapterResult>(`${MODEL_LORA_ADAPTER_API}`, {
params,
cancelToken: options?.token,
method: 'GET'
});
}
// ===================== Model Instances start =====================
export async function queryModelInstancesList(
+18
View File
@@ -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;
}>;
}
@@ -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}
></LabelSelector>
</Form.Item>
<ModelLoraList></ModelLoraList>
{(backend === backendOptionsMap.custom ||
!currentBackendOptions?.isBuiltIn) && (
<Form.Item<FormData>
+9
View File
@@ -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<DataFormProps> = 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']),
+211
View File
@@ -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<string>;
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<LoraListItemProps> = ({
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<HTMLInputElement>) => {
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 (
<AutoTooltip
ghost
maxWidth={300}
title={
<span>
{labels[0]} / {labels[1]}
</span>
}
>
<span>
{labels[0]} / {labels[1]}
</span>
</AutoTooltip>
);
};
const optionNode = (option: any) => {
const { data } = option;
if (data.isParent) {
return (
<AutoTooltip ghost maxWidth={100}>
{data.label}
</AutoTooltip>
);
}
return (
<AutoTooltip ghost maxWidth={200}>
{data.label}
</AutoTooltip>
);
};
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 120px',
gap: 8,
alignItems: 'center',
flex: 1
}}
>
<SealCascader
showSearch
expandTrigger="hover"
multiple={false}
alwaysFocus={true}
status={cascaderStatus}
value={item.value}
options={groupedOptions}
onChange={handleCascaderChange}
onSearch={handleSearch}
placeholder="Select LoRA"
showCheckedStrategy="SHOW_CHILD"
displayRender={displayRender}
optionNode={optionNode}
classNames={{
popup: {
root: 'cascader-popup-wrapper'
}
}}
styles={{
popup: {
listItem: {
padding: '5px 10px'
}
}
}}
getPopupContainer={(triggerNode) => triggerNode.parentNode}
></SealCascader>
<CInput.Input
style={{ flex: 1 }}
status={inputStatus}
value={item.lora_name}
onChange={handleNameChange}
placeholder="LoRA name"
/>
</div>
);
};
export default LoraListItem;
@@ -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<FormData>();
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<ItemValue[]>([]);
const initializedRef = useRef(false);
const prevBaseRef = useRef<string>('');
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 (
<Form.Item<FormData> name="lora_list" trigger="">
<MetadataList
label="LoRA Adapter"
dataList={itemList}
btnText="Add LoRA Adapter"
onAdd={handleAdd}
onDelete={handleDelete}
>
{(item, index) => (
<LoraItem
item={item}
base={base}
defaultDataList={defaultDataList}
selectedRepoNames={selectedRepoNames}
onChange={(partial) => handleItemChange(index, partial)}
/>
)}
</MetadataList>
</Form.Item>
);
};
export default ModelLoraList;
@@ -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;