From 2f892071d235e2888805667ee1697ff99d229211 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:41:42 +0800 Subject: [PATCH] fix(dataset): align New RAG acceptance flows --- api/extensions/ext_blueprints.py | 1 + .../extensions/test_ext_blueprints_cors.py | 3 +- .../__tests__/add-source-page.spec.tsx | 7 +- .../__tests__/document-detail-page.spec.tsx | 68 ++++-- .../new-rag/__tests__/documents-page.spec.tsx | 58 +++--- .../knowledge-settings-form.spec.tsx | 1 + .../new-rag/__tests__/quality-page.spec.tsx | 19 ++ .../__tests__/retrieval-test-model.spec.ts | 32 +++ .../__tests__/retrieval-test-page.spec.tsx | 110 +++++++++- .../new-rag/__tests__/sources-page.spec.tsx | 85 +++++++- web/features/new-rag/add-source-page.tsx | 113 +++++++++- .../new-rag/add-source-placeholder.tsx | 4 +- .../new-rag/connected-source-setup.tsx | 8 +- web/features/new-rag/create-source-setup.tsx | 4 +- .../new-rag/document-actions-dropdown.tsx | 36 +--- .../new-rag/document-detail-header.tsx | 6 - web/features/new-rag/document-list.tsx | 25 +-- web/features/new-rag/documents-page.tsx | 40 ++++ .../new-rag/knowledge-settings-form.tsx | 4 +- .../new-rag/knowledge-space-shell.tsx | 2 +- .../quality/golden-question-dialog.tsx | 6 +- web/features/new-rag/quality/quality-page.tsx | 8 +- web/features/new-rag/retrieval-test-model.ts | 19 ++ web/features/new-rag/retrieval-test-page.tsx | 194 ++++++++++++++---- web/features/new-rag/sources-page.tsx | 94 +++++++-- web/features/new-rag/use-document-reindex.ts | 19 +- .../new-rag/use-document-task-status.ts | 18 +- web/i18n/en-US/dataset.json | 12 +- 28 files changed, 801 insertions(+), 195 deletions(-) diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 51aa2a3db5f..c7c7f6a13a0 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -17,6 +17,7 @@ AUTHENTICATED_HEADERS: tuple[str, ...] = ( HEADER_NAME_CSRF_TOKEN, HEADER_NAME_IDEMPOTENCY_KEY, HEADER_NAME_REQUEST_ID, + "X-Trace-ID", ) FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE) diff --git a/api/tests/unit_tests/extensions/test_ext_blueprints_cors.py b/api/tests/unit_tests/extensions/test_ext_blueprints_cors.py index 47b30bcf087..84cfa04d6eb 100644 --- a/api/tests/unit_tests/extensions/test_ext_blueprints_cors.py +++ b/api/tests/unit_tests/extensions/test_ext_blueprints_cors.py @@ -25,7 +25,7 @@ def test_authenticated_cors_allows_request_metadata_headers() -> None: response = app.test_client().options( "/console/api/probe", headers={ - "Access-Control-Request-Headers": "Idempotency-Key, X-Request-ID", + "Access-Control-Request-Headers": "Idempotency-Key, X-Request-ID, X-Trace-ID", "Access-Control-Request-Method": "POST", "Origin": "http://localhost:3000", }, @@ -34,3 +34,4 @@ def test_authenticated_cors_allows_request_metadata_headers() -> None: allowed_headers = response.headers.get("Access-Control-Allow-Headers", "").lower() assert "idempotency-key" in allowed_headers assert "x-request-id" in allowed_headers + assert "x-trace-id" in allowed_headers diff --git a/web/features/new-rag/__tests__/add-source-page.spec.tsx b/web/features/new-rag/__tests__/add-source-page.spec.tsx index 4851b3929b8..011e0c51464 100644 --- a/web/features/new-rag/__tests__/add-source-page.spec.tsx +++ b/web/features/new-rag/__tests__/add-source-page.spec.tsx @@ -814,8 +814,7 @@ describe('AddSourcePage', () => { expect(screen.queryByDisplayValue('secret-value')).not.toBeInTheDocument() }) - it('binds the default Dify Firecrawl credential for the real KnowledgeFS provider', async () => { - const user = userEvent.setup() + it('automatically binds the default Dify Firecrawl credential for the real KnowledgeFS provider', async () => { queryState.providers.data = { items: [difyManagedFirecrawlProvider] } queryState.datasourceAuth.data = { result: [firecrawlDatasourceAuth] } clientMock.createConnection.mockResolvedValue({ @@ -831,7 +830,6 @@ describe('AddSourcePage', () => { }) render() - await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName })) await waitFor(() => expect(clientMock.createConnection).toHaveBeenCalledWith({ @@ -851,6 +849,9 @@ describe('AddSourcePage', () => { params: { control_space_id: 'space-1' }, }), ) + expect( + screen.queryByRole('button', { name: connectFirecrawlButtonName }), + ).not.toBeInTheDocument() expect(screen.queryByLabelText(/Api Key/)).not.toBeInTheDocument() }) diff --git a/web/features/new-rag/__tests__/document-detail-page.spec.tsx b/web/features/new-rag/__tests__/document-detail-page.spec.tsx index df01eeaa4ed..b11db3a907d 100644 --- a/web/features/new-rag/__tests__/document-detail-page.spec.tsx +++ b/web/features/new-rag/__tests__/document-detail-page.spec.tsx @@ -14,7 +14,7 @@ type BulkDocumentReindexResult = { bulkJobId: string items: Array<{ asset?: unknown - compilationJob?: unknown + compilation_job?: { id: string; stage: 'queued' } documentId?: string status: 'not_found' | 'queued' statusUrl?: string @@ -420,7 +420,7 @@ const queuedReindexResult = (): BulkDocumentReindexResult => ({ sizeBytes: 1200, version: 2, }, - compilationJob: { id: 'compilation-job-1', stage: 'queued' }, + compilation_job: { id: 'compilation-job-1', stage: 'queued' }, status: 'queued', statusUrl: '/knowledge-fs/status/compilation-job-1', }, @@ -478,7 +478,6 @@ describe('DocumentDetailPage', () => { }) it('loads the document, revisions, chunks, and task status through generated contracts', async () => { - const user = userEvent.setup() render() expect(documentOptions).toHaveBeenCalledWith( @@ -505,17 +504,9 @@ describe('DocumentDetailPage', () => { params: { control_space_id: 'space-1' }, query: { limit: 100 }, }) - await user.click( - screen.getByRole('button', { - name: /dataset\.newKnowledge\.documentActions/, - }), - ) - expect(screen.getAllByRole('menuitem')).toHaveLength(5) - expect(screen.getByRole('menuitem', { name: 'common.operation.rename' })).toBeInTheDocument() expect( - screen.queryByRole('menuitem', { name: 'dataset.newKnowledge.reindexDocument' }), + screen.queryByRole('button', { name: /dataset\.newKnowledge\.documentActions/ }), ).not.toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'common.operation.delete' })).toBeInTheDocument() }) it('does not construct a chunks request while the document is loading', () => { @@ -1366,7 +1357,17 @@ describe('DocumentDetailPage', () => { await waitFor(() => expect(toastState.success).toHaveBeenCalled()) tasksQuery.data = { - pages: [{ items: [task({ documentRevision: 4, state: 'running' })] }], + pages: [ + { + items: [ + task({ + documentRevision: 3, + id: 'compilation-job-1', + state: 'running', + }), + ], + }, + ], } rendered.rerender() @@ -1398,6 +1399,47 @@ describe('DocumentDetailPage', () => { ).toBe(5000) }) + it('recognizes the accepted re-index task when it recompiles the active revision', async () => { + vi.useFakeTimers() + try { + const rendered = render( + , + ) + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' }), + ) + await Promise.resolve() + await Promise.resolve() + }) + + tasksQuery.data = { + pages: [ + { + items: [ + task({ + documentRevision: 3, + id: 'compilation-job-1', + state: 'running', + }), + ], + }, + ], + } + rendered.rerender() + + expect( + screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' }), + ).toHaveAttribute('data-disabled') + await act(() => vi.advanceTimersByTimeAsync(30000)) + expect( + screen.queryByRole('button', { name: 'dataset.newKnowledge.retryReindexDocument' }), + ).not.toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + it('surfaces unified task-list authorization failures and blocks re-indexing', () => { tasksQuery.data = undefined tasksQuery.error = { status: 403 } diff --git a/web/features/new-rag/__tests__/documents-page.spec.tsx b/web/features/new-rag/__tests__/documents-page.spec.tsx index dce1a11be96..345e1a2eaa3 100644 --- a/web/features/new-rag/__tests__/documents-page.spec.tsx +++ b/web/features/new-rag/__tests__/documents-page.spec.tsx @@ -731,17 +731,8 @@ describe('DocumentsPage', () => { expect(rowActions).toBeEnabled() await user.click(rowActions) const rowMenuItems = await screen.findAllByRole('menuitem') - expect(rowMenuItems).toHaveLength(6) - expect(rowMenuItems[0]).toHaveAccessibleName('common.operation.rename') - expect(rowMenuItems[1]).toHaveAccessibleName('dataset.newKnowledge.reindexDocument') - expect(rowMenuItems[2]).toHaveAccessibleName('dataset.newKnowledge.disableSource') - expect(rowMenuItems[3]).toHaveAccessibleName('dataset.batchAction.archive') - expect(rowMenuItems[4]).toHaveAccessibleName('dataset.newKnowledge.downloadDocuments') - expect(rowMenuItems[5]).toHaveAccessibleName('common.operation.delete') - await user.click( - screen.getByRole('menuitem', { name: 'dataset.newKnowledge.downloadDocuments' }), - ) - expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.documentActionsUnavailable') + expect(rowMenuItems).toHaveLength(1) + expect(rowMenuItems[0]).toHaveAccessibleName('dataset.newKnowledge.reindexDocument') expect(screen.getByRole('searchbox')).toHaveValue('report') expect(screen.getByRole('combobox')).toHaveTextContent( @@ -751,6 +742,35 @@ describe('DocumentsPage', () => { expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument() }) + it('starts re-indexing from a document row action', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }], + } + + render() + await user.click( + screen.getByRole('button', { + name: /dataset\.newKnowledge\.documentActions/, + }), + ) + await user.click( + await screen.findByRole('menuitem', { + name: 'dataset.newKnowledge.reindexDocument', + }), + ) + + expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({ + body: { documentIds: ['one'] }, + params: { control_space_id: 'space-1' }, + }) + await waitFor(() => + expect(toastMock.success).toHaveBeenCalledWith( + 'dataset.newKnowledge.documentsReindexStarted', + ), + ) + }) + it('opens the upload form and consumes the one-shot URL request', async () => { const user = userEvent.setup() const { onUrlUpdate } = render(, { @@ -2096,24 +2116,10 @@ describe('DocumentsPage', () => { expect(reindex).toBeEnabled() const orderedActions = within(actions).getAllByRole('button') expect(orderedActions[0]).toHaveAccessibleName('dataset.newKnowledge.reindexDocuments') - expect(orderedActions[1]).toHaveAccessibleName('dataset.newKnowledge.downloadDocuments') - expect(orderedActions[2]).toHaveAccessibleName('dataset.newKnowledge.deleteDocuments') - expect(orderedActions[3]).toHaveAccessibleName('dataset.newKnowledge.clearDocumentSelection') + expect(orderedActions[1]).toHaveAccessibleName('dataset.newKnowledge.clearDocumentSelection') expect(actions.firstElementChild).toHaveTextContent( 'dataset.newKnowledge.documentsSelected:{"count":1}', ) - const download = within(actions).getByRole('button', { - name: 'dataset.newKnowledge.downloadDocuments', - }) - const remove = within(actions).getByRole('button', { - name: 'dataset.newKnowledge.deleteDocuments', - }) - expect(download).toBeEnabled() - expect(remove).toBeEnabled() - await user.click(download) - await user.click(remove) - expect(toastMock.info).toHaveBeenCalledTimes(2) - expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.documentActionsUnavailable') await user.dblClick(reindex) expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce() expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({ diff --git a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx index 4b22182da5c..75a2d270d12 100644 --- a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx +++ b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx @@ -386,6 +386,7 @@ describe('KnowledgeSettingsForm', () => { name: /^dataset\.newKnowledge\.settings\.deleteConfirmPrompt/, }) + expect(confirmationInput).toHaveAttribute('placeholder', 'Camera Technical Spec') expect(confirmButton).toBeDisabled() await user.type(confirmationInput, 'Camera') expect(confirmButton).toBeDisabled() diff --git a/web/features/new-rag/__tests__/quality-page.spec.tsx b/web/features/new-rag/__tests__/quality-page.spec.tsx index 7b710114ec1..1738da89318 100644 --- a/web/features/new-rag/__tests__/quality-page.spec.tsx +++ b/web/features/new-rag/__tests__/quality-page.spec.tsx @@ -205,6 +205,25 @@ describe('QualityPage', () => { }) }) + it('shows both required-field messages after an empty golden question submission', async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText('What is the refund policy?') + await user.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.addGolden' }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.save' })) + + expect( + screen.getByText('dataset.newKnowledge.qualityPage.questionRequired'), + ).toBeInTheDocument() + expect( + screen.getByText('dataset.newKnowledge.qualityPage.annotationRequired'), + ).toBeInTheDocument() + expect(serviceMock.createGolden).not.toHaveBeenCalled() + }) + it('resolves the protected trace reference before navigating', async () => { const user = userEvent.setup() navigationMock.tab = 'bad-cases' diff --git a/web/features/new-rag/__tests__/retrieval-test-model.spec.ts b/web/features/new-rag/__tests__/retrieval-test-model.spec.ts index 6c1ff0b1d0d..6a083da98bf 100644 --- a/web/features/new-rag/__tests__/retrieval-test-model.spec.ts +++ b/web/features/new-rag/__tests__/retrieval-test-model.spec.ts @@ -92,6 +92,38 @@ describe('retrieval test model', () => { expect(evidence[0]).toEqual(expect.objectContaining({ id: 'chunk-1', score: 0.77 })) }) + it('keeps document references carried by research citations', () => { + expect( + extractRetrievalEvidence({ + data: [ + { + evidence_bundle: { + items: [ + { + citations: [ + { + documentAssetId: 'asset-1', + documentVersion: 2, + }, + ], + nodeId: 'node-1', + score: 0.45, + text: 'Research evidence with a durable document citation.', + }, + ], + }, + }, + ], + }), + ).toEqual([ + expect.objectContaining({ + documentId: 'asset-1', + revision: 'Revision 2', + score: 0.45, + }), + ]) + }) + it('merges trace and research histories newest-first', () => { const records = retrievalTestRecords( [ diff --git a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx index 89626910910..dbd2c989e5f 100644 --- a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx +++ b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx @@ -13,6 +13,9 @@ const apiMock = vi.hoisted(() => ({ refetchPartials: vi.fn(), refetchTasks: vi.fn(), refetchTraces: vi.fn(), + streamQuery: vi.fn(), + documentReferences: {} as Record, + evidence: undefined as Record | undefined, traceDetail: undefined as Record | undefined, traces: [] as Array>, })) @@ -27,6 +30,10 @@ vi.mock('@/next/navigation', () => ({ }), })) +vi.mock('../services/knowledge-query-events', () => ({ + streamKnowledgeQuery: apiMock.streamQuery, +})) + vi.mock('@tanstack/react-query', async (importOriginal) => { const original = await importOriginal() return { @@ -44,6 +51,16 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { data: apiMock.traceDetail, isPending: false, } + if (resource === 'evidence') + return { + data: apiMock.evidence, + isPending: false, + } + if (resource === 'retrieval-document-references') + return { + data: apiMock.documentReferences, + isPending: false, + } if (resource === 'tasks') return { data: { data: [] }, @@ -65,6 +82,7 @@ vi.mock('@/service/client', () => ({ spaces: { byControlSpaceId: { queries: { admission: { post: apiMock.queryAdmission } }, + logicalDocuments: { get: vi.fn() }, goldenQuestions: { post: apiMock.createGolden }, quality: { badCases: { post: apiMock.createBadCase } }, researchTasks: { @@ -136,7 +154,12 @@ describe('RetrievalTestPage', () => { updated_at: 1_800_000_000, }) apiMock.refetchTasks.mockResolvedValue(undefined) + apiMock.refetchTraces.mockResolvedValue(undefined) + apiMock.streamQuery.mockResolvedValue(undefined) + apiMock.queryAdmission.mockResolvedValue({}) apiMock.createBadCase.mockResolvedValue({ id: 'bad-case-1' }) + apiMock.documentReferences = {} + apiMock.evidence = undefined apiMock.traceDetail = undefined apiMock.traces = [] navigationMock.trace = undefined @@ -236,7 +259,9 @@ describe('RetrievalTestPage', () => { render() expect( - screen.getByRole('heading', { name: 'An older production question' }), + screen.getByRole('heading', { + name: 'dataset.newKnowledge.retrievalTest.result:{"mode":"dataset.newKnowledge.settings.retrievalMode.deep"}', + }), ).toBeInTheDocument() await user.click( screen.getByRole('button', { @@ -255,4 +280,87 @@ describe('RetrievalTestPage', () => { }), ) }) + + it('opens retrieval evidence through its logical document instead of its asset', async () => { + apiMock.traces = [ + { + completed: true, + created_at: '2026-07-29T00:00:00.000Z', + id: 'trace-1', + mode: 'fast', + profile: {}, + query: 'What is the refund policy?', + scores: {}, + stages: [], + }, + ] + apiMock.evidence = { + data: [ + { + kind: 'resource', + metadata: { + documentId: 'asset-1', + score: 0.9, + text: 'Refunds are available within 30 days.', + }, + name: 'chunk-1', + path: '/queries/trace-1/evidence/chunk-1', + resourceType: 'node', + targetId: 'chunk-1', + }, + ], + } + apiMock.documentReferences = { + 'asset-1': { id: 'document-1', title: 'refund-policy.txt' }, + } + const user = userEvent.setup() + render() + + await user.click(screen.getByText('What is the refund policy?')) + + expect( + screen.getByRole('link', { name: 'dataset.newKnowledge.retrievalTest.open' }), + ).toHaveAttribute('href', '/datasets/new/space-1/documents/document-1') + expect(screen.getByText('refund-policy.txt')).toBeInTheDocument() + }) + + it('keeps a failed run in Records and renders the failure inline', async () => { + apiMock.streamQuery.mockRejectedValueOnce(new Error('provider timed out')) + const user = userEvent.setup() + render() + + await user.type( + screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'), + 'Why did this fail?', + ) + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retrievalTest.run' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'dataset.newKnowledge.retrievalTest.failedTitle', + ) + expect(screen.getAllByText('Why did this fail?')).toHaveLength(2) + expect(screen.getByText('provider timed out')).toBeInTheDocument() + }) + + it('maps an empty unpublished knowledge space to the designed no-results state', async () => { + apiMock.streamQuery.mockRejectedValueOnce( + new Response('Published runtime snapshot unavailable', { status: 503 }), + ) + const user = userEvent.setup() + render() + + await user.type( + screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'), + 'Anything here?', + ) + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retrievalTest.run' })) + + expect( + await screen.findByText('dataset.newKnowledge.retrievalTest.noChunksTitle'), + ).toBeInTheDocument() + expect( + screen.queryByText('dataset.newKnowledge.retrievalTest.failedTitle'), + ).not.toBeInTheDocument() + expect(screen.getAllByText('Anything here?')).toHaveLength(2) + }) }) diff --git a/web/features/new-rag/__tests__/sources-page.spec.tsx b/web/features/new-rag/__tests__/sources-page.spec.tsx index 284b7c952aa..8a9841b997a 100644 --- a/web/features/new-rag/__tests__/sources-page.spec.tsx +++ b/web/features/new-rag/__tests__/sources-page.spec.tsx @@ -70,6 +70,29 @@ const settingsState = vi.hoisted(() => ({ configurationState: 'active' as 'active' | 'setup-required', refetch: vi.fn(), })) +const workflowState = vi.hoisted(() => ({ + data: undefined as + | { + canceled_at: null + checkpoint: string + completed_at: null + created_at: string + execution_attempts: number + id: string + kind: string + knowledge_space_id: string + last_error_code: null + max_execution_attempts: number + progress_completed: number + progress_failed: number + progress_skipped: number + progress_total: number + source_id: string + state: string + updated_at: string + } + | undefined, +})) vi.mock('@tanstack/react-query', async (importOriginal) => { const original = await importOriginal() @@ -86,15 +109,18 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } : undefined, }), - useQuery: () => ({ - data: { - configuration_state: settingsState.configurationState, - embedding: null, - retrieval: null, - revision: 1, - }, - refetch: settingsState.refetch, - }), + useQuery: (options: { queryKey?: unknown[] }) => + options.queryKey?.[1] === 'source-workflow' + ? { data: workflowState.data } + : { + data: { + configuration_state: settingsState.configurationState, + embedding: null, + retrieval: null, + revision: 1, + }, + refetch: settingsState.refetch, + }, useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }), } }) @@ -132,6 +158,15 @@ vi.mock('@/service/client', () => ({ }), }, }, + sourceWorkflows: { + byRunId: { + get: { + queryOptions: ({ input }: { input: { params: { run_id: string } } }) => ({ + queryKey: ['knowledge-fs', 'source-workflow', input.params.run_id], + }), + }, + }, + }, sources: { get: { infiniteOptions: infiniteOptionsMock, @@ -158,6 +193,26 @@ const source = (overrides: Partial): Source => ({ ...overrides, }) +const workflow = (state = 'queued') => ({ + canceled_at: null, + checkpoint: 'sync', + completed_at: null, + created_at: '2026-07-20T10:00:00Z', + execution_attempts: 1, + id: 'workflow-1', + kind: 'sync', + knowledge_space_id: 'space-1', + last_error_code: null, + max_execution_attempts: 3, + progress_completed: 0, + progress_failed: 0, + progress_skipped: 0, + progress_total: 1, + source_id: 'source-1', + state, + updated_at: '2026-07-20T10:00:00Z', +}) + describe('SourcesPage', () => { beforeEach(() => { vi.clearAllMocks() @@ -169,7 +224,8 @@ describe('SourcesPage', () => { sourcesQuery.isPending = false clientMock.deleteSource.mockResolvedValue({ status: 'accepted' }) clientMock.patchSource.mockResolvedValue(source({})) - clientMock.syncSource.mockResolvedValue({ state: 'queued' }) + workflowState.data = undefined + clientMock.syncSource.mockResolvedValue(workflow()) permissionState.workspacePermissionKeys = ['dataset.acl.edit', 'dataset.external.connect'] settingsState.configurationState = 'active' settingsState.refetch.mockImplementation(async () => ({ @@ -516,7 +572,7 @@ describe('SourcesPage', () => { }), ) - render() + const { rerender } = render() await user.click( screen.getByRole('button', { name: 'dataset.newKnowledge.sourceActions:{"name":"Product documentation"}', @@ -536,7 +592,14 @@ describe('SourcesPage', () => { 'dataset.newKnowledge.sourceStatus.syncing', ), ).toBeInTheDocument() + expect( + within(screen.getByRole('row', { name: /Product documentation/ })).getByText( + 'dataset.newKnowledge.sourceSyncProgress:{"completed":0,"total":1}', + ), + ).toBeInTheDocument() finishRefresh?.() + workflowState.data = workflow('completed') + rerender() await waitFor(() => expect( within(screen.getByRole('row', { name: /Product documentation/ })).getByText( diff --git a/web/features/new-rag/add-source-page.tsx b/web/features/new-rag/add-source-page.tsx index 74a4abbdf59..d0b5c375bed 100644 --- a/web/features/new-rag/add-source-page.tsx +++ b/web/features/new-rag/add-source-page.tsx @@ -497,6 +497,105 @@ function ConnectionForm({ ) } +function ManagedProviderConnection({ + credentialId, + knowledgeSpaceId, + onConnected, + onReconcile, + provider, +}: { + credentialId: string + knowledgeSpaceId: string + onConnected: (connection: Connection) => void + onReconcile: () => Promise + provider: Provider +}) { + const { t } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const [attempt, setAttempt] = useState(0) + const [error, setError] = useState(false) + const requestRef = useRef< + | { + attempt: number + promise: Promise + } + | undefined + >(undefined) + + useEffect(() => { + if (requestRef.current?.attempt !== attempt) { + requestRef.current = { + attempt, + promise: (async () => { + try { + return sourceConnectionFromApi( + await consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceConnections.post({ + body: { + authKind: 'endpoint', + configuration: { + ...FIRECRAWL_CONFIGURATION, + credentialId, + }, + credentials: {}, + name: FIRECRAWL_CONNECTION_NAME, + providerId: provider.id, + }, + params: { control_space_id: knowledgeSpaceId }, + }), + ) + } catch { + return onReconcile() + } + })(), + } + } + + let subscribed = true + void requestRef.current.promise + .then((connection) => { + if (!subscribed) return + if (connection) onConnected(connection) + else setError(true) + }) + .catch(() => { + if (subscribed) setError(true) + }) + return () => { + subscribed = false + } + }, [attempt, credentialId, knowledgeSpaceId, onConnected, onReconcile, provider.id]) + + return ( +
+ {error ? ( + <> + +

+ {t(($) => $['newKnowledge.connectionFailed'])} +

+ + + ) : ( + <> + +

+ {t(($) => $['newKnowledge.connectingProvider'])} +

+ + )} +
+ ) +} + function UnconfiguredProvider({ knowledgeSpaceId, onConnected, @@ -518,10 +617,20 @@ function UnconfiguredProvider({ const [configuring, setConfiguring] = useState(false) const difyManaged = isDifyManagedProvider(provider) - if ((difyManaged && credentialId) || configuring) + if (difyManaged && credentialId) + return ( + + ) + + if (configuring) return ( {option.label} diff --git a/web/features/new-rag/create-source-setup.tsx b/web/features/new-rag/create-source-setup.tsx index 9fb1528b8df..fff04a72c0d 100644 --- a/web/features/new-rag/create-source-setup.tsx +++ b/web/features/new-rag/create-source-setup.tsx @@ -50,8 +50,8 @@ const DEFAULT_MAX_PAGES = 100 const providers = { onlineDocuments: [ { icon: 'i-custom-public-common-notion', label: 'Notion' }, - { icon: 'i-ri-file-text-line', label: 'Google Docs' }, - { icon: 'i-ri-links-line', label: 'Confluence' }, + { icon: 'i-ri-file-text-fill text-[#4d8bf5]', label: 'Google Docs' }, + { icon: 'i-custom-public-common-confluence', label: 'Confluence' }, ], onlineDrive: [ { icon: 'i-custom-public-common-google-drive', label: 'Google Drive' }, diff --git a/web/features/new-rag/document-actions-dropdown.tsx b/web/features/new-rag/document-actions-dropdown.tsx index 59a4eca8764..56027e16831 100644 --- a/web/features/new-rag/document-actions-dropdown.tsx +++ b/web/features/new-rag/document-actions-dropdown.tsx @@ -5,24 +5,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { toast } from '@langgenius/dify-ui/toast' import { useTranslation } from 'react-i18next' export function DocumentActionsDropdown({ className, documentTitle, + onReindex, showReindex = true, }: { className?: string documentTitle: string + onReindex?: () => void showReindex?: boolean }) { const { t } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const unavailable = () => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable'])) + if (!showReindex || !onReindex) return null return ( @@ -36,32 +35,9 @@ export function DocumentActionsDropdown({ - - - {tCommon(($) => $['operation.rename'])} - - {showReindex && ( - - - {t(($) => $['newKnowledge.reindexDocument'])} - - )} - - - {t(($) => $['newKnowledge.disableSource'])} - - - - {t(($) => $['batchAction.archive'])} - - - - - {t(($) => $['newKnowledge.downloadDocuments'])} - - - - {tCommon(($) => $['operation.delete'])} + + + {t(($) => $['newKnowledge.reindexDocument'])} diff --git a/web/features/new-rag/document-detail-header.tsx b/web/features/new-rag/document-detail-header.tsx index 7fb479060ba..8b2dd184782 100644 --- a/web/features/new-rag/document-detail-header.tsx +++ b/web/features/new-rag/document-detail-header.tsx @@ -13,7 +13,6 @@ import { import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Link from '@/next/link' -import { DocumentActionsDropdown } from './document-actions-dropdown' export function DocumentDetailHeader({ backPath, @@ -135,11 +134,6 @@ export function DocumentDetailHeader({ {t(($) => $['newKnowledge.reindexDocument'])} - {isFetchNextRevisionPageError && ( diff --git a/web/features/new-rag/document-list.tsx b/web/features/new-rag/document-list.tsx index b0a3493a0ef..61cee6660f9 100644 --- a/web/features/new-rag/document-list.tsx +++ b/web/features/new-rag/document-list.tsx @@ -106,6 +106,7 @@ const DocumentRow = memo( documentHref, formatTimeFromNow, onSelectedChange, + onReindex, readOnlyReasonId, selected, selectionDisabled, @@ -118,6 +119,7 @@ const DocumentRow = memo( documentHref: string formatTimeFromNow: (time: number) => string onSelectedChange: (documentId: string) => void + onReindex: (documentId: string) => void readOnlyReasonId?: string selected: boolean selectionDisabled: boolean @@ -209,7 +211,10 @@ const DocumentRow = memo( {Number.isNaN(updatedTime) ? document.updatedAt : formatTimeFromNow(updatedTime)} - + onReindex(document.id)} + /> ) @@ -306,6 +311,7 @@ export function DocumentsList({ onFilterChange, onLoadMore, onOpenTasks, + onReindexDocument, onSearchChange, onSelectAll, onSelectDocument, @@ -345,6 +351,7 @@ export function DocumentsList({ onFilterChange: (filter: DocumentFilter) => void onLoadMore: () => void onOpenTasks: () => void + onReindexDocument: (documentId: string) => void onSearchChange: (search: string) => void onSelectAll: () => void onSelectDocument: (documentId: string) => void @@ -512,6 +519,7 @@ export function DocumentsList({ documentHref={getDocumentHref(document.id)} formatTimeFromNow={formatTimeFromNow} onSelectedChange={onSelectDocument} + onReindex={onReindexDocument} readOnlyReasonId={ !canEdit ? readOnlyReasonId @@ -685,21 +693,6 @@ export function DocumentBulkActions({ {disabledReason} )} - - -
+
{children}
diff --git a/web/features/new-rag/quality/golden-question-dialog.tsx b/web/features/new-rag/quality/golden-question-dialog.tsx index cb36d165852..08c08ab79aa 100644 --- a/web/features/new-rag/quality/golden-question-dialog.tsx +++ b/web/features/new-rag/quality/golden-question-dialog.tsx @@ -94,7 +94,7 @@ export function GoldenQuestionDialog({ onValueChange={setQuestion} /> {questionInvalid && ( - + {t(($) => $['newKnowledge.qualityPage.questionRequired'])} )} @@ -112,11 +112,11 @@ export function GoldenQuestionDialog({ onValueChange={setAnnotation} /> {annotationInvalid && ( - + {t(($) => $['newKnowledge.qualityPage.annotationRequired'])} )} - {!annotationInvalid && error && {error}} + {!annotationInvalid && error && {error}} {t(($) => $['newKnowledge.qualityPage.tags'])} diff --git a/web/features/new-rag/quality/quality-page.tsx b/web/features/new-rag/quality/quality-page.tsx index 6ac2df83699..8eb970f4f23 100644 --- a/web/features/new-rag/quality/quality-page.tsx +++ b/web/features/new-rag/quality/quality-page.tsx @@ -558,8 +558,8 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) )} ) : ( -
- +
+

{t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])}

@@ -689,8 +689,8 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) )}
) : ( -
- +
+

{t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])}

diff --git a/web/features/new-rag/retrieval-test-model.ts b/web/features/new-rag/retrieval-test-model.ts index bca22a6746d..59743f598ca 100644 --- a/web/features/new-rag/retrieval-test-model.ts +++ b/web/features/new-rag/retrieval-test-model.ts @@ -18,6 +18,14 @@ export type RetrievalEvidence = { } export type RetrievalTestRecord = + | { + createdAt: number + id: string + kind: 'local' + mode: Exclude + query: string + status: 'completed' | 'failed' | 'running' + } | { createdAt: number id: string @@ -80,6 +88,7 @@ function evidenceFromValue( if (!record) return undefined const metadata = objectValue(record.metadata) ?? {} const document = objectValue(record.document) ?? objectValue(metadata.document) ?? {} + const citation = Array.isArray(record.citations) ? (objectValue(record.citations[0]) ?? {}) : {} const text = firstString( record.text, record.content, @@ -117,6 +126,8 @@ function evidenceFromValue( document.id, metadata.document_id, metadata.documentId, + citation.documentAssetId, + citation.document_asset_id, record.target_id, record.targetId, ) @@ -153,6 +164,8 @@ function evidenceFromValue( record.id, record.chunk_id, record.chunkId, + record.node_id, + record.nodeId, record.target_id, record.targetId, metadata.id, @@ -181,6 +194,12 @@ function evidenceFromValue( metadata.revision_label, metadata.revisionLabel, typeof metadata.documentVersion === 'number' ? String(metadata.documentVersion) : undefined, + typeof citation.documentVersion === 'number' + ? `Revision ${citation.documentVersion}` + : undefined, + typeof citation.document_version === 'number' + ? `Revision ${citation.document_version}` + : undefined, ), score, text, diff --git a/web/features/new-rag/retrieval-test-page.tsx b/web/features/new-rag/retrieval-test-page.tsx index 237655ae74d..1de1f059ac3 100644 --- a/web/features/new-rag/retrieval-test-page.tsx +++ b/web/features/new-rag/retrieval-test-page.tsx @@ -13,7 +13,7 @@ import type { KnowledgeQueryEvent } from './services/knowledge-query-events' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { skipToken, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Link from '@/next/link' @@ -38,7 +38,7 @@ type LocalQueryRun = { mode: Exclude query: string startedAt: number - status: 'completed' | 'failed' | 'running' + status: 'completed' | 'failed' | 'no-results' | 'running' traceId?: string } @@ -69,7 +69,7 @@ function ScorePill({ score }: { score: number }) { const normalized = Math.max(0, Math.min(1, score)) return ( - {normalized.toFixed(2)} + Score {normalized.toFixed(2)} - {evidence.documentName ?? evidence.title} + {documentReference?.title ?? evidence.documentName ?? evidence.title} {evidence.revision && ( {evidence.revision} @@ -197,6 +225,27 @@ function EmptyState({ ) } +function FailedResult({ description, onRetry }: { description: string; onRetry: () => void }) { + const { t } = useTranslation('dataset') + return ( +
+ + + + {t(($) => $['newKnowledge.retrievalTest.failedTitle'])} + + {description} + + +
+ ) +} + function QualityActions({ decision, noResults, @@ -428,6 +477,11 @@ function RecordButton({ {record.query} {t(($) => $[`newKnowledge.settings.retrievalMode.${record.mode}`])} + {' · '} + {new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + }).format(record.createdAt)}
@@ -480,6 +534,24 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri () => retrievalTestRecords(tracesQuery.data?.data ?? [], researchTasksQuery.data?.data ?? []), [researchTasksQuery.data?.data, tracesQuery.data?.data], ) + const displayRecords = useMemo(() => { + if (!localRun) return records + const traceAlreadyListed = + localRun.traceId && + records.some((record) => record.kind === 'trace' && record.id === localRun.traceId) + if (traceAlreadyListed) return records + return [ + { + createdAt: localRun.startedAt, + id: localRun.id, + kind: 'local', + mode: localRun.mode, + query: localRun.query, + status: localRun.status === 'no-results' ? 'completed' : localRun.status, + }, + ...records, + ] + }, [localRun, records]) const selectedRecord = records.find( (record) => record.id === selected?.id && record.kind === selected.kind, ) @@ -494,30 +566,28 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri ? localRun?.traceId : undefined - const traceDetailQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.get.queryOptions({ - input: selectedTraceId - ? { - params: { - control_space_id: knowledgeSpaceId, - trace_id: selectedTraceId, - }, - } - : skipToken, + const traceDetailQuery = useQuery({ + ...consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.get.queryOptions({ + input: { + params: { + control_space_id: knowledgeSpaceId, + trace_id: selectedTraceId ?? '', + }, + }, }), - ) + enabled: Boolean(selectedTraceId), + }) const traceEvidenceQuery = useQuery({ ...consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.evidence.get.queryOptions({ - input: selectedTraceId - ? { - params: { - control_space_id: knowledgeSpaceId, - trace_id: selectedTraceId, - }, - query: { limit: 100 }, - } - : skipToken, + input: { + params: { + control_space_id: knowledgeSpaceId, + trace_id: selectedTraceId ?? '', + }, + query: { limit: 100 }, + }, }), + enabled: Boolean(selectedTraceId), }) const researchPartialsQuery = useQuery({ ...consoleQuery.knowledgeFs.spaces.byControlSpaceId.researchTasks.byTaskId.partials.get.queryOptions( @@ -545,6 +615,34 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri : selected?.kind === 'research' ? researchEvidence : historicalEvidence + const evidenceDocumentReferencesQuery = useQuery({ + queryKey: ['retrieval-document-references', knowledgeSpaceId], + enabled: currentEvidence.some((evidence) => evidence.documentId), + queryFn: async () => { + const references: Record = {} + const visitedCursors = new Set() + let cursor: string | undefined + do { + const response = + await consoleClient.knowledgeFs.spaces.byControlSpaceId.logicalDocuments.get({ + params: { control_space_id: knowledgeSpaceId }, + ...(cursor ? { query: { cursor } } : {}), + }) + response.data.forEach((document) => { + if (document.active) + references[document.active.document_asset_id] = { + id: document.id, + title: document.title, + } + }) + const nextCursor = response.next_cursor ?? undefined + if (!nextCursor || visitedCursors.has(nextCursor)) break + visitedCursors.add(nextCursor) + cursor = nextCursor + } while (cursor) + return references + }, + }) const resultKey = selected ? `${selected.kind}:${selected.id}` : undefined const selectedQuery = selected?.kind === 'local' @@ -560,6 +658,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri (selected?.kind === 'trace' && !selectedRecord && traceDetailQuery.isPending) || (selected?.kind === 'trace' && traceEvidenceQuery.isPending) const selectedFailed = selected?.kind === 'local' && localRun?.status === 'failed' + const selectedHasNoResults = selected?.kind === 'local' && localRun?.status === 'no-results' const visibleEvidence = showAll ? currentEvidence : currentEvidence.slice(0, 3) const selectRecord = (record: RetrievalTestRecord) => { @@ -677,13 +776,14 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri await tracesQuery.refetch() } catch (error) { if (controller.signal.aborted) return + const failure = await queryFailure(error) setLocalRun((current) => current?.id === id ? { ...current, endedAt: Date.now(), - error: error instanceof Error ? error.message : undefined, - status: 'failed', + error: failure.message, + status: failure.status, } : current, ) @@ -802,12 +902,14 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri

{t(($) => $['newKnowledge.retrievalTest.records'])}

- {records.length} + + {displayRecords.length} +
- {records.length > 0 ? ( + {displayRecords.length > 0 ? (
- {records.map((record) => ( + {displayRecords.map((record) => ( {selected?.kind === 'research' ? t(($) => $['newKnowledge.retrievalTest.researchResult']) - : selectedQuery} + : t(($) => $['newKnowledge.retrievalTest.result'], { + mode: selectedMode + ? t(($) => $[`newKnowledge.settings.retrievalMode.${selectedMode}`]) + : '', + })} {selectedMode @@ -881,9 +987,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri {selectedIsLoading && } {selectedFailed && ( - $['newKnowledge.retrievalTest.failedTitle'])} + $['newKnowledge.retrievalTest.failedDescription']) } @@ -891,12 +995,15 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri /> )} - {!selectedIsLoading && !selectedFailed && currentEvidence.length === 0 && ( - $['newKnowledge.retrievalTest.noChunksTitle'])} - description={t(($) => $['newKnowledge.retrievalTest.noChunksDescription'])} - /> - )} + {!selectedIsLoading && + !selectedFailed && + !researchTaskIsActive(selectedResearchTask) && + (selectedHasNoResults || currentEvidence.length === 0) && ( + $['newKnowledge.retrievalTest.noChunksTitle'])} + description={t(($) => $['newKnowledge.retrievalTest.noChunksDescription'])} + /> + )} {currentEvidence.length > 0 && (
@@ -913,6 +1020,11 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri {visibleEvidence.map((evidence, index) => ( = { active: 'success', @@ -76,6 +93,17 @@ function createIdempotencyKey() { return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` } +function normalizedWorkflowState(state: string) { + return state.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_') +} + +function sourceWorkflowStatus(state: string): SourceStatus { + const normalized = normalizedWorkflowState(state) + if (SOURCE_WORKFLOW_FAILURE_STATES.has(normalized)) return 'error' + if (SOURCE_WORKFLOW_SUCCESS_STATES.has(normalized)) return 'active' + return 'syncing' +} + function getOpenableSourceUri(uri: string) { try { const url = new URL(uri) @@ -254,6 +282,30 @@ function SourceRow({ const { t: tCommon } = useTranslation('common') const queryClient = useQueryClient() const [pendingAction, setPendingAction] = useState() + const [acceptedSyncRun, setAcceptedSyncRun] = useState>() + const syncWorkflowQuery = useQuery({ + ...consoleQuery.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.get.queryOptions({ + input: { + params: { + control_space_id: knowledgeSpaceId, + run_id: acceptedSyncRun?.id ?? '', + }, + }, + }), + enabled: Boolean(acceptedSyncRun), + refetchInterval: (query) => { + const workflow = query.state.data ? sourceWorkflowFromApi(query.state.data) : acceptedSyncRun + return workflow && sourceWorkflowStatus(workflow.state) === 'syncing' + ? SOURCE_WORKFLOW_POLL_INTERVAL + : false + }, + }) + const syncWorkflow = syncWorkflowQuery.data + ? sourceWorkflowFromApi(syncWorkflowQuery.data) + : acceptedSyncRun + const visibleSource = syncWorkflow + ? { ...source, status: sourceWorkflowStatus(syncWorkflow.state) } + : source const providerName = metadataString(source.metadata, 'providerName') const syncPolicy = metadataString(source.metadata, 'syncPolicy') const lastSync = metadataString(source.metadata, 'lastSyncedAt') @@ -318,7 +370,11 @@ function SourceRow({ headers: { 'Idempotency-Key': createIdempotencyKey() }, params: { control_space_id: knowledgeSpaceId, source_id: source.id }, }), - () => onSourceChange({ ...source, status: 'syncing' }), + (workflow) => { + const run = sourceWorkflowFromApi(workflow) + setAcceptedSyncRun(run) + onSourceChange({ ...source, status: sourceWorkflowStatus(run.state) }) + }, onSourceReconciled, ensureModelSetupReady, ) @@ -358,7 +414,7 @@ function SourceRow({ td:not(:first-child)]:opacity-60', + visibleSource.status === 'disabled' && '[&>td:not(:first-child)]:opacity-60', )} > @@ -379,13 +435,13 @@ function SourceRow({ - {t(($) => $[`newKnowledge.sourceStatus.${source.status}`])} + {t(($) => $[`newKnowledge.sourceStatus.${visibleSource.status}`])} @@ -394,13 +450,27 @@ function SourceRow({ - {source.status === 'error' ? ( + {visibleSource.status === 'syncing' && syncWorkflow ? ( + + + {t(($) => $['newKnowledge.sourceSyncProgress'], { + completed: + syncWorkflow.progressCompleted + + syncWorkflow.progressFailed + + syncWorkflow.progressSkipped, + total: syncWorkflow.progressTotal ?? '—', + })} + + ) : visibleSource.status === 'error' ? ( - {t(($) => $['newKnowledge.sourceSyncFailed'])} + {syncWorkflow?.lastErrorCode ?? t(($) => $['newKnowledge.sourceSyncFailed'])} ) : ( (lastSync ?? '—') @@ -408,7 +478,7 @@ function SourceRow({
- {canSync && source.status === 'error' && ( + {canSync && visibleSource.status === 'error' && (