refactor: implement SettingsModal with retrieval settings and add tests for RetrievalChangeTip component (#29786)

This commit is contained in:
yyh
2025-12-18 16:58:41 +08:00
committed by GitHub
parent b0bef1a120
commit e228b802c5
4 changed files with 999 additions and 91 deletions

View File

@@ -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: <T,>(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 } }) => (
<div data-testid='model-selector'>
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
</div>
),
}))
jest.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: (...args: unknown[]) => mockCheckShowMultiModalTip(...args),
}))
const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction<typeof updateDatasetSetting>
const mockFetchMembers = fetchMembers as jest.MockedFunction<typeof fetchMembers>
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): 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<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): 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(
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
<SettingsModal
currentDataset={dataset}
onCancel={mockOnCancel}
onSave={mockOnSave}
/>
</ToastContext.Provider>,
)
}
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' }))
})
})
})

View File

@@ -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<SettingsModalProps> = ({
)}
{/* Retrieval Method Config */}
{currentDataset?.provider === 'external'
? <>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={handleSettingsChange}
isInRetrievalSetting={true}
/>
</div>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
</div>
<div className='system-xs-regular text-text-tertiary'>·</div>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
</div>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
</div>
</div>
</div>
<div className={rowClass}><Divider /></div>
</>
: <div className={rowClass}>
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
<div>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
{t('datasetSettings.form.retrievalSetting.description')}
</div>
</div>
</div>
<div>
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
/>
)}
</div>
</div>}
{isExternal ? (
<RetrievalSection
isExternal
rowClass={rowClass}
labelClass={labelClass}
t={t}
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onExternalSettingChange={handleSettingsChange}
currentDataset={currentDataset}
/>
) : (
<RetrievalSection
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={indexMethod}
retrievalConfig={retrievalConfig}
showMultiModalTip={showMultiModalTip}
onRetrievalConfigChange={setRetrievalConfig}
docLink={docLink}
/>
)}
</div>
{isRetrievalChanged && !isHideChangedTip && (
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
<div className='flex items-center'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
<div className='text-xs font-medium leading-[18px] text-gray-700'>{t('appDebug.datasetConfig.retrieveChangeTip')}</div>
</div>
<div className='cursor-pointer p-1' onClick={(e) => {
setIsHideChangedTip(true)
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</div>
</div>
)}
<RetrievalChangeTip
visible={isRetrievalChanged && !isHideChangedTip}
message={t('appDebug.datasetConfig.retrieveChangeTip')}
onDismiss={() => setIsHideChangedTip(true)}
/>
<div
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4'

View File

@@ -0,0 +1,277 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
const mockUseModelList = jest.fn()
const mockUseModelListAndDefaultModel = jest.fn()
const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn()
const mockUseCurrentProviderAndModel = jest.fn()
jest.mock('ky', () => {
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 } }) => (
<div data-testid='model-selector'>
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
</div>
),
}))
jest.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
},
}))
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): 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<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): 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(<RetrievalChangeTip {...defaultProps} onDismiss={onDismiss} />)
// 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(<RetrievalChangeTip {...defaultProps} visible={false} />)
// 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(
<RetrievalSection
isExternal
rowClass={rowClass}
labelClass={labelClass}
t={t}
topK={3}
scoreThreshold={0.4}
scoreThresholdEnabled
onExternalSettingChange={handleExternalChange}
currentDataset={dataset}
/>,
)
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(
<RetrievalSection
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={IndexingType.QUALIFIED}
retrievalConfig={retrievalConfig}
showMultiModalTip
onRetrievalConfigChange={jest.fn()}
docLink={docLink}
/>,
)
// 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(
<RetrievalSection
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={IndexingType.ECONOMICAL}
retrievalConfig={createRetrievalConfig()}
showMultiModalTip={false}
onRetrievalConfigChange={handleRetrievalChange}
docLink={path => 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,
}))
})
})

View File

@@ -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<ExternalRetrievalSectionProps> = ({
rowClass,
labelClass,
t,
topK,
scoreThreshold,
scoreThresholdEnabled,
onExternalSettingChange,
currentDataset,
}) => (
<>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={onExternalSettingChange}
isInRetrievalSetting={true}
/>
</div>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
</div>
<div className='system-xs-regular text-text-tertiary'>·</div>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
</div>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
</div>
</div>
</div>
<div className={rowClass}><Divider /></div>
</>
)
type InternalRetrievalSectionProps = CommonSectionProps & {
indexMethod: IndexingType
retrievalConfig: RetrievalConfig
showMultiModalTip: boolean
onRetrievalConfigChange: (value: RetrievalConfig) => void
docLink: (path: string) => string
}
const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
rowClass,
labelClass,
t,
indexMethod,
retrievalConfig,
showMultiModalTip,
onRetrievalConfigChange,
docLink,
}) => (
<div className={rowClass}>
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
<div>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
{t('datasetSettings.form.retrievalSetting.description')}
</div>
</div>
</div>
<div>
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={onRetrievalConfigChange}
showMultiModalTip={showMultiModalTip}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={onRetrievalConfigChange}
/>
)}
</div>
</div>
)
type RetrievalSectionProps
= | (ExternalRetrievalSectionProps & { isExternal: true })
| (InternalRetrievalSectionProps & { isExternal: false })
export const RetrievalSection: FC<RetrievalSectionProps> = (props) => {
if (props.isExternal) {
const {
rowClass,
labelClass,
t,
topK,
scoreThreshold,
scoreThresholdEnabled,
onExternalSettingChange,
currentDataset,
} = props
return (
<ExternalRetrievalSection
rowClass={rowClass}
labelClass={labelClass}
t={t}
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onExternalSettingChange={onExternalSettingChange}
currentDataset={currentDataset}
/>
)
}
const {
rowClass,
labelClass,
t,
indexMethod,
retrievalConfig,
showMultiModalTip,
onRetrievalConfigChange,
docLink,
} = props
return (
<InternalRetrievalSection
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={indexMethod}
retrievalConfig={retrievalConfig}
showMultiModalTip={showMultiModalTip}
onRetrievalConfigChange={onRetrievalConfigChange}
docLink={docLink}
/>
)
}
type RetrievalChangeTipProps = {
visible: boolean
message: string
onDismiss: () => void
}
export const RetrievalChangeTip: FC<RetrievalChangeTipProps> = ({
visible,
message,
onDismiss,
}) => {
if (!visible)
return null
return (
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
<div className='flex items-center'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
<div className='text-xs font-medium leading-[18px] text-gray-700'>{message}</div>
</div>
<button
type='button'
className='cursor-pointer p-1'
onClick={(event) => {
onDismiss()
event.stopPropagation()
}}
aria-label='close-retrieval-change-tip'
>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</button>
</div>
)
}