From 24bab5fb2a7436a0c52dd2f5f774da3d1293e272 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 22 May 2026 17:01:31 +0800 Subject: [PATCH 1/3] refactor(web): improve retrieval and tag control semantics (#36521) --- .../block-selector/__tests__/main.spec.tsx | 25 + .../nodes/data-source-empty/index.tsx | 1 + .../__tests__/search-method-option.spec.tsx | 190 ++++--- .../top-k-and-score-threshold.spec.tsx | 52 +- .../components/retrieval-setting/index.tsx | 88 ++- .../search-method-option.tsx | 501 +++++++++++------- .../top-k-and-score-threshold.tsx | 172 +++--- .../workflow/nodes/trigger-webhook/panel.tsx | 6 +- .../__tests__/tag-filter.spec.tsx | 16 +- ...l.spec.tsx => tag-search-content.spec.tsx} | 8 +- .../tag-management/components/tag-filter.tsx | 4 +- .../{tag-panel.tsx => tag-search-content.tsx} | 77 ++- .../components/tag-selector.tsx | 4 +- 13 files changed, 707 insertions(+), 437 deletions(-) rename web/features/tag-management/__tests__/{tag-panel.spec.tsx => tag-search-content.spec.tsx} (97%) rename web/features/tag-management/components/{tag-panel.tsx => tag-search-content.tsx} (58%) diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index dedf64c56e..7c7f131f70 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -1,5 +1,6 @@ import type { ButtonHTMLAttributes } from 'react' import type { NodeDefault } from '../../types' +import { Button } from '@langgenius/dify-ui/button' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' @@ -191,4 +192,28 @@ describe('NodeSelector', () => { expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() }) + + it('can render the shared Button trigger as the popover root', async () => { + const user = userEvent.setup() + + renderWorkflowComponent( + ( + + )} + />, + ) + + const trigger = screen.getByRole('button', { name: 'open-shared-button-trigger' }) + await user.click(trigger) + + expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) + expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/nodes/data-source-empty/index.tsx b/web/app/components/workflow/nodes/data-source-empty/index.tsx index bd03621b87..dfb3a55788 100644 --- a/web/app/components/workflow/nodes/data-source-empty/index.tsx +++ b/web/app/components/workflow/nodes/data-source-empty/index.tsx @@ -56,6 +56,7 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => { vi.fn()) const mockUseProviderContext = vi.hoisted(() => vi.fn()) @@ -68,29 +71,62 @@ const createProps = () => ({ description: 'Semantic description', effectColor: 'purple', }, - hybridSearchModeOptions, searchMethod: RetrievalSearchMethodEnum.semantic, onRetrievalSearchMethodChange: vi.fn(), - hybridSearchMode: HybridSearchModeEnum.WeightedScore, - onHybridSearchModeChange: vi.fn(), - weightedScore, - onWeightedScoreChange: vi.fn(), - rerankingModelEnabled: false, - onRerankingModelEnabledChange: vi.fn(), - rerankingModel: { - reranking_provider_name: '', - reranking_model_name: '', + hybridSearch: { + mode: HybridSearchModeEnum.WeightedScore, + options: hybridSearchModeOptions, + onModeChange: vi.fn(), + weightedScore, + onWeightedScoreChange: vi.fn(), + }, + reranking: { + enabled: false, + onEnabledChange: vi.fn(), + rerankingModel: { + reranking_provider_name: '', + reranking_model_name: '', + }, + onRerankingModelChange: vi.fn(), + showMultiModalTip: false, + }, + retrievalParameters: { + topK: { + value: 3, + onChange: vi.fn(), + }, + scoreThreshold: { + value: 0.5, + onChange: vi.fn(), + enabled: true, + onEnabledChange: vi.fn(), + }, }, - onRerankingModelChange: vi.fn(), - topK: 3, - onTopKChange: vi.fn(), - scoreThreshold: 0.5, - onScoreThresholdChange: vi.fn(), - isScoreThresholdEnabled: true, - onScoreThresholdEnabledChange: vi.fn(), - showMultiModalTip: false, }) +function renderSearchMethodOption(props: ReturnType) { + const { + onRetrievalSearchMethodChange, + ...optionProps + } = props + + render( + + onRetrievalSearchMethodChange(value)} + /> + )} + > + Retrieval search method + + + , + ) +} + describe('SearchMethodOption', () => { beforeEach(() => { vi.clearAllMocks() @@ -116,37 +152,32 @@ describe('SearchMethodOption', () => { it('should render semantic search controls and notify retrieval and reranking changes', () => { const props = createProps() - render() + renderSearchMethodOption(props) expect(screen.getByText('Semantic title'))!.toBeInTheDocument() expect(screen.getByText('common.modelProvider.rerankModel.key'))!.toBeInTheDocument() expect(screen.getByText('plugin.detailPanel.configureModel'))!.toBeInTheDocument() expect(screen.getAllByRole('switch')).toHaveLength(2) - fireEvent.click(screen.getByText('Semantic title')) fireEvent.click(screen.getAllByRole('switch')[0]!) - expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.semantic) - expect(props.onRerankingModelEnabledChange).toHaveBeenCalledWith(true) + expect(props.reranking.onEnabledChange).toHaveBeenCalledWith(true) }) - it('should render the reranking switch for full-text search as well', () => { + it('should notify retrieval changes when an inactive option is selected', () => { const props = createProps() + const fullTextProps = { + ...props, + option: { + ...props.option, + id: RetrievalSearchMethodEnum.fullText, + title: 'Full-text title', + }, + } - render( - , - ) + renderSearchMethodOption(fullTextProps) expect(screen.getByText('Full-text title'))!.toBeInTheDocument() - expect(screen.getByText('common.modelProvider.rerankModel.key'))!.toBeInTheDocument() fireEvent.click(screen.getByText('Full-text title')) @@ -155,20 +186,25 @@ describe('SearchMethodOption', () => { it('should render hybrid weighted-score controls without reranking model selector', () => { const props = createProps() + const hybridProps = { + ...props, + option: { + ...props.option, + id: RetrievalSearchMethodEnum.hybrid, + title: 'Hybrid title', + }, + searchMethod: RetrievalSearchMethodEnum.hybrid, + hybridSearch: { + ...props.hybridSearch, + mode: HybridSearchModeEnum.WeightedScore, + }, + reranking: { + ...props.reranking, + showMultiModalTip: true, + }, + } - render( - , - ) + renderSearchMethodOption(hybridProps) expect(screen.getByText('Weighted mode'))!.toBeInTheDocument() expect(screen.getByText('Rerank mode'))!.toBeInTheDocument() @@ -179,25 +215,30 @@ describe('SearchMethodOption', () => { fireEvent.click(screen.getByText('Rerank mode')) - expect(props.onHybridSearchModeChange).toHaveBeenCalledWith(HybridSearchModeEnum.RerankingModel) + expect(props.hybridSearch.onModeChange).toHaveBeenCalledWith(HybridSearchModeEnum.RerankingModel) }) it('should render the hybrid reranking selector when reranking mode is selected', () => { const props = createProps() + const hybridProps = { + ...props, + option: { + ...props.option, + id: RetrievalSearchMethodEnum.hybrid, + title: 'Hybrid title', + }, + searchMethod: RetrievalSearchMethodEnum.hybrid, + hybridSearch: { + ...props.hybridSearch, + mode: HybridSearchModeEnum.RerankingModel, + }, + reranking: { + ...props.reranking, + showMultiModalTip: true, + }, + } - render( - , - ) + renderSearchMethodOption(hybridProps) expect(screen.getByText('plugin.detailPanel.configureModel'))!.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument() @@ -207,23 +248,22 @@ describe('SearchMethodOption', () => { it('should hide the score-threshold control for keyword search', () => { const props = createProps() + const keywordProps = { + ...props, + option: { + ...props.option, + id: RetrievalSearchMethodEnum.keywordSearch, + title: 'Keyword title', + }, + searchMethod: RetrievalSearchMethodEnum.keywordSearch, + } - render( - , - ) + renderSearchMethodOption(keywordProps) fireEvent.change(screen.getByRole('textbox'), { target: { value: '9' } }) expect(screen.getAllByRole('textbox')).toHaveLength(1) expect(screen.queryAllByRole('switch')).toHaveLength(0) - expect(props.onTopKChange).toHaveBeenCalledWith(9) + expect(props.retrievalParameters.topK.onChange).toHaveBeenCalledWith(9) }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx index 47755ec101..840b704577 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx @@ -1,40 +1,44 @@ import { fireEvent, render, screen } from '@testing-library/react' -import TopKAndScoreThreshold from '../top-k-and-score-threshold' +import { TopKAndScoreThreshold } from '../top-k-and-score-threshold' describe('TopKAndScoreThreshold', () => { + const topKLabel = /datasetConfig\.top_k/ + const scoreThresholdLabel = /datasetConfig\.score_threshold/ const defaultProps = { - topK: 3, - onTopKChange: vi.fn(), - scoreThreshold: 0.4, - onScoreThresholdChange: vi.fn(), - isScoreThresholdEnabled: true, - onScoreThresholdEnabledChange: vi.fn(), + topK: { + value: 3, + onChange: vi.fn(), + }, + scoreThreshold: { + value: 0.4, + onChange: vi.fn(), + enabled: true, + onEnabledChange: vi.fn(), + }, } beforeEach(() => { vi.clearAllMocks() }) - it('should round top-k input values before notifying the parent', () => { + it('should notify top-k input values without additional rounding', () => { render() - const [topKInput] = screen.getAllByRole('textbox') - fireEvent.change(topKInput!, { target: { value: '3.7' } }) + fireEvent.change(screen.getByRole('textbox', { name: topKLabel }), { target: { value: '3.7' } }) - expect(defaultProps.onTopKChange).toHaveBeenCalledWith(4) + expect(defaultProps.topK.onChange).toHaveBeenCalledWith(3.7) }) - it('should round score-threshold input values to two decimals', () => { + it('should notify score-threshold input values without additional rounding', () => { render() - const [, scoreThresholdInput] = screen.getAllByRole('textbox') - fireEvent.change(scoreThresholdInput!, { target: { value: '0.456' } }) + fireEvent.change(screen.getByRole('textbox', { name: scoreThresholdLabel }), { target: { value: '0.456' } }) - expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46) + expect(defaultProps.scoreThreshold.onChange).toHaveBeenCalledWith(0.456) }) it('should hide the score-threshold column when requested', () => { - render() + render() expect(screen.getAllByRole('textbox')).toHaveLength(1) expect(screen.queryByRole('switch')).not.toBeInTheDocument() @@ -44,15 +48,18 @@ describe('TopKAndScoreThreshold', () => { render( , ) const [topKInput, scoreThresholdInput] = screen.getAllByRole('textbox') fireEvent.change(topKInput!, { target: { value: '' } }) - expect(defaultProps.onTopKChange).toHaveBeenCalledWith(0) + expect(defaultProps.topK.onChange).toHaveBeenCalledWith(0) expect(scoreThresholdInput)!.toHaveValue('') }) @@ -60,10 +67,13 @@ describe('TopKAndScoreThreshold', () => { render( , ) - expect(screen.getByRole('switch'))!.toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('switch', { name: scoreThresholdLabel }))!.toHaveAttribute('aria-checked', 'false') }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx index e316f941ea..91a24c4e8b 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx @@ -5,7 +5,13 @@ import type { WeightedScore, } from '../../types' import type { RerankingModelSelectorProps } from './reranking-model-selector' -import type { TopKAndScoreThresholdProps } from './top-k-and-score-threshold' +import type { + TopKFieldProps, + VisibleScoreThresholdFieldProps, +} from './top-k-and-score-threshold' +import { FieldRoot } from '@langgenius/dify-ui/field' +import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { memo, } from 'react' @@ -13,7 +19,7 @@ import { useTranslation } from 'react-i18next' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' import { useDocLink } from '@/context/i18n' import { useRetrievalSetting } from './hooks' -import SearchMethodOption from './search-method-option' +import { SearchMethodOption } from './search-method-option' type RetrievalSettingProps = { indexMethod?: IndexMethodEnum @@ -23,11 +29,18 @@ type RetrievalSettingProps = { hybridSearchMode?: HybridSearchModeEnum onHybridSearchModeChange: (value: HybridSearchModeEnum) => void rerankingModelEnabled?: boolean - onRerankingModelEnabledChange?: (value: boolean) => void + onRerankingModelEnabledChange: (value: boolean) => void weightedScore?: WeightedScore onWeightedScoreChange: (value: { value: number[] }) => void showMultiModalTip?: boolean -} & RerankingModelSelectorProps & TopKAndScoreThresholdProps +} & RerankingModelSelectorProps & { + topK: TopKFieldProps['value'] + onTopKChange: TopKFieldProps['onChange'] + scoreThreshold: VisibleScoreThresholdFieldProps['value'] + onScoreThresholdChange: VisibleScoreThresholdFieldProps['onChange'] + isScoreThresholdEnabled?: VisibleScoreThresholdFieldProps['enabled'] + onScoreThresholdEnabledChange: VisibleScoreThresholdFieldProps['onEnabledChange'] +} const RetrievalSetting = ({ indexMethod, @@ -70,35 +83,56 @@ const RetrievalSetting = ({ ), }} > -
- { - options.map(option => ( + + + value={searchMethod} + onValueChange={value => onRetrievalSearchMethodChange(value)} + disabled={readonly} + className="flex-col items-stretch gap-1" + /> + )} + > + + {t('form.retrievalSetting.title', { ns: 'datasetSettings' })} + + {options.map(option => ( - )) - } -
+ ))} + + ) } diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx index be31b49f95..7f80b26eae 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx @@ -1,221 +1,358 @@ +import type { ReactNode } from 'react' import type { WeightedScore, } from '../../types' import type { RerankingModelSelectorProps } from './reranking-model-selector' -import type { TopKAndScoreThresholdProps } from './top-k-and-score-threshold' +import type { + TopKFieldProps, + VisibleScoreThresholdFieldProps, +} from './top-k-and-score-threshold' import type { HybridSearchModeOption, Option, } from './type' import { cn } from '@langgenius/dify-ui/cn' +import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' +import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset' +import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { Switch } from '@langgenius/dify-ui/switch' -import { - memo, - useCallback, - useMemo, -} from 'react' import { useTranslation } from 'react-i18next' import WeightedScoreComponent from '@/app/components/app/configuration/dataset-config/params-config/weighted-score' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' +import Badge from '@/app/components/base/badge' +import { + OptionCardEffectBlue, + OptionCardEffectBlueLight, + OptionCardEffectOrange, + OptionCardEffectPurple, + OptionCardEffectTeal, +} from '@/app/components/base/icons/src/public/knowledge' import { Infotip } from '@/app/components/base/infotip' import { DEFAULT_WEIGHTED_SCORE } from '@/models/datasets' import { HybridSearchModeEnum, RetrievalSearchMethodEnum, } from '../../types' -import OptionCard from '../option-card' import RerankingModelSelector from './reranking-model-selector' -import TopKAndScoreThreshold from './top-k-and-score-threshold' +import { TopKAndScoreThreshold } from './top-k-and-score-threshold' -type SearchMethodOptionProps = { - readonly?: boolean - option: Option - hybridSearchModeOptions: HybridSearchModeOption[] - searchMethod?: RetrievalSearchMethodEnum - onRetrievalSearchMethodChange: (value: RetrievalSearchMethodEnum) => void - hybridSearchMode?: HybridSearchModeEnum - onHybridSearchModeChange: (value: HybridSearchModeEnum) => void +type HybridSearchConfig = { + mode?: HybridSearchModeEnum + options: HybridSearchModeOption[] + onModeChange: (value: HybridSearchModeEnum) => void weightedScore?: WeightedScore onWeightedScoreChange: (value: { value: number[] }) => void - rerankingModelEnabled?: boolean - onRerankingModelEnabledChange?: (value: boolean) => void +} + +type RerankingConfig = RerankingModelSelectorProps & { + enabled?: boolean + onEnabledChange: (value: boolean) => void showMultiModalTip?: boolean -} & RerankingModelSelectorProps & TopKAndScoreThresholdProps -const SearchMethodOption = ({ - readonly, - option, - hybridSearchModeOptions, - searchMethod, - onRetrievalSearchMethodChange, - hybridSearchMode, - onHybridSearchModeChange, - weightedScore, - onWeightedScoreChange, - rerankingModelEnabled, - onRerankingModelEnabledChange, - rerankingModel, - onRerankingModelChange, - topK, - onTopKChange, - scoreThreshold, - onScoreThresholdChange, - isScoreThresholdEnabled, - onScoreThresholdEnabledChange, - showMultiModalTip = false, -}: SearchMethodOptionProps) => { - const { t } = useTranslation() - const Icon = option.icon - const isHybridSearch = option.id === RetrievalSearchMethodEnum.hybrid - const isHybridSearchWeightedScoreMode = hybridSearchMode === HybridSearchModeEnum.WeightedScore +} - const weightedScoreValue = useMemo(() => { - const sematicWeightedScore = weightedScore?.vector_setting.vector_weight ?? DEFAULT_WEIGHTED_SCORE.other.semantic - const keywordWeightedScore = weightedScore?.keyword_setting.keyword_weight ?? DEFAULT_WEIGHTED_SCORE.other.keyword - const mergedValue = [sematicWeightedScore, keywordWeightedScore] +type RetrievalParametersConfig = { + topK: TopKFieldProps + scoreThreshold: VisibleScoreThresholdFieldProps +} - return { - value: mergedValue, - } - }, [weightedScore]) +type SearchMethodRadioCardProps = { + option: Option + searchMethod?: RetrievalSearchMethodEnum + readonly?: boolean + isRecommended?: boolean + children?: ReactNode +} - const icon = useCallback((isActive: boolean) => { - return ( - - ) - }, [Icon]) +export type SearchMethodOptionProps = { + readonly?: boolean + option: Option + searchMethod?: RetrievalSearchMethodEnum + hybridSearch: HybridSearchConfig + reranking: RerankingConfig + retrievalParameters: RetrievalParametersConfig +} - const hybridSearchModeWrapperClassName = useCallback((isActive: boolean) => { - return isActive ? 'border-[1.5px] bg-components-option-card-option-selected-bg' : '' - }, []) +const HEADER_EFFECT_MAP: Record = { + 'blue': , + 'blue-light': , + 'orange': , + 'purple': , + 'teal': , +} - const showRerankModelSelectorSwitch = useMemo(() => { - if (searchMethod === RetrievalSearchMethodEnum.semantic) - return true +function getWeightedScoreValue(weightedScore?: WeightedScore) { + const semanticWeightedScore = weightedScore?.vector_setting.vector_weight ?? DEFAULT_WEIGHTED_SCORE.other.semantic + const keywordWeightedScore = weightedScore?.keyword_setting.keyword_weight ?? DEFAULT_WEIGHTED_SCORE.other.keyword - if (searchMethod === RetrievalSearchMethodEnum.fullText) - return true + return { + value: [semanticWeightedScore, keywordWeightedScore], + } +} - return false - }, [searchMethod]) - const showRerankModelSelector = useMemo(() => { - if (searchMethod === RetrievalSearchMethodEnum.semantic) - return true +function shouldShowRerankModelSelectorSwitch(searchMethod?: RetrievalSearchMethodEnum) { + return searchMethod === RetrievalSearchMethodEnum.semantic || searchMethod === RetrievalSearchMethodEnum.fullText +} - if (searchMethod === RetrievalSearchMethodEnum.fullText) - return true +function shouldShowRerankModelSelector(searchMethod: RetrievalSearchMethodEnum | undefined, hybridSearchMode: HybridSearchModeEnum | undefined) { + if (shouldShowRerankModelSelectorSwitch(searchMethod)) + return true - if (searchMethod === RetrievalSearchMethodEnum.hybrid && hybridSearchMode !== HybridSearchModeEnum.WeightedScore) - return true + return searchMethod === RetrievalSearchMethodEnum.hybrid && hybridSearchMode !== HybridSearchModeEnum.WeightedScore +} - return false - }, [hybridSearchMode, searchMethod]) +function getSearchMethodEffect(effectColor: string | undefined, isActive: boolean) { + const effect = effectColor ? HEADER_EFFECT_MAP[effectColor] : undefined + + if (!effect) + return null return ( -