diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx
index e94a44ebb2..3b57390cce 100644
--- a/web/app/components/datasets/create/step-one/index.tsx
+++ b/web/app/components/datasets/create/step-one/index.tsx
@@ -15,6 +15,7 @@ import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
+import { useCurrentPlanVectorSpace } from '@/service/use-billing'
import EmptyDatasetCreationModal from '../empty-dataset-creation-modal'
import FileUploader from '../file-uploader'
import Website from '../website'
@@ -119,7 +120,15 @@ const StepOne = ({
const allFileLoaded = files.length > 0 && files.every(file => file.file.id)
const hasNotion = notionPages.length > 0
- const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
+ const shouldCheckVectorSpace = enableBilling && (allFileLoaded || hasNotion)
+ const {
+ data: vectorSpace,
+ isFetching: isFetchingVectorSpacePlan,
+ } = useCurrentPlanVectorSpace(shouldCheckVectorSpace)
+ const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan
+ const isVectorSpaceFull = !!vectorSpace
+ && vectorSpace.limit > 0
+ && vectorSpace.size >= vectorSpace.limit
const isShowVectorSpaceFull = (allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling
const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox
@@ -131,8 +140,10 @@ const StepOne = ({
return true
if (files.some(file => !file.file.id))
return true
+ if (isCheckingVectorSpace)
+ return true
return isShowVectorSpaceFull
- }, [files, isShowVectorSpaceFull])
+ }, [files, isCheckingVectorSpace, isShowVectorSpaceFull])
// Clear previews when switching data source type
const handleClearPreviews = useCallback((newType: DataSourceType) => {
diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx
index 1029714661..ae1eb817b4 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx
@@ -42,6 +42,16 @@ vi.mock('@/context/provider-context', () => ({
selector({ plan: mockPlan, enableBilling: true }),
}))
+vi.mock('@/service/use-billing', () => ({
+ useCurrentPlanVectorSpace: () => ({
+ data: {
+ size: mockPlan.usage.vectorSpace,
+ limit: mockPlan.total.vectorSpace,
+ },
+ isFetching: false,
+ }),
+}))
+
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => unknown) =>
selector({ dataset: { pipeline_id: 'test-pipeline-id' } }),
diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts
index f4c222f652..5cfd557b3d 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts
+++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts
@@ -13,6 +13,7 @@ type DatasourceUIStateParams = {
selectedFileIdsLength: number
onlineDriveFileList: OnlineDriveFile[]
isVectorSpaceFull: boolean
+ isCheckingVectorSpace?: boolean
enableBilling: boolean
currentWorkspacePagesLength: number
fileUploadConfig: { file_size_limit: number, batch_count_limit: number }
@@ -30,6 +31,7 @@ export const useDatasourceUIState = ({
selectedFileIdsLength,
onlineDriveFileList,
isVectorSpaceFull,
+ isCheckingVectorSpace = false,
enableBilling,
currentWorkspacePagesLength,
fileUploadConfig,
@@ -59,14 +61,14 @@ export const useDatasourceUIState = ({
return true
const disabledConditions: Record
= {
- [DatasourceType.localFile]: isShowVectorSpaceFull || localFileListLength === 0 || !allFileLoaded,
- [DatasourceType.onlineDocument]: isShowVectorSpaceFull || onlineDocumentsLength === 0,
- [DatasourceType.websiteCrawl]: isShowVectorSpaceFull || websitePagesLength === 0,
- [DatasourceType.onlineDrive]: isShowVectorSpaceFull || selectedFileIdsLength === 0,
+ [DatasourceType.localFile]: isCheckingVectorSpace || isShowVectorSpaceFull || localFileListLength === 0 || !allFileLoaded,
+ [DatasourceType.onlineDocument]: isCheckingVectorSpace || isShowVectorSpaceFull || onlineDocumentsLength === 0,
+ [DatasourceType.websiteCrawl]: isCheckingVectorSpace || isShowVectorSpaceFull || websitePagesLength === 0,
+ [DatasourceType.onlineDrive]: isCheckingVectorSpace || isShowVectorSpaceFull || selectedFileIdsLength === 0,
}
return disabledConditions[datasourceType] ?? true
- }, [datasource, datasourceType, isShowVectorSpaceFull, localFileListLength, allFileLoaded, onlineDocumentsLength, websitePagesLength, selectedFileIdsLength])
+ }, [datasource, datasourceType, isCheckingVectorSpace, isShowVectorSpaceFull, localFileListLength, allFileLoaded, onlineDocumentsLength, websitePagesLength, selectedFileIdsLength])
// Check if select all should be shown
const showSelect = useMemo(() => {
diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx
index 799f24fa2a..07843217ef 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx
@@ -12,6 +12,7 @@ import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContextSelector } from '@/context/provider-context'
import { DatasourceType } from '@/models/pipeline'
+import { useCurrentPlanVectorSpace } from '@/service/use-billing'
import { useFileUploadConfig } from '@/service/use-common'
import { usePublishedPipelineInfo } from '@/service/use-pipeline'
import { useDataSourceStore } from './data-source/store'
@@ -91,7 +92,20 @@ const CreateFormPipeline = () => {
} = useOnlineDrive()
// Computed values
- const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
+ const shouldCheckVectorSpace = enableBilling && (
+ allFileLoaded
+ || onlineDocuments.length > 0
+ || websitePages.length > 0
+ || selectedFileIds.length > 0
+ )
+ const {
+ data: vectorSpace,
+ isFetching: isFetchingVectorSpacePlan,
+ } = useCurrentPlanVectorSpace(shouldCheckVectorSpace)
+ const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan
+ const isVectorSpaceFull = !!vectorSpace
+ && vectorSpace.limit > 0
+ && vectorSpace.size >= vectorSpace.limit
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
// UI state
@@ -112,6 +126,7 @@ const CreateFormPipeline = () => {
selectedFileIdsLength: selectedFileIds.length,
onlineDriveFileList,
isVectorSpaceFull,
+ isCheckingVectorSpace,
enableBilling,
currentWorkspacePagesLength: currentWorkspace?.pages.length ?? 0,
fileUploadConfig,
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 (
-
-
- {
- isHybridSearch && (
-
- {
- hybridSearchModeOptions.map(hybridOption => (
-
- ))
- }
-
- )
- }
- {
- isHybridSearch && isHybridSearchWeightedScoreMode && (
-
- )
- }
- {
- showRerankModelSelector && (
-
- {
- showRerankModelSelectorSwitch && (
-
-
- {t('modelProvider.rerankModel.key', { ns: 'common' })}
-
- {t('modelProvider.rerankModel.tip', { ns: 'common' })}
-
-
- )
- }
-
- {showMultiModalTip && (
-
-
-
-
- {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
-
-
- )}
-
- )
- }
-
-
-
+ {effect}
+
)
}
-export default memo(SearchMethodOption)
+function renderSearchMethodIcon(Icon: Option['icon'], isActive: boolean) {
+ return (
+
+
}
+ disabled={readonly}
+ className={cn(
+ 'relative flex w-full rounded-t-xl p-2 text-left outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-active',
+ readonly ? 'cursor-not-allowed' : 'cursor-pointer',
+ )}
+ >
+ {getSearchMethodEffect(option.effectColor, isActive)}
+
+ {renderSearchMethodIcon(Icon, isActive)}
+
+
+
+
+ {option.title}
+ {isRecommended
+ ? (
+
+ {t('stepTwo.recommend', { ns: 'datasetCreation' })}
+
+ )
+ : null}
+
+
+ {option.description
+ ? (
+
+ {option.description}
+
+ )
+ : null}
+
+
+ {!!(children && isActive) && (
+
+ )}
+
+ )
+}
+
+function HybridSearchModeRadioCard({
+ option,
+ readonly,
+}: {
+ option: HybridSearchModeOption
+ readonly?: boolean
+}) {
+ return (
+
+ {isHybridSearch
+ ? (
+
+
+ value={hybridSearch.mode}
+ onValueChange={value => hybridSearch.onModeChange(value)}
+ disabled={readonly}
+ className="flex-col items-stretch gap-1"
+ />
+ )}
+ >
+ Hybrid search mode
+ {hybridSearch.options.map(hybridOption => (
+
+ ))}
+
+
+ )
+ : null}
+ {isHybridSearch && isHybridSearchWeightedScoreMode
+ ? (
+
+ )
+ : null}
+ {showRerankModelSelector
+ ? (
+
+ {showRerankModelSelectorSwitch
+ ? (
+
+
+
+
+ {rerankModelLabel}
+
+
+ {rerankModelTip}
+
+
+
+ )
+ : null}
+
+ {reranking.showMultiModalTip
+ ? (
+
+
+
+
+ {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
+
+
+ )
+ : null}
+
+ )
+ : null}
+
+
+ )
+
+ return (
+
-
-
- {topKLabel}
+
+
+
+ {topKLabel}
+
- {t('datasetConfig.top_kTip', { ns: 'appDebug' })}
+ {topKTip}
handleTopKChange(value ?? 0)}
+ value={topK.value}
+ onValueChange={value => topK.onChange(value ?? 0)}
>
-
+
-
- {
- !hiddenScoreThreshold && (
-
-
-
-
- {scoreThresholdLabel}
-
-
- {t('datasetConfig.score_thresholdTip', { ns: 'appDebug' })}
-
-
-
handleScoreThresholdChange(value ?? 0)}
- >
-
-
-
-
-
-
-
-
-
- )
- }
+
+ {scoreThresholdHidden
+ ? null
+ : (
+
+ {scoreThresholdLabel}
+
+
+
+
+
+ {scoreThresholdLabel}
+
+
+
+ {scoreThresholdTip}
+
+
+
+
+ {scoreThresholdLabel}
+ scoreThreshold.onChange(value ?? 0)}
+ >
+
+
+
+
+
+
+
+
+
+
+ )}
)
}
-
-export default memo(TopKAndScoreThreshold)
diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx
index 0a71b25cb2..e5236fad1c 100644
--- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx
+++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx
@@ -24,7 +24,7 @@ import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
import HeaderTable from './components/header-table'
import ParagraphInput from './components/paragraph-input'
import ParameterTable from './components/parameter-table'
-import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from './use-config'
+import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, useConfig } from './use-config'
import { OutputVariablesContent } from './utils/render-output-vars'
const i18nPrefix = 'nodes.triggerWebhook'
@@ -262,8 +262,8 @@ const Panel: FC
> = ({
disabled={readOnly}
onValueChange={value => value !== null && handleStatusCodeChange(value)}
onValueCommitted={(value, eventDetails) => {
- if (eventDetails.reason === 'input-blur' || eventDetails.reason === 'input-clear')
- handleStatusCodeChange(normalizeStatusCode(value ?? DEFAULT_STATUS_CODE))
+ if (eventDetails.reason === 'input-clear')
+ handleStatusCodeChange(value ?? DEFAULT_STATUS_CODE)
}}
>
diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx
index adf109079a..5ae051505e 100644
--- a/web/features/tag-management/__tests__/tag-filter.spec.tsx
+++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx
@@ -29,7 +29,7 @@ const i18n = {
placeholder: 'common.tag.placeholder',
selectorPlaceholder: 'common.tag.selectorPlaceholder',
operationClear: 'common.operation.clear',
- noTag: 'common.tag.noTag',
+ noTag: /common\.tag\.noTag/,
manageTags: 'common.tag.manageTags',
}
@@ -230,6 +230,20 @@ describe('TagFilter', () => {
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
+ it('should keep search input focused when search has no results', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ await user.click(screen.getByText(i18n.placeholder))
+
+ const searchInput = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
+ await user.type(searchInput, 'NonExistentTag')
+
+ expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
+ expect(searchInput).toHaveFocus()
+ })
+
it('should clear search and show all tags when clear icon is clicked', async () => {
const user = userEvent.setup()
diff --git a/web/features/tag-management/__tests__/tag-panel.spec.tsx b/web/features/tag-management/__tests__/tag-search-content.spec.tsx
similarity index 97%
rename from web/features/tag-management/__tests__/tag-panel.spec.tsx
rename to web/features/tag-management/__tests__/tag-search-content.spec.tsx
index 78c129b89d..30b3cef8ad 100644
--- a/web/features/tag-management/__tests__/tag-panel.spec.tsx
+++ b/web/features/tag-management/__tests__/tag-search-content.spec.tsx
@@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMemo, useState } from 'react'
import { isCreateTagOption } from '../components/tag-combobox-item'
-import { TagPanel } from '../components/tag-panel'
+import { TagSearchContent } from '../components/tag-search-content'
const { onValueChangeSpy } = vi.hoisted(() => ({
onValueChangeSpy: vi.fn(),
@@ -15,7 +15,7 @@ const i18n = {
selectorPlaceholder: 'common.tag.selectorPlaceholder',
operationClear: 'common.operation.clear',
create: 'common.tag.create',
- noTag: 'common.tag.noTag',
+ noTag: /common\.tag\.noTag/,
manageTags: 'common.tag.manageTags',
}
@@ -78,7 +78,7 @@ const PanelHarness = ({
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
- {
+describe('TagSearchContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx
index 4402d118c6..188a052f07 100644
--- a/web/features/tag-management/components/tag-filter.tsx
+++ b/web/features/tag-management/components/tag-filter.tsx
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
import Tag01Icon from '@/app/components/base/icons/src/vender/line/financeAndECommerce/Tag01'
import XCircleIcon from '@/app/components/base/icons/src/vender/solid/general/XCircle'
import { consoleQuery } from '@/service/client'
-import { TagPanel } from './tag-panel'
+import { TagSearchContent } from './tag-search-content'
const tagFilterComboboxFilter: NonNullable['filter']> = (tag, query) => tag.name.includes(query)
const tagToString = (tag: Tag) => tag.name
@@ -114,7 +114,7 @@ export const TagFilter = ({
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
- void
@@ -13,17 +13,16 @@ type TagPanelProps = {
onClose?: () => void
}
-export const TagPanel = ({
+export const TagSearchContent = ({
type,
inputValue,
onInputValueChange,
onOpenTagManagement,
onClose,
-}: TagPanelProps) => {
+}: TagSearchContentProps) => {
const { t } = useTranslation()
const filteredItems = useComboboxFilteredItems()
const realItemCount = filteredItems.filter(tag => !isCreateTagOption(tag)).length
- const hasCreateOption = filteredItems.some(isCreateTagOption)
const placeholder = t('tag.selectorPlaceholder', { ns: 'common' }) || ''
return (
@@ -50,45 +49,41 @@ export const TagPanel = ({
)}
- {filteredItems.length > 0 && (
-
- {(tag: TagComboboxItem) => {
- if (isCreateTagOption(tag)) {
- return (
-
-
-
-
-
- {`${t('tag.create', { ns: 'common' })} `}
- {`'${tag.name}'`}
-
-
-
- {realItemCount > 0 && }
-
- )
- }
-
+
+ {(tag: TagComboboxItem) => {
+ if (isCreateTagOption(tag)) {
return (
-
- {tag.name}
-
-
+
+
+
+
+
+ {`${t('tag.create', { ns: 'common' })} `}
+ {`'${tag.name}'`}
+
+
+
+ {realItemCount > 0 && }
+
)
- }}
-
- )}
- {!hasCreateOption && realItemCount === 0 && (
-
-
-
-
{t('tag.noTag', { ns: 'common' })}
-
+ }
+
+ return (
+
+ {tag.name}
+
+
+ )
+ }}
+
+
+
+
+
{t('tag.noTag', { ns: 'common' })}
- )}
+