mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-26 18:33:30 +08:00
Feat: tenant llm provider (#14595)
### What problem does this PR solve? Python implementation of the Go-based model_provider API suite. ### Type of change - [x] New Feature (non-breaking change which adds functionality) --------- Co-authored-by: bill <yibie_jingnian@163.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { LlmModelType } from '@/constants/knowledge';
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { useSelectLlmOptionsByModelType } from '@/hooks/use-llm-request';
|
||||
import { useFetchAllAddedModels } from '@/hooks/use-llm-request';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { camelCase } from 'lodash';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { MinerUOptionsFormField } from './mineru-options-form-field';
|
||||
import { SelectWithSearch } from './originui/select-with-search';
|
||||
import { buildModelTree } from './model-tree-select';
|
||||
import { PaddleOCROptionsFormField } from './paddleocr-options-form-field';
|
||||
import { TreeSelect, TreeSelectNode } from './tree-select';
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -44,9 +44,9 @@ export function LayoutRecognizeFormField({
|
||||
const form = useFormContext();
|
||||
|
||||
const { t } = useTranslate('knowledgeDetails');
|
||||
const allOptions = useSelectLlmOptionsByModelType();
|
||||
const { data: allAddedModels } = useFetchAllAddedModels();
|
||||
|
||||
const options = useMemo(() => {
|
||||
const treeData = useMemo(() => {
|
||||
const list = optionsWithoutLLM
|
||||
? optionsWithoutLLM
|
||||
: [
|
||||
@@ -60,28 +60,28 @@ export function LayoutRecognizeFormField({
|
||||
value: x,
|
||||
}));
|
||||
|
||||
const image2TextList = [
|
||||
...allOptions[LlmModelType.Image2text],
|
||||
...allOptions[LlmModelType.Ocr],
|
||||
].map((x) => {
|
||||
return {
|
||||
...x,
|
||||
options: x.options.map((y) => {
|
||||
return {
|
||||
...y,
|
||||
label: (
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
{y.label}
|
||||
<span className="text-red-500 text-sm">Experimental</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const prependNodes: TreeSelectNode[] = list.map((x) => ({
|
||||
id: x.value,
|
||||
title: x.label,
|
||||
}));
|
||||
|
||||
return [...list, ...image2TextList];
|
||||
}, [allOptions, optionsWithoutLLM, t]);
|
||||
const modelTree = buildModelTree(
|
||||
allAddedModels,
|
||||
['image2text', 'ocr'],
|
||||
(node) => (
|
||||
<div className="flex justify-between items-center gap-2 w-full">
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="text-state-error text-sm flex-shrink-0">
|
||||
Experimental
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
return [...prependNodes, ...modelTree];
|
||||
}, [allAddedModels, optionsWithoutLLM, t]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
@@ -107,11 +107,17 @@ export function LayoutRecognizeFormField({
|
||||
</FormLabel>
|
||||
<div className={horizontal ? 'w-3/4' : 'w-full'}>
|
||||
<FormControl>
|
||||
<SelectWithSearch
|
||||
<TreeSelect
|
||||
{...field}
|
||||
options={options}
|
||||
data={treeData}
|
||||
testId={testId}
|
||||
></SelectWithSearch>
|
||||
showSearch
|
||||
defaultExpandAll
|
||||
renderSelected={(node) => {
|
||||
if (!node) return null;
|
||||
return node.label ?? node.title;
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { getLLMIconName, getLlmNameAndFIdByLlmId } from '@/utils/llm-util';
|
||||
import { parseModelValue } from '@/utils/llm-util';
|
||||
import { memo } from 'react';
|
||||
import { LlmIcon } from '../svg-icon';
|
||||
|
||||
interface IProps {
|
||||
id?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LLMLabel = ({ value }: IProps) => {
|
||||
const { llmName, fId } = getLlmNameAndFIdByLlmId(value);
|
||||
export const LLMLabel = ({ value }: IProps) => {
|
||||
const parsed = value ? parseModelValue(value) : null;
|
||||
const modelName = parsed?.model_name;
|
||||
const instanceName = parsed?.model_instance;
|
||||
const iconName = parsed ? parsed.model_provider : '';
|
||||
|
||||
if (!modelName) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-text-secondary">
|
||||
<LlmIcon name={getLLMIconName(fId, llmName)} width={20} height={20} />
|
||||
<span className="flex-1 truncate"> {llmName}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<LlmIcon
|
||||
name={iconName}
|
||||
width={22}
|
||||
height={22}
|
||||
imgClass="size-[22px] flex-shrink-0"
|
||||
/>
|
||||
<span className="font-medium truncate">{modelName}</span>
|
||||
{instanceName && (
|
||||
<span className="text-slate-400 truncate flex-shrink-0">
|
||||
{instanceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { LlmModelType } from '@/constants/knowledge';
|
||||
import { useComposeLlmOptionsByModelTypes } from '@/hooks/use-llm-request';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { forwardRef, memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LlmSettingFieldItems } from '../llm-setting-items/next';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Select, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import LLMLabel from './llm-label';
|
||||
|
||||
export interface NextInnerLLMSelectProps {
|
||||
id?: string;
|
||||
@@ -51,8 +51,6 @@ const NextInnerLLMSelect = forwardRef<
|
||||
}
|
||||
}, [filter, ttsModel]);
|
||||
|
||||
const modelOptions = useComposeLlmOptionsByModelTypes(modelTypes);
|
||||
|
||||
return (
|
||||
<Select disabled={disabled} value={value}>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
@@ -66,17 +64,13 @@ const NextInnerLLMSelect = forwardRef<
|
||||
data-testid={triggerTestId}
|
||||
>
|
||||
<SelectValue placeholder={t('common.pleaseSelect')}>
|
||||
{
|
||||
modelOptions
|
||||
.flatMap((x) => x.options)
|
||||
.find((x) => x.value === value)?.label
|
||||
}
|
||||
<LLMLabel value={value} />
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={'left'}>
|
||||
<LlmSettingFieldItems
|
||||
options={modelOptions}
|
||||
modelTypes={modelTypes}
|
||||
llmOptionTestIdPrefix={optionTestIdPrefix}
|
||||
></LlmSettingFieldItems>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
import { LlmModelType } from '@/constants/knowledge';
|
||||
import { useComposeLlmOptionsByModelTypes } from '@/hooks/use-llm-request';
|
||||
import { ModelTreeSelect } from '@/components/model-tree-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SelectWithSearch } from '../originui/select-with-search';
|
||||
import { RAGFlowFormItem } from '../ragflow-form';
|
||||
|
||||
export type LLMFormFieldProps = {
|
||||
options?: any[];
|
||||
modelTypes?: string[];
|
||||
name?: string;
|
||||
testId?: string;
|
||||
optionTestIdPrefix?: string;
|
||||
config?: any;
|
||||
};
|
||||
|
||||
export const useModelOptions = () => {
|
||||
const modelOptions = useComposeLlmOptionsByModelTypes([
|
||||
LlmModelType.Chat,
|
||||
LlmModelType.Image2text,
|
||||
]);
|
||||
return {
|
||||
modelOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export function LLMFormField({
|
||||
options,
|
||||
name,
|
||||
testId,
|
||||
optionTestIdPrefix,
|
||||
config,
|
||||
}: LLMFormFieldProps) {
|
||||
export function LLMFormField({ name, config, modelTypes }: LLMFormFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const { modelOptions } = useModelOptions();
|
||||
|
||||
return (
|
||||
<RAGFlowFormItem name={name || 'llm_id'} label={t('chat.model')}>
|
||||
<SelectWithSearch
|
||||
options={options || modelOptions}
|
||||
testId={testId}
|
||||
optionTestIdPrefix={optionTestIdPrefix}
|
||||
{...config}
|
||||
></SelectWithSearch>
|
||||
<ModelTreeSelect
|
||||
allowClear={config?.allowClear ?? false}
|
||||
modelTypes={modelTypes}
|
||||
/>
|
||||
</RAGFlowFormItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useHandleFreedomChange } from './use-watch-change';
|
||||
|
||||
interface LlmSettingFieldItemsProps {
|
||||
prefix?: string;
|
||||
options?: any[];
|
||||
modelTypes?: string[];
|
||||
llmId?: string;
|
||||
llmSelectTestId?: string;
|
||||
llmOptionTestIdPrefix?: string;
|
||||
@@ -69,7 +69,7 @@ export const LlmSettingSchema = {
|
||||
|
||||
export function LlmSettingFieldItems({
|
||||
prefix,
|
||||
options,
|
||||
modelTypes,
|
||||
llmSelectTestId,
|
||||
llmOptionTestIdPrefix,
|
||||
showFields = [
|
||||
@@ -137,7 +137,7 @@ export function LlmSettingFieldItems({
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<LLMFormField
|
||||
options={options}
|
||||
modelTypes={modelTypes}
|
||||
name={llmId ?? getFieldWithPrefix('llm_id')}
|
||||
testId={llmSelectTestId}
|
||||
optionTestIdPrefix={llmOptionTestIdPrefix}
|
||||
|
||||
190
web/src/components/model-tree-select.tsx
Normal file
190
web/src/components/model-tree-select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import LLMLabel from '@/components/llm-select/llm-label';
|
||||
import { LlmIcon } from '@/components/svg-icon';
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { useFetchAllAddedModels } from '@/hooks/use-llm-request';
|
||||
import { IAddedModel } from '@/interfaces/database/llm';
|
||||
import { getRealModelName } from '@/utils/llm-util';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TreeSelect, TreeSelectNode } from './tree-select';
|
||||
|
||||
/** Maps form field names to their supported model types */
|
||||
export const ModelTypeMap: Record<string, string[]> = {
|
||||
llm_id: ['chat', 'image2text'],
|
||||
embd_id: ['embedding'],
|
||||
img2txt_id: ['image2text'],
|
||||
asr_id: ['speech2text'],
|
||||
rerank_id: ['rerank'],
|
||||
tts_id: ['tts'],
|
||||
};
|
||||
|
||||
export function buildModelTree(
|
||||
allModels: IAddedModel[],
|
||||
modelTypes: string[],
|
||||
renderLeafLabel?: (
|
||||
node: TreeSelectNode,
|
||||
model: IAddedModel,
|
||||
) => React.ReactNode,
|
||||
): TreeSelectNode[] {
|
||||
const filtered = allModels.filter((m) =>
|
||||
m.model_type?.some((t) => modelTypes.includes(t)),
|
||||
);
|
||||
|
||||
const seenLeafIds = new Set<string>();
|
||||
const providerMap = new Map<string, Map<string, IAddedModel[]>>();
|
||||
|
||||
for (const model of filtered) {
|
||||
let instances = providerMap.get(model.provider_name);
|
||||
if (!instances) {
|
||||
instances = new Map();
|
||||
providerMap.set(model.provider_name, instances);
|
||||
}
|
||||
let modelList = instances.get(model.instance_name);
|
||||
if (!modelList) {
|
||||
modelList = [];
|
||||
instances.set(model.instance_name, modelList);
|
||||
}
|
||||
modelList.push(model);
|
||||
}
|
||||
|
||||
return Array.from(providerMap.entries()).map(([provider, instances]) => ({
|
||||
id: provider,
|
||||
title: provider,
|
||||
children: Array.from(instances.entries()).map(([instance, models]) => ({
|
||||
id: `${provider}||${instance}`,
|
||||
title: instance,
|
||||
children: models.reduce<TreeSelectNode[]>((acc, m) => {
|
||||
const modelName = getRealModelName(m.name);
|
||||
const id = `${modelName}@${m.instance_name}@${m.provider_name}`;
|
||||
if (seenLeafIds.has(id)) return acc;
|
||||
seenLeafIds.add(id);
|
||||
const leafNode: TreeSelectNode = {
|
||||
id,
|
||||
title: modelName,
|
||||
label: (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<LlmIcon
|
||||
name={m.provider_name}
|
||||
width={22}
|
||||
height={22}
|
||||
imgClass="size-[22px] flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate">{modelName}</span>
|
||||
</span>
|
||||
),
|
||||
data: {
|
||||
provider_name: m.provider_name,
|
||||
instance_name: m.instance_name,
|
||||
model_name: modelName,
|
||||
},
|
||||
};
|
||||
if (renderLeafLabel) {
|
||||
leafNode.label = renderLeafLabel(leafNode, m);
|
||||
}
|
||||
acc.push(leafNode);
|
||||
return acc;
|
||||
}, []),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface ModelTreeSelectProps {
|
||||
modelTypes?: string[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
showSearch?: boolean;
|
||||
allowClear?: boolean;
|
||||
className?: string;
|
||||
renderSelected?: (node: TreeSelectNode | undefined) => React.ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ModelTreeSelect({
|
||||
modelTypes = ModelTypeMap.llm_id,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
showSearch = true,
|
||||
allowClear = false,
|
||||
className,
|
||||
renderSelected,
|
||||
testId,
|
||||
}: ModelTreeSelectProps) {
|
||||
const { data: allAddedModels } = useFetchAllAddedModels();
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildModelTree(allAddedModels, modelTypes),
|
||||
[allAddedModels, modelTypes],
|
||||
);
|
||||
|
||||
const defaultRenderSelected = useCallback(
|
||||
(node: TreeSelectNode | undefined) => {
|
||||
if (!node?.id) return null;
|
||||
return <LLMLabel value={node.id} />;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
data={treeData}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
showSearch={showSearch}
|
||||
allowClear={allowClear}
|
||||
defaultExpandAll
|
||||
className={className}
|
||||
renderSelected={renderSelected ?? defaultRenderSelected}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ModelTreeSelectFormFieldProps extends ModelTreeSelectProps {
|
||||
name?: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function ModelTreeSelectFormField({
|
||||
name = 'llm_id',
|
||||
label,
|
||||
tooltip,
|
||||
...rest
|
||||
}: ModelTreeSelectFormFieldProps) {
|
||||
const form = useFormContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{label && <FormLabel tooltip={tooltip}>{label}</FormLabel>}
|
||||
<FormControl>
|
||||
<ModelTreeSelect
|
||||
{...rest}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={rest.placeholder ?? t('common.pleaseSelect')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { LlmModelType } from '@/constants/knowledge';
|
||||
import { ModelTreeSelect } from '@/components/model-tree-select';
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { useSelectLlmOptionsByModelType } from '@/hooks/use-llm-request';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { SelectWithSearch } from './originui/select-with-search';
|
||||
import { SliderInputFormField } from './slider-input-form-field';
|
||||
import {
|
||||
FormControl,
|
||||
@@ -26,8 +24,6 @@ const RerankId = 'rerank_id';
|
||||
function RerankFormField() {
|
||||
const form = useFormContext();
|
||||
const { t } = useTranslate('knowledgeDetails');
|
||||
const allOptions = useSelectLlmOptionsByModelType();
|
||||
const options = allOptions[LlmModelType.Rerank];
|
||||
|
||||
return (
|
||||
<FormField
|
||||
@@ -37,12 +33,12 @@ function RerankFormField() {
|
||||
<FormItem>
|
||||
<FormLabel tooltip={t('rerankTip')}>{t('rerankModel')}</FormLabel>
|
||||
<FormControl>
|
||||
<SelectWithSearch
|
||||
<ModelTreeSelect
|
||||
modelTypes={['rerank']}
|
||||
allowClear
|
||||
placeholder={t('rerankPlaceholder')}
|
||||
{...field}
|
||||
options={options}
|
||||
></SelectWithSearch>
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
269
web/src/components/tree-select.tsx
Normal file
269
web/src/components/tree-select.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface TreeSelectNode {
|
||||
id: string;
|
||||
title: string;
|
||||
label?: React.ReactNode;
|
||||
children?: TreeSelectNode[];
|
||||
disabled?: boolean;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface TreeSelectProps {
|
||||
data: TreeSelectNode[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowClear?: boolean;
|
||||
showSearch?: boolean;
|
||||
className?: string;
|
||||
defaultExpandAll?: boolean;
|
||||
renderSelected?: (node: TreeSelectNode | undefined) => React.ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function TreeSelect({
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
allowClear,
|
||||
showSearch,
|
||||
className,
|
||||
defaultExpandAll,
|
||||
renderSelected,
|
||||
testId,
|
||||
}: TreeSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultExpandAll) return;
|
||||
const ids = new Set<string>();
|
||||
const walk = (nodes: TreeSelectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.children?.length) {
|
||||
ids.add(node.id);
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(data);
|
||||
setExpandedIds(ids);
|
||||
}, [data, defaultExpandAll]);
|
||||
|
||||
const selectedNode = useMemo(() => {
|
||||
const find = (nodes: TreeSelectNode[]): TreeSelectNode | undefined => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === value) return node;
|
||||
if (node.children) {
|
||||
const found = find(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
};
|
||||
return find(data);
|
||||
}, [data, value]);
|
||||
|
||||
const isLeaf = useCallback(
|
||||
(node: TreeSelectNode) => !node.children?.length,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(node: TreeSelectNode) => {
|
||||
if (node.disabled) return;
|
||||
if (isLeaf(node)) {
|
||||
onChange?.(node.id);
|
||||
setOpen(false);
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
handleToggle(node.id);
|
||||
}
|
||||
},
|
||||
[isLeaf, onChange, handleToggle],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange?.('');
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const filterTree = useCallback(
|
||||
(nodes: TreeSelectNode[], term: string): TreeSelectNode[] => {
|
||||
if (!term) return nodes;
|
||||
return nodes.reduce<TreeSelectNode[]>((acc, node) => {
|
||||
const titleMatch = node.title
|
||||
.toLowerCase()
|
||||
.includes(term.toLowerCase());
|
||||
const filteredChildren = node.children
|
||||
? filterTree(node.children, term)
|
||||
: undefined;
|
||||
if (titleMatch || filteredChildren?.length) {
|
||||
acc.push({ ...node, children: filteredChildren ?? node.children });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filteredData = useMemo(
|
||||
() => filterTree(data, searchTerm),
|
||||
[data, searchTerm, filterTree],
|
||||
);
|
||||
|
||||
const visibleExpandedIds = useMemo(() => {
|
||||
if (!searchTerm) return expandedIds;
|
||||
const ids = new Set<string>();
|
||||
const walk = (nodes: TreeSelectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.children?.length) {
|
||||
ids.add(node.id);
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(filteredData);
|
||||
return ids;
|
||||
}, [searchTerm, expandedIds, filteredData]);
|
||||
|
||||
const renderTree = useCallback(
|
||||
(nodes: TreeSelectNode[], level = 0): React.ReactNode => {
|
||||
return nodes.map((node) => {
|
||||
const leaf = isLeaf(node);
|
||||
const expanded = visibleExpandedIds.has(node.id);
|
||||
const selected = value === node.id;
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-sm cursor-pointer text-sm',
|
||||
'hover:bg-accent transition-colors',
|
||||
!leaf && 'text-text-primary font-medium',
|
||||
selected && 'bg-accent text-accent-foreground',
|
||||
node.disabled && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: `${level * 20 + 4}px`,
|
||||
paddingRight: '8px',
|
||||
paddingTop: '6px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onClick={() => handleSelect(node)}
|
||||
>
|
||||
<span className="w-4 h-4 mr-0.5 flex-shrink-0 flex items-center justify-center">
|
||||
{!leaf && (
|
||||
<>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-text-secondary" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-text-secondary" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{node.label ?? node.title}</span>
|
||||
</div>
|
||||
{!leaf && expanded && node.children && (
|
||||
<div>{renderTree(node.children, level + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
},
|
||||
[isLeaf, visibleExpandedIds, value, handleSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
'flex items-center justify-between border border-border-button rounded-md px-3 py-1.5 w-full',
|
||||
'bg-bg-input text-sm',
|
||||
'hover:bg-border-button transition-colors',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn('truncate', !selectedNode && 'text-slate-400')}>
|
||||
{renderSelected
|
||||
? renderSelected(selectedNode)
|
||||
: selectedNode?.title || placeholder || t('common.pleaseSelect')}
|
||||
</span>
|
||||
<div className="flex items-center ml-2 flex-shrink-0">
|
||||
{allowClear && value ? (
|
||||
<X
|
||||
className="h-4 w-4 opacity-50 hover:opacity-100"
|
||||
onClick={handleClear}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
{showSearch && (
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<Search className="h-4 w-4 text-slate-400 mr-2 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400"
|
||||
placeholder={t('common.search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="max-h-60 overflow-auto p-1"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{filteredData.length > 0 ? (
|
||||
renderTree(filteredData)
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm text-slate-400">
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user