Files
gpustack-ui/src/pages/llmodels/forms/lora-list-item.tsx
T
2026-05-25 13:56:44 +08:00

172 lines
4.4 KiB
TypeScript

import {
AutoTooltip,
Input as CInput,
Cascader as SealCascader
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import _ from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import useQueryModelLoraList, {
LoraOptionGroup
} from '../services/use-query-lora-list';
import loraSelectionStyles from '../style/lora-selection.less';
interface LoraListItemProps {
item: { value: any[]; lora_name: string; source: string };
base: string;
defaultDataList: LoraOptionGroup[];
selectedRepoNames: Set<string>;
duplicateNames: Set<string>;
validated: boolean;
onChange: (partial: {
value?: any[];
lora_name?: string;
source?: string;
}) => void;
}
const LoraListItem: React.FC<LoraListItemProps> = ({
item,
base,
defaultDataList,
selectedRepoNames,
duplicateNames,
validated,
onChange
}) => {
const intl = useIntl();
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 currentRepo = item.value?.[1];
return itemDataList
.map((group) => ({
...group,
children: group.children.filter(
(child) =>
!selectedRepoNames.has(child.value) || child.value === currentRepo
)
}))
.filter((group) => group.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, selectedOptions?: any[]) => {
const leaf = selectedOptions?.[selectedOptions.length - 1];
onChange({ value: value || [], source: leaf?.source || '' });
};
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?.trim();
const isDuplicate =
!!item.lora_name?.trim() && duplicateNames.has(item.lora_name.trim());
const cascaderStatus =
validated && cascaderEmpty ? ('error' as const) : 'success';
const inputStatus =
validated && (nameEmpty || isDuplicate) ? ('error' as const) : 'success';
const displayRender = (labels: any[]) => {
const left =
typeof labels[0] === 'string' ? labels[0].replace(/\/+$/, '') : labels[0];
const right =
typeof labels[1] === 'string' ? labels[1].replace(/^\/+/, '') : labels[1];
const content = (
<span>
{left}/{right}
</span>
);
return (
<AutoTooltip ghost maxWidth={300} title={content}>
{content}
</AutoTooltip>
);
};
const optionNode = (option: any) => {
const { data } = option;
return (
<AutoTooltip ghost maxWidth={'100%'}>
{data.label}
</AutoTooltip>
);
};
return (
<div className={loraSelectionStyles.item}>
<SealCascader
expandTrigger="click"
multiple={false}
alwaysFocus={true}
status={cascaderStatus}
value={item.value}
options={groupedOptions}
onChange={handleCascaderChange}
showSearch={{
onSearch: handleSearch
}}
placeholder={intl.formatMessage({ id: 'models.form.lora.select' })}
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
status={inputStatus}
value={item.lora_name}
onChange={handleNameChange}
placeholder={intl.formatMessage({ id: 'models.form.lora.name' })}
/>
</div>
);
};
export default LoraListItem;