From 49e0e1b939b283b85e4fa525a9da5f07de37b2c1 Mon Sep 17 00:00:00 2001 From: KVOJJJin Date: Tue, 17 Mar 2026 10:35:07 +0800 Subject: [PATCH] fix(web): page crash in knowledge retrieval node caused by dataset selection and score threshold (#33553) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../select-dataset/index.spec.tsx | 27 ++++++++ .../dataset-config/select-dataset/index.tsx | 68 +++++-------------- .../__tests__/score-threshold-item.spec.tsx | 6 ++ .../base/param-item/score-threshold-item.tsx | 26 ++++--- web/eslint-suppressions.json | 6 -- 5 files changed, 67 insertions(+), 66 deletions(-) diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx index 40cb3ffc81..bd6c1976a6 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx @@ -137,4 +137,31 @@ describe('SelectDataSet', () => { expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create') expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() }) + + it('uses selectedIds as the initial modal selection', async () => { + const datasetOne = makeDataset({ + id: 'set-1', + name: 'Dataset One', + }) + mockUseInfiniteDatasets.mockReturnValue({ + data: { pages: [{ data: [datasetOne] }] }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + }) + + const onSelect = vi.fn() + await act(async () => { + render() + }) + + expect(screen.getByText('1 appDebug.feature.dataSet.selected')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + }) + + expect(onSelect).toHaveBeenCalledWith([datasetOne]) + }) }) diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 330223f974..91e5353cc4 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -4,7 +4,7 @@ import type { DataSet } from '@/models/datasets' import { useInfiniteScroll } from 'ahooks' import Link from 'next/link' import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' @@ -31,17 +31,21 @@ const SelectDataSet: FC = ({ onSelect, }) => { const { t } = useTranslation() - const [selected, setSelected] = useState([]) + const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds) const canSelectMulti = true const { formatIndexingTechniqueAndMethod } = useKnowledge() const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets( { page: 1 }, { enabled: isShow, staleTime: 0, refetchOnMount: 'always' }, ) - const pages = data?.pages || [] const datasets = useMemo(() => { + const pages = data?.pages || [] return pages.flatMap(page => page.data.filter(item => item.indexing_technique || item.provider === 'external')) - }, [pages]) + }, [data]) + const datasetMap = useMemo(() => new Map(datasets.map(item => [item.id, item])), [datasets]) + const selected = useMemo(() => { + return selectedIdsInModal.map(id => datasetMap.get(id) || ({ id } as DataSet)) + }, [datasetMap, selectedIdsInModal]) const hasNoData = !isLoading && datasets.length === 0 const listRef = useRef(null) @@ -61,50 +65,14 @@ const SelectDataSet: FC = ({ }, ) - const prevSelectedIdsRef = useRef([]) - const hasUserModifiedSelectionRef = useRef(false) - useEffect(() => { - if (isShow) - hasUserModifiedSelectionRef.current = false - }, [isShow]) - useEffect(() => { - const prevSelectedIds = prevSelectedIdsRef.current - const idsChanged = selectedIds.length !== prevSelectedIds.length - || selectedIds.some((id, idx) => id !== prevSelectedIds[idx]) - - if (!selectedIds.length && (!hasUserModifiedSelectionRef.current || idsChanged)) { - setSelected([]) - prevSelectedIdsRef.current = selectedIds - hasUserModifiedSelectionRef.current = false - return - } - - if (!idsChanged && hasUserModifiedSelectionRef.current) - return - - setSelected((prev) => { - const prevMap = new Map(prev.map(item => [item.id, item])) - const nextSelected = selectedIds - .map(id => datasets.find(item => item.id === id) || prevMap.get(id)) - .filter(Boolean) as DataSet[] - return nextSelected - }) - prevSelectedIdsRef.current = selectedIds - hasUserModifiedSelectionRef.current = false - }, [datasets, selectedIds]) - const toggleSelect = (dataSet: DataSet) => { - hasUserModifiedSelectionRef.current = true - const isSelected = selected.some(item => item.id === dataSet.id) - if (isSelected) { - setSelected(selected.filter(item => item.id !== dataSet.id)) - } - else { - if (canSelectMulti) - setSelected([...selected, dataSet]) - else - setSelected([dataSet]) - } + setSelectedIdsInModal((prev) => { + const isSelected = prev.includes(dataSet.id) + if (isSelected) + return prev.filter(id => id !== dataSet.id) + + return canSelectMulti ? [...prev, dataSet.id] : [dataSet.id] + }) } const handleSelect = () => { @@ -126,7 +94,7 @@ const SelectDataSet: FC = ({ {hasNoData && (
= ({ key={item.id} className={cn( 'flex h-10 cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', - selected.some(i => i.id === item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs', + selectedIdsInModal.includes(item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs', !item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs', )} onClick={() => { @@ -195,7 +163,7 @@ const SelectDataSet: FC = ({ )} {!isLoading && (
-
+
{selected.length > 0 && `${selected.length} ${t('feature.dataSet.selected', { ns: 'appDebug' })}`}
diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index 026908fa9e..54a13e1b74 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -137,5 +137,11 @@ describe('ScoreThresholdItem', () => { const input = screen.getByRole('textbox') expect(input).toHaveValue('1') }) + + it('should fall back to default value when value is undefined', () => { + render() + const input = screen.getByRole('textbox') + expect(input).toHaveValue('0.7') + }) }) }) diff --git a/web/app/components/base/param-item/score-threshold-item.tsx b/web/app/components/base/param-item/score-threshold-item.tsx index c6c73713d7..cbaf190b99 100644 --- a/web/app/components/base/param-item/score-threshold-item.tsx +++ b/web/app/components/base/param-item/score-threshold-item.tsx @@ -6,7 +6,7 @@ import ParamItem from '.' type Props = { className?: string - value: number + value?: number onChange: (key: string, value: number) => void enable: boolean hasSwitch?: boolean @@ -20,6 +20,18 @@ const VALUE_LIMIT = { max: 1, } +const normalizeScoreThreshold = (value?: number): number => { + const normalizedValue = typeof value === 'number' && Number.isFinite(value) + ? value + : VALUE_LIMIT.default + const roundedValue = Number.parseFloat(normalizedValue.toFixed(2)) + + return Math.min( + VALUE_LIMIT.max, + Math.max(VALUE_LIMIT.min, roundedValue), + ) +} + const ScoreThresholdItem: FC = ({ className, value, @@ -29,16 +41,10 @@ const ScoreThresholdItem: FC = ({ onSwitchChange, }) => { const { t } = useTranslation() - const handleParamChange = (key: string, value: number) => { - let notOutRangeValue = Number.parseFloat(value.toFixed(2)) - notOutRangeValue = Math.max(VALUE_LIMIT.min, notOutRangeValue) - notOutRangeValue = Math.min(VALUE_LIMIT.max, notOutRangeValue) - onChange(key, notOutRangeValue) + const handleParamChange = (key: string, nextValue: number) => { + onChange(key, normalizeScoreThreshold(nextValue)) } - const safeValue = Math.min( - VALUE_LIMIT.max, - Math.max(VALUE_LIMIT.min, Number.parseFloat(value.toFixed(2))), - ) + const safeValue = normalizeScoreThreshold(value) return (