From ffedb2c6d344ede0c1a0427dfb9421824b3f921c Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Tue, 13 Jan 2026 17:03:25 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20The=20MetadataFilterConditions=20compon?= =?UTF-8?q?ent=20supports=20adding=20values=20=E2=80=8B=E2=80=8Bvia=20sear?= =?UTF-8?q?ch.=20(#12585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What problem does this PR solve? Feat: The MetadataFilterConditions component supports adding values ​​via search. ### Type of change - [x] New Feature (non-breaking change which adds functionality) --- .../metadata-filter-conditions.tsx | 159 +++++---- web/src/components/ui/input-select.tsx | 303 ++++++++++++++++++ .../modal/paddleocr-modal/index.tsx | 26 +- 3 files changed, 411 insertions(+), 77 deletions(-) create mode 100644 web/src/components/ui/input-select.tsx diff --git a/web/src/components/metadata-filter/metadata-filter-conditions.tsx b/web/src/components/metadata-filter/metadata-filter-conditions.tsx index 599a6ed80..1ddf90acf 100644 --- a/web/src/components/metadata-filter/metadata-filter-conditions.tsx +++ b/web/src/components/metadata-filter/metadata-filter-conditions.tsx @@ -20,10 +20,11 @@ import { useBuildSwitchOperatorOptions } from '@/hooks/logic-hooks/use-build-ope import { useFetchKnowledgeMetadata } from '@/hooks/use-knowledge-request'; import { PromptEditor } from '@/pages/agent/form/components/prompt-editor'; import { Plus, X } from 'lucide-react'; -import { useCallback } from 'react'; -import { useFieldArray, useFormContext } from 'react-hook-form'; +import { useCallback, useMemo } from 'react'; +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { LogicalOperator } from '../logical-operator'; +import { InputSelect } from '../ui/input-select'; export function MetadataFilterConditions({ kbIds, @@ -61,6 +62,94 @@ export function MetadataFilterConditions({ [append, fields.length, form, logic], ); + const RenderField = ({ + fieldName, + index, + }: { + fieldName: string; + index: number; + }) => { + const form = useFormContext(); + const key = useWatch({ name: fieldName }); + const valueOptions = useMemo(() => { + if (!key || !metadata?.data || !metadata?.data[key]) return []; + if (typeof metadata?.data[key] === 'object') { + return Object.keys(metadata?.data[key]).map((item: string) => ({ + value: item, + label: item, + })); + } + return []; + }, [key]); + + return ( +
+
+
+ ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> +
+ ( + + + {canReference ? ( + + ) : ( + + )} + + + + )} + /> +
+ +
+ ); + }; return (
@@ -84,73 +173,11 @@ export function MetadataFilterConditions({
{fields.length > 1 && } -
+
{fields.map((field, index) => { const typeField = `${name}.${index}.key`; return ( -
-
-
- ( - - - - - - - )} - /> - - ( - - - - - - - )} - /> -
- ( - - - {canReference ? ( - - ) : ( - - )} - - - - )} - /> -
- -
+ ); })}
diff --git a/web/src/components/ui/input-select.tsx b/web/src/components/ui/input-select.tsx new file mode 100644 index 000000000..9c7099944 --- /dev/null +++ b/web/src/components/ui/input-select.tsx @@ -0,0 +1,303 @@ +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { X } from 'lucide-react'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +/** Interface for tag select options */ +export interface InputSelectOption { + /** Value of the option */ + value: string; + /** Display label of the option */ + label: string; +} + +/** Properties for the InputSelect component */ +export interface InputSelectProps { + /** Options for the select component */ + options?: InputSelectOption[]; + /** Selected values - string for single select, array for multi select */ + value?: string | string[]; + /** Callback when value changes */ + onChange?: (value: string | string[]) => void; + /** Placeholder text */ + placeholder?: string; + /** Additional class names */ + className?: string; + /** Style object */ + style?: React.CSSProperties; + /** Whether to allow multiple selections */ + multi?: boolean; +} + +const InputSelect = React.forwardRef( + ( + { + options = [], + value = [], + onChange, + placeholder = 'Select tags...', + className, + style, + multi = false, + }, + ref, + ) => { + const [inputValue, setInputValue] = React.useState(''); + const [open, setOpen] = React.useState(false); + const [isFocused, setIsFocused] = React.useState(false); + const inputRef = React.useRef(null); + const { t } = useTranslation(); + + // Normalize value to array for consistent handling + const normalizedValue = Array.isArray(value) ? value : value ? [value] : []; + + /** + * Removes a tag from the selected values + * @param tagValue - The value of the tag to remove + */ + const handleRemoveTag = (tagValue: string) => { + const newValue = normalizedValue.filter((v) => v !== tagValue); + // Return single value if not multi-select, otherwise return array + onChange?.(multi ? newValue : newValue[0] || ''); + }; + + /** + * Adds a tag to the selected values + * @param optionValue - The value of the tag to add + */ + const handleAddTag = (optionValue: string) => { + let newValue: string[]; + + if (multi) { + // For multi-select, add to array if not already included + if (!normalizedValue.includes(optionValue)) { + newValue = [...normalizedValue, optionValue]; + onChange?.(newValue); + } + } else { + // For single-select, replace the value + newValue = [optionValue]; + onChange?.(optionValue); + } + + setInputValue(''); + setOpen(false); // Close the popover after adding a tag + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + setOpen(newValue.length > 0); // Open popover when there's input + + // If input matches an option exactly, add it + const matchedOption = options.find( + (opt) => opt.label.toLowerCase() === newValue.toLowerCase(), + ); + + if (matchedOption && !normalizedValue.includes(matchedOption.value)) { + handleAddTag(matchedOption.value); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + e.key === 'Backspace' && + inputValue === '' && + normalizedValue.length > 0 + ) { + // Remove last tag when pressing backspace on empty input + const newValue = [...normalizedValue]; + newValue.pop(); + // Return single value if not multi-select, otherwise return array + onChange?.(multi ? newValue : newValue[0] || ''); + } else if (e.key === 'Enter' && inputValue.trim() !== '') { + e.preventDefault(); + // Add input value as a new tag if it doesn't exist in options + const matchedOption = options.find( + (opt) => opt.label.toLowerCase() === inputValue.toLowerCase(), + ); + + if (matchedOption) { + handleAddTag(matchedOption.value); + } else { + // If not in options, create a new tag with the input value + if ( + !normalizedValue.includes(inputValue) && + inputValue.trim() !== '' + ) { + handleAddTag(inputValue); + } + } + } else if (e.key === 'Escape') { + inputRef.current?.blur(); + setOpen(false); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + // Allow navigation in the dropdown + return; + } + }; + + const handleContainerClick = () => { + inputRef.current?.focus(); + setOpen(true); + setIsFocused(true); + }; + + const handleInputFocus = () => { + setOpen(true); + setIsFocused(true); + }; + + const handleInputBlur = () => { + // Delay closing to allow click on options + setTimeout(() => { + setOpen(false); + setIsFocused(false); + }, 150); + }; + + // Filter options to exclude already selected ones (only for multi-select) + const availableOptions = multi + ? options.filter((option) => !normalizedValue.includes(option.value)) + : options; + + const filteredOptions = availableOptions.filter( + (option) => + !inputValue || + option.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + + // If there are no matching options but there is an input value, create a new option with the input value + const hasMatchingOptions = filteredOptions.length > 0; + const showInputAsOption = + inputValue && + !hasMatchingOptions && + !normalizedValue.includes(inputValue); + + const triggerElement = ( +
+ {/* Render selected tags - only show tags if multi is true or if single select has a value */} + {multi && + normalizedValue.map((tagValue) => { + const option = options.find((opt) => opt.value === tagValue) || { + value: tagValue, + label: tagValue, + }; + return ( +
+ {option.label} + +
+ ); + })} + + {/* For single select, show the selected value as text instead of a tag */} + {!multi && normalizedValue[0] && ( +
+
+ {options.find((opt) => opt.value === normalizedValue[0])?.label || + normalizedValue[0]} +
+ +
+ )} + + {/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */} + {(multi ? isFocused : multi || !normalizedValue[0]) && ( + e.stopPropagation()} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + /> + )} +
+ ); + + return ( + + {triggerElement} + e.preventDefault()} // Prevent auto focus on content + > +
+ {filteredOptions.length > 0 && + filteredOptions.map((option) => ( +
handleAddTag(option.value)} + > + {option.label} +
+ ))} + {showInputAsOption && ( +
handleAddTag(inputValue)} + > + {t('common.add')} "{inputValue}" +
+ )} + {filteredOptions.length === 0 && !showInputAsOption && ( +
+ {t('common.noResults')} +
+ )} +
+
+
+ ); + }, +); + +InputSelect.displayName = 'InputSelect'; + +export { InputSelect }; diff --git a/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx index 5c4fcbfef..2aab764af 100644 --- a/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/paddleocr-modal/index.tsx @@ -1,20 +1,20 @@ -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { t } from 'i18next'; +import { RAGFlowFormItem } from '@/components/ragflow-form'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { RAGFlowFormItem } from '@/components/ragflow-form'; -import { RAGFlowSelect, RAGFlowSelectOptionType } from '@/components/ui/select'; -import { Input } from '@/components/ui/input'; import { Form } from '@/components/ui/form'; -import { LLMHeader } from '../../components/llm-header'; +import { Input } from '@/components/ui/input'; +import { RAGFlowSelect, RAGFlowSelectOptionType } from '@/components/ui/select'; import { LLMFactory } from '@/constants/llm'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { t } from 'i18next'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { LLMHeader } from '../../components/llm-header'; const FormSchema = z.object({ llm_name: z.string().min(1, { @@ -81,7 +81,9 @@ const PaddleOCRModal = ({ label={t('setting.modelName')} required > - + - +