mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(dataset): add website crawl preview (#39324)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
922ed4d67d
commit
ba5b26be03
@@ -2,7 +2,7 @@ import type {
|
||||
GetKnowledgeSpacesByIdSourceConnectionsResponse,
|
||||
GetSourceProvidersResponse,
|
||||
} from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { AddSourcePage } from '../add-source-page'
|
||||
@@ -45,11 +45,22 @@ const queryState = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
cancelRun: vi.fn(),
|
||||
createConnection: vi.fn(),
|
||||
createSource: vi.fn(),
|
||||
deleteConnection: vi.fn(),
|
||||
deleteSource: vi.fn(),
|
||||
getPages: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
getSource: vi.fn(),
|
||||
listSources: vi.fn(),
|
||||
refreshConnection: vi.fn(),
|
||||
retryRun: vi.fn(),
|
||||
startPreview: vi.fn(),
|
||||
}))
|
||||
|
||||
const routerMock = vi.hoisted(() => ({ replace: vi.fn() }))
|
||||
|
||||
const queryClientMock = vi.hoisted(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}))
|
||||
@@ -73,8 +84,17 @@ vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
knowledgeFs: {
|
||||
deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId: clientMock.deleteConnection,
|
||||
deleteKnowledgeSpacesByIdSourcesBySourceId: clientMock.deleteSource,
|
||||
getKnowledgeSpacesByIdSources: clientMock.listSources,
|
||||
getKnowledgeSpacesByIdSourcesBySourceId: clientMock.getSource,
|
||||
getKnowledgeSpacesByIdSourceWorkflowsByRunId: clientMock.getRun,
|
||||
getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages: clientMock.getPages,
|
||||
postKnowledgeSpacesByIdSourceConnections: clientMock.createConnection,
|
||||
postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh: clientMock.refreshConnection,
|
||||
postKnowledgeSpacesByIdSources: clientMock.createSource,
|
||||
postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview: clientMock.startPreview,
|
||||
postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel: clientMock.cancelRun,
|
||||
postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry: clientMock.retryRun,
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
@@ -90,6 +110,10 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => routerMock,
|
||||
}))
|
||||
|
||||
const firecrawlProvider: GetSourceProvidersResponse['items'][number] = {
|
||||
authKinds: ['api-key', 'endpoint'],
|
||||
available: true,
|
||||
@@ -186,12 +210,48 @@ const connection = (
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const provisionalSource = {
|
||||
connectionId: 'connection-1',
|
||||
createdAt: '2026-07-20T10:00:00Z',
|
||||
id: 'source-1',
|
||||
knowledgeSpaceId: 'space-1',
|
||||
metadata: { preview: true },
|
||||
name: 'Dify docs',
|
||||
status: 'disabled' as const,
|
||||
type: 'web' as const,
|
||||
updatedAt: '2026-07-20T10:00:00Z',
|
||||
uri: 'https://docs.dify.ai/',
|
||||
version: 1,
|
||||
}
|
||||
|
||||
const previewRun = {
|
||||
checkpoint: 'queued',
|
||||
createdAt: '2026-07-20T10:00:00Z',
|
||||
executionAttempts: 1,
|
||||
id: 'run-1',
|
||||
knowledgeSpaceId: 'space-1',
|
||||
kind: 'crawl-preview',
|
||||
maxExecutionAttempts: 3,
|
||||
progressCompleted: 0,
|
||||
progressFailed: 0,
|
||||
progressSkipped: 0,
|
||||
sourceId: 'source-1',
|
||||
state: 'running',
|
||||
updatedAt: '2026-07-20T10:00:00Z',
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('AddSourcePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clientMock.createConnection.mockReset()
|
||||
clientMock.deleteConnection.mockReset()
|
||||
clientMock.refreshConnection.mockReset()
|
||||
for (const mock of Object.values(clientMock)) mock.mockReset()
|
||||
queryState.connections.refetch.mockReset()
|
||||
queryState.providers.refetch.mockReset()
|
||||
queryState.providers.data = { items: [firecrawlProvider, jinaProvider] }
|
||||
@@ -203,6 +263,13 @@ describe('AddSourcePage', () => {
|
||||
queryState.connections.isFetchNextPageError = false
|
||||
queryState.connections.isFetchingNextPage = false
|
||||
queryState.connections.isPending = false
|
||||
clientMock.createSource.mockResolvedValue(provisionalSource)
|
||||
clientMock.deleteSource.mockResolvedValue({ id: 'deletion-1' })
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
clientMock.getRun.mockResolvedValue({ ...previewRun, state: 'preview_ready' })
|
||||
clientMock.getSource.mockResolvedValue(provisionalSource)
|
||||
clientMock.listSources.mockResolvedValue({ items: [] })
|
||||
clientMock.startPreview.mockResolvedValue(previewRun)
|
||||
})
|
||||
|
||||
it('loads the provider catalog and every scoped connection cursor page', () => {
|
||||
@@ -254,13 +321,10 @@ describe('AddSourcePage', () => {
|
||||
|
||||
expect(screen.getByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
|
||||
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ })).toBeEnabled()
|
||||
expect(
|
||||
screen.getByText('dataset.newKnowledge.crawlSetupUnavailableDescription'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }),
|
||||
).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('creates the exact Firecrawl provider connection without leaking credentials', async () => {
|
||||
@@ -760,6 +824,265 @@ describe('AddSourcePage', () => {
|
||||
expect(toastInfoMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks before discarding an edited source draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardSourceDraftTitle',
|
||||
})
|
||||
expect(dialog).toHaveTextContent('dataset.newKnowledge.discardSourceDraftDescription')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(routerMock.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks before discarding provider connection credentials', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /dataset\.newKnowledge\.configureProvider/ }),
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Api Key/), 'unsaved-secret')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardSourceDraftTitle',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(routerMock.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('guards client-side marketplace navigation while the source draft is dirty', async () => {
|
||||
const user = userEvent.setup()
|
||||
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.click(screen.getByRole('link', { name: /dataset\.newKnowledge\.moreProviders/ }))
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardSourceDraftTitle',
|
||||
})
|
||||
expect(routerMock.replace).not.toHaveBeenCalled()
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: 'dataset.newKnowledge.discardDraftConfirm' }),
|
||||
)
|
||||
await waitFor(() => expect(historyBack).toHaveBeenCalledOnce())
|
||||
|
||||
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/marketplace?category=datasource')
|
||||
})
|
||||
|
||||
it('guards client-side navigation from links outside the add source form', async () => {
|
||||
const user = userEvent.setup()
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
|
||||
render(
|
||||
<>
|
||||
<a href="/datasets/space-1/documents">Documents</a>
|
||||
<AddSourcePage knowledgeSpaceId="space-1" />
|
||||
</>,
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.click(screen.getByRole('link', { name: 'Documents' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardSourceDraftTitle',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(routerMock.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for source creation before deleting the provisional source on discard', async () => {
|
||||
const user = userEvent.setup()
|
||||
const sourceRequest = deferred<typeof provisionalSource>()
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
clientMock.createSource.mockReturnValue(sourceRequest.promise)
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ }),
|
||||
'Dify docs',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await waitFor(() => expect(clientMock.createSource).toHaveBeenCalledOnce())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
|
||||
const dialog = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardSourceDraftTitle',
|
||||
})
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: 'dataset.newKnowledge.discardDraftConfirm' }),
|
||||
)
|
||||
expect(clientMock.deleteSource).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => sourceRequest.resolve(provisionalSource))
|
||||
|
||||
await waitFor(() => expect(clientMock.deleteSource).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('starts with a fresh preview draft after switching active providers', async () => {
|
||||
const user = userEvent.setup()
|
||||
const jinaConnection = connection('active', 2, {
|
||||
configuration: { provider: 'jina' },
|
||||
id: 'jina-connection',
|
||||
name: 'Jina Reader',
|
||||
providerId: 'jina-reader',
|
||||
})
|
||||
const jinaSource = {
|
||||
...provisionalSource,
|
||||
connectionId: jinaConnection.id,
|
||||
id: 'source-2',
|
||||
}
|
||||
queryState.connections.data = {
|
||||
pages: [{ items: [connection('active'), jinaConnection] }],
|
||||
}
|
||||
clientMock.createSource
|
||||
.mockResolvedValueOnce(provisionalSource)
|
||||
.mockResolvedValueOnce(jinaSource)
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ }),
|
||||
'Dify docs',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByText(/^dataset\.newKnowledge\.noPagesFound:/)
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Jina Reader' }))
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ })).toHaveValue('')
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://jina.ai',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ }),
|
||||
'Jina docs',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.createSource).toHaveBeenCalledTimes(2))
|
||||
expect(clientMock.createSource.mock.calls[1]?.[0].body.connectionId).toBe('jina-connection')
|
||||
})
|
||||
|
||||
it('reconciles a response-lost provisional source deletion before leaving', async () => {
|
||||
const user = userEvent.setup()
|
||||
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
clientMock.deleteSource.mockRejectedValue(new Error('response lost'))
|
||||
clientMock.getSource.mockRejectedValue(
|
||||
Object.assign(new Error('source not found'), { status: 404 }),
|
||||
)
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ }),
|
||||
'Dify docs',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await waitFor(() => expect(clientMock.createSource).toHaveBeenCalledOnce())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
|
||||
const dialog = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardSourceDraftTitle',
|
||||
})
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: 'dataset.newKnowledge.discardDraftConfirm' }),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(clientMock.deleteSource).toHaveBeenCalledWith({
|
||||
body: { expectedRevision: 1 },
|
||||
headers: { 'idempotency-key': expect.any(String) },
|
||||
params: { id: 'space-1', sourceId: 'source-1' },
|
||||
query: { documents: 'cascade' },
|
||||
}),
|
||||
)
|
||||
expect(clientMock.getSource).toHaveBeenCalledWith({
|
||||
params: { id: 'space-1', sourceId: 'source-1' },
|
||||
})
|
||||
expect(historyBack).toHaveBeenCalledOnce()
|
||||
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets/new/space-1/sources')
|
||||
})
|
||||
|
||||
it('protects an edited source draft from browser unload', async () => {
|
||||
const user = userEvent.setup()
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
|
||||
act(() => window.dispatchEvent(event))
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('deletes a known provisional source with keepalive when the page unloads', async () => {
|
||||
const user = userEvent.setup()
|
||||
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
|
||||
|
||||
render(<AddSourcePage knowledgeSpaceId="space-1" />)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
|
||||
'https://docs.dify.ai',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ }),
|
||||
'Dify docs',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await waitFor(() => expect(clientMock.createSource).toHaveBeenCalledOnce())
|
||||
|
||||
act(() => window.dispatchEvent(new Event('pagehide')))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(clientMock.deleteSource).toHaveBeenCalledWith(
|
||||
{
|
||||
body: { expectedRevision: 1 },
|
||||
headers: { 'idempotency-key': expect.any(String) },
|
||||
params: { id: 'space-1', sourceId: 'source-1' },
|
||||
query: { documents: 'cascade' },
|
||||
},
|
||||
{ context: { keepalive: true, silent: true } },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows catalog unavailability instead of offering a fake connection', () => {
|
||||
queryState.providers.data = {
|
||||
items: [{ ...firecrawlProvider, available: false, unavailableReason: 'Disabled by admin' }],
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
import type { Source, SourceWorkflowRun } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { WebsiteCrawlPreview } from '../website-crawl-preview'
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
cancel: vi.fn(),
|
||||
createSource: vi.fn(),
|
||||
getPages: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
listSources: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
startPreview: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
knowledgeFs: {
|
||||
getKnowledgeSpacesByIdSources: clientMock.listSources,
|
||||
getKnowledgeSpacesByIdSourceWorkflowsByRunId: clientMock.getRun,
|
||||
getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages: clientMock.getPages,
|
||||
postKnowledgeSpacesByIdSources: clientMock.createSource,
|
||||
postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview: clientMock.startPreview,
|
||||
postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel: clientMock.cancel,
|
||||
postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry: clientMock.retry,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
providerId: 'plugin-daemon-website',
|
||||
}
|
||||
|
||||
const source = (metadata: Source['metadata'] = {}): Source => ({
|
||||
connectionId: connection.id,
|
||||
createdAt: '2026-07-20T10:00:00Z',
|
||||
id: 'source-1',
|
||||
knowledgeSpaceId: 'space-1',
|
||||
metadata,
|
||||
name: 'Dify docs',
|
||||
status: 'disabled',
|
||||
type: 'web',
|
||||
updatedAt: '2026-07-20T10:00:00Z',
|
||||
uri: 'https://docs.dify.ai/',
|
||||
})
|
||||
|
||||
const run = (state: string, overrides: Partial<SourceWorkflowRun> = {}): SourceWorkflowRun => ({
|
||||
checkpoint: 'crawl',
|
||||
createdAt: '2026-07-20T10:00:00Z',
|
||||
executionAttempts: 1,
|
||||
id: 'run-1',
|
||||
knowledgeSpaceId: 'space-1',
|
||||
kind: 'crawl-preview',
|
||||
maxExecutionAttempts: 3,
|
||||
progressCompleted: 0,
|
||||
progressFailed: 0,
|
||||
progressSkipped: 0,
|
||||
sourceId: 'source-1',
|
||||
state,
|
||||
updatedAt: '2026-07-20T10:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
async function fillValidForm() {
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/^dataset\.newKnowledge\.rootUrl/), 'https://docs.dify.ai')
|
||||
await user.type(screen.getByLabelText(/^dataset\.newKnowledge\.sourceName/), 'Dify docs')
|
||||
return user
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('WebsiteCrawlPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const mock of Object.values(clientMock)) mock.mockReset()
|
||||
clientMock.createSource.mockResolvedValue(source())
|
||||
clientMock.startPreview.mockResolvedValue(run('running'))
|
||||
clientMock.getRun.mockResolvedValue(
|
||||
run('preview_ready', {
|
||||
checkpoint: 'preview-staged',
|
||||
progressCompleted: 1,
|
||||
progressTotal: 1,
|
||||
}),
|
||||
)
|
||||
clientMock.getPages.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
pageId: 'page-1',
|
||||
sourceUrl: 'https://docs.dify.ai/getting-started',
|
||||
title: 'Getting started',
|
||||
},
|
||||
],
|
||||
})
|
||||
clientMock.listSources.mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('validates the URL and starts a real crawl preview with a provisional source', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
|
||||
const start = screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' })
|
||||
const rootUrl = screen.getByLabelText(/^dataset\.newKnowledge\.rootUrl/)
|
||||
expect(start).toBeEnabled()
|
||||
await user.click(start)
|
||||
expect(rootUrl).toHaveFocus()
|
||||
expect(rootUrl).toHaveAttribute('aria-invalid', 'true')
|
||||
await user.type(rootUrl, 'ftp://docs.dify.ai')
|
||||
await user.tab()
|
||||
expect(rootUrl).toHaveAttribute('aria-invalid', 'true')
|
||||
await user.clear(rootUrl)
|
||||
await user.type(rootUrl, 'https://docs.dify.ai')
|
||||
await user.type(screen.getByLabelText(/^dataset\.newKnowledge\.sourceName/), 'Dify docs')
|
||||
await user.click(start)
|
||||
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledOnce())
|
||||
expect(clientMock.createSource).toHaveBeenCalledWith({
|
||||
body: {
|
||||
connectionId: 'connection-1',
|
||||
metadata: {
|
||||
clientRequestId: expect.any(String),
|
||||
crawlOptions: { includeSubpages: true, limit: 100 },
|
||||
preview: true,
|
||||
providerId: 'plugin-daemon-website',
|
||||
},
|
||||
name: 'Dify docs',
|
||||
status: 'disabled',
|
||||
type: 'web',
|
||||
uri: 'https://docs.dify.ai/',
|
||||
},
|
||||
params: { id: 'space-1' },
|
||||
})
|
||||
expect(clientMock.startPreview).toHaveBeenCalledWith({
|
||||
headers: { 'Idempotency-Key': expect.any(String) },
|
||||
params: { id: 'space-1', sourceId: 'source-1' },
|
||||
})
|
||||
expect(await screen.findByText('Getting started')).toBeInTheDocument()
|
||||
expect(screen.getByText(/^dataset\.newKnowledge\.pagesCrawled/)).toHaveAttribute(
|
||||
'role',
|
||||
'status',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the shared form controls with stable field names and autocomplete metadata', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText(/^dataset\.newKnowledge\.rootUrl/)).toHaveAttribute(
|
||||
'name',
|
||||
'root-url',
|
||||
)
|
||||
expect(screen.getByLabelText(/^dataset\.newKnowledge\.rootUrl/)).toHaveAttribute(
|
||||
'autocomplete',
|
||||
'off',
|
||||
)
|
||||
expect(screen.getByLabelText(/^dataset\.newKnowledge\.sourceName/)).toHaveAttribute(
|
||||
'name',
|
||||
'source-name',
|
||||
)
|
||||
expect(screen.getByLabelText(/^dataset\.newKnowledge\.sourceName/)).toHaveAttribute(
|
||||
'autocomplete',
|
||||
'off',
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dataset\.newKnowledge\.crawlOptions/ }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.includeSubpages' }),
|
||||
).toBeInTheDocument()
|
||||
expect(document.querySelector('input[name="include-subpages"]')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.maxPages' })).toHaveAttribute(
|
||||
'name',
|
||||
'page-limit',
|
||||
)
|
||||
})
|
||||
|
||||
it('only labels collapsed crawl options as defaults when they are unchanged', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.usingDefaults')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /dataset\.newKnowledge\.crawlOptions/ }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'dataset.newKnowledge.includeSubpages' }))
|
||||
await user.click(screen.getByRole('button', { name: /dataset\.newKnowledge\.crawlOptions/ }))
|
||||
|
||||
expect(screen.queryByText('dataset.newKnowledge.usingDefaults')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps completed results associated with the submitted URL while the draft is edited', async () => {
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByText('Getting started')
|
||||
|
||||
const rootUrl = screen.getByLabelText(/^dataset\.newKnowledge\.rootUrl/)
|
||||
await user.clear(rootUrl)
|
||||
await user.type(rootUrl, 'https://example.com')
|
||||
|
||||
const completedStatus = screen.getByText(/^dataset\.newKnowledge\.pagesCrawled/)
|
||||
expect(completedStatus).toHaveTextContent('docs.dify.ai')
|
||||
expect(completedStatus).not.toHaveTextContent('example.com')
|
||||
})
|
||||
|
||||
it('submits the crawl form with Enter and enforces the source name contract limit', async () => {
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
const name = screen.getByLabelText(/^dataset\.newKnowledge\.sourceName/)
|
||||
expect(name).toHaveAttribute('maxlength', '200')
|
||||
await user.clear(name)
|
||||
await user.type(name, 'x'.repeat(201))
|
||||
expect(name).toHaveValue('x'.repeat(200))
|
||||
await user.type(name, '{enter}')
|
||||
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('shows pending feedback while a completed crawl is being restarted', async () => {
|
||||
const restartRequest = deferred<SourceWorkflowRun>()
|
||||
clientMock.startPreview
|
||||
.mockResolvedValueOnce(run('running'))
|
||||
.mockReturnValueOnce(restartRequest.promise)
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
const reCrawl = await screen.findByRole('button', { name: 'dataset.newKnowledge.reCrawl' })
|
||||
await user.click(reCrawl)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.crawling' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
await act(async () =>
|
||||
restartRequest.resolve(
|
||||
run('running', {
|
||||
id: 'run-2',
|
||||
updatedAt: '2026-07-20T10:01:00Z',
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
|
||||
const firstKey = clientMock.startPreview.mock.calls[0]?.[0].headers['Idempotency-Key']
|
||||
const secondKey = clientMock.startPreview.mock.calls[1]?.[0].headers['Idempotency-Key']
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
expect(clientMock.createSource).toHaveBeenCalledOnce()
|
||||
expect(clientMock.retry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('streams page cursors while running and replaces them with the final snapshot', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
clientMock.getRun
|
||||
.mockResolvedValueOnce(run('running', { progressCompleted: 2, progressTotal: 4 }))
|
||||
.mockResolvedValueOnce(run('succeeded', { progressCompleted: 4, progressTotal: 4 }))
|
||||
clientMock.getPages
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ pageId: 'page-1', sourceUrl: 'https://docs.dify.ai/one', title: 'Old one' }],
|
||||
nextCursor: 'page-2',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ pageId: 'page-2', sourceUrl: 'https://docs.dify.ai/two', title: 'Two' },
|
||||
{
|
||||
pageId: 'page-deleted',
|
||||
sourceUrl: 'https://docs.dify.ai/deleted',
|
||||
title: 'Deleted page',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ pageId: 'page-0', sourceUrl: 'https://docs.dify.ai/new', title: 'New first' },
|
||||
{ pageId: 'page-2', sourceUrl: 'https://docs.dify.ai/two', title: 'Two' },
|
||||
],
|
||||
nextCursor: 'final-page-2',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ pageId: 'page-1', sourceUrl: 'https://docs.dify.ai/one', title: 'Updated one' },
|
||||
{ pageId: 'page-3', sourceUrl: 'https://docs.dify.ai/three', title: 'Three' },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
|
||||
expect(await screen.findByText('Two')).toBeInTheDocument()
|
||||
expect(clientMock.getRun).toHaveBeenNthCalledWith(1, {
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
})
|
||||
expect(clientMock.getPages).toHaveBeenNthCalledWith(1, {
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
query: { limit: 200 },
|
||||
})
|
||||
expect(clientMock.getPages).toHaveBeenNthCalledWith(2, {
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
query: { cursor: 'page-2', limit: 200 },
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: 'dataset.newKnowledge.crawlProgress:{"host":"docs.dify.ai"}',
|
||||
}),
|
||||
).toHaveValue(3)
|
||||
const status = screen.getByText(/^dataset\.newKnowledge\.crawlingPages/)
|
||||
expect(status).toHaveAttribute('role', 'status')
|
||||
expect(status.querySelector('button')).not.toBeInTheDocument()
|
||||
const skeletons = screen.getAllByTestId('crawl-page-skeleton')
|
||||
expect(skeletons).toHaveLength(2)
|
||||
expect(skeletons[0]?.closest('ul')).toBe(screen.getByRole('list'))
|
||||
await act(async () => vi.advanceTimersByTime(1500))
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument()
|
||||
expect(screen.getByText('New first')).toBeInTheDocument()
|
||||
expect(screen.getByText('Updated one')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Old one')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Deleted page')).not.toBeInTheDocument()
|
||||
expect(clientMock.getPages).toHaveBeenNthCalledWith(3, {
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
query: { limit: 200 },
|
||||
})
|
||||
expect(clientMock.getPages).toHaveBeenNthCalledWith(4, {
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
query: { cursor: 'final-page-2', limit: 200 },
|
||||
})
|
||||
expect(
|
||||
screen.getAllByRole('checkbox').map((checkbox) => checkbox.getAttribute('aria-label')),
|
||||
).toEqual(['New first', 'Two', 'Updated one', 'Three'])
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stops the active run once and keeps pages already discovered', async () => {
|
||||
clientMock.getRun.mockResolvedValue(run('running', { progressCompleted: 1 }))
|
||||
clientMock.cancel.mockResolvedValue(run('canceled', { progressCompleted: 1 }))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByText('Getting started')
|
||||
const stop = screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' })
|
||||
await user.dblClick(stop)
|
||||
|
||||
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
|
||||
expect(clientMock.cancel).toHaveBeenCalledWith({
|
||||
body: { reason: 'user_requested' },
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
})
|
||||
expect(screen.getByText('Getting started')).toBeInTheDocument()
|
||||
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toHaveAttribute(
|
||||
'role',
|
||||
'status',
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles a lost Stop response without sending cancel twice', async () => {
|
||||
clientMock.getRun
|
||||
.mockResolvedValueOnce(run('running', { progressCompleted: 1 }))
|
||||
.mockResolvedValueOnce(run('canceled', { progressCompleted: 1 }))
|
||||
clientMock.cancel.mockRejectedValue(new Error('response lost'))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByText('Getting started')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }))
|
||||
|
||||
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toHaveAttribute(
|
||||
'role',
|
||||
'status',
|
||||
)
|
||||
expect(clientMock.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('only reconciles Stop while an ambiguous cancel response remains stale', async () => {
|
||||
clientMock.getRun.mockResolvedValue(run('running', { progressCompleted: 1 }))
|
||||
clientMock.cancel.mockRejectedValue(
|
||||
Object.assign(new Error('proxy connection lost'), { status: 502 }),
|
||||
)
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByText('Getting started')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }))
|
||||
clientMock.getRun.mockRejectedValueOnce(
|
||||
Object.assign(new Error('reconciliation unavailable'), { status: 503 }),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }))
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.getRun.mock.calls.length).toBeGreaterThanOrEqual(3))
|
||||
expect(clientMock.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows Stop to be retried after a definitive HTTP rejection', async () => {
|
||||
clientMock.getRun.mockResolvedValue(run('running', { progressCompleted: 1 }))
|
||||
clientMock.cancel
|
||||
.mockRejectedValueOnce(Object.assign(new Error('rate limited'), { status: 429 }))
|
||||
.mockResolvedValueOnce(run('canceled', { progressCompleted: 1 }))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByText('Getting started')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }))
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }))
|
||||
|
||||
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toBeInTheDocument()
|
||||
expect(clientMock.cancel).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows provider failures and retries the same workflow without duplicate requests', async () => {
|
||||
clientMock.getRun.mockResolvedValue(
|
||||
run('failed', { lastErrorCode: 'PROVIDER_403', progressFailed: 1 }),
|
||||
)
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
clientMock.retry.mockResolvedValue(run('running'))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.crawlFailed403',
|
||||
)
|
||||
const retry = screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' })
|
||||
await user.dblClick(retry)
|
||||
|
||||
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledOnce())
|
||||
expect(clientMock.retry).toHaveBeenCalledWith({
|
||||
params: { id: 'space-1', runId: 'run-1' },
|
||||
})
|
||||
expect(clientMock.createSource).toHaveBeenCalledOnce()
|
||||
expect(clientMock.startPreview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reconciles a lost Retry response without sending retry twice', async () => {
|
||||
clientMock.getRun
|
||||
.mockResolvedValueOnce(run('failed', { lastErrorCode: 'PROVIDER_403' }))
|
||||
.mockResolvedValueOnce(
|
||||
run('running', {
|
||||
executionAttempts: 2,
|
||||
updatedAt: '2026-07-20T10:01:00Z',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
run('succeeded', {
|
||||
executionAttempts: 2,
|
||||
progressCompleted: 1,
|
||||
updatedAt: '2026-07-20T10:02:00Z',
|
||||
}),
|
||||
)
|
||||
clientMock.retry.mockRejectedValue(new Error('response lost'))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'dataset.newKnowledge.reCrawl' }),
|
||||
).toBeInTheDocument()
|
||||
expect(clientMock.retry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('only reconciles Retry while an ambiguous retry response remains stale', async () => {
|
||||
const failedRun = run('failed', { lastErrorCode: 'PROVIDER_403', progressFailed: 1 })
|
||||
clientMock.getRun
|
||||
.mockResolvedValueOnce(failedRun)
|
||||
.mockResolvedValueOnce(failedRun)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('reconciliation unavailable'), { status: 503 }),
|
||||
)
|
||||
.mockResolvedValue(failedRun)
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
clientMock.retry.mockRejectedValue(Object.assign(new Error('proxy timeout'), { status: 504 }))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.getRun).toHaveBeenCalledTimes(4))
|
||||
expect(clientMock.retry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows Retry to be repeated after a definitive HTTP rejection', async () => {
|
||||
clientMock.getRun.mockResolvedValue(
|
||||
run('failed', { lastErrorCode: 'PROVIDER_403', progressFailed: 1 }),
|
||||
)
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
clientMock.retry
|
||||
.mockRejectedValueOnce(Object.assign(new Error('rate limited'), { status: 429 }))
|
||||
.mockResolvedValueOnce(run('running', { executionAttempts: 2 }))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['failed', 'PROVIDER_TIMEOUT', 'dataset.newKnowledge.crawlFailedTimeout'],
|
||||
['failed', 'PROVIDER_UNAVAILABLE', 'dataset.newKnowledge.crawlFailedProvider'],
|
||||
['timed_out', undefined, 'dataset.newKnowledge.crawlFailedTimeout'],
|
||||
])('shows a recovery message for %s / %s', async (state, lastErrorCode, message) => {
|
||||
clientMock.getRun.mockResolvedValue(run(state, { lastErrorCode }))
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Jina Reader"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(message)
|
||||
if (lastErrorCode === 'PROVIDER_UNAVAILABLE')
|
||||
expect(alert).toHaveTextContent('"provider":"Jina Reader"')
|
||||
})
|
||||
|
||||
it('offers an adjust-and-recrawl path after a successful zero-result crawl', async () => {
|
||||
clientMock.getRun.mockResolvedValue(run('succeeded'))
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
|
||||
const noPages = await screen.findByText(/^dataset\.newKnowledge\.noPagesFound:/)
|
||||
expect(noPages.closest('[role="status"]')).toHaveAttribute('aria-live', 'polite')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.adjustAndRecrawl' }))
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
|
||||
const firstKey = clientMock.startPreview.mock.calls[0]?.[0].headers['Idempotency-Key']
|
||||
const secondKey = clientMock.startPreview.mock.calls[1]?.[0].headers['Idempotency-Key']
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
expect(clientMock.createSource).toHaveBeenCalledOnce()
|
||||
expect(clientMock.retry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports draft and provisional source lifecycle to the parent flow', async () => {
|
||||
const onDraftChange = vi.fn()
|
||||
const onProvisionalSource = vi.fn()
|
||||
const view = render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
onDraftChange={onDraftChange}
|
||||
onProvisionalSource={onProvisionalSource}
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
|
||||
const user = await fillValidForm()
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith(true)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await waitFor(() => expect(onProvisionalSource).toHaveBeenCalledWith(source()))
|
||||
|
||||
view.unmount()
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('treats a superseded workflow as terminal and stops polling', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
clientMock.getRun.mockResolvedValue(run('superseded'))
|
||||
clientMock.getPages.mockResolvedValue({ items: [] })
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toHaveAttribute(
|
||||
'role',
|
||||
'status',
|
||||
)
|
||||
await act(async () => vi.advanceTimersByTime(6000))
|
||||
|
||||
expect(clientMock.getRun).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resumes polling after a transient status request failure without retrying the workflow', async () => {
|
||||
clientMock.getRun
|
||||
.mockRejectedValueOnce(new Error('temporary network failure'))
|
||||
.mockResolvedValueOnce(run('succeeded', { progressCompleted: 1 }))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.crawlFailedDescription',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.getRun).toHaveBeenCalledTimes(2))
|
||||
expect(clientMock.retry).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('Getting started')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reuses the idempotency key when the preview start response is lost', async () => {
|
||||
clientMock.startPreview
|
||||
.mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValueOnce(run('running'))
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
|
||||
expect(clientMock.createSource).toHaveBeenCalledOnce()
|
||||
expect(clientMock.startPreview.mock.calls[0]?.[0].headers).toEqual(
|
||||
clientMock.startPreview.mock.calls[1]?.[0].headers,
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles a response-lost provisional source by its client request id', async () => {
|
||||
clientMock.createSource.mockRejectedValue(new Error('response lost'))
|
||||
clientMock.listSources.mockImplementation(
|
||||
async ({ query }: { query?: { cursor?: string } }) => {
|
||||
const clientRequestId =
|
||||
clientMock.createSource.mock.calls[0]?.[0].body.metadata.clientRequestId
|
||||
return query?.cursor
|
||||
? { items: [source({ clientRequestId })] }
|
||||
: { items: [], nextCursor: 'next' }
|
||||
},
|
||||
)
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.listSources).toHaveBeenCalledTimes(2))
|
||||
expect(clientMock.startPreview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('checks for a response-lost source before repeating its create request', async () => {
|
||||
clientMock.createSource.mockRejectedValue(new Error('response lost'))
|
||||
clientMock.listSources.mockResolvedValueOnce({ items: [] }).mockImplementationOnce(async () => {
|
||||
const clientRequestId =
|
||||
clientMock.createSource.mock.calls[0]?.[0].body.metadata.clientRequestId
|
||||
return { items: [source({ clientRequestId })] }
|
||||
})
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledOnce())
|
||||
expect(clientMock.createSource).toHaveBeenCalledOnce()
|
||||
expect(clientMock.listSources).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries source creation after bounded reconciliation of an ambiguous result', async () => {
|
||||
clientMock.createSource
|
||||
.mockRejectedValueOnce(Object.assign(new Error('proxy timeout'), { status: 504 }))
|
||||
.mockResolvedValueOnce(source())
|
||||
clientMock.listSources.mockResolvedValue({ items: [] })
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
await waitFor(() => expect(clientMock.listSources).toHaveBeenCalledTimes(2))
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledOnce())
|
||||
expect(clientMock.listSources).toHaveBeenCalledTimes(3)
|
||||
expect(clientMock.createSource).toHaveBeenCalledTimes(2)
|
||||
expect(clientMock.createSource.mock.calls[0]?.[0].body.metadata.clientRequestId).toBe(
|
||||
clientMock.createSource.mock.calls[1]?.[0].body.metadata.clientRequestId,
|
||||
)
|
||||
})
|
||||
|
||||
it('retries source creation after a definitive HTTP rejection', async () => {
|
||||
clientMock.createSource
|
||||
.mockRejectedValueOnce(Object.assign(new Error('rate limited'), { status: 429 }))
|
||||
.mockResolvedValueOnce(source())
|
||||
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await screen.findByRole('alert')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryCrawl' }))
|
||||
|
||||
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledOnce())
|
||||
expect(clientMock.createSource).toHaveBeenCalledTimes(2)
|
||||
expect(clientMock.listSources).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up polling when the component unmounts', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
clientMock.getRun.mockResolvedValue(run('running'))
|
||||
const view = render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
knowledgeSpaceId="space-1"
|
||||
providerName="Firecrawl"
|
||||
/>,
|
||||
)
|
||||
const user = await fillValidForm()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
|
||||
await waitFor(() => expect(clientMock.getRun).toHaveBeenCalledOnce())
|
||||
view.unmount()
|
||||
await act(async () => vi.advanceTimersByTime(6000))
|
||||
|
||||
expect(clientMock.getRun).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -3,16 +3,20 @@
|
||||
import type {
|
||||
GetKnowledgeSpacesByIdSourceConnectionsResponse,
|
||||
GetSourceProvidersResponse,
|
||||
Source,
|
||||
} from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { AddSourceExitDialog } from './components/add-source-exit-dialog'
|
||||
import { newKnowledgeDetailPath } from './routes'
|
||||
import { WebsiteCrawlPreview } from './website-crawl-preview'
|
||||
|
||||
type Provider = GetSourceProvidersResponse['items'][number]
|
||||
type ProviderField = Provider['configuration'][number]
|
||||
@@ -23,6 +27,7 @@ type SourceType = 'onlineDocuments' | 'onlineDrive' | 'websiteCrawl'
|
||||
const CONNECTION_PAGE_SIZE = 200
|
||||
const CONNECTION_RECONCILIATION_RETRY_DELAYS = [0, 100, 300]
|
||||
const FIRECRAWL_PROVIDER_ID = 'plugin-daemon-website'
|
||||
const SOURCE_MARKETPLACE_PATH = '/marketplace?category=datasource'
|
||||
const FIRECRAWL_CONFIGURATION = {
|
||||
datasource: 'crawl',
|
||||
pluginId: 'langgenius/firecrawl_datasource',
|
||||
@@ -81,6 +86,15 @@ function findConnectionById(connections: Connection[], connectionId: string) {
|
||||
)[0]
|
||||
}
|
||||
|
||||
function responseStatus(error: unknown) {
|
||||
if (error instanceof Response) return error.status
|
||||
if (!error || typeof error !== 'object') return undefined
|
||||
if ('status' in error && typeof error.status === 'number') return error.status
|
||||
if ('data' in error && error.data && typeof error.data === 'object' && 'status' in error.data) {
|
||||
return typeof error.data.status === 'number' ? error.data.status : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthFields(provider: Provider, authKind: ConnectionAuthKind) {
|
||||
const configurableFields = provider.configuration.filter(
|
||||
(field) =>
|
||||
@@ -153,10 +167,12 @@ function SourceTypeSelector({
|
||||
}
|
||||
|
||||
function ProviderSelector({
|
||||
onNavigate,
|
||||
onChange,
|
||||
providers,
|
||||
value,
|
||||
}: {
|
||||
onNavigate: (path: string) => void
|
||||
onChange: (providerId: string) => void
|
||||
providers: Provider[]
|
||||
value?: string
|
||||
@@ -171,8 +187,21 @@ function ProviderSelector({
|
||||
{t(($) => $['newKnowledge.providerLabel'])}
|
||||
</span>
|
||||
<Link
|
||||
href="/marketplace?category=datasource"
|
||||
href={SOURCE_MARKETPLACE_PATH}
|
||||
className="inline-flex items-center gap-0.5 rounded-sm system-xs-medium text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
onNavigate(SOURCE_MARKETPLACE_PATH)
|
||||
}}
|
||||
>
|
||||
{t(($) => $['newKnowledge.moreProviders'])}
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
|
||||
@@ -270,48 +299,43 @@ function ProviderFieldControl({
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderConnected({ provider }: { provider: Provider }) {
|
||||
const { t } = useTranslation('dataset')
|
||||
|
||||
return (
|
||||
<div role="status" className="rounded-xl bg-background-section p-4">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg border border-divider-subtle bg-background-default">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(providerIconClass(provider), 'size-[18px] text-text-tertiary')}
|
||||
/>
|
||||
</span>
|
||||
<h3 className="mt-3 system-sm-semibold text-text-primary">
|
||||
{t(($) => $['newKnowledge.providerConnected'], {
|
||||
provider: provider.displayName,
|
||||
})}
|
||||
</h3>
|
||||
<p className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.crawlSetupUnavailableDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionForm({
|
||||
knowledgeSpaceId,
|
||||
onConnected,
|
||||
onDraftChange,
|
||||
onReconcile,
|
||||
provider,
|
||||
}: {
|
||||
knowledgeSpaceId: string
|
||||
onConnected: (connection: Connection) => void
|
||||
onDraftChange?: (dirty: boolean) => void
|
||||
onReconcile: () => Promise<Connection | undefined>
|
||||
provider: Provider
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const supportedAuthKinds = getSupportedAuthKinds(provider)
|
||||
const [authKind, setAuthKind] = useState<ConnectionAuthKind>(supportedAuthKinds[0] ?? 'api-key')
|
||||
const defaultAuthKind = supportedAuthKinds[0] ?? 'api-key'
|
||||
const [authKind, setAuthKind] = useState<ConnectionAuthKind>(defaultAuthKind)
|
||||
const [configuration, setConfiguration] = useState<Record<string, string>>({})
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const visibleFields = getAuthFields(provider, authKind)
|
||||
const hasDraftChanges =
|
||||
authKind !== defaultAuthKind ||
|
||||
Object.values(configuration).some((value) => Boolean(value.trim())) ||
|
||||
Object.values(credentials).some((value) => Boolean(value.trim()))
|
||||
|
||||
useEffect(() => {
|
||||
onDraftChange?.(hasDraftChanges)
|
||||
}, [hasDraftChanges, onDraftChange])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onDraftChange?.(false)
|
||||
},
|
||||
[onDraftChange],
|
||||
)
|
||||
|
||||
const changeAuthKind = (nextAuthKind: ConnectionAuthKind) => {
|
||||
if (nextAuthKind !== authKind) setCredentials({})
|
||||
@@ -437,11 +461,13 @@ function ConnectionForm({
|
||||
function UnconfiguredProvider({
|
||||
knowledgeSpaceId,
|
||||
onConnected,
|
||||
onDraftChange,
|
||||
onReconcile,
|
||||
provider,
|
||||
}: {
|
||||
knowledgeSpaceId: string
|
||||
onConnected: (connection: Connection) => void
|
||||
onDraftChange?: (dirty: boolean) => void
|
||||
onReconcile: () => Promise<Connection | undefined>
|
||||
provider: Provider
|
||||
}) {
|
||||
@@ -453,6 +479,7 @@ function UnconfiguredProvider({
|
||||
<ConnectionForm
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
onConnected={onConnected}
|
||||
onDraftChange={onDraftChange}
|
||||
onReconcile={onReconcile}
|
||||
provider={provider}
|
||||
/>
|
||||
@@ -626,10 +653,30 @@ function ProvisioningConnection({
|
||||
|
||||
export function AddSourcePage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [sourceType, setSourceType] = useState<SourceType>('websiteCrawl')
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>()
|
||||
const [ignoredConnectionIds, setIgnoredConnectionIds] = useState<Set<string>>(() => new Set())
|
||||
const [crawlDraftDirty, setCrawlDraftDirty] = useState(false)
|
||||
const [provisionalSourceCount, setProvisionalSourceCount] = useState(0)
|
||||
const [exitOpen, setExitOpen] = useState(false)
|
||||
const [discarding, setDiscarding] = useState(false)
|
||||
const [discardError, setDiscardError] = useState(false)
|
||||
const detailPath = newKnowledgeDetailPath(knowledgeSpaceId)
|
||||
const provisionalSourcesRef = useRef(new Map<string, Source>())
|
||||
const pendingPreviewOperationsRef = useRef(new Set<Promise<void>>())
|
||||
const deletionKeysRef = useRef(new Map<string, string>())
|
||||
const historyGuardArmedRef = useRef(false)
|
||||
const browserBackExitRef = useRef(false)
|
||||
const pendingNavigationRef = useRef<string | undefined>(undefined)
|
||||
const exitDestinationRef = useRef(detailPath)
|
||||
const hasUnsavedChanges = Boolean(
|
||||
crawlDraftDirty ||
|
||||
provisionalSourceCount ||
|
||||
sourceType !== 'websiteCrawl' ||
|
||||
selectedProviderId,
|
||||
)
|
||||
const providersQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.getSourceProviders.queryOptions({
|
||||
input: {},
|
||||
@@ -699,6 +746,229 @@ export function AddSourcePage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
refetch: refetchConnections,
|
||||
} = connectionsQuery
|
||||
|
||||
const armHistoryGuard = useCallback(() => {
|
||||
globalThis.history.pushState(globalThis.history.state, '', globalThis.location.href)
|
||||
historyGuardArmedRef.current = true
|
||||
}, [])
|
||||
|
||||
const replaceAfterHistoryGuard = useCallback(
|
||||
(path: string) => {
|
||||
if (!historyGuardArmedRef.current) {
|
||||
router.replace(path)
|
||||
return
|
||||
}
|
||||
|
||||
pendingNavigationRef.current = path
|
||||
globalThis.history.back()
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const requestNavigation = useCallback(
|
||||
(path: string) => {
|
||||
if (discarding) return
|
||||
if (hasUnsavedChanges) {
|
||||
exitDestinationRef.current = path
|
||||
browserBackExitRef.current = false
|
||||
setDiscardError(false)
|
||||
setExitOpen(true)
|
||||
return
|
||||
}
|
||||
replaceAfterHistoryGuard(path)
|
||||
},
|
||||
[discarding, hasUnsavedChanges, replaceAfterHistoryGuard],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasUnsavedChanges ||
|
||||
historyGuardArmedRef.current ||
|
||||
browserBackExitRef.current ||
|
||||
pendingNavigationRef.current
|
||||
)
|
||||
return
|
||||
|
||||
armHistoryGuard()
|
||||
}, [armHistoryGuard, hasUnsavedChanges])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (!historyGuardArmedRef.current) return
|
||||
|
||||
historyGuardArmedRef.current = false
|
||||
const pendingNavigation = pendingNavigationRef.current
|
||||
if (pendingNavigation) {
|
||||
pendingNavigationRef.current = undefined
|
||||
router.replace(pendingNavigation)
|
||||
return
|
||||
}
|
||||
if (!hasUnsavedChanges) {
|
||||
router.replace(detailPath)
|
||||
return
|
||||
}
|
||||
|
||||
browserBackExitRef.current = true
|
||||
exitDestinationRef.current = detailPath
|
||||
setDiscardError(false)
|
||||
setExitOpen(true)
|
||||
}
|
||||
|
||||
globalThis.addEventListener('popstate', handlePopState)
|
||||
return () => globalThis.removeEventListener('popstate', handlePopState)
|
||||
}, [detailPath, hasUnsavedChanges, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
globalThis.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => globalThis.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [hasUnsavedChanges])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
|
||||
const anchor = event
|
||||
.composedPath()
|
||||
.find((target): target is HTMLAnchorElement => target instanceof HTMLAnchorElement)
|
||||
if (
|
||||
!anchor ||
|
||||
anchor.hasAttribute('download') ||
|
||||
(anchor.target && anchor.target !== '_self')
|
||||
)
|
||||
return
|
||||
|
||||
const destination = new URL(anchor.href, globalThis.location.href)
|
||||
if (destination.origin !== globalThis.location.origin) return
|
||||
|
||||
const current = new URL(globalThis.location.href)
|
||||
if (destination.pathname === current.pathname && destination.search === current.search) return
|
||||
|
||||
event.preventDefault()
|
||||
requestNavigation(`${destination.pathname}${destination.search}${destination.hash}`)
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, true)
|
||||
return () => document.removeEventListener('click', handleDocumentClick, true)
|
||||
}, [hasUnsavedChanges, requestNavigation])
|
||||
|
||||
const rememberProvisionalSource = useCallback((source: Source) => {
|
||||
provisionalSourcesRef.current.set(source.id, source)
|
||||
setProvisionalSourceCount(provisionalSourcesRef.current.size)
|
||||
}, [])
|
||||
|
||||
const getDeletionKey = useCallback((sourceId: string) => {
|
||||
let idempotencyKey = deletionKeysRef.current.get(sourceId)
|
||||
if (!idempotencyKey) {
|
||||
idempotencyKey = globalThis.crypto.randomUUID()
|
||||
deletionKeysRef.current.set(sourceId, idempotencyKey)
|
||||
}
|
||||
return idempotencyKey
|
||||
}, [])
|
||||
|
||||
const rememberPendingPreviewOperation = useCallback((operation: Promise<void>) => {
|
||||
pendingPreviewOperationsRef.current.add(operation)
|
||||
void operation.then(
|
||||
() => pendingPreviewOperationsRef.current.delete(operation),
|
||||
() => pendingPreviewOperationsRef.current.delete(operation),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const requestExit = () => requestNavigation(detailPath)
|
||||
|
||||
const cancelExit = () => {
|
||||
setExitOpen(false)
|
||||
setDiscardError(false)
|
||||
exitDestinationRef.current = detailPath
|
||||
if (!browserBackExitRef.current) return
|
||||
|
||||
browserBackExitRef.current = false
|
||||
armHistoryGuard()
|
||||
}
|
||||
|
||||
const confirmExit = async () => {
|
||||
if (discarding) return
|
||||
setDiscarding(true)
|
||||
setDiscardError(false)
|
||||
try {
|
||||
await Promise.allSettled([...pendingPreviewOperationsRef.current])
|
||||
for (const source of provisionalSourcesRef.current.values()) {
|
||||
try {
|
||||
await consoleClient.knowledgeFs.deleteKnowledgeSpacesByIdSourcesBySourceId({
|
||||
body: { expectedRevision: source.version ?? 1 },
|
||||
headers: { 'idempotency-key': getDeletionKey(source.id) },
|
||||
params: { id: knowledgeSpaceId, sourceId: source.id },
|
||||
query: { documents: 'cascade' },
|
||||
})
|
||||
} catch (deletionError) {
|
||||
let deletionConfirmed = false
|
||||
try {
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourcesBySourceId({
|
||||
params: { id: knowledgeSpaceId, sourceId: source.id },
|
||||
})
|
||||
} catch (reconciliationError) {
|
||||
deletionConfirmed = responseStatus(reconciliationError) === 404
|
||||
}
|
||||
if (!deletionConfirmed) throw deletionError
|
||||
}
|
||||
provisionalSourcesRef.current.delete(source.id)
|
||||
deletionKeysRef.current.delete(source.id)
|
||||
setProvisionalSourceCount(provisionalSourcesRef.current.size)
|
||||
}
|
||||
setCrawlDraftDirty(false)
|
||||
setExitOpen(false)
|
||||
browserBackExitRef.current = false
|
||||
const exitDestination = exitDestinationRef.current
|
||||
exitDestinationRef.current = detailPath
|
||||
replaceAfterHistoryGuard(exitDestination)
|
||||
} catch {
|
||||
setDiscardError(true)
|
||||
} finally {
|
||||
setDiscarding(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handlePageHide = () => {
|
||||
for (const source of provisionalSourcesRef.current.values()) {
|
||||
void consoleClient.knowledgeFs
|
||||
.deleteKnowledgeSpacesByIdSourcesBySourceId(
|
||||
{
|
||||
body: { expectedRevision: source.version ?? 1 },
|
||||
headers: { 'idempotency-key': getDeletionKey(source.id) },
|
||||
params: { id: knowledgeSpaceId, sourceId: source.id },
|
||||
query: { documents: 'cascade' },
|
||||
},
|
||||
{
|
||||
context: {
|
||||
keepalive: true,
|
||||
silent: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener('pagehide', handlePageHide)
|
||||
return () => globalThis.removeEventListener('pagehide', handlePageHide)
|
||||
}, [getDeletionKey, knowledgeSpaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextConnectionPage &&
|
||||
@@ -814,6 +1084,7 @@ export function AddSourcePage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
{sourceType === 'websiteCrawl' ? (
|
||||
<>
|
||||
<ProviderSelector
|
||||
onNavigate={requestNavigation}
|
||||
providers={providers}
|
||||
value={provider?.id}
|
||||
onChange={setSelectedProviderId}
|
||||
@@ -844,7 +1115,15 @@ export function AddSourcePage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
</p>
|
||||
</div>
|
||||
) : connection?.status === 'active' ? (
|
||||
<ProviderConnected key={provider.id} provider={provider} />
|
||||
<WebsiteCrawlPreview
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
onDraftChange={setCrawlDraftDirty}
|
||||
onPendingOperation={rememberPendingPreviewOperation}
|
||||
onProvisionalSource={rememberProvisionalSource}
|
||||
providerName={provider.displayName}
|
||||
/>
|
||||
) : connection?.status === 'provisioning' ? (
|
||||
<ProvisioningConnection
|
||||
key={provider.id}
|
||||
@@ -866,6 +1145,7 @@ export function AddSourcePage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
key={provider.id}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
onConnected={rememberConnection}
|
||||
onDraftChange={setCrawlDraftDirty}
|
||||
onReconcile={reconcileConnection}
|
||||
provider={provider}
|
||||
/>
|
||||
@@ -882,17 +1162,36 @@ export function AddSourcePage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 border-t border-divider-subtle pt-5">
|
||||
<Link
|
||||
href={newKnowledgeDetailPath(knowledgeSpaceId)}
|
||||
className="inline-flex h-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 system-sm-medium text-components-button-secondary-text shadow-xs outline-hidden hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<Button type="button" onClick={requestExit}>
|
||||
{t(($) => $['newKnowledge.cancelAddSource'])}
|
||||
</Link>
|
||||
<Button variant="primary" disabled>
|
||||
{t(($) => $['newKnowledge.addSource'])}
|
||||
</Button>
|
||||
{sourceType === 'websiteCrawl' ? (
|
||||
<>
|
||||
<span id="add-source-selection-requirement" className="sr-only">
|
||||
{t(($) => $['newKnowledge.addSourceRequiresSelection'])}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled
|
||||
aria-describedby="add-source-selection-requirement"
|
||||
>
|
||||
{t(($) => $['newKnowledge.addSource'])}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" disabled>
|
||||
{t(($) => $['newKnowledge.addSource'])}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AddSourceExitDialog
|
||||
discarding={discarding}
|
||||
error={discardError}
|
||||
onCancel={cancelExit}
|
||||
onConfirm={() => void confirmExit()}
|
||||
open={exitOpen}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function AddSourceExitDialog({
|
||||
discarding,
|
||||
error,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open,
|
||||
}: {
|
||||
discarding: boolean
|
||||
error: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
open: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !discarding) onCancel()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="w-120 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-lg">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['newKnowledge.discardSourceDraftTitle'])}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
render={<div />}
|
||||
className="system-md-regular text-text-secondary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.discardSourceDraftDescription'])}
|
||||
</AlertDialogDescription>
|
||||
{error && (
|
||||
<p role="alert" className="system-sm-regular text-text-destructive">
|
||||
{t(($) => $['newKnowledge.discardSourceDraftFailed'])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AlertDialogActions className="gap-2 p-6">
|
||||
<AlertDialogCancelButton variant="secondary" disabled={discarding}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={discarding} disabled={discarding} onClick={onConfirm}>
|
||||
{t(($) => $['newKnowledge.discardDraftConfirm'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse,
|
||||
Source,
|
||||
SourceWorkflowRun,
|
||||
} from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { NumberField, NumberFieldGroup, NumberFieldInput } from '@langgenius/dify-ui/number-field'
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleClient } from '@/service/client'
|
||||
|
||||
type ConnectionReference = {
|
||||
id: string
|
||||
providerId: string
|
||||
}
|
||||
|
||||
type PreviewPage = GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse['items'][number]
|
||||
|
||||
type CrawlConfiguration = {
|
||||
includeSubpages: boolean
|
||||
limit: number
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
type PreviewDraft = {
|
||||
clientRequestId: string
|
||||
configurationKey: string
|
||||
creationAttempted?: boolean
|
||||
previewRequestId: string
|
||||
reconciliationAttempts?: number
|
||||
source?: Source
|
||||
}
|
||||
|
||||
function previewPagesEqual(left: PreviewPage, right: PreviewPage) {
|
||||
return (
|
||||
left.pageId === right.pageId &&
|
||||
left.description === right.description &&
|
||||
left.etag === right.etag &&
|
||||
left.sourceUrl === right.sourceUrl &&
|
||||
left.title === right.title
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200
|
||||
const MAX_CURSOR_PAGES = 100
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
const DEFAULT_PAGE_LIMIT = 100
|
||||
const MAX_PAGE_LIMIT = 1000
|
||||
const MAX_SOURCE_NAME_LENGTH = 200
|
||||
const MAX_SOURCE_RECONCILIATION_ATTEMPTS = 3
|
||||
const SUCCESS_STATES = new Set(['complete', 'completed', 'preview_ready', 'success', 'succeeded'])
|
||||
const FAILURE_STATES = new Set(['error', 'exhausted', 'failed', 'timed_out', 'timeout'])
|
||||
const CANCELED_STATES = new Set(['canceled', 'cancelled', 'superseded'])
|
||||
|
||||
function normalizedState(state: string) {
|
||||
return state.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_')
|
||||
}
|
||||
|
||||
function isSuccessful(state: string) {
|
||||
return SUCCESS_STATES.has(normalizedState(state))
|
||||
}
|
||||
|
||||
function isFailed(state: string) {
|
||||
return FAILURE_STATES.has(normalizedState(state))
|
||||
}
|
||||
|
||||
function isCanceled(state: string) {
|
||||
return CANCELED_STATES.has(normalizedState(state))
|
||||
}
|
||||
|
||||
function isTerminal(state: string) {
|
||||
return isSuccessful(state) || isFailed(state) || isCanceled(state)
|
||||
}
|
||||
|
||||
function normalizeURL(value: string) {
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
!url.hostname ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
return undefined
|
||||
url.hash = ''
|
||||
return url
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function configurationKey(configuration: CrawlConfiguration) {
|
||||
return JSON.stringify(configuration)
|
||||
}
|
||||
|
||||
function workflowAttemptKey(run: SourceWorkflowRun) {
|
||||
return `${run.id}:${run.executionAttempts}`
|
||||
}
|
||||
|
||||
function responseStatus(error: unknown) {
|
||||
if (error instanceof Response) return error.status
|
||||
if (!error || typeof error !== 'object') return undefined
|
||||
if ('status' in error && typeof error.status === 'number') return error.status
|
||||
if ('data' in error && error.data && typeof error.data === 'object' && 'status' in error.data) {
|
||||
return typeof error.data.status === 'number' ? error.data.status : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isDefinitiveRequestFailure(error: unknown) {
|
||||
const status = responseStatus(error)
|
||||
return status !== undefined && [400, 401, 403, 404, 409, 422, 429].includes(status)
|
||||
}
|
||||
|
||||
function isRetryConfirmed(previous: SourceWorkflowRun, current: SourceWorkflowRun) {
|
||||
return (
|
||||
previous.id === current.id &&
|
||||
(current.executionAttempts > previous.executionAttempts ||
|
||||
(isTerminal(previous.state) && !isTerminal(current.state)))
|
||||
)
|
||||
}
|
||||
|
||||
function isCancelConfirmed(current: SourceWorkflowRun) {
|
||||
return isTerminal(current.state)
|
||||
}
|
||||
|
||||
function createRequestId() {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
|
||||
async function listWorkflowPageUpdates(
|
||||
knowledgeSpaceId: string,
|
||||
runId: string,
|
||||
initialCursor?: string,
|
||||
) {
|
||||
const pages = new Map<string, PreviewPage>()
|
||||
const seenCursors = new Set(initialCursor ? [initialCursor] : [])
|
||||
let cursor = initialCursor
|
||||
let resumeCursor = initialCursor
|
||||
let pageCount = 0
|
||||
|
||||
do {
|
||||
pageCount += 1
|
||||
if (pageCount > MAX_CURSOR_PAGES) throw new Error('Workflow page cursor limit exceeded')
|
||||
const response =
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages({
|
||||
params: { id: knowledgeSpaceId, runId },
|
||||
query: { ...(cursor ? { cursor } : {}), limit: PAGE_SIZE },
|
||||
})
|
||||
for (const page of response.items) pages.set(page.pageId, page)
|
||||
const nextCursor = response.nextCursor
|
||||
if (!nextCursor || seenCursors.has(nextCursor)) break
|
||||
seenCursors.add(nextCursor)
|
||||
cursor = nextCursor
|
||||
resumeCursor = nextCursor
|
||||
} while (cursor)
|
||||
|
||||
return { items: [...pages.values()], resumeCursor }
|
||||
}
|
||||
|
||||
async function findProvisionalSource(knowledgeSpaceId: string, clientRequestId: string) {
|
||||
const seenCursors = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
let pageCount = 0
|
||||
|
||||
do {
|
||||
pageCount += 1
|
||||
if (pageCount > MAX_CURSOR_PAGES) throw new Error('Source cursor limit exceeded')
|
||||
const response = await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSources({
|
||||
params: { id: knowledgeSpaceId },
|
||||
query: { ...(cursor ? { cursor } : {}), limit: PAGE_SIZE },
|
||||
})
|
||||
const source = response.items.find(
|
||||
(candidate) => candidate.metadata.clientRequestId === clientRequestId,
|
||||
)
|
||||
if (source) return source
|
||||
const nextCursor = response.nextCursor
|
||||
if (!nextCursor || seenCursors.has(nextCursor)) return undefined
|
||||
seenCursors.add(nextCursor)
|
||||
cursor = nextCursor
|
||||
} while (cursor)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const CrawlPageList = memo(
|
||||
({ loading = false, pages }: { loading?: boolean; pages: PreviewPage[] }) => {
|
||||
if (!pages.length && !loading) return null
|
||||
|
||||
return (
|
||||
<ul className="max-h-64 divide-y divide-divider-subtle overflow-y-auto" aria-live="polite">
|
||||
{pages.map((page) => (
|
||||
<li
|
||||
key={page.pageId}
|
||||
className="flex items-start gap-2.5 px-4 py-2.5 [contain-intrinsic-size:auto_40px] [content-visibility:auto]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
aria-label={page.title || page.sourceUrl}
|
||||
className="mt-0.5 size-4"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate system-xs-medium text-text-primary">
|
||||
{page.title || page.sourceUrl}
|
||||
</span>
|
||||
<span className="block truncate system-2xs-regular text-text-tertiary">
|
||||
{page.sourceUrl}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{loading &&
|
||||
[0, 1].map((placeholder) => (
|
||||
<li
|
||||
key={`placeholder-${placeholder}`}
|
||||
data-testid="crawl-page-skeleton"
|
||||
aria-hidden
|
||||
className="flex items-start gap-2.5 px-4 py-2.5"
|
||||
>
|
||||
<span className="size-4 animate-pulse rounded bg-background-section motion-reduce:animate-none" />
|
||||
<span className="min-w-0 flex-1 space-y-1.5">
|
||||
<span className="block h-3 w-2/3 animate-pulse rounded bg-background-section motion-reduce:animate-none" />
|
||||
<span className="block h-2.5 w-full animate-pulse rounded bg-background-section motion-reduce:animate-none" />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function EmptyPreview() {
|
||||
const { t } = useTranslation('dataset')
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center rounded-xl border border-dashed border-divider-regular px-6 text-center">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-background-section">
|
||||
<span aria-hidden className="i-ri-global-line size-5 text-text-tertiary" />
|
||||
</span>
|
||||
<p className="mt-2 system-xs-semibold text-text-primary">
|
||||
{t(($) => $['newKnowledge.pagesAppearTitle'])}
|
||||
</p>
|
||||
<p className="mt-2 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.pagesAppearDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebsiteCrawlPreview({
|
||||
connection,
|
||||
knowledgeSpaceId,
|
||||
onDraftChange,
|
||||
onPendingOperation,
|
||||
onProvisionalSource,
|
||||
providerName,
|
||||
}: {
|
||||
connection: ConnectionReference
|
||||
knowledgeSpaceId: string
|
||||
onDraftChange?: (dirty: boolean) => void
|
||||
onPendingOperation?: (operation: Promise<void>) => void
|
||||
onProvisionalSource?: (source: Source) => void
|
||||
providerName: string
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const rootUrlErrorId = useId()
|
||||
const [rootUrl, setRootUrl] = useState('')
|
||||
const [sourceName, setSourceName] = useState('')
|
||||
const [urlTouched, setUrlTouched] = useState(false)
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false)
|
||||
const [includeSubpages, setIncludeSubpages] = useState(true)
|
||||
const [pageLimit, setPageLimit] = useState(DEFAULT_PAGE_LIMIT)
|
||||
const [run, setRun] = useState<SourceWorkflowRun>()
|
||||
const [previewConfiguration, setPreviewConfiguration] = useState<CrawlConfiguration>()
|
||||
const [pages, setPages] = useState<PreviewPage[]>([])
|
||||
const [pagesLoaded, setPagesLoaded] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [stopping, setStopping] = useState(false)
|
||||
const [pollPaused, setPollPaused] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string>()
|
||||
const draftRef = useRef<PreviewDraft | undefined>(undefined)
|
||||
const actionPendingRef = useRef(false)
|
||||
const retryFingerprintRef = useRef<string | undefined>(undefined)
|
||||
const cancelFingerprintRef = useRef<string | undefined>(undefined)
|
||||
const rootUrlInputRef = useRef<HTMLInputElement>(null)
|
||||
const sourceNameInputRef = useRef<HTMLInputElement>(null)
|
||||
const pageMapRef = useRef(new Map<string, PreviewPage>())
|
||||
const pageCursorRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const resetPreviewPages = useCallback(() => {
|
||||
pageMapRef.current.clear()
|
||||
pageCursorRef.current = undefined
|
||||
setPages([])
|
||||
setPagesLoaded(false)
|
||||
}, [])
|
||||
|
||||
const normalizedURL = useMemo(() => normalizeURL(rootUrl), [rootUrl])
|
||||
const normalizedLimit = Math.min(Math.max(Math.trunc(pageLimit) || 1, 1), MAX_PAGE_LIMIT)
|
||||
const configuration = useMemo<CrawlConfiguration | undefined>(
|
||||
() =>
|
||||
normalizedURL && sourceName.trim() && sourceName.trim().length <= MAX_SOURCE_NAME_LENGTH
|
||||
? {
|
||||
includeSubpages,
|
||||
limit: normalizedLimit,
|
||||
name: sourceName.trim(),
|
||||
url: normalizedURL.toString(),
|
||||
}
|
||||
: undefined,
|
||||
[includeSubpages, normalizedLimit, normalizedURL, sourceName],
|
||||
)
|
||||
const active = Boolean(run && !isTerminal(run.state))
|
||||
const shouldPoll = Boolean(
|
||||
run && !starting && !stopping && !pollPaused && (active || !pagesLoaded),
|
||||
)
|
||||
const runId = run?.id
|
||||
const locked = starting || stopping || active
|
||||
const draftHost = normalizedURL?.host ?? ''
|
||||
const previewHost = previewConfiguration ? new URL(previewConfiguration.url).host : draftHost
|
||||
const completedCount = Math.max(run?.progressCompleted ?? 0, pages.length)
|
||||
const crawlingStatusText = t(($) => $['newKnowledge.crawlingPages'], {
|
||||
count: completedCount,
|
||||
host: previewHost,
|
||||
})
|
||||
const completedStatusText = t(($) => $['newKnowledge.pagesCrawled'], {
|
||||
count: pages.length,
|
||||
host: previewHost,
|
||||
})
|
||||
const hasDraftChanges = Boolean(
|
||||
rootUrl ||
|
||||
sourceName ||
|
||||
!includeSubpages ||
|
||||
pageLimit !== DEFAULT_PAGE_LIMIT ||
|
||||
run ||
|
||||
requestError,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onDraftChange?.(hasDraftChanges)
|
||||
}, [hasDraftChanges, onDraftChange])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onDraftChange?.(false)
|
||||
},
|
||||
[onDraftChange],
|
||||
)
|
||||
|
||||
const ensureProvisionalSource = useCallback(
|
||||
async (nextConfiguration: CrawlConfiguration) => {
|
||||
const nextConfigurationKey = configurationKey(nextConfiguration)
|
||||
let draft = draftRef.current
|
||||
if (!draft || draft.configurationKey !== nextConfigurationKey) {
|
||||
draft = {
|
||||
clientRequestId: createRequestId(),
|
||||
configurationKey: nextConfigurationKey,
|
||||
previewRequestId: createRequestId(),
|
||||
}
|
||||
draftRef.current = draft
|
||||
}
|
||||
if (draft.source) return draft
|
||||
|
||||
if (draft.creationAttempted) {
|
||||
const reconciled = await findProvisionalSource(knowledgeSpaceId, draft.clientRequestId)
|
||||
if (reconciled) {
|
||||
draft.source = reconciled
|
||||
onProvisionalSource?.(reconciled)
|
||||
return draft
|
||||
}
|
||||
draft.reconciliationAttempts = (draft.reconciliationAttempts ?? 0) + 1
|
||||
if (draft.reconciliationAttempts < MAX_SOURCE_RECONCILIATION_ATTEMPTS)
|
||||
throw new Error('Provisional source creation is still reconciling')
|
||||
draft.creationAttempted = false
|
||||
draft.reconciliationAttempts = 0
|
||||
}
|
||||
|
||||
draft.creationAttempted = true
|
||||
try {
|
||||
draft.source = await consoleClient.knowledgeFs.postKnowledgeSpacesByIdSources({
|
||||
body: {
|
||||
connectionId: connection.id,
|
||||
metadata: {
|
||||
clientRequestId: draft.clientRequestId,
|
||||
crawlOptions: {
|
||||
includeSubpages: nextConfiguration.includeSubpages,
|
||||
limit: nextConfiguration.limit,
|
||||
},
|
||||
preview: true,
|
||||
providerId: connection.providerId,
|
||||
},
|
||||
name: nextConfiguration.name,
|
||||
status: 'disabled',
|
||||
type: 'web',
|
||||
uri: nextConfiguration.url,
|
||||
},
|
||||
params: { id: knowledgeSpaceId },
|
||||
})
|
||||
onProvisionalSource?.(draft.source)
|
||||
} catch (error) {
|
||||
if (isDefinitiveRequestFailure(error)) {
|
||||
draft.creationAttempted = false
|
||||
draft.reconciliationAttempts = 0
|
||||
throw error
|
||||
}
|
||||
const reconciled = await findProvisionalSource(knowledgeSpaceId, draft.clientRequestId)
|
||||
if (!reconciled) {
|
||||
draft.reconciliationAttempts = (draft.reconciliationAttempts ?? 0) + 1
|
||||
throw error
|
||||
}
|
||||
draft.source = reconciled
|
||||
onProvisionalSource?.(reconciled)
|
||||
}
|
||||
return draft
|
||||
},
|
||||
[connection.id, connection.providerId, knowledgeSpaceId, onProvisionalSource],
|
||||
)
|
||||
|
||||
const startPreview = useCallback(
|
||||
async (nextConfiguration: CrawlConfiguration, forceNewRun = false) => {
|
||||
if (actionPendingRef.current) return
|
||||
actionPendingRef.current = true
|
||||
setStarting(true)
|
||||
setRequestError(undefined)
|
||||
setPollPaused(false)
|
||||
setPreviewConfiguration(nextConfiguration)
|
||||
resetPreviewPages()
|
||||
setRun(undefined)
|
||||
try {
|
||||
const draft = await ensureProvisionalSource(nextConfiguration)
|
||||
if (!draft.source) throw new Error('Provisional source is missing')
|
||||
if (forceNewRun) draft.previewRequestId = createRequestId()
|
||||
const nextRun =
|
||||
await consoleClient.knowledgeFs.postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview({
|
||||
headers: { 'Idempotency-Key': draft.previewRequestId },
|
||||
params: { id: knowledgeSpaceId, sourceId: draft.source.id },
|
||||
})
|
||||
setRun(nextRun)
|
||||
} catch {
|
||||
setRequestError('START_FAILED')
|
||||
} finally {
|
||||
actionPendingRef.current = false
|
||||
setStarting(false)
|
||||
}
|
||||
},
|
||||
[ensureProvisionalSource, knowledgeSpaceId, resetPreviewPages],
|
||||
)
|
||||
|
||||
const retryRun = useCallback(async () => {
|
||||
if (!run || actionPendingRef.current) return
|
||||
const attemptKey = workflowAttemptKey(run)
|
||||
const retryAlreadySent = retryFingerprintRef.current === attemptKey
|
||||
actionPendingRef.current = true
|
||||
setStarting(true)
|
||||
setRequestError(undefined)
|
||||
setPollPaused(false)
|
||||
try {
|
||||
if (retryAlreadySent) {
|
||||
try {
|
||||
const reconciled =
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunId({
|
||||
params: { id: knowledgeSpaceId, runId: run.id },
|
||||
})
|
||||
if (!isRetryConfirmed(run, reconciled)) {
|
||||
setRequestError('RETRY_FAILED')
|
||||
return
|
||||
}
|
||||
resetPreviewPages()
|
||||
setRun(reconciled)
|
||||
} catch {
|
||||
setRequestError('RETRY_FAILED')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
retryFingerprintRef.current = attemptKey
|
||||
try {
|
||||
const retried =
|
||||
await consoleClient.knowledgeFs.postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry({
|
||||
params: { id: knowledgeSpaceId, runId: run.id },
|
||||
})
|
||||
resetPreviewPages()
|
||||
setRun(retried)
|
||||
} catch (error) {
|
||||
if (isDefinitiveRequestFailure(error)) {
|
||||
retryFingerprintRef.current = undefined
|
||||
setRequestError('RETRY_FAILED')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const reconciled =
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunId({
|
||||
params: { id: knowledgeSpaceId, runId: run.id },
|
||||
})
|
||||
if (!isRetryConfirmed(run, reconciled)) {
|
||||
setRequestError('RETRY_FAILED')
|
||||
} else {
|
||||
resetPreviewPages()
|
||||
setRun(reconciled)
|
||||
}
|
||||
} catch {
|
||||
setRequestError('RETRY_FAILED')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
actionPendingRef.current = false
|
||||
setStarting(false)
|
||||
}
|
||||
}, [knowledgeSpaceId, resetPreviewPages, run])
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId || !shouldPoll) return
|
||||
let disposed = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const nextRun =
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunId({
|
||||
params: { id: knowledgeSpaceId, runId },
|
||||
})
|
||||
if (disposed) return
|
||||
let pageUpdates: Awaited<ReturnType<typeof listWorkflowPageUpdates>>
|
||||
const finalSnapshot = isSuccessful(nextRun.state)
|
||||
try {
|
||||
pageUpdates = await listWorkflowPageUpdates(
|
||||
knowledgeSpaceId,
|
||||
runId,
|
||||
finalSnapshot ? undefined : pageCursorRef.current,
|
||||
)
|
||||
} catch (error) {
|
||||
if (isFailed(nextRun.state) || isCanceled(nextRun.state)) {
|
||||
setRun(nextRun)
|
||||
setPagesLoaded(true)
|
||||
setRequestError(undefined)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (disposed) return
|
||||
let pagesChanged = false
|
||||
if (finalSnapshot) {
|
||||
const currentPages = [...pageMapRef.current.values()]
|
||||
const finalPageMap = new Map(pageUpdates.items.map((page) => [page.pageId, page]))
|
||||
pagesChanged =
|
||||
currentPages.length !== pageUpdates.items.length ||
|
||||
pageUpdates.items.some(
|
||||
(page, index) =>
|
||||
!currentPages[index] || !previewPagesEqual(currentPages[index], page),
|
||||
)
|
||||
pageMapRef.current = finalPageMap
|
||||
} else {
|
||||
for (const page of pageUpdates.items) {
|
||||
const current = pageMapRef.current.get(page.pageId)
|
||||
if (!current || !previewPagesEqual(current, page)) {
|
||||
pageMapRef.current.set(page.pageId, page)
|
||||
pagesChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
pageCursorRef.current = pageUpdates.resumeCursor
|
||||
setRun(nextRun)
|
||||
if (pagesChanged) setPages([...pageMapRef.current.values()])
|
||||
setPagesLoaded(true)
|
||||
setRequestError(undefined)
|
||||
if (!isTerminal(nextRun.state)) timer = setTimeout(() => void poll(), POLL_INTERVAL_MS)
|
||||
} catch {
|
||||
if (disposed) return
|
||||
setRequestError('POLL_FAILED')
|
||||
setPollPaused(true)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
return () => {
|
||||
disposed = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [knowledgeSpaceId, runId, shouldPoll])
|
||||
|
||||
const stop = async () => {
|
||||
if (!run || actionPendingRef.current || !active) return
|
||||
const attemptKey = workflowAttemptKey(run)
|
||||
const cancelAlreadySent = cancelFingerprintRef.current === attemptKey
|
||||
actionPendingRef.current = true
|
||||
setStopping(true)
|
||||
setRequestError(undefined)
|
||||
try {
|
||||
if (cancelAlreadySent) {
|
||||
try {
|
||||
const reconciled =
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunId({
|
||||
params: { id: knowledgeSpaceId, runId: run.id },
|
||||
})
|
||||
if (!isCancelConfirmed(reconciled)) {
|
||||
setRequestError('CANCEL_FAILED')
|
||||
return
|
||||
}
|
||||
cancelFingerprintRef.current = undefined
|
||||
setRun(reconciled)
|
||||
} catch {
|
||||
setRequestError('CANCEL_FAILED')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cancelFingerprintRef.current = attemptKey
|
||||
try {
|
||||
const canceled =
|
||||
await consoleClient.knowledgeFs.postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel({
|
||||
body: { reason: 'user_requested' },
|
||||
params: { id: knowledgeSpaceId, runId: run.id },
|
||||
})
|
||||
cancelFingerprintRef.current = undefined
|
||||
setRun(canceled)
|
||||
} catch (error) {
|
||||
if (isDefinitiveRequestFailure(error)) {
|
||||
cancelFingerprintRef.current = undefined
|
||||
setRequestError('CANCEL_FAILED')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const reconciled =
|
||||
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunId({
|
||||
params: { id: knowledgeSpaceId, runId: run.id },
|
||||
})
|
||||
if (!isCancelConfirmed(reconciled)) {
|
||||
setRequestError('CANCEL_FAILED')
|
||||
} else {
|
||||
cancelFingerprintRef.current = undefined
|
||||
setRun(reconciled)
|
||||
}
|
||||
} catch {
|
||||
setRequestError('CANCEL_FAILED')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
actionPendingRef.current = false
|
||||
setStopping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchOperation = (operation: Promise<void>) => {
|
||||
onPendingOperation?.(operation)
|
||||
void operation
|
||||
}
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!configuration) {
|
||||
setUrlTouched(true)
|
||||
if (!normalizedURL) rootUrlInputRef.current?.focus()
|
||||
else sourceNameInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (requestError === 'POLL_FAILED' && run) {
|
||||
setRequestError(undefined)
|
||||
setPollPaused(false)
|
||||
return
|
||||
}
|
||||
if (run && isSuccessful(run.state)) {
|
||||
const currentKey = configurationKey(configuration)
|
||||
dispatchOperation(
|
||||
startPreview(configuration, draftRef.current?.configurationKey === currentKey),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (run && (isFailed(run.state) || isCanceled(run.state))) {
|
||||
const currentKey = configurationKey(configuration)
|
||||
if (draftRef.current?.configurationKey === currentKey) {
|
||||
dispatchOperation(retryRun())
|
||||
return
|
||||
}
|
||||
}
|
||||
dispatchOperation(startPreview(configuration))
|
||||
}
|
||||
|
||||
const handleSubmit = () => handlePrimaryAction()
|
||||
|
||||
const primaryLabel =
|
||||
starting || (active && !pollPaused)
|
||||
? t(($) => $['newKnowledge.crawling'])
|
||||
: (requestError && requestError !== 'CANCEL_FAILED') ||
|
||||
(run && (isFailed(run.state) || isCanceled(run.state)))
|
||||
? t(($) => $['newKnowledge.retryCrawl'])
|
||||
: run && isSuccessful(run.state) && pagesLoaded && pages.length === 0
|
||||
? t(($) => $['newKnowledge.adjustAndRecrawl'])
|
||||
: t(($) => $['newKnowledge.crawlAndPreview'])
|
||||
|
||||
const showFailure = Boolean(
|
||||
requestError === 'START_FAILED' ||
|
||||
requestError === 'RETRY_FAILED' ||
|
||||
requestError === 'POLL_FAILED' ||
|
||||
(run && isFailed(run.state)),
|
||||
)
|
||||
const showZero = Boolean(run && isSuccessful(run.state) && pagesLoaded && pages.length === 0)
|
||||
const showSuccess = Boolean(run && isSuccessful(run.state) && pages.length > 0)
|
||||
const showCanceled = Boolean(run && isCanceled(run.state))
|
||||
const errorCode = run?.lastErrorCode ?? requestError
|
||||
const is403 = errorCode?.includes('403')
|
||||
const isTimeout =
|
||||
errorCode?.toUpperCase().includes('TIMEOUT') ||
|
||||
(run ? ['timed_out', 'timeout'].includes(normalizedState(run.state)) : false)
|
||||
const isProviderError = errorCode?.toUpperCase().includes('PROVIDER')
|
||||
|
||||
return (
|
||||
<section aria-label={t(($) => $['newKnowledge.crawlAndPreview'])}>
|
||||
<p role="status" className="sr-only">
|
||||
{t(($) => $['newKnowledge.providerConnected'], { provider: providerName })}
|
||||
</p>
|
||||
<Form onFormSubmit={handleSubmit}>
|
||||
<div className={cn('mt-4 space-y-4', locked && 'opacity-70')}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
name="root-url"
|
||||
className="gap-1.5"
|
||||
disabled={locked}
|
||||
invalid={urlTouched && !normalizedURL}
|
||||
touched={urlTouched}
|
||||
>
|
||||
<FieldLabel>
|
||||
{t(($) => $['newKnowledge.rootUrl'])}
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">
|
||||
*
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
ref={rootUrlInputRef}
|
||||
name="root-url"
|
||||
type="url"
|
||||
required
|
||||
value={rootUrl}
|
||||
autoComplete="off"
|
||||
placeholder={t(($) => $['newKnowledge.rootUrlPlaceholder'])}
|
||||
onBlur={() => setUrlTouched(true)}
|
||||
onValueChange={setRootUrl}
|
||||
/>
|
||||
<FieldError id={rootUrlErrorId} match={urlTouched && !normalizedURL}>
|
||||
{t(($) => $['newKnowledge.invalidRootUrl'])}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field name="source-name" className="gap-1.5" disabled={locked}>
|
||||
<FieldLabel>
|
||||
{t(($) => $['newKnowledge.sourceName'])}
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">
|
||||
*
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
ref={sourceNameInputRef}
|
||||
name="source-name"
|
||||
type="text"
|
||||
required
|
||||
maxLength={MAX_SOURCE_NAME_LENGTH}
|
||||
value={sourceName}
|
||||
autoComplete="off"
|
||||
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
|
||||
onValueChange={setSourceName}
|
||||
/>
|
||||
<FieldError match="valueMissing">
|
||||
{t(($) => $['newKnowledge.sourceNameRequired'])}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-components-option-card-option-border bg-background-default">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={optionsExpanded}
|
||||
disabled={locked}
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
|
||||
onClick={() => setOptionsExpanded((expanded) => !expanded)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-arrow-right-s-line size-4 text-text-tertiary transition-transform',
|
||||
optionsExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t(($) => $['newKnowledge.crawlOptions'])}
|
||||
</span>
|
||||
{!optionsExpanded && includeSubpages && pageLimit === DEFAULT_PAGE_LIMIT && (
|
||||
<span className="ml-auto system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.usingDefaults'])}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{optionsExpanded && (
|
||||
<div className="grid grid-cols-1 gap-3 border-t border-divider-subtle p-3 sm:grid-cols-2">
|
||||
<Field name="include-subpages" className="block" disabled={locked}>
|
||||
<FieldLabel className="flex h-9 items-center gap-2 system-xs-regular text-text-secondary">
|
||||
<Checkbox
|
||||
name="include-subpages"
|
||||
checked={includeSubpages}
|
||||
onCheckedChange={setIncludeSubpages}
|
||||
/>
|
||||
{t(($) => $['newKnowledge.includeSubpages'])}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field
|
||||
name="page-limit"
|
||||
className="grid grid-cols-[1fr_6rem] items-center gap-2"
|
||||
disabled={locked}
|
||||
>
|
||||
<FieldLabel className="system-xs-regular text-text-secondary">
|
||||
{t(($) => $['newKnowledge.maxPages'])}
|
||||
</FieldLabel>
|
||||
<NumberField
|
||||
disabled={locked}
|
||||
min={1}
|
||||
max={MAX_PAGE_LIMIT}
|
||||
value={pageLimit}
|
||||
onValueChange={(value) => setPageLimit(value ?? 1)}
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldInput name="page-limit" autoComplete="off" />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showSuccess && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="mt-4 w-full"
|
||||
disabled={locked && requestError !== 'POLL_FAILED'}
|
||||
loading={starting}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<div className="mt-4">
|
||||
{!run && !requestError && <EmptyPreview />}
|
||||
{run && active && !pollPaused && (
|
||||
<div className="overflow-hidden rounded-xl border border-divider-regular">
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-4-line size-4 animate-spin text-text-accent motion-reduce:animate-none"
|
||||
/>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="min-w-0 flex-1 truncate system-xs-medium text-text-primary"
|
||||
title={crawlingStatusText}
|
||||
>
|
||||
{crawlingStatusText}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
className="ml-auto shrink-0"
|
||||
disabled={stopping}
|
||||
onClick={() => dispatchOperation(stop())}
|
||||
>
|
||||
{stopping
|
||||
? t(($) => $['newKnowledge.stoppingCrawl'])
|
||||
: t(($) => $['newKnowledge.stopCrawl'])}
|
||||
</Button>
|
||||
</div>
|
||||
{requestError === 'CANCEL_FAILED' && (
|
||||
<p role="alert" className="px-4 pb-3 system-xs-regular text-text-destructive">
|
||||
{t(($) => $['newKnowledge.crawlFailedDescription'])}
|
||||
</p>
|
||||
)}
|
||||
{run.progressTotal !== undefined && run.progressTotal > 0 && (
|
||||
<progress
|
||||
max={run.progressTotal}
|
||||
value={Math.min(completedCount, run.progressTotal)}
|
||||
aria-label={t(($) => $['newKnowledge.crawlProgress'], { host: previewHost })}
|
||||
className="block h-1 w-full accent-state-accent-solid"
|
||||
/>
|
||||
)}
|
||||
<CrawlPageList pages={pages} loading />
|
||||
</div>
|
||||
)}
|
||||
{showSuccess && (
|
||||
<div className="overflow-hidden rounded-xl border border-divider-regular">
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="min-w-0 flex-1 truncate system-xs-semibold text-text-primary"
|
||||
title={completedStatusText}
|
||||
>
|
||||
{completedStatusText}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
className="ml-auto shrink-0"
|
||||
disabled={starting || !configuration}
|
||||
loading={starting}
|
||||
onClick={handlePrimaryAction}
|
||||
>
|
||||
{t(($) => $['newKnowledge.reCrawl'])}
|
||||
</Button>
|
||||
</div>
|
||||
<CrawlPageList pages={pages} />
|
||||
</div>
|
||||
)}
|
||||
{showCanceled && (
|
||||
<div className="overflow-hidden rounded-xl border border-divider-regular">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="px-4 py-3 system-xs-semibold text-text-primary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.crawlStopped'])}
|
||||
</p>
|
||||
<CrawlPageList pages={pages} />
|
||||
</div>
|
||||
)}
|
||||
{showFailure && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-divider-regular px-6 text-center"
|
||||
>
|
||||
<span aria-hidden className="i-ri-error-warning-fill size-6 text-text-destructive" />
|
||||
<p className="mt-2 system-sm-semibold text-text-primary">
|
||||
{t(($) => $['newKnowledge.crawlFailed'], { host: previewHost })}
|
||||
</p>
|
||||
<p className="mt-1 max-w-lg system-xs-regular text-text-tertiary">
|
||||
{is403
|
||||
? t(($) => $['newKnowledge.crawlFailed403'])
|
||||
: isTimeout
|
||||
? t(($) => $['newKnowledge.crawlFailedTimeout'])
|
||||
: isProviderError
|
||||
? t(($) => $['newKnowledge.crawlFailedProvider'], {
|
||||
provider: providerName,
|
||||
})
|
||||
: requestError === 'START_FAILED'
|
||||
? t(($) => $['newKnowledge.crawlStartFailed'])
|
||||
: t(($) => $['newKnowledge.crawlFailedDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{showZero && !showFailure && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex min-h-40 flex-col items-center justify-center rounded-xl border border-divider-regular px-6 text-center"
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-background-section">
|
||||
<span aria-hidden className="i-ri-global-line size-5 text-text-tertiary" />
|
||||
</span>
|
||||
<p className="mt-2 system-xs-semibold text-text-primary">
|
||||
{t(($) => $['newKnowledge.noPagesFound'], { host: previewHost })}
|
||||
</p>
|
||||
<p className="mt-2 max-w-lg system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.noPagesFoundDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user