mirror of
https://github.com/langgenius/dify.git
synced 2025-12-20 06:32:45 +00:00
refactor: implement SettingsModal with retrieval settings and add tests for RetrievalChangeTip component (#29786)
This commit is contained in:
@@ -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' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,10 +4,8 @@ import { useMount } from 'ahooks'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { isEqual } from 'lodash-es'
|
import { isEqual } from 'lodash-es'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
|
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import IndexMethod from '@/app/components/datasets/settings/index-method'
|
import IndexMethod from '@/app/components/datasets/settings/index-method'
|
||||||
import Divider from '@/app/components/base/divider'
|
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import Textarea from '@/app/components/base/textarea'
|
import Textarea from '@/app/components/base/textarea'
|
||||||
@@ -18,11 +16,7 @@ import { useAppContext } from '@/context/app-context'
|
|||||||
import { useModalContext } from '@/context/modal-context'
|
import { useModalContext } from '@/context/modal-context'
|
||||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||||
import type { RetrievalConfig } from '@/types/app'
|
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 { 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 PermissionSelector from '@/app/components/datasets/settings/permission-selector'
|
||||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-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'
|
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 { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||||
import { useDocLink } from '@/context/i18n'
|
import { useDocLink } from '@/context/i18n'
|
||||||
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
|
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
|
||||||
|
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
|
||||||
|
|
||||||
type SettingsModalProps = {
|
type SettingsModalProps = {
|
||||||
currentDataset: DataSet
|
currentDataset: DataSet
|
||||||
@@ -298,92 +293,37 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Retrieval Method Config */}
|
{/* Retrieval Method Config */}
|
||||||
{currentDataset?.provider === 'external'
|
{isExternal ? (
|
||||||
? <>
|
<RetrievalSection
|
||||||
<div className={rowClass}><Divider /></div>
|
isExternal
|
||||||
<div className={rowClass}>
|
rowClass={rowClass}
|
||||||
<div className={labelClass}>
|
labelClass={labelClass}
|
||||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
t={t}
|
||||||
</div>
|
topK={topK}
|
||||||
<RetrievalSettings
|
scoreThreshold={scoreThreshold}
|
||||||
topK={topK}
|
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||||
scoreThreshold={scoreThreshold}
|
onExternalSettingChange={handleSettingsChange}
|
||||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
currentDataset={currentDataset}
|
||||||
onChange={handleSettingsChange}
|
/>
|
||||||
isInRetrievalSetting={true}
|
) : (
|
||||||
/>
|
<RetrievalSection
|
||||||
</div>
|
isExternal={false}
|
||||||
<div className={rowClass}><Divider /></div>
|
rowClass={rowClass}
|
||||||
<div className={rowClass}>
|
labelClass={labelClass}
|
||||||
<div className={labelClass}>
|
t={t}
|
||||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
|
indexMethod={indexMethod}
|
||||||
</div>
|
retrievalConfig={retrievalConfig}
|
||||||
<div className='w-full max-w-[480px]'>
|
showMultiModalTip={showMultiModalTip}
|
||||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
onRetrievalConfigChange={setRetrievalConfig}
|
||||||
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
|
docLink={docLink}
|
||||||
<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>}
|
|
||||||
</div>
|
</div>
|
||||||
{isRetrievalChanged && !isHideChangedTip && (
|
<RetrievalChangeTip
|
||||||
<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'>
|
visible={isRetrievalChanged && !isHideChangedTip}
|
||||||
<div className='flex items-center'>
|
message={t('appDebug.datasetConfig.retrieveChangeTip')}
|
||||||
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
|
onDismiss={() => setIsHideChangedTip(true)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4'
|
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user