fix: search lora ux

This commit is contained in:
jialin
2026-05-25 13:56:44 +08:00
committed by jialin
parent aad3b458c8
commit 5e889563ca
2 changed files with 102 additions and 74 deletions
+94 -74
View File
@@ -1,11 +1,14 @@
import { LoadingOutlined } from '@ant-design/icons';
import { import {
AutoTooltip, AutoTooltip,
Input as CInput, Input as CInput,
Cascader as SealCascader Select as SealSelect
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { modelSourceMap } from '../config';
import useQueryModelLoraList, { import useQueryModelLoraList, {
LoraOptionGroup LoraOptionGroup
} from '../services/use-query-lora-list'; } from '../services/use-query-lora-list';
@@ -25,6 +28,12 @@ interface LoraListItemProps {
}) => void; }) => void;
} }
type RenderableGroup = {
label: string;
groupValue: string;
options: { label: string; value: string; source: string }[];
};
const LoraListItem: React.FC<LoraListItemProps> = ({ const LoraListItem: React.FC<LoraListItemProps> = ({
item, item,
base, base,
@@ -35,7 +44,11 @@ const LoraListItem: React.FC<LoraListItemProps> = ({
onChange onChange
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { dataList: ownSearchList, fetchData } = useQueryModelLoraList(); const {
dataList: ownSearchList,
fetchData,
loading
} = useQueryModelLoraList();
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
const itemDataList = hasSearched ? ownSearchList : defaultDataList; const itemDataList = hasSearched ? ownSearchList : defaultDataList;
@@ -58,18 +71,44 @@ const LoraListItem: React.FC<LoraListItemProps> = ({
setHasSearched(false); setHasSearched(false);
}, [base]); }, [base]);
const groupedOptions = useMemo(() => { const groupedOptions = useMemo<RenderableGroup[]>(() => {
const currentRepo = item.value?.[1]; const currentRepo = item.value?.[1];
return itemDataList const sourcePrefix = intl.formatMessage({ id: 'models.form.source' });
.map((group) => ({ const groups: RenderableGroup[] = [];
...group, let hasLocal = false;
children: group.children.filter(
(child) => itemDataList.forEach((group) => {
!selectedRepoNames.has(child.value) || child.value === currentRepo const isLocal = group.value === modelSourceMap.local_path_value;
) if (isLocal) hasLocal = true;
})) const filtered = group.children.filter(
.filter((group) => group.children.length > 0); (child) =>
}, [itemDataList, selectedRepoNames, item.value]); !selectedRepoNames.has(child.value) || child.value === currentRepo
);
const labelText = isLocal
? intl.formatMessage({ id: 'menu.resources.modelfiles' })
: group.label;
groups.push({
label: `${sourcePrefix}: ${labelText}`,
groupValue: group.value,
options: filtered.map((c) => ({
label: c.label,
value: c.value,
source: c.source,
data: { source: c.source, groupValue: group.value }
}))
});
});
if (!hasLocal) {
groups.push({
label: `${sourcePrefix}: ${intl.formatMessage({ id: 'menu.resources.modelfiles' })}`,
groupValue: modelSourceMap.local_path_value,
options: []
});
}
return groups;
}, [itemDataList, selectedRepoNames, item.value, intl]);
const handleSearch = (q: string) => { const handleSearch = (q: string) => {
if (!base || !q) { if (!base || !q) {
@@ -81,83 +120,64 @@ const LoraListItem: React.FC<LoraListItemProps> = ({
debouncedSearch(q); debouncedSearch(q);
}; };
const handleCascaderChange = (value: any, selectedOptions?: any[]) => { const handleSelectChange = (value: string, option: any) => {
const leaf = selectedOptions?.[selectedOptions.length - 1]; if (!value) {
onChange({ value: value || [], source: leaf?.source || '' }); onChange({ value: [], source: '' });
return;
}
const data = option?.data || {};
const groupValue = data.groupValue || modelSourceMap.local_path_value;
const source = data.source || modelSourceMap.local_path_value;
onChange({ value: [groupValue, value], source });
}; };
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({ lora_name: e.target.value }); onChange({ lora_name: e.target.value });
}; };
const cascaderEmpty = !item.value || item.value.length === 0; const optionRender = (option: any) => {
return <AutoTooltip ghost>{option.label}</AutoTooltip>;
};
const labelRender = (option: any) => {
return option.label;
};
const valueEmpty = !item.value?.[1];
const nameEmpty = !item.lora_name?.trim(); const nameEmpty = !item.lora_name?.trim();
const isDuplicate = const isDuplicate =
!!item.lora_name?.trim() && duplicateNames.has(item.lora_name.trim()); !!item.lora_name?.trim() && duplicateNames.has(item.lora_name.trim());
const cascaderStatus = const selectStatus = validated && valueEmpty ? ('error' as const) : 'success';
validated && cascaderEmpty ? ('error' as const) : 'success';
const inputStatus = const inputStatus =
validated && (nameEmpty || isDuplicate) ? ('error' as const) : 'success'; 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 ( return (
<div className={loraSelectionStyles.item}> <div className={loraSelectionStyles.wrapper}>
<SealCascader <div style={{ minWidth: 0 }}>
expandTrigger="click" <SealSelect
multiple={false} allowClear={!loading}
alwaysFocus={true} showSearch
status={cascaderStatus} filterOption={false}
value={item.value} status={selectStatus}
options={groupedOptions} loading={loading}
onChange={handleCascaderChange} suffixIcon={
showSearch={{ loading ? (
onSearch: handleSearch <Button size="small" type="link">
}} <LoadingOutlined />
placeholder={intl.formatMessage({ id: 'models.form.lora.select' })} </Button>
showCheckedStrategy="SHOW_CHILD" ) : null
displayRender={displayRender}
optionNode={optionNode}
classNames={{
popup: {
root: 'cascader-popup-wrapper'
} }
}} value={item.value?.[1] || undefined}
styles={{ onChange={handleSelectChange}
popup: { onSearch={handleSearch}
listItem: { labelRender={labelRender}
padding: '5px 10px' optionRender={optionRender}
} placeholder={intl.formatMessage({ id: 'models.form.lora.select' })}
} options={groupedOptions}
}} ></SealSelect>
getPopupContainer={(triggerNode) => triggerNode.parentNode} </div>
></SealCascader>
<CInput.Input <CInput.Input
status={inputStatus} status={inputStatus}
value={item.lora_name} value={item.lora_name}
@@ -28,3 +28,11 @@
min-width: 0; min-width: 0;
} }
} }
.wrapper {
display: grid;
width: 100%;
grid-template-columns: 1fr 140px;
gap: 8px;
align-items: center;
}