diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx new file mode 100644 index 0000000000..08db7186ec --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx @@ -0,0 +1,473 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SettingsModal from './index' +import { ToastContext } from '@/app/components/base/toast' +import type { DataSet } from '@/models/datasets' +import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { updateDatasetSetting } from '@/service/datasets' +import { fetchMembers } from '@/service/common' +import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app' + +const mockNotify = jest.fn() +const mockOnCancel = jest.fn() +const mockOnSave = jest.fn() +const mockSetShowAccountSettingModal = jest.fn() +let mockIsWorkspaceDatasetOperator = false + +const mockUseModelList = jest.fn() +const mockUseModelListAndDefaultModel = jest.fn() +const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn() +const mockUseCurrentProviderAndModel = jest.fn() +const mockCheckShowMultiModalTip = jest.fn() + +jest.mock('ky', () => { + const ky = () => ky + ky.extend = () => ky + ky.create = () => ky + return { __esModule: true, default: ky } +}) + +jest.mock('@/app/components/datasets/create/step-two', () => ({ + __esModule: true, + IndexingType: { + QUALIFIED: 'high_quality', + ECONOMICAL: 'economy', + }, +})) + +jest.mock('@/service/datasets', () => ({ + updateDatasetSetting: jest.fn(), +})) + +jest.mock('@/service/common', () => ({ + fetchMembers: jest.fn(), +})) + +jest.mock('@/context/app-context', () => ({ + useAppContext: () => ({ isCurrentWorkspaceDatasetOperator: mockIsWorkspaceDatasetOperator }), + useSelector: (selector: (value: { userProfile: { id: string; name: string; email: string; avatar_url: string } }) => T) => selector({ + userProfile: { + id: 'user-1', + name: 'User One', + email: 'user@example.com', + avatar_url: 'avatar.png', + }, + }), +})) + +jest.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +jest.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs${path}`, +})) + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders: [], + textGenerationModelList: [], + supportRetrievalMethods: [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + RETRIEVE_METHOD.keywordSearch, + ], + }), +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + __esModule: true, + useModelList: (...args: unknown[]) => mockUseModelList(...args), + useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args), + useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) => + mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args), + useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args), +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + __esModule: true, + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( +
+ {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} +
+ ), +})) + +jest.mock('@/app/components/datasets/settings/utils', () => ({ + checkShowMultiModalTip: (...args: unknown[]) => mockCheckShowMultiModalTip(...args), +})) + +const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction +const mockFetchMembers = fetchMembers as jest.MockedFunction + +const createRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 2, + score_threshold_enabled: false, + score_threshold: 0.5, + reranking_mode: RerankingModeEnum.RerankingModel, + ...overrides, +}) + +const createDataset = (overrides: Partial = {}, retrievalOverrides: Partial = {}): DataSet => { + const retrievalConfig = createRetrievalConfig(retrievalOverrides) + return { + id: 'dataset-id', + name: 'Test Dataset', + indexing_status: 'completed', + icon_info: { + icon: 'icon', + icon_type: 'emoji', + }, + description: 'Description', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + author_name: 'Author', + created_by: 'creator', + updated_by: 'updater', + updated_at: 1700000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 0, + total_document_count: 0, + total_available_documents: 0, + word_count: 0, + provider: 'internal', + embedding_model: 'embed-model', + embedding_model_provider: 'embed-provider', + embedding_available: true, + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: 'ext-id', + external_knowledge_api_id: 'ext-api-id', + external_knowledge_api_name: 'External API', + external_knowledge_api_endpoint: 'https://api.example.com', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + doc_metadata: [], + keyword_number: 10, + pipeline_id: 'pipeline-id', + is_published: false, + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...overrides, + retrieval_model_dict: { + ...retrievalConfig, + ...overrides.retrieval_model_dict, + }, + retrieval_model: { + ...retrievalConfig, + ...overrides.retrieval_model, + }, + } +} + +const renderWithProviders = (dataset: DataSet) => { + return render( + + + , + ) +} + +describe('SettingsModal', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIsWorkspaceDatasetOperator = false + mockUseModelList.mockImplementation((type: ModelTypeEnum) => { + if (type === ModelTypeEnum.rerank) { + return { + data: [ + { + provider: 'rerank-provider', + models: [{ model: 'rerank-model' }], + }, + ], + } + } + return { data: [{ provider: 'embed-provider', models: [{ model: 'embed-model' }] }] } + }) + mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null }) + mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null }) + mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null }) + mockCheckShowMultiModalTip.mockReturnValue(false) + mockFetchMembers.mockResolvedValue({ + accounts: [ + { + id: 'user-1', + name: 'User One', + email: 'user@example.com', + avatar: 'avatar.png', + avatar_url: 'avatar.png', + status: 'active', + role: 'owner', + }, + { + id: 'member-2', + name: 'Member Two', + email: 'member@example.com', + avatar: 'avatar.png', + avatar_url: 'avatar.png', + status: 'active', + role: 'editor', + }, + ], + }) + mockUpdateDatasetSetting.mockResolvedValue(createDataset()) + }) + + it('renders dataset details', async () => { + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset') + expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description') + }) + + it('calls onCancel when cancel is clicked', async () => { + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + + it('shows external knowledge info for external datasets', async () => { + const dataset = createDataset({ + provider: 'external', + external_knowledge_info: { + external_knowledge_id: 'ext-id-123', + external_knowledge_api_id: 'ext-api-id-123', + external_knowledge_api_name: 'External Knowledge API', + external_knowledge_api_endpoint: 'https://api.external.com', + }, + }) + + renderWithProviders(dataset) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + expect(screen.getByText('External Knowledge API')).toBeInTheDocument() + expect(screen.getByText('https://api.external.com')).toBeInTheDocument() + expect(screen.getByText('ext-id-123')).toBeInTheDocument() + }) + + it('updates name when user types', async () => { + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + await userEvent.clear(nameInput) + await userEvent.type(nameInput, 'New Dataset Name') + + expect(nameInput).toHaveValue('New Dataset Name') + }) + + it('updates description when user types', async () => { + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder') + await userEvent.clear(descriptionInput) + await userEvent.type(descriptionInput, 'New description') + + expect(descriptionInput).toHaveValue('New description') + }) + + it('shows and dismisses retrieval change tip when index method changes', async () => { + const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL }) + + renderWithProviders(dataset) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + await userEvent.click(screen.getByText('datasetCreation.stepTwo.qualified')) + + expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument() + + await userEvent.click(screen.getByLabelText('close-retrieval-change-tip')) + + await waitFor(() => { + expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument() + }) + }) + + it('requires dataset name before saving', async () => { + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + await userEvent.clear(nameInput) + await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: 'datasetSettings.form.nameError', + })) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) + + it('requires rerank model when reranking is enabled', async () => { + mockUseModelList.mockReturnValue({ data: [] }) + const dataset = createDataset({}, createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + })) + + renderWithProviders(dataset) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: 'appDebug.datasetConfig.rerankModelRequired', + })) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) + + it('saves internal dataset changes', async () => { + const rerankRetrieval = createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'rerank-provider', + reranking_model_name: 'rerank-model', + }, + }) + const dataset = createDataset({ + retrieval_model: rerankRetrieval, + retrieval_model_dict: rerankRetrieval, + }) + + renderWithProviders(dataset) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + await userEvent.clear(nameInput) + await userEvent.type(nameInput, 'Updated Internal Dataset') + await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + name: 'Updated Internal Dataset', + permission: DatasetPermission.allTeamMembers, + }), + })) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + })) + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Updated Internal Dataset', + retrieval_model_dict: expect.objectContaining({ + reranking_enable: true, + }), + })) + }) + + it('saves external dataset with partial members and updated retrieval params', async () => { + const dataset = createDataset({ + provider: 'external', + permission: DatasetPermission.partialMembers, + partial_member_list: ['member-2'], + external_retrieval_model: { + top_k: 5, + score_threshold: 0.3, + score_threshold_enabled: true, + }, + }, { + score_threshold_enabled: true, + score_threshold: 0.8, + }) + + renderWithProviders(dataset) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + permission: DatasetPermission.partialMembers, + external_retrieval_model: expect.objectContaining({ + top_k: 5, + }), + partial_member_list: [ + { + user_id: 'member-2', + role: 'editor', + }, + ], + }), + })) + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + retrieval_model_dict: expect.objectContaining({ + score_threshold_enabled: true, + score_threshold: 0.8, + }), + })) + }) + + it('disables save button while saving', async () => { + mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await userEvent.click(saveButton) + + expect(saveButton).toBeDisabled() + }) + + it('shows error toast when save fails', async () => { + mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error')) + + renderWithProviders(createDataset()) + + await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + + await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index cd6e39011e..37d9ddd372 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -4,10 +4,8 @@ import { useMount } from 'ahooks' import { useTranslation } from 'react-i18next' import { isEqual } from 'lodash-es' import { RiCloseLine } from '@remixicon/react' -import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development' import cn from '@/utils/classnames' import IndexMethod from '@/app/components/datasets/settings/index-method' -import Divider from '@/app/components/base/divider' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' @@ -18,11 +16,7 @@ import { useAppContext } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import type { RetrievalConfig } from '@/types/app' -import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings' -import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' -import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config' import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import PermissionSelector from '@/app/components/datasets/settings/permission-selector' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -32,6 +26,7 @@ import type { Member } from '@/models/common' import { IndexingType } from '@/app/components/datasets/create/step-two' import { useDocLink } from '@/context/i18n' import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' +import { RetrievalChangeTip, RetrievalSection } from './retrieval-section' type SettingsModalProps = { currentDataset: DataSet @@ -298,92 +293,37 @@ const SettingsModal: FC = ({ )} {/* Retrieval Method Config */} - {currentDataset?.provider === 'external' - ? <> -
-
-
-
{t('datasetSettings.form.retrievalSetting.title')}
-
- -
-
-
-
-
{t('datasetSettings.form.externalKnowledgeAPI')}
-
-
-
- -
- {currentDataset?.external_knowledge_info.external_knowledge_api_name} -
-
·
-
{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
-
-
-
-
-
-
{t('datasetSettings.form.externalKnowledgeID')}
-
-
-
-
{currentDataset?.external_knowledge_info.external_knowledge_id}
-
-
-
-
- - :
-
-
-
{t('datasetSettings.form.retrievalSetting.title')}
-
- {t('datasetSettings.form.retrievalSetting.learnMore')} - {t('datasetSettings.form.retrievalSetting.description')} -
-
-
-
- {indexMethod === IndexingType.QUALIFIED - ? ( - - ) - : ( - - )} -
-
} + {isExternal ? ( + + ) : ( + + )} - {isRetrievalChanged && !isHideChangedTip && ( -
-
- -
{t('appDebug.datasetConfig.retrieveChangeTip')}
-
-
{ - setIsHideChangedTip(true) - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - }}> - -
-
- )} + setIsHideChangedTip(true)} + />
{ + const ky = () => ky + ky.extend = () => ky + ky.create = () => ky + return { __esModule: true, default: ky } +}) + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders: [], + textGenerationModelList: [], + supportRetrievalMethods: [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + RETRIEVE_METHOD.keywordSearch, + ], + }), +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + __esModule: true, + useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) => + mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args), + useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args), + useModelList: (...args: unknown[]) => mockUseModelList(...args), + useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args), +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + __esModule: true, + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( +
+ {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} +
+ ), +})) + +jest.mock('@/app/components/datasets/create/step-two', () => ({ + __esModule: true, + IndexingType: { + QUALIFIED: 'high_quality', + ECONOMICAL: 'economy', + }, +})) + +const createRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 2, + score_threshold_enabled: false, + score_threshold: 0.5, + reranking_mode: RerankingModeEnum.RerankingModel, + ...overrides, +}) + +const createDataset = (overrides: Partial = {}, retrievalOverrides: Partial = {}): DataSet => { + const retrievalConfig = createRetrievalConfig(retrievalOverrides) + return { + id: 'dataset-id', + name: 'Test Dataset', + indexing_status: 'completed', + icon_info: { + icon: 'icon', + icon_type: 'emoji', + }, + description: 'Description', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + author_name: 'Author', + created_by: 'creator', + updated_by: 'updater', + updated_at: 1700000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 0, + total_document_count: 0, + total_available_documents: 0, + word_count: 0, + provider: 'internal', + embedding_model: 'embed-model', + embedding_model_provider: 'embed-provider', + embedding_available: true, + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: 'ext-id', + external_knowledge_api_id: 'ext-api-id', + external_knowledge_api_name: 'External API', + external_knowledge_api_endpoint: 'https://api.example.com', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + doc_metadata: [], + keyword_number: 10, + pipeline_id: 'pipeline-id', + is_published: false, + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...overrides, + retrieval_model_dict: { + ...retrievalConfig, + ...overrides.retrieval_model_dict, + }, + retrieval_model: { + ...retrievalConfig, + ...overrides.retrieval_model, + }, + } +} + +describe('RetrievalChangeTip', () => { + const defaultProps = { + visible: true, + message: 'Test message', + onDismiss: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders and supports dismiss', async () => { + // Arrange + const onDismiss = jest.fn() + render() + + // Act + await userEvent.click(screen.getByRole('button', { name: 'close-retrieval-change-tip' })) + + // Assert + expect(screen.getByText('Test message')).toBeInTheDocument() + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('does not render when hidden', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByText('Test message')).not.toBeInTheDocument() + }) +}) + +describe('RetrievalSection', () => { + const t = (key: string) => key + const rowClass = 'row' + const labelClass = 'label' + + beforeEach(() => { + jest.clearAllMocks() + mockUseModelList.mockImplementation((type: ModelTypeEnum) => { + if (type === ModelTypeEnum.rerank) + return { data: [{ provider: 'rerank-provider', models: [{ model: 'rerank-model' }] }] } + return { data: [] } + }) + mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null }) + mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null }) + mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null }) + }) + + it('renders external retrieval details and propagates changes', async () => { + // Arrange + const dataset = createDataset({ + provider: 'external', + external_knowledge_info: { + external_knowledge_id: 'ext-id-999', + external_knowledge_api_id: 'ext-api-id-999', + external_knowledge_api_name: 'External API', + external_knowledge_api_endpoint: 'https://api.external.com', + }, + }) + const handleExternalChange = jest.fn() + + // Act + render( + , + ) + const [topKIncrement] = screen.getAllByLabelText('increment') + await userEvent.click(topKIncrement) + + // Assert + expect(screen.getByText('External API')).toBeInTheDocument() + expect(screen.getByText('https://api.external.com')).toBeInTheDocument() + expect(screen.getByText('ext-id-999')).toBeInTheDocument() + expect(handleExternalChange).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 })) + }) + + it('renders internal retrieval config with doc link', () => { + // Arrange + const docLink = jest.fn((path: string) => `https://docs.example${path}`) + const retrievalConfig = createRetrievalConfig() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' }) + expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting') + expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting') + }) + + it('propagates retrieval config changes for economical indexing', async () => { + // Arrange + const handleRetrievalChange = jest.fn() + + // Act + render( + path} + />, + ) + const [topKIncrement] = screen.getAllByLabelText('increment') + await userEvent.click(topKIncrement) + + // Assert + expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument() + expect(handleRetrievalChange).toHaveBeenCalledWith(expect.objectContaining({ + top_k: 3, + })) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx new file mode 100644 index 0000000000..5ea799d092 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx @@ -0,0 +1,218 @@ +import { RiCloseLine } from '@remixicon/react' +import type { FC } from 'react' +import cn from '@/utils/classnames' +import Divider from '@/app/components/base/divider' +import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development' +import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' +import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings' +import type { DataSet } from '@/models/datasets' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import type { RetrievalConfig } from '@/types/app' +import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' +import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config' + +type CommonSectionProps = { + rowClass: string + labelClass: string + t: (key: string, options?: any) => string +} + +type ExternalRetrievalSectionProps = CommonSectionProps & { + topK: number + scoreThreshold: number + scoreThresholdEnabled: boolean + onExternalSettingChange: (data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => void + currentDataset: DataSet +} + +const ExternalRetrievalSection: FC = ({ + rowClass, + labelClass, + t, + topK, + scoreThreshold, + scoreThresholdEnabled, + onExternalSettingChange, + currentDataset, +}) => ( + <> +
+
+
+
{t('datasetSettings.form.retrievalSetting.title')}
+
+ +
+
+
+
+
{t('datasetSettings.form.externalKnowledgeAPI')}
+
+
+
+ +
+ {currentDataset?.external_knowledge_info.external_knowledge_api_name} +
+
·
+
{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
+
+
+
+
+
+
{t('datasetSettings.form.externalKnowledgeID')}
+
+
+
+
{currentDataset?.external_knowledge_info.external_knowledge_id}
+
+
+
+
+ +) + +type InternalRetrievalSectionProps = CommonSectionProps & { + indexMethod: IndexingType + retrievalConfig: RetrievalConfig + showMultiModalTip: boolean + onRetrievalConfigChange: (value: RetrievalConfig) => void + docLink: (path: string) => string +} + +const InternalRetrievalSection: FC = ({ + rowClass, + labelClass, + t, + indexMethod, + retrievalConfig, + showMultiModalTip, + onRetrievalConfigChange, + docLink, +}) => ( +
+
+
+
{t('datasetSettings.form.retrievalSetting.title')}
+
+ {t('datasetSettings.form.retrievalSetting.learnMore')} + {t('datasetSettings.form.retrievalSetting.description')} +
+
+
+
+ {indexMethod === IndexingType.QUALIFIED + ? ( + + ) + : ( + + )} +
+
+) + +type RetrievalSectionProps + = | (ExternalRetrievalSectionProps & { isExternal: true }) + | (InternalRetrievalSectionProps & { isExternal: false }) + +export const RetrievalSection: FC = (props) => { + if (props.isExternal) { + const { + rowClass, + labelClass, + t, + topK, + scoreThreshold, + scoreThresholdEnabled, + onExternalSettingChange, + currentDataset, + } = props + + return ( + + ) + } + + const { + rowClass, + labelClass, + t, + indexMethod, + retrievalConfig, + showMultiModalTip, + onRetrievalConfigChange, + docLink, + } = props + + return ( + + ) +} + +type RetrievalChangeTipProps = { + visible: boolean + message: string + onDismiss: () => void +} + +export const RetrievalChangeTip: FC = ({ + visible, + message, + onDismiss, +}) => { + if (!visible) + return null + + return ( +
+
+ +
{message}
+
+ +
+ ) +}