feat(dataset): add crawl source selection (#39325)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou
2026-07-24 07:11:48 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent e4dc29f98e
commit eac9f69ad1
47 changed files with 5830 additions and 1852 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,723 @@
import type {
GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse,
GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse,
Source,
SourceWorkflowRun,
} from '@dify/contracts/knowledge-fs/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { getInitOptions } from '@/i18n-config/settings'
import datasetTranslations from '@/i18n/en-US/dataset.json'
import { render } from '@/test/console/render'
import { CrawlSelectionForm } from '../crawl-selection-form'
const clientMock = vi.hoisted(() => ({
getPolicy: vi.fn(),
getWorkflow: vi.fn(),
selectPages: vi.fn(),
updatePolicy: vi.fn(),
}))
const routerMock = vi.hoisted(() => ({ push: vi.fn() }))
const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() }))
const policyQueryOptionsMock = vi.hoisted(() =>
vi.fn(({ input }) => ({
queryFn: () => clientMock.getPolicy(input),
queryKey: ['sync-policy', input.params.sourceId],
retry: false,
})),
)
vi.mock('@/next/navigation', () => ({ useRouter: () => routerMock }))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return { ...original, useQueryClient: () => queryClientMock }
})
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy: clientMock.getPolicy,
getKnowledgeSpacesByIdSourceWorkflowsByRunId: clientMock.getWorkflow,
},
},
consoleQuery: {
knowledgeFs: {
getKnowledgeSpacesByIdSources: {
key: vi.fn(() => ['knowledge-sources']),
},
getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy: {
queryOptions: policyQueryOptionsMock,
},
postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection: {
mutationOptions: vi.fn(() => ({
mutationFn: (input: unknown) => clientMock.selectPages(input),
})),
},
putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy: {
mutationOptions: vi.fn(() => ({
mutationFn: (input: unknown) => clientMock.updatePolicy(input),
})),
},
},
},
}))
type PreviewPage = GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse['items'][number]
const source: Source = {
connectionId: 'connection-1',
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/',
version: 3,
}
const run: SourceWorkflowRun = {
checkpoint: 'crawl',
createdAt: '2026-07-20T10:00:00Z',
executionAttempts: 1,
id: 'run-1',
knowledgeSpaceId: 'space-1',
kind: 'crawl-preview',
maxExecutionAttempts: 3,
progressCompleted: 3,
progressFailed: 1,
progressSkipped: 0,
progressTotal: 4,
sourceId: source.id,
state: 'succeeded',
updatedAt: '2026-07-20T10:01:00Z',
}
const pages: PreviewPage[] = [
{
pageId: 'page-1',
sourceUrl: 'https://docs.dify.ai/getting-started',
title: 'Getting started',
},
{
pageId: 'page-2',
sourceUrl: 'https://docs.dify.ai/guides',
title: 'Guides',
},
{
pageId: 'off-domain',
sourceUrl: 'https://github.com/langgenius/dify/edit/main/docs.md',
title: 'Edit this page',
},
]
const policy = (
overrides: Partial<GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse> = {},
): GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse => ({
createdAt: '2026-07-20T10:00:00Z',
enabled: true,
expectedSourceVersion: 3,
id: 'policy-1',
knowledgeSpaceId: 'space-1',
mode: 'interval',
revision: 2,
sourceId: source.id,
updatedAt: '2026-07-20T10:00:00Z',
...overrides,
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve
})
return { promise, resolve }
}
function renderSelectionForm(
onCancel = vi.fn(),
workflowUncertain = false,
discardRequested = () => false,
previewPages = pages,
initialSyncMode?: 'custom' | 'interval' | 'manual' | 'provider',
) {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
const onSubmissionUncertainChange = vi.fn()
const onRecrawl = vi.fn()
const onWorkflowPending = vi.fn()
const onWorkflowRun = vi.fn()
const view = render(
<QueryClientProvider client={queryClient}>
<CrawlSelectionForm
discardRequested={discardRequested}
initialSyncMode={initialSyncMode}
knowledgeSpaceId="space-1"
onCancel={onCancel}
onRecrawl={onRecrawl}
onSubmissionUncertainChange={onSubmissionUncertainChange}
onSubmitted={vi.fn()}
onWorkflowPending={onWorkflowPending}
onWorkflowRun={onWorkflowRun}
pages={previewPages}
rootUrl="https://docs.dify.ai/"
run={run}
source={source}
workflowUncertain={workflowUncertain}
/>
</QueryClientProvider>,
)
return {
...view,
onRecrawl,
onSubmissionUncertainChange,
onWorkflowPending,
onWorkflowRun,
}
}
describe('CrawlSelectionForm', () => {
beforeEach(() => {
vi.clearAllMocks()
clientMock.getPolicy.mockResolvedValue(policy())
clientMock.getWorkflow.mockResolvedValue({ ...run, checkpoint: 'complete', state: 'completed' })
clientMock.updatePolicy.mockResolvedValue(policy({ mode: 'manual', revision: 3 }))
clientMock.selectPages.mockResolvedValue({ ...run, checkpoint: 'import', state: 'queued' })
})
it('uses singular and plural copy for the crawl summary', async () => {
const instance = createInstance()
await instance.init({
...getInitOptions(),
defaultNS: 'dataset',
lng: 'en-US',
ns: ['dataset'],
resources: { 'en-US': { dataset: datasetTranslations } },
})
const tDataset = instance.getFixedT('en-US', 'dataset')
expect(tDataset(($) => $['newKnowledge.pagesCrawled'], { count: 1, host: 'example.com' })).toBe(
'1 page crawled at example.com',
)
expect(tDataset(($) => $['newKnowledge.pagesCrawled'], { count: 2, host: 'example.com' })).toBe(
'2 pages crawled at example.com',
)
})
it('loads a missing initial sync policy without a global error notification', async () => {
renderSelectionForm()
await screen.findByRole('checkbox', { name: 'Getting started' })
expect(policyQueryOptionsMock).toHaveBeenCalledWith(
expect.objectContaining({ context: { silent: true } }),
)
})
it('preserves the sync policy selected in the creation flow', async () => {
renderSelectionForm(vi.fn(), false, () => false, pages, 'manual')
expect(
await screen.findByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
).toHaveValue('manual')
})
it('selects only valid same-domain pages and exposes an indeterminate select-all state', async () => {
const user = userEvent.setup()
renderSelectionForm()
await screen.findByRole('checkbox', { name: 'Getting started' })
const addSource = screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })
expect(addSource).toBeDisabled()
const offDomainPage = screen.getByRole('checkbox', { name: 'Edit this page' })
expect(offDomainPage).toHaveAttribute('aria-disabled', 'true')
expect(offDomainPage).toHaveAccessibleDescription(
'https://github.com/langgenius/dify/edit/main/docs.md dataset.newKnowledge.skippedOffDomain',
)
expect(screen.getByText('dataset.newKnowledge.skippedOffDomain')).toBeInTheDocument()
expect(screen.getByText(/dataset\.newKnowledge\.skippedFailed/)).toBeInTheDocument()
await user.click(screen.getByRole('checkbox', { name: 'Getting started' }))
expect(addSource).toBeEnabled()
expect(
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.selectAll' }),
).toHaveAttribute('aria-checked', 'mixed')
expect(
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.selectAll' }),
).toHaveAttribute('data-indeterminate')
expect(screen.getByText('dataset.newKnowledge.pagesSelected:{"count":1}')).toBeInTheDocument()
await user.click(screen.getByRole('checkbox', { name: 'dataset.newKnowledge.selectAll' }))
expect(screen.getByRole('checkbox', { name: 'Getting started' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'Guides' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'Edit this page' })).not.toBeChecked()
})
it('toggles selection from the visible checkbox labels', async () => {
const user = userEvent.setup()
renderSelectionForm()
await screen.findByRole('checkbox', { name: 'Getting started' })
await user.click(screen.getByText('Getting started'))
expect(screen.getByRole('checkbox', { name: 'Getting started' })).toBeChecked()
await user.click(screen.getByText('dataset.newKnowledge.selectAll'))
expect(screen.getByRole('checkbox', { name: 'Getting started' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'Guides' })).toBeChecked()
})
it('locks stale selections while keeping workflow reconciliation available', async () => {
const onCancel = vi.fn()
const user = userEvent.setup()
const { onRecrawl } = renderSelectionForm(onCancel, true)
await screen.findByRole('checkbox', { name: 'Getting started' })
expect(screen.getByRole('checkbox', { name: 'Getting started' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
expect(onRecrawl).toHaveBeenCalledOnce()
expect(onCancel).toHaveBeenCalledOnce()
})
it('never submits more page IDs than the selection contract accepts', async () => {
const longPageList: PreviewPage[] = Array.from({ length: 201 }, (_, index) => ({
pageId: `page-${index + 1}`,
sourceUrl: `https://docs.dify.ai/page-${index + 1}`,
title: `Page ${index + 1}`,
}))
const user = userEvent.setup()
renderSelectionForm(vi.fn(), false, () => false, longPageList)
await user.click(
await screen.findByRole('checkbox', { name: 'dataset.newKnowledge.selectAll' }),
)
const overflowPage = screen.getByRole('checkbox', { name: 'Page 201' })
expect(overflowPage).toHaveAttribute('aria-disabled', 'true')
expect(overflowPage).toHaveAccessibleDescription(
'https://docs.dify.ai/page-201 dataset.newKnowledge.maxPages: 200',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.selectPages).toHaveBeenCalledOnce())
expect(clientMock.selectPages.mock.calls[0]?.[0].body.pageIds).toHaveLength(200)
})
it('validates a custom interval and submits policy plus selection exactly once', async () => {
const selectionRequest = deferred<SourceWorkflowRun>()
clientMock.selectPages.mockReturnValue(selectionRequest.promise)
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'custom',
)
const interval = screen.getByRole('spinbutton', {
name: 'dataset.newKnowledge.customIntervalHours',
})
await user.clear(interval)
await user.type(interval, '0')
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })).toBeDisabled()
expect(interval).toHaveAccessibleDescription('dataset.newKnowledge.customIntervalInvalid')
await user.clear(interval)
await user.type(interval, '6')
const addSource = screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })
await user.dblClick(addSource)
await waitFor(() => expect(clientMock.updatePolicy).toHaveBeenCalledOnce())
expect(clientMock.updatePolicy).toHaveBeenCalledWith({
body: {
customIntervalSeconds: 21600,
enabled: true,
expectedRevision: 2,
expectedSourceVersion: 3,
mode: 'custom',
},
params: { id: 'space-1', sourceId: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
expect(clientMock.selectPages).toHaveBeenCalledWith({
body: { pageIds: ['page-1'] },
headers: { 'Idempotency-Key': expect.any(String) },
params: { id: 'space-1', runId: 'run-1' },
})
selectionRequest.resolve({ ...run, checkpoint: 'import', state: 'queued' })
await waitFor(() => expect(clientMock.getWorkflow).toHaveBeenCalledOnce())
await waitFor(() =>
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
queryKey: ['knowledge-sources'],
}),
)
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources')
})
it.each(['complete', 'success'])(
'accepts the %s workflow state as a successful import',
async (state) => {
clientMock.selectPages.mockResolvedValue({ ...run, checkpoint: 'complete', state })
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources'),
)
expect(clientMock.getWorkflow).not.toHaveBeenCalled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
},
)
it.each(['error', 'exhausted', 'superseded'])(
'stops polling when the import reaches the %s workflow state',
async (state) => {
clientMock.selectPages.mockResolvedValue({ ...run, checkpoint: 'import', state })
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.addSourceFailed',
)
expect(clientMock.getWorkflow).not.toHaveBeenCalled()
expect(routerMock.push).not.toHaveBeenCalled()
},
)
it('tracks policy updates and stops before selection when discard is requested', async () => {
const policyRequest = deferred<GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse>()
clientMock.updatePolicy.mockReturnValue(policyRequest.promise)
let discardRequested = false
const user = userEvent.setup()
const { onWorkflowPending } = renderSelectionForm(vi.fn(), false, () => discardRequested)
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'manual',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
expect(onWorkflowPending).toHaveBeenCalledOnce()
expect(clientMock.selectPages).not.toHaveBeenCalled()
discardRequested = true
await act(async () =>
policyRequest.resolve(policy({ enabled: false, mode: 'manual', revision: 3 })),
)
await expect(onWorkflowPending.mock.calls[0]?.[0]).resolves.toBeUndefined()
expect(clientMock.selectPages).not.toHaveBeenCalled()
})
it('releases a pending import for cancellation as soon as the current poll returns', async () => {
const workflowRequest = deferred<SourceWorkflowRun>()
clientMock.getWorkflow.mockReturnValue(workflowRequest.promise)
let discardRequested = false
const user = userEvent.setup()
const { onWorkflowPending } = renderSelectionForm(vi.fn(), false, () => discardRequested)
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.getWorkflow).toHaveBeenCalledOnce())
expect(onWorkflowPending).toHaveBeenCalledOnce()
discardRequested = true
const importRun = { ...run, checkpoint: 'import', state: 'running' }
await act(async () => workflowRequest.resolve(importRun))
await expect(onWorkflowPending.mock.calls[0]?.[0]).resolves.toEqual(importRun)
expect(clientMock.getWorkflow).toHaveBeenCalledOnce()
expect(queryClientMock.invalidateQueries).not.toHaveBeenCalled()
expect(routerMock.push).not.toHaveBeenCalled()
})
it('keeps Cancel available while the import is pending', async () => {
const workflowRequest = deferred<SourceWorkflowRun>()
clientMock.getWorkflow.mockReturnValue(workflowRequest.promise)
const onCancel = vi.fn()
const user = userEvent.setup()
const { onWorkflowPending } = renderSelectionForm(onCancel)
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.getWorkflow).toHaveBeenCalledOnce())
const cancel = screen.getByRole('button', {
name: 'dataset.newKnowledge.cancelAddSource',
})
expect(cancel).toBeEnabled()
await user.click(cancel)
expect(onCancel).toHaveBeenCalledOnce()
await act(async () =>
workflowRequest.resolve({ ...run, checkpoint: 'complete', state: 'completed' }),
)
await expect(onWorkflowPending.mock.calls[0]?.[0]).resolves.toEqual(
expect.objectContaining({ state: 'completed' }),
)
})
it.each([
{ enabled: true, initialMode: 'interval', mode: 'provider' },
{ enabled: false, initialMode: 'interval', mode: 'manual' },
{ enabled: true, initialMode: 'manual', mode: 'interval' },
] as const)(
'submits the $mode sync policy using the KnowledgeFS contract',
async ({ enabled, initialMode, mode }) => {
clientMock.getPolicy.mockResolvedValue(
policy({ enabled: initialMode !== 'manual', mode: initialMode }),
)
clientMock.updatePolicy.mockResolvedValue(policy({ enabled, mode, revision: 3 }))
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
mode,
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.updatePolicy).toHaveBeenCalledOnce())
expect(clientMock.updatePolicy).toHaveBeenCalledWith({
body: {
enabled,
expectedRevision: 2,
expectedSourceVersion: 3,
mode,
},
params: { id: 'space-1', sourceId: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
},
)
it('reconciles a lost sync-policy response before importing the selected pages', async () => {
clientMock.getPolicy
.mockReset()
.mockResolvedValueOnce(policy())
.mockResolvedValueOnce(policy({ enabled: false, mode: 'manual', revision: 3 }))
clientMock.updatePolicy.mockRejectedValue(new Error('response lost'))
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'manual',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.selectPages).toHaveBeenCalledOnce())
expect(clientMock.getPolicy).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('allows discard when only the sync-policy outcome is uncertain', async () => {
clientMock.getPolicy
.mockReset()
.mockResolvedValueOnce(policy())
.mockRejectedValueOnce(new Error('reconciliation unavailable'))
clientMock.updatePolicy.mockRejectedValue(new Error('response lost'))
const onCancel = vi.fn()
const user = userEvent.setup()
const { onSubmissionUncertainChange } = renderSelectionForm(onCancel)
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'manual',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.addSourceFailed',
)
expect(onSubmissionUncertainChange).not.toHaveBeenCalledWith(true)
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('uses the latest concurrency tokens after a conflicting sync-policy update', async () => {
clientMock.getPolicy
.mockReset()
.mockResolvedValueOnce(policy())
.mockResolvedValueOnce(
policy({
expectedSourceVersion: 4,
mode: 'provider',
revision: 3,
}),
)
clientMock.updatePolicy
.mockRejectedValueOnce(Object.assign(new Error('conflict'), { status: 409 }))
.mockResolvedValueOnce(policy({ enabled: false, mode: 'manual', revision: 4 }))
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'manual',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.addSourceFailed',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.updatePolicy).toHaveBeenCalledTimes(2))
expect(clientMock.updatePolicy.mock.calls[1]?.[0]).toEqual({
body: {
enabled: false,
expectedRevision: 3,
expectedSourceVersion: 4,
mode: 'manual',
},
params: { id: 'space-1', sourceId: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
})
it('recovers when the initial sync policy cannot load', async () => {
clientMock.getPolicy
.mockReset()
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce(policy())
const user = userEvent.setup()
renderSelectionForm()
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.syncPolicyLoadFailed',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retrySyncPolicy' }))
await screen.findByRole('checkbox', { name: 'Getting started' })
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })).toBeDisabled()
})
it('creates the initial provider policy when a provisional source has no policy yet', async () => {
clientMock.getPolicy.mockRejectedValue(
Object.assign(new Error('policy not found'), { status: 404 }),
)
clientMock.updatePolicy.mockResolvedValue(policy({ mode: 'provider', revision: 1 }))
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.updatePolicy).toHaveBeenCalledOnce())
expect(clientMock.updatePolicy).toHaveBeenCalledWith({
body: {
enabled: true,
expectedRevision: 0,
expectedSourceVersion: 3,
mode: 'provider',
},
params: { id: 'space-1', sourceId: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
})
it('keeps Cancel available when the sync policy cannot load', async () => {
clientMock.getPolicy.mockRejectedValue(new Error('temporary failure'))
const onCancel = vi.fn()
const user = userEvent.setup()
renderSelectionForm(onCancel)
await screen.findByRole('alert')
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('keeps Cancel available while the sync policy is loading', async () => {
const policyRequest = deferred<GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse>()
clientMock.getPolicy.mockReturnValue(policyRequest.promise)
const onCancel = vi.fn()
const user = userEvent.setup()
renderSelectionForm(onCancel)
expect(
screen.getByRole('status', { name: 'dataset.newKnowledge.loadingSyncPolicy' }),
).toBeVisible()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('uses a new selection idempotency key after a confirmed import failure', async () => {
clientMock.getWorkflow.mockResolvedValueOnce({ ...run, checkpoint: 'import', state: 'failed' })
const user = userEvent.setup()
renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.addSourceFailed',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.selectPages).toHaveBeenCalledTimes(2))
expect(clientMock.selectPages.mock.calls[0]?.[0].headers['Idempotency-Key']).not.toBe(
clientMock.selectPages.mock.calls[1]?.[0].headers['Idempotency-Key'],
)
})
it('reuses the selection idempotency key after a lost response without rewriting policy', async () => {
clientMock.getPolicy.mockResolvedValue(policy({ enabled: false, mode: 'manual' }))
clientMock.selectPages
.mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValueOnce({ ...run, checkpoint: 'import', state: 'queued' })
const user = userEvent.setup()
const { onSubmissionUncertainChange, onWorkflowPending, onWorkflowRun } = renderSelectionForm()
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'manual',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.addSourceFailed',
)
expect(onWorkflowPending).toHaveBeenCalledOnce()
expect(onSubmissionUncertainChange).toHaveBeenLastCalledWith(true)
expect(screen.getByRole('checkbox', { name: 'Guides' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeDisabled()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }),
).toBeEnabled()
await user.click(screen.getByText('Guides'))
expect(screen.getByRole('checkbox', { name: 'Guides' })).not.toBeChecked()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' }))
await waitFor(() => expect(clientMock.selectPages).toHaveBeenCalledTimes(2))
expect(onWorkflowRun).toHaveBeenCalledWith(
expect.objectContaining({ checkpoint: 'import', state: 'queued' }),
)
expect(clientMock.updatePolicy).not.toHaveBeenCalled()
expect(clientMock.selectPages.mock.calls[0]?.[0].headers).toEqual(
clientMock.selectPages.mock.calls[1]?.[0].headers,
)
})
})
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CreateKnowledgePage } from '../create-knowledge-page'
import { newKnowledgeSourceDraftStorageKey } from '../routes'
const serviceMock = vi.hoisted(() => ({
create: vi.fn(),
@@ -103,6 +104,7 @@ async function choosePermission(user: ReturnType<typeof userEvent.setup>, option
describe('CreateKnowledgePage', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.sessionStorage.clear()
serviceMock.create.mockResolvedValue(createdKnowledge)
serviceMock.getPolicy.mockResolvedValue({
id: 'policy-1',
@@ -126,6 +128,7 @@ describe('CreateKnowledgePage', () => {
})
afterEach(() => {
globalThis.sessionStorage.clear()
vi.restoreAllMocks()
})
@@ -370,27 +373,82 @@ describe('CreateKnowledgePage', () => {
expect(serviceMock.patchPolicy).toHaveBeenCalledOnce()
})
it('keeps source and upload disabled until their stacked flows land', () => {
it('integrates source setup while keeping the later upload flow disabled', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
const startEmpty = screen.getByRole('radio', { name: 'dataset.newKnowledge.startEmpty' })
expect(startEmpty).toBeChecked()
expect(startEmpty).not.toBeChecked()
expect(startEmpty).toHaveAccessibleDescription('dataset.newKnowledge.startEmptyDescription')
const connectSource = screen.getByRole('radio', {
name: 'dataset.newKnowledge.connectSource',
})
const uploadFiles = screen.getByRole('radio', { name: 'dataset.newKnowledge.uploadFiles' })
expect(connectSource).toBeDisabled()
expect(connectSource).toHaveAccessibleDescription(
'dataset.newKnowledge.connectSourceDescription dataset.cornerLabel.unavailable',
expect(connectSource).toBeEnabled()
expect(connectSource).toBeChecked()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' })).toBeChecked()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder'),
'https://docs.dify.ai',
)
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Dify docs',
)
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeEnabled()
expect(uploadFiles).toBeDisabled()
expect(uploadFiles).toHaveAccessibleDescription(
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
)
})
it('hands the configured source draft to the add-source workflow after creation', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
await fillRequiredFields(user)
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder'),
'https://docs.dify.ai',
)
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Dify docs',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlOptions' }))
await user.click(screen.getByRole('checkbox', { name: 'dataset.newKnowledge.includeSubpages' }))
const maxPages = screen.getByRole('spinbutton', { name: 'dataset.newKnowledge.maxPages' })
await user.clear(maxPages)
await user.type(maxPages, '25')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() =>
expect(routerMock.replace).toHaveBeenCalledWith(
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources/new?type=websiteCrawl&draft=a9c36c57-2d84-44d6-a36d-841f0d92a179',
),
)
expect(
JSON.parse(
globalThis.sessionStorage.getItem(
newKnowledgeSourceDraftStorageKey('a9c36c57-2d84-44d6-a36d-841f0d92a179'),
) ?? '',
),
).toEqual({
includeSubpages: false,
maxPages: 25,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
})
})
it('renders the approved creation modal and exposes both dismiss actions', async () => {
const user = userEvent.setup()
renderPage()
@@ -453,6 +511,27 @@ describe('CreateKnowledgePage', () => {
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
})
it('asks before discarding a preserved source draft after switching source types', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Release notes',
)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' }))
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(
await screen.findByRole('alertdialog', {
name: 'dataset.newKnowledge.discardDraftTitle',
}),
).toBeInTheDocument()
expect(routerMock.replace).not.toHaveBeenCalled()
})
it('protects an unsaved draft from browser unload', async () => {
const user = userEvent.setup()
renderPage()
@@ -0,0 +1,41 @@
import { createRequestId } from '../request-id'
const uuidV4Mock = vi.hoisted(() => vi.fn())
vi.mock('uuid', () => ({
v4: uuidV4Mock,
}))
describe('createRequestId', () => {
beforeEach(() => {
vi.clearAllMocks()
uuidV4Mock.mockReturnValue('fallback-request-id')
})
it('uses the native random UUID when it is available', () => {
const randomUUID = vi
.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValue('11111111-1111-4111-8111-111111111111')
expect(createRequestId()).toBe('11111111-1111-4111-8111-111111111111')
expect(uuidV4Mock).not.toHaveBeenCalled()
randomUUID.mockRestore()
})
it('falls back when randomUUID is unavailable', () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
Object.defineProperty(globalThis.crypto, 'randomUUID', {
configurable: true,
value: undefined,
})
try {
expect(createRequestId()).toBe('fallback-request-id')
expect(uuidV4Mock).toHaveBeenCalledOnce()
} finally {
if (descriptor) Object.defineProperty(globalThis.crypto, 'randomUUID', descriptor)
else Reflect.deleteProperty(globalThis.crypto, 'randomUUID')
}
})
})
@@ -154,14 +154,19 @@ describe('SourcesPage', () => {
'href',
'/datasets/new/space-1/sources/new',
)
for (const brand of ['firecrawl', 'jina', 'notion', 'google-drive', 'confluence', 'more'])
expect(container.querySelector(`[data-brand="${brand}"]`)).toBeInTheDocument()
expect(container.querySelector('[data-brand="firecrawl"]')?.tagName).toBe('SPAN')
expect(container.querySelector('[data-brand="firecrawl"]')).toHaveClass('size-8')
expect(container.querySelector('[data-brand="jina"]')).toHaveClass('i-custom-public-llm-jina')
expect(container.querySelector('[data-brand="confluence"]')).toHaveClass(
'i-custom-public-common-confluence',
)
for (const [brand, iconClass] of [
['firecrawl', 'i-custom-public-common-firecrawl'],
['jina', 'i-custom-public-llm-jina'],
['notion', 'i-custom-public-common-notion'],
['google-drive', 'i-custom-public-common-google-drive'],
['confluence', 'i-custom-public-common-confluence'],
['dropbox', 'i-custom-public-common-dropbox'],
] as const) {
const icon = container.querySelector(`[data-brand="${brand}"]`)
expect(icon).toBeInTheDocument()
expect(icon?.tagName).toBe('SPAN')
expect(icon).toHaveClass(iconClass)
}
})
it('renders real source statuses and filters by status and search text', async () => {
@@ -231,6 +236,102 @@ describe('SourcesPage', () => {
expect(within(rowsDescending[1]!).getByText('Alpha docs')).toBeInTheDocument()
})
it('places the newest source first until the user selects a name sort', () => {
sourcesQuery.data = {
pages: [
{
items: [
source({ id: 'older', name: 'Older source', createdAt: '2026-07-20T10:00:00Z' }),
source({ id: 'newer', name: 'Newest source', createdAt: '2026-07-20T10:01:00Z' }),
],
},
],
}
render(<SourcesPage knowledgeSpaceId="space-1" />)
const rows = screen.getAllByRole('row').slice(1)
expect(within(rows[0]!).getByText('Newest source')).toBeInTheDocument()
expect(within(rows[1]!).getByText('Older source')).toBeInTheDocument()
})
it('keeps provisional crawl sources out of the source list', () => {
sourcesQuery.data = {
pages: [
{
items: [
source({
id: 'preview',
metadata: { preview: true },
name: 'Discarded preview',
status: 'disabled',
}),
source({ id: 'connected', name: 'Connected source' }),
source({
id: 'submitted-preview',
metadata: { preview: true },
name: 'Submitted preview',
status: 'syncing',
}),
],
},
],
}
render(<SourcesPage knowledgeSpaceId="space-1" />)
expect(screen.queryByText('Discarded preview')).not.toBeInTheDocument()
expect(screen.getByText('Connected source')).toBeInTheDocument()
expect(screen.getByText('Submitted preview')).toBeInTheDocument()
})
it('continues past cursor pages containing only hidden preview drafts', () => {
sourcesQuery.data = {
pages: [
{
items: [
source({
id: 'preview',
metadata: { preview: true },
status: 'disabled',
}),
],
nextCursor: 'next',
},
],
}
sourcesQuery.hasNextPage = true
render(<SourcesPage knowledgeSpaceId="space-1" />)
expect(sourcesQuery.fetchNextPage).toHaveBeenCalledOnce()
expect(screen.queryByText('dataset.newKnowledge.sourcesEmptyTitle')).not.toBeInTheDocument()
})
it('continues when the newest loaded page contributes only hidden preview drafts', () => {
sourcesQuery.data = {
pages: [
{ items: [source({ id: 'connected', name: 'Connected source' })] },
{
items: [
source({
id: 'preview',
metadata: { preview: true },
status: 'disabled',
}),
],
nextCursor: 'next',
},
],
}
sourcesQuery.hasNextPage = true
render(<SourcesPage knowledgeSpaceId="space-1" />)
expect(screen.getByText('Connected source')).toBeInTheDocument()
expect(sourcesQuery.fetchNextPage).toHaveBeenCalledOnce()
})
it('opens a source URI from the row action menu', async () => {
const user = userEvent.setup()
sourcesQuery.data = { pages: [{ items: [source({})] }] }
@@ -361,19 +462,21 @@ describe('SourcesPage', () => {
sourcesQuery.data = {
pages: [
{
items: [source({ id: 'active-source' }), source({ id: 'disabled', status: 'disabled' })],
items: [
source({ id: 'active-source', name: 'Active source' }),
source({ id: 'disabled', name: 'Disabled source', status: 'disabled' }),
],
},
],
}
render(<SourcesPage knowledgeSpaceId="space-1" />)
const actionButtons = screen.getAllByRole('button', {
name: /dataset.newKnowledge.sourceActions/,
})
const activeSourceActions = actionButtons[0]
const disabledSourceActions = actionButtons[1]
if (!activeSourceActions || !disabledSourceActions)
throw new Error('Expected actions for both sources')
const activeSourceActions = within(
screen.getByRole('row', { name: /Active source/ }),
).getByRole('button', { name: /dataset.newKnowledge.sourceActions/ })
const disabledSourceActions = within(
screen.getByRole('row', { name: /Disabled source/ }),
).getByRole('button', { name: /dataset.newKnowledge.sourceActions/ })
await user.click(activeSourceActions)
await user.click(screen.getByRole('menuitem', { name: 'dataset.newKnowledge.disableSource' }))
await waitFor(() =>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
'use client'
import type {
NewKnowledgeOnlineDocumentsProvider,
NewKnowledgeOnlineDocumentsSourceDraft,
NewKnowledgeOnlineDriveProvider,
NewKnowledgeOnlineDriveSourceDraft,
NewKnowledgeSourceDraft,
NewKnowledgeWebsiteSourceDraft,
} from './routes'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
isValidWebsiteSourceDraft,
NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH,
NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH,
} from './routes'
const connectedProviders = {
onlineDocuments: [
{ icon: 'i-custom-public-common-notion', label: 'Notion' },
{ icon: 'i-ri-file-text-line', label: 'Google Docs' },
{ icon: 'i-ri-links-line', label: 'Confluence' },
],
onlineDrive: [
{ icon: 'i-custom-public-common-google-drive', label: 'Google Drive' },
{ icon: 'i-ri-cloud-line', label: 'OneDrive' },
{ icon: 'i-ri-box-3-line', label: 'Amazon S3' },
],
} as const
export function PendingWebsiteSetup({
draft,
onDraftChange,
}: {
draft: NewKnowledgeWebsiteSourceDraft
onDraftChange: (draft: NewKnowledgeWebsiteSourceDraft) => void
}) {
const { t } = useTranslation('dataset')
const [optionsExpanded, setOptionsExpanded] = useState(false)
const [backendBoundaryVisible, setBackendBoundaryVisible] = useState(false)
const updateDraft = (nextDraft: NewKnowledgeWebsiteSourceDraft) => {
onDraftChange(nextDraft)
setBackendBoundaryVisible(false)
}
return (
<section className="space-y-4" aria-label={t(($) => $['newKnowledge.crawlAndPreview'])}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.rootUrl'])}
<input
type="url"
inputMode="url"
autoComplete="off"
maxLength={NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH}
value={draft.rootUrl}
placeholder={t(($) => $['newKnowledge.rootUrlPlaceholder'])}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid"
onChange={(event) => {
updateDraft({ ...draft, rootUrl: event.target.value })
}}
/>
</label>
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.sourceName'])}
<input
type="text"
autoComplete="off"
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={draft.sourceName}
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid"
onChange={(event) => {
updateDraft({ ...draft, sourceName: event.target.value })
}}
/>
</label>
</div>
<div className="overflow-hidden rounded-lg bg-background-section">
<button
type="button"
aria-expanded={optionsExpanded}
className="flex min-h-9 w-full items-center gap-2 px-3 text-left system-xs-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setOptionsExpanded((value) => !value)}
>
<span
aria-hidden
className={cn(
'size-4',
optionsExpanded ? 'i-ri-arrow-down-s-line' : 'i-ri-arrow-right-s-line',
)}
/>
{t(($) => $['newKnowledge.crawlOptions'])}
{!optionsExpanded && (
<span className="ml-auto system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.usingDefaults'])}
</span>
)}
</button>
{optionsExpanded && (
<fieldset className="grid grid-cols-1 gap-3 px-3 pb-3 sm:grid-cols-2">
<label className="flex items-center gap-2 system-xs-regular text-text-secondary">
<input
type="checkbox"
checked={draft.includeSubpages}
onChange={(event) =>
updateDraft({ ...draft, includeSubpages: event.target.checked })
}
/>
{t(($) => $['newKnowledge.includeSubpages'])}
</label>
<label className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.maxPages'])}
<input
type="number"
min={1}
max={200}
value={draft.maxPages}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden focus:ring-2 focus:ring-state-accent-solid"
onChange={(event) =>
updateDraft({ ...draft, maxPages: event.target.valueAsNumber || 0 })
}
/>
</label>
</fieldset>
)}
</div>
<Button
type="button"
variant="primary"
className="w-full"
disabled={!isValidWebsiteSourceDraft(draft)}
onClick={() => setBackendBoundaryVisible(true)}
>
{t(($) => $['newKnowledge.crawlAndPreview'])}
</Button>
<div className="flex min-h-40 flex-col items-center justify-center rounded-lg 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>
{backendBoundaryVisible && (
<p
role="alert"
className="rounded-md bg-components-badge-status-light-warning-bg px-3 py-2 system-xs-regular text-text-warning"
>
{t(($) => $['newKnowledge.sourceSetupBackendDependency'])}
</p>
)}
</section>
)
}
export function UnavailableConnectedSourceSetup({
draft,
onDraftChange,
}: {
draft: NewKnowledgeOnlineDocumentsSourceDraft | NewKnowledgeOnlineDriveSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
}) {
const { t } = useTranslation('dataset')
const { t: tCreation } = useTranslation('datasetCreation')
const sourceType = draft.sourceType
const providers = connectedProviders[sourceType]
const activeProvider = draft.provider
const selectProvider = (provider: string) => {
if (draft.sourceType === 'onlineDocuments')
onDraftChange({ ...draft, provider: provider as NewKnowledgeOnlineDocumentsProvider })
else onDraftChange({ ...draft, provider: provider as NewKnowledgeOnlineDriveProvider })
}
return (
<div className="space-y-4">
<fieldset>
<legend className="mb-1.5 system-xs-medium text-text-secondary">
{tCreation(($) => $['stepOne.website.chooseProvider'])}
</legend>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{providers.map((option) => (
<label
key={option.label}
className={cn(
'flex min-h-9 cursor-pointer items-center justify-center gap-2 rounded-lg border px-3 system-xs-medium outline-hidden has-focus-visible:ring-2 has-focus-visible:ring-state-accent-solid',
activeProvider === option.label
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary'
: 'border-divider-subtle text-text-secondary hover:bg-state-base-hover',
)}
>
<input
type="radio"
name={`${sourceType}-provider`}
value={option.label}
checked={activeProvider === option.label}
onChange={() => selectProvider(option.label)}
className="sr-only"
/>
<span aria-hidden className={`${option.icon} size-4 shrink-0`} />
<span className="truncate">{option.label}</span>
</label>
))}
</div>
</fieldset>
{sourceType === 'onlineDrive' && (
<section
aria-label={t(($) => $['newKnowledge.selectFilesAndFolders'])}
className="flex min-h-40 flex-col items-center justify-center rounded-lg 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-folder-open-line size-5 text-text-tertiary" />
</span>
<p className="mt-2 system-xs-semibold text-text-primary">
{t(($) => $['newKnowledge.selectFilesAndFolders'])}
</p>
</section>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.sourceName'])}
<span aria-hidden className="ml-0.5 text-text-destructive">
*
</span>
<input
type="text"
autoComplete="off"
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={draft.sourceName}
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid"
onChange={(event) => onDraftChange({ ...draft, sourceName: event.target.value })}
/>
</label>
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.syncPolicy'])}
<select
value={draft.syncPolicy}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden focus:ring-2 focus:ring-state-accent-solid"
onChange={(event) =>
onDraftChange({
...draft,
syncPolicy: event.target.value as NewKnowledgeSourceDraft['syncPolicy'],
})
}
>
<option value="provider">{t(($) => $['newKnowledge.syncPolicyProvider'])}</option>
<option value="daily">{t(($) => $['newKnowledge.syncPolicyDaily'])}</option>
<option value="manual">{t(($) => $['newKnowledge.syncPolicyManual'])}</option>
</select>
</label>
</div>
<div role="status" className="rounded-lg bg-background-section px-3 py-2">
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</p>
</div>
</div>
)
}
@@ -1,5 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import type { NewKnowledgeStartMode } from '../routes'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioControl, RadioItem } from '@langgenius/dify-ui/radio'
@@ -7,15 +8,19 @@ import { useId } from 'react'
import { useTranslation } from 'react-i18next'
export function StartMode({
children,
description,
disabled = false,
icon,
selected = false,
title,
value,
}: {
children?: ReactNode
description: string
disabled?: boolean
icon: string
selected?: boolean
title: string
value: NewKnowledgeStartMode
}) {
@@ -25,44 +30,52 @@ export function StartMode({
const unavailableId = useId()
return (
<RadioItem
value={value}
nativeButton
render={<button type="button" />}
aria-labelledby={titleId}
aria-describedby={disabled ? `${descriptionId} ${unavailableId}` : descriptionId}
disabled={disabled}
<div
className={cn(
'relative flex min-h-16 w-full items-center gap-3 overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg px-4 py-3.5 text-left outline-hidden transition-colors motion-reduce:transition-none',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:border-[1.5px] data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg',
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-components-option-card-option-bg',
'overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg transition-colors motion-reduce:transition-none',
selected &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg',
)}
>
<RadioControl aria-hidden />
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-option-card-option-border bg-background-default">
<span aria-hidden className={`${icon} size-[18px] text-text-accent`} />
</span>
<span className="min-w-0 flex-1">
<span id={titleId} className="block system-sm-medium text-text-primary">
{title}
<RadioItem
value={value}
nativeButton
render={<button type="button" />}
aria-labelledby={titleId}
aria-describedby={disabled ? `${descriptionId} ${unavailableId}` : descriptionId}
disabled={disabled}
className={cn(
'relative flex min-h-16 w-full items-center gap-3 px-4 py-3.5 text-left outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset',
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent',
)}
>
<RadioControl aria-hidden />
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-option-card-option-border bg-background-default">
<span aria-hidden className={`${icon} size-[18px] text-text-accent`} />
</span>
<span id={descriptionId} className="mt-0.5 block system-xs-regular text-text-tertiary">
{description}
<span className="min-w-0 flex-1">
<span id={titleId} className="block system-sm-medium text-text-primary">
{title}
</span>
<span id={descriptionId} className="mt-0.5 block system-xs-regular text-text-tertiary">
{description}
</span>
</span>
</span>
{value === 'source' && (
<span
aria-hidden
className="h-4 w-[82px] shrink-0 bg-[url('/images/new-rag/create-knowledge-connectors.svg')] bg-contain bg-center bg-no-repeat"
/>
)}
{disabled && (
<span id={unavailableId} className="ml-3 shrink-0 system-xs-medium text-text-disabled">
{t(($) => $['cornerLabel.unavailable'])}
</span>
)}
</RadioItem>
{value === 'source' && (
<span
aria-hidden
className="h-4 w-[82px] shrink-0 bg-[url('/images/new-rag/create-knowledge-connectors.svg')] bg-contain bg-center bg-no-repeat"
/>
)}
{disabled && (
<span id={unavailableId} className="ml-3 shrink-0 system-xs-medium text-text-disabled">
{t(($) => $['cornerLabel.unavailable'])}
</span>
)}
</RadioItem>
{selected && children}
</div>
)
}
@@ -0,0 +1,675 @@
'use client'
import type {
GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse,
GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse,
PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyData,
Source,
SourceWorkflowRun,
} from '@dify/contracts/knowledge-fs/types.gen'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { consoleClient, consoleQuery } from '@/service/client'
import { createRequestId } from './request-id'
import { newKnowledgeDetailPath } from './routes'
type PreviewPage = GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse['items'][number]
type SyncPolicy = GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse
type SyncMode = SyncPolicy['mode']
type SyncPolicyBody = PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyData['body']
const MIN_CUSTOM_INTERVAL_HOURS = 1
const MAX_CUSTOM_INTERVAL_HOURS = 720
const MAX_SELECTED_PAGES = 200
const IMPORT_POLL_INTERVAL_MS = 1_000
const IMPORT_POLL_ATTEMPTS = 120
const SUCCESSFUL_IMPORT_STATES = new Set(['complete', 'completed', 'success', 'succeeded'])
const TERMINAL_IMPORT_STATES = new Set([
...SUCCESSFUL_IMPORT_STATES,
'canceled',
'cancelled',
'error',
'exhausted',
'failed',
'superseded',
'timed_out',
'timeout',
'zero_results',
])
type PageSkipReason = 'failed' | 'off-domain'
function requestStatus(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 = requestStatus(error)
return status !== undefined && [400, 401, 403, 404, 409, 422, 429].includes(status)
}
function normalizedWorkflowState(run: SourceWorkflowRun) {
return run.state.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_')
}
function isSuccessfulImport(run: SourceWorkflowRun) {
return SUCCESSFUL_IMPORT_STATES.has(normalizedWorkflowState(run))
}
function isTerminalImport(run: SourceWorkflowRun) {
return TERMINAL_IMPORT_STATES.has(normalizedWorkflowState(run))
}
async function waitForImportTerminal(
knowledgeSpaceId: string,
initialRun: SourceWorkflowRun,
onWorkflowRun: (run: SourceWorkflowRun) => void,
discardRequested: () => boolean,
) {
let current = initialRun
for (let attempt = 0; attempt < IMPORT_POLL_ATTEMPTS; attempt += 1) {
if (discardRequested() || isTerminalImport(current)) return current
current = await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourceWorkflowsByRunId({
params: { id: knowledgeSpaceId, runId: current.id },
})
onWorkflowRun(current)
if (discardRequested() || isTerminalImport(current)) return current
await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_INTERVAL_MS))
}
throw new Error('Source import did not reach a terminal state')
}
function pageSkipReason(page: PreviewPage, rootUrl: string): PageSkipReason | undefined {
try {
const root = new URL(rootUrl)
const candidate = new URL(page.sourceUrl)
if (
!['http:', 'https:'].includes(candidate.protocol) ||
candidate.username ||
candidate.password
)
return 'failed'
if (candidate.hostname.toLocaleLowerCase() !== root.hostname.toLocaleLowerCase())
return 'off-domain'
return undefined
} catch {
return 'failed'
}
}
function policyConfiguration(mode: SyncMode, customIntervalHours: number) {
if (mode === 'manual') return { enabled: false, mode } as const
if (mode === 'custom') {
return {
customIntervalSeconds: customIntervalHours * 3600,
enabled: true,
mode,
} as const
}
return { enabled: true, mode } as const
}
function policyMatches(policy: SyncPolicy, desired: ReturnType<typeof policyConfiguration>) {
return (
policy.revision > 0 &&
policy.enabled === desired.enabled &&
policy.mode === desired.mode &&
(desired.mode !== 'custom' || policy.customIntervalSeconds === desired.customIntervalSeconds)
)
}
function initialSyncPolicy(source: Source): SyncPolicy | undefined {
if (!source.version) return undefined
return {
createdAt: source.createdAt,
enabled: true,
expectedSourceVersion: source.version,
id: source.id,
knowledgeSpaceId: source.knowledgeSpaceId,
mode: 'provider',
revision: 0,
sourceId: source.id,
updatedAt: source.updatedAt,
}
}
function PolicyLoading() {
const { t } = useTranslation('dataset')
return (
<div
role="status"
aria-label={t(($) => $['newKnowledge.loadingSyncPolicy'])}
className="space-y-3"
>
<div className="h-6 w-28 animate-pulse rounded bg-background-section" />
<div className="h-9 w-full animate-pulse rounded-lg bg-background-section" />
<div className="h-8 w-full animate-pulse rounded-lg bg-background-section" />
</div>
)
}
function ReadyCrawlSelectionForm({
busy,
discardRequested,
initialSyncMode,
knowledgeSpaceId,
onCancel,
onRecrawl,
onSubmissionUncertainChange,
onSubmitted,
onWorkflowPending,
onWorkflowRun,
pages,
policy,
rootUrl,
run,
source,
workflowUncertain,
}: {
busy: boolean
discardRequested: () => boolean
initialSyncMode?: SyncMode
knowledgeSpaceId: string
onCancel: () => void
onRecrawl: () => void
onSubmissionUncertainChange: (uncertain: boolean) => void
onSubmitted: () => Promise<void> | void
onWorkflowPending: (request: Promise<SourceWorkflowRun | undefined>) => void
onWorkflowRun: (run: SourceWorkflowRun) => void
pages: PreviewPage[]
policy: SyncPolicy
rootUrl: string
run: SourceWorkflowRun
source: Source
workflowUncertain: boolean
}) {
const { t } = useTranslation('dataset')
const router = useRouter()
const queryClient = useQueryClient()
const customIntervalErrorId = 'crawl-custom-interval-error'
const pageDescriptionPrefixId = useId()
const pageSkipReasons = useMemo(
() => new Map(pages.map((page) => [page.pageId, pageSkipReason(page, rootUrl)])),
[pages, rootUrl],
)
const selectablePages = useMemo(
() => pages.filter((page) => !pageSkipReasons.get(page.pageId)),
[pageSkipReasons, pages],
)
const selectablePageIds = useMemo(
() => new Set(selectablePages.map((page) => page.pageId)),
[selectablePages],
)
const bulkSelectablePages = selectablePages.slice(0, MAX_SELECTED_PAGES)
const [selectedPageIds, setSelectedPageIds] = useState<Set<string>>(() => new Set())
const [syncMode, setSyncMode] = useState<SyncMode>(
initialSyncMode ?? (policy.enabled ? policy.mode : 'manual'),
)
const [customIntervalHours, setCustomIntervalHours] = useState<number | ''>(
policy.customIntervalSeconds ? policy.customIntervalSeconds / 3600 : MIN_CUSTOM_INTERVAL_HOURS,
)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState(false)
const [policyUncertain, setPolicyUncertain] = useState(false)
const [selectionUncertain, setSelectionUncertain] = useState(false)
const policySnapshotRef = useRef(policy)
const submissionPendingRef = useRef(false)
const selectionRequestRef = useRef<{ fingerprint: string; requestId: string } | undefined>(
undefined,
)
const updatePolicy = useMutation(
consoleQuery.knowledgeFs.putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy.mutationOptions(),
)
const selectPages = useMutation(
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection.mutationOptions(),
)
const allSelected =
bulkSelectablePages.length > 0 &&
bulkSelectablePages.every((page) => selectedPageIds.has(page.pageId))
const someSelected = selectedPageIds.size > 0
const selectionAtLimit = selectedPageIds.size >= MAX_SELECTED_PAGES
const customIntervalValid =
typeof customIntervalHours === 'number' &&
Number.isInteger(customIntervalHours) &&
customIntervalHours >= MIN_CUSTOM_INTERVAL_HOURS &&
customIntervalHours <= MAX_CUSTOM_INTERVAL_HOURS
const canSubmit = selectedPageIds.size > 0 && (syncMode !== 'custom' || customIntervalValid)
const formBusy = busy || submitting
const submissionLocked = formBusy || policyUncertain || selectionUncertain
const selectionLocked = submissionLocked || workflowUncertain
const updateSelectionUncertain = (uncertain: boolean) => {
setSelectionUncertain(uncertain)
onSubmissionUncertainChange(uncertain)
}
const togglePage = (pageId: string) => {
if (
!selectablePageIds.has(pageId) ||
submissionPendingRef.current ||
policyUncertain ||
selectionUncertain
)
return
setSelectedPageIds((current) => {
const next = new Set(current)
if (next.has(pageId)) next.delete(pageId)
else if (next.size < MAX_SELECTED_PAGES) next.add(pageId)
return next
})
setSubmitError(false)
}
const toggleAll = () => {
if (submissionPendingRef.current || policyUncertain || selectionUncertain) return
setSelectedPageIds(
allSelected ? new Set() : new Set(bulkSelectablePages.map((page) => page.pageId)),
)
setSubmitError(false)
}
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!canSubmit || busy || submissionPendingRef.current) return
submissionPendingRef.current = true
setSubmitting(true)
setSubmitError(false)
const desiredPolicy = policyConfiguration(
syncMode,
typeof customIntervalHours === 'number' ? customIntervalHours : 0,
)
const sortedPageIds = [...selectedPageIds].sort()
const fingerprint = JSON.stringify({ pageIds: sortedPageIds, policy: desiredPolicy })
if (
(policyUncertain || selectionUncertain) &&
selectionRequestRef.current?.fingerprint !== fingerprint
) {
submissionPendingRef.current = false
setSubmitting(false)
setSubmitError(true)
return
}
if (selectionRequestRef.current?.fingerprint !== fingerprint) {
selectionRequestRef.current = {
fingerprint,
requestId: createRequestId(),
}
}
let resolveTransaction!: (run: SourceWorkflowRun | undefined) => void
let transactionRun: SourceWorkflowRun | undefined
const transaction = new Promise<SourceWorkflowRun | undefined>((resolve) => {
resolveTransaction = resolve
})
onWorkflowPending(transaction)
try {
let currentPolicy = policySnapshotRef.current
if (!policyMatches(currentPolicy, desiredPolicy)) {
const body: SyncPolicyBody = {
...desiredPolicy,
expectedRevision: currentPolicy.revision,
expectedSourceVersion: currentPolicy.expectedSourceVersion,
}
try {
currentPolicy = await updatePolicy.mutateAsync({
body,
params: { id: knowledgeSpaceId, sourceId: source.id },
})
} catch (error) {
let reconciled: SyncPolicy
try {
reconciled =
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy({
params: { id: knowledgeSpaceId, sourceId: source.id },
})
} catch (reconciliationError) {
setPolicyUncertain(!isDefinitiveRequestFailure(error))
throw reconciliationError
}
policySnapshotRef.current = reconciled
if (!policyMatches(reconciled, desiredPolicy)) {
setPolicyUncertain(!isDefinitiveRequestFailure(error))
throw error
}
currentPolicy = reconciled
}
policySnapshotRef.current = currentPolicy
}
setPolicyUncertain(false)
if (discardRequested()) return
try {
const selectionRequest = selectPages.mutateAsync({
body: { pageIds: sortedPageIds },
headers: { 'Idempotency-Key': selectionRequestRef.current.requestId },
params: { id: knowledgeSpaceId, runId: run.id },
})
const selectionRun = await selectionRequest
transactionRun = selectionRun
onWorkflowRun(selectionRun)
} catch (error) {
updateSelectionUncertain(!isDefinitiveRequestFailure(error))
throw error
}
updateSelectionUncertain(true)
if (discardRequested()) return
const terminalRun = await waitForImportTerminal(
knowledgeSpaceId,
transactionRun,
onWorkflowRun,
discardRequested,
)
transactionRun = terminalRun
if (discardRequested()) return
if (!isSuccessfulImport(terminalRun)) {
selectionRequestRef.current = undefined
updateSelectionUncertain(false)
throw new Error('Source import failed')
}
updateSelectionUncertain(false)
await queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.key(),
})
if (discardRequested()) return
await onSubmitted()
router.push(newKnowledgeDetailPath(knowledgeSpaceId))
} catch {
setSubmitError(true)
} finally {
resolveTransaction(transactionRun)
submissionPendingRef.current = false
setSubmitting(false)
}
}
return (
<form className="space-y-4" onSubmit={(event) => void submit(event)}>
<section aria-labelledby="crawl-selection-summary">
<div className="flex flex-wrap items-center gap-2">
<h3
id="crawl-selection-summary"
role="status"
aria-live="polite"
className="min-w-0 flex-1 truncate system-xs-semibold text-text-primary"
>
{t(($) => $['newKnowledge.pagesCrawled'], {
count: pages.length,
host: new URL(rootUrl).host,
})}
</h3>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.pagesSelected'], { count: selectedPageIds.size })}
</span>
{run.progressFailed > 0 && (
<span className="system-xs-regular text-text-destructive">
{run.progressFailed} {t(($) => $['newKnowledge.skippedFailed'])}
</span>
)}
<Button
type="button"
variant="tertiary"
size="small"
disabled={submissionLocked}
loading={busy}
onClick={onRecrawl}
>
{t(($) => $['newKnowledge.reCrawl'])}
</Button>
</div>
<div className="mt-3 overflow-hidden rounded-xl border border-divider-regular">
<label className="flex cursor-pointer items-center gap-2.5 border-b border-divider-subtle bg-background-section px-3 py-2.5 system-xs-medium text-text-secondary">
<Checkbox
checked={allSelected}
indeterminate={someSelected && !allSelected}
onCheckedChange={toggleAll}
disabled={!selectablePages.length || selectionLocked}
/>
{t(($) => $['newKnowledge.selectAll'])}
</label>
<ul className="max-h-[280px] divide-y divide-divider-subtle overflow-y-auto">
{pages.map((page, index) => {
const skipReason = pageSkipReasons.get(page.pageId)
const selectable = !skipReason
const selectionLimitReached =
selectable && selectionAtLimit && !selectedPageIds.has(page.pageId)
const titleId = `${pageDescriptionPrefixId}-title-${index}`
const urlId = `${pageDescriptionPrefixId}-url-${index}`
const reasonId = `${pageDescriptionPrefixId}-reason-${index}`
return (
<li key={page.pageId}>
<label className="flex cursor-pointer items-center gap-2.5 px-3 py-2.5">
<Checkbox
checked={selectedPageIds.has(page.pageId)}
disabled={!selectable || selectionLimitReached || selectionLocked}
aria-labelledby={titleId}
aria-describedby={`${urlId}${skipReason || selectionLimitReached ? ` ${reasonId}` : ''}`}
onCheckedChange={() => togglePage(page.pageId)}
/>
<span className="min-w-0 flex-1">
<span
id={titleId}
className="block truncate system-xs-medium text-text-primary"
>
{page.title || page.sourceUrl}
</span>
<span
id={urlId}
className="block truncate system-2xs-regular text-text-tertiary"
>
{page.sourceUrl}
</span>
</span>
{(!selectable || selectionLimitReached) && (
<span id={reasonId} className="shrink-0 system-xs-medium text-text-tertiary">
{selectionLimitReached
? `${t(($) => $['newKnowledge.maxPages'])}: ${MAX_SELECTED_PAGES}`
: skipReason === 'off-domain'
? t(($) => $['newKnowledge.skippedOffDomain'])
: t(($) => $['newKnowledge.skippedFailed'])}
</span>
)}
</label>
</li>
)
})}
</ul>
</div>
</section>
<fieldset disabled={selectionLocked}>
<label className="block">
<span className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.syncPolicy'])}
</span>
<select
name="syncMode"
value={syncMode}
onChange={(event) => {
setSyncMode(event.target.value as SyncMode)
setSubmitError(false)
}}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden focus:ring-2 focus:ring-state-accent-solid sm:w-72"
>
<option value="provider">{t(($) => $['newKnowledge.syncPolicyProvider'])}</option>
<option value="manual">{t(($) => $['newKnowledge.syncPolicyManual'])}</option>
<option value="interval">{t(($) => $['newKnowledge.syncPolicyDaily'])}</option>
<option value="custom">{t(($) => $['newKnowledge.syncPolicyCustom'])}</option>
</select>
</label>
{syncMode === 'custom' && (
<label className="mt-3 block sm:w-72">
<span className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.customIntervalHours'])}
</span>
<input
type="number"
name="customIntervalHours"
min={MIN_CUSTOM_INTERVAL_HOURS}
max={MAX_CUSTOM_INTERVAL_HOURS}
step={1}
value={customIntervalHours}
aria-invalid={!customIntervalValid}
aria-describedby={!customIntervalValid ? customIntervalErrorId : undefined}
onChange={(event) => {
setCustomIntervalHours(
Number.isFinite(event.target.valueAsNumber) ? event.target.valueAsNumber : '',
)
setSubmitError(false)
}}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden focus:ring-2 focus:ring-state-accent-solid"
/>
{!customIntervalValid && (
<span
id={customIntervalErrorId}
className="mt-1 block system-xs-regular text-text-destructive"
>
{t(($) => $['newKnowledge.customIntervalInvalid'])}
</span>
)}
</label>
)}
</fieldset>
{submitError && (
<p role="alert" className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.addSourceFailed'])}
</p>
)}
<div className="flex justify-end gap-2 border-t border-divider-subtle pt-5">
<Button type="button" onClick={onCancel}>
{t(($) => $['newKnowledge.cancelAddSource'])}
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSubmit || formBusy || workflowUncertain}
loading={submitting}
aria-describedby={!selectedPageIds.size ? 'add-source-selection-requirement' : undefined}
>
{t(($) => $['newKnowledge.addSource'])}
</Button>
{!selectedPageIds.size && (
<span id="add-source-selection-requirement" className="sr-only">
{t(($) => $['newKnowledge.addSourceRequiresSelection'])}
</span>
)}
</div>
</form>
)
}
export function CrawlSelectionForm({
busy = false,
discardRequested,
initialSyncMode,
knowledgeSpaceId,
onCancel,
onRecrawl,
onSubmissionUncertainChange,
onSubmitted,
onWorkflowPending,
onWorkflowRun,
pages,
rootUrl,
run,
source,
workflowUncertain = false,
}: {
busy?: boolean
discardRequested: () => boolean
initialSyncMode?: SyncMode
knowledgeSpaceId: string
onCancel: () => void
onRecrawl: () => void
onSubmissionUncertainChange: (uncertain: boolean) => void
onSubmitted: () => Promise<void> | void
onWorkflowPending: (request: Promise<SourceWorkflowRun | undefined>) => void
onWorkflowRun: (run: SourceWorkflowRun) => void
pages: PreviewPage[]
rootUrl: string
run: SourceWorkflowRun
source: Source
workflowUncertain?: boolean
}) {
const { t } = useTranslation('dataset')
const policyQuery = useQuery(
consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy.queryOptions({
context: { silent: true },
input: { params: { id: knowledgeSpaceId, sourceId: source.id } },
retry: false,
}),
)
const policy =
policyQuery.data ??
(requestStatus(policyQuery.error) === 404 ? initialSyncPolicy(source) : undefined)
if (policyQuery.isPending) {
return (
<div className="space-y-4">
<PolicyLoading />
<div className="flex justify-end gap-2 border-t border-divider-subtle pt-5">
<Button type="button" onClick={onCancel}>
{t(($) => $['newKnowledge.cancelAddSource'])}
</Button>
<Button type="button" variant="primary" disabled>
{t(($) => $['newKnowledge.addSource'])}
</Button>
</div>
</div>
)
}
if (!policy) {
return (
<div className="space-y-4">
<div role="alert" className="rounded-xl border border-divider-regular p-4">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.syncPolicyLoadFailed'])}
</p>
<Button className="mt-3" onClick={() => void policyQuery.refetch()}>
{t(($) => $['newKnowledge.retrySyncPolicy'])}
</Button>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle pt-5">
<Button type="button" onClick={onCancel}>
{t(($) => $['newKnowledge.cancelAddSource'])}
</Button>
<Button type="button" variant="primary" disabled>
{t(($) => $['newKnowledge.addSource'])}
</Button>
</div>
</div>
)
}
return (
<ReadyCrawlSelectionForm
key={`${run.id}:${policy.revision}`}
busy={busy}
discardRequested={discardRequested}
initialSyncMode={initialSyncMode}
knowledgeSpaceId={knowledgeSpaceId}
onCancel={onCancel}
onRecrawl={onRecrawl}
onSubmissionUncertainChange={onSubmissionUncertainChange}
onSubmitted={onSubmitted}
onWorkflowPending={onWorkflowPending}
onWorkflowRun={onWorkflowRun}
pages={pages}
policy={policy}
rootUrl={rootUrl}
run={run}
source={source}
workflowUncertain={workflowUncertain}
/>
)
}
+98 -14
View File
@@ -3,6 +3,7 @@
import type { KnowledgeSpaceCreationResponse } from '@dify/contracts/knowledge-fs/types.gen'
import type { CreateKnowledgeExitReason } from './components/create-knowledge-exit-dialog'
import type { KnowledgeVisibility } from './create-knowledge-workflow'
import type { NewKnowledgeSourceDraft, NewKnowledgeStartMode } from './routes'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@@ -30,12 +31,13 @@ import {
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useRouter } from '@/next/navigation'
import { useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
import { KnowledgeIllustration, StartMode } from './components/create-knowledge-dialog-parts'
@@ -47,12 +49,26 @@ import {
KnowledgeCreationError,
NAME_MAX_LENGTH,
} from './create-knowledge-workflow'
import { newKnowledgeDetailPath, newKnowledgeListPath } from './routes'
import { CreateSourceSetup } from './create-source-setup'
import { createRequestId } from './request-id'
import {
createNewKnowledgeSourceDraft,
isValidWebsiteSourceDraft,
newKnowledgeAddSourcePath,
newKnowledgeDetailPath,
newKnowledgeListPath,
newKnowledgeSourceDraftStorageKey,
} from './routes'
function normalizeStartMode(value: string | null): NewKnowledgeStartMode {
return value === 'source' ? value : 'empty'
}
export function CreateKnowledgePage() {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const router = useRouter()
const searchParams = useSearchParams()
const queryClient = useQueryClient()
const dialogTitleId = useId()
const permissionDescriptionId = useId()
@@ -65,6 +81,14 @@ export function CreateKnowledgePage() {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState<KnowledgeVisibility>(defaultVisibility)
const initialStartMode = normalizeStartMode(searchParams.get('start'))
const [startMode, setStartMode] = useState<NewKnowledgeStartMode>(initialStartMode)
const [sourceDraft, setSourceDraft] = useState<NewKnowledgeSourceDraft>(() =>
createNewKnowledgeSourceDraft('websiteCrawl'),
)
const sourceDraftsRef = useRef<
Partial<Record<NewKnowledgeSourceDraft['sourceType'], NewKnowledgeSourceDraft>>
>({})
const [createdKnowledge, setCreatedKnowledge] = useState<KnowledgeSpaceCreationResponse>()
const [submissionLocked, setSubmissionLocked] = useState(false)
const [exitReason, setExitReason] = useState<CreateKnowledgeExitReason | null>(null)
@@ -73,8 +97,25 @@ export function CreateKnowledgePage() {
const browserBackExitRef = useRef(false)
const pendingNavigationRef = useRef<string | undefined>(undefined)
const createMutation = useMutation({ mutationFn: createKnowledge })
const sourceSubmissionBlocked =
startMode === 'source' &&
(sourceDraft.sourceType === 'websiteCrawl'
? !isValidWebsiteSourceDraft(sourceDraft)
: !sourceDraft.sourceName.trim())
const sourceDraftChanged = Object.values({
...sourceDraftsRef.current,
[sourceDraft.sourceType]: sourceDraft,
}).some(
(draft) =>
JSON.stringify(draft) !== JSON.stringify(createNewKnowledgeSourceDraft(draft.sourceType)),
)
const hasUnsavedChanges = Boolean(
name || description || visibility !== defaultVisibility || createdKnowledge,
name ||
description ||
visibility !== defaultVisibility ||
startMode !== initialStartMode ||
sourceDraftChanged ||
createdKnowledge,
)
const armHistoryGuard = useCallback(() => {
@@ -153,7 +194,7 @@ export function CreateKnowledgePage() {
setExitReason('partial')
return
}
if (name || description || visibility !== defaultVisibility) {
if (hasUnsavedChanges) {
setExitReason('discard')
return
}
@@ -181,13 +222,13 @@ export function CreateKnowledgePage() {
}
const handleSubmit = async () => {
if (createMutation.isPending) return
if (createMutation.isPending || sourceSubmissionBlocked) return
const normalizedName = name.trim()
const normalizedDescription = description.trim()
if (!normalizedName) return
idempotencyKeyRef.current ??= globalThis.crypto.randomUUID()
idempotencyKeyRef.current ??= createRequestId()
setSubmissionLocked(true)
try {
const created = await createMutation.mutateAsync({
@@ -203,6 +244,21 @@ export function CreateKnowledgePage() {
},
visibility,
})
if (startMode === 'source') {
try {
const sourceDraftKey = createRequestId()
globalThis.sessionStorage.setItem(
newKnowledgeSourceDraftStorageKey(sourceDraftKey),
JSON.stringify(sourceDraft),
)
replaceAfterHistoryGuard(
newKnowledgeAddSourcePath(created.id, sourceDraft.sourceType, sourceDraftKey),
)
} catch {
toast.error(t(($) => $['newKnowledge.addSourceFailed']))
}
return
}
replaceAfterHistoryGuard(newKnowledgeDetailPath(created.id))
} catch (error) {
if (error instanceof KnowledgeCreationError && error.createdKnowledge)
@@ -246,7 +302,7 @@ export function CreateKnowledgePage() {
<div className="flex min-h-0 min-w-0 flex-col items-end border-divider-subtle xl:border-r">
<div className="min-h-6 w-full max-w-[760px] flex-1 [@media(max-height:850px)]:h-6 [@media(max-height:850px)]:flex-none" />
<Form
className="flex w-full max-w-[760px] shrink-0 flex-col [@media(max-height:850px)]:min-h-0 [@media(max-height:850px)]:flex-1"
className="flex max-h-full min-h-0 w-full max-w-[760px] flex-col"
onFormSubmit={handleSubmit}
>
<header className="shrink-0 px-6 pt-2 pb-6 sm:px-10">
@@ -255,7 +311,7 @@ export function CreateKnowledgePage() {
</DialogTitle>
</header>
<div className="flex min-h-0 flex-col gap-4 px-6 sm:px-10 [@media(max-height:850px)]:flex-1 [@media(max-height:850px)]:overflow-y-auto">
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto px-6 sm:px-10">
<div className="space-y-4">
<Field
name="name"
@@ -361,25 +417,48 @@ export function CreateKnowledgePage() {
<p className="pb-0.5 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.startWithHelp'])}
</p>
<RadioGroup
value="empty"
<RadioGroup<NewKnowledgeStartMode>
value={startMode}
aria-label={t(($) => $['newKnowledge.startWith'])}
className="mt-2 flex-col items-stretch gap-2"
disabled={createMutation.isPending}
disabled={submissionLocked}
onValueChange={(value) => {
setStartMode(value)
resetUnsubmittedError()
}}
>
<StartMode
value="empty"
icon="i-ri-folder-6-line"
selected={startMode === 'empty'}
title={t(($) => $['newKnowledge.startEmpty'])}
description={t(($) => $['newKnowledge.startEmptyDescription'])}
/>
<StartMode
disabled
value="source"
icon="i-custom-vender-solid-development-api-connection-mod"
selected={startMode === 'source'}
title={t(($) => $['newKnowledge.connectSource'])}
description={t(($) => $['newKnowledge.connectSourceDescription'])}
/>
>
<CreateSourceSetup
disabled={submissionLocked}
draft={sourceDraft}
onDraftChange={(value) => {
sourceDraftsRef.current[value.sourceType] = value
setSourceDraft(value)
resetUnsubmittedError()
}}
onSourceTypeChange={(value) => {
sourceDraftsRef.current[sourceDraft.sourceType] = sourceDraft
const nextDraft =
sourceDraftsRef.current[value] ?? createNewKnowledgeSourceDraft(value)
sourceDraftsRef.current[value] = nextDraft
setSourceDraft(nextDraft)
resetUnsubmittedError()
}}
/>
</StartMode>
<StartMode
disabled
value="upload"
@@ -410,7 +489,12 @@ export function CreateKnowledgePage() {
<Button type="button" disabled={createMutation.isPending} onClick={requestClose}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary" loading={createMutation.isPending}>
<Button
type="submit"
variant="primary"
loading={createMutation.isPending}
disabled={sourceSubmissionBlocked}
>
{t(($) => $['newKnowledge.createTitle'])}
</Button>
</div>
@@ -0,0 +1,391 @@
'use client'
import type {
NewKnowledgeOnlineDocumentsProvider,
NewKnowledgeOnlineDriveProvider,
NewKnowledgeSourceDraft,
NewKnowledgeWebsiteProvider,
} from './routes'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
isValidWebsiteSourceDraft,
NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH,
NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH,
} from './routes'
const sourceTypes = [
{ icon: 'i-ri-global-line', value: 'websiteCrawl' },
{ icon: 'i-ri-file-text-line', value: 'onlineDocuments' },
{ icon: 'i-ri-hard-drive-3-line', value: 'onlineDrive' },
] as const
const providers = {
onlineDocuments: [
{ icon: 'i-custom-public-common-notion', label: 'Notion' },
{ icon: 'i-ri-file-text-line', label: 'Google Docs' },
{ icon: 'i-ri-links-line', label: 'Confluence' },
],
onlineDrive: [
{ icon: 'i-custom-public-common-google-drive', label: 'Google Drive' },
{ icon: 'i-ri-cloud-line', label: 'OneDrive' },
{ icon: 'i-ri-box-3-line', label: 'Amazon S3' },
],
websiteCrawl: [
{ icon: 'i-ri-fire-fill text-orange-500', label: 'Firecrawl', available: true },
{ icon: 'i-custom-public-llm-jina', label: 'Jina Reader' },
{ icon: 'i-ri-water-flash-line', label: 'WaterCrawl' },
],
} as const
function ConnectedSourceConfiguration({
disabled,
draft,
onDraftChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
}) {
const { t } = useTranslation('dataset')
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.sourceName'])}
<span aria-hidden className="ml-0.5 text-text-destructive">
*
</span>
<input
type="text"
autoComplete="off"
name="sourceName"
disabled={disabled}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={draft.sourceName}
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid disabled:text-text-disabled"
onChange={(event) => onDraftChange({ ...draft, sourceName: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</label>
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.syncPolicy'])}
<select
name="syncPolicy"
disabled={disabled}
value={draft.syncPolicy}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden focus:ring-2 focus:ring-state-accent-solid disabled:text-text-disabled"
onChange={(event) =>
onDraftChange({
...draft,
syncPolicy: event.target.value as NewKnowledgeSourceDraft['syncPolicy'],
})
}
>
<option value="provider">{t(($) => $['newKnowledge.syncPolicyProvider'])}</option>
<option value="daily">{t(($) => $['newKnowledge.syncPolicyDaily'])}</option>
<option value="manual">{t(($) => $['newKnowledge.syncPolicyManual'])}</option>
</select>
</label>
</div>
)
}
export function CreateSourceSetup({
disabled,
draft,
onDraftChange,
onSourceTypeChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onSourceTypeChange: (sourceType: NewKnowledgeSourceDraft['sourceType']) => void
}) {
const { t } = useTranslation('dataset')
const { t: tCreation } = useTranslation('datasetCreation')
const [optionsExpanded, setOptionsExpanded] = useState(false)
const [backendBoundaryVisible, setBackendBoundaryVisible] = useState(false)
const sourceType = draft.sourceType
const availableProviders = providers[sourceType]
const activeProvider = availableProviders.some((provider) => provider.label === draft.provider)
? draft.provider
: availableProviders[0].label
const previewReady = draft.sourceType === 'websiteCrawl' && isValidWebsiteSourceDraft(draft)
const showBackendBoundary = () => setBackendBoundaryVisible(true)
const updateDraft = (nextDraft: NewKnowledgeSourceDraft) => {
onDraftChange(nextDraft)
setBackendBoundaryVisible(false)
}
const selectProvider = (provider: string) => {
if (draft.sourceType === 'onlineDocuments')
updateDraft({ ...draft, provider: provider as NewKnowledgeOnlineDocumentsProvider })
else if (draft.sourceType === 'onlineDrive')
updateDraft({ ...draft, provider: provider as NewKnowledgeOnlineDriveProvider })
else updateDraft({ ...draft, provider: provider as NewKnowledgeWebsiteProvider })
}
return (
<div className="mx-4 mb-4 space-y-4 border-t border-divider-subtle pt-4">
<fieldset disabled={disabled}>
<legend className="mb-1.5 system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.sourceTypeLabel'])}
</legend>
<div className="grid grid-cols-1 gap-0.5 rounded-lg bg-background-default p-0.5 sm:grid-cols-3">
{sourceTypes.map((option) => (
<label
key={option.value}
className={cn(
'relative flex min-h-8 items-center justify-center gap-1.5 rounded-md px-2 system-xs-medium outline-hidden has-focus-visible:ring-2 has-focus-visible:ring-state-accent-solid',
sourceType === option.value
? 'bg-components-option-card-option-selected-bg text-text-primary shadow-xs'
: 'cursor-pointer text-text-tertiary hover:text-text-secondary',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<input
type="radio"
name="create-source-type"
value={option.value}
checked={sourceType === option.value}
onChange={() => {
setBackendBoundaryVisible(false)
onSourceTypeChange(option.value)
}}
className="sr-only"
/>
<span aria-hidden className={`${option.icon} size-4`} />
{t(($) => $[`newKnowledge.${option.value}`])}
</label>
))}
</div>
</fieldset>
<fieldset disabled={disabled}>
<legend className="sr-only">{tCreation(($) => $['stepOne.website.chooseProvider'])}</legend>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="system-xs-medium text-text-secondary">
{tCreation(($) => $['stepOne.website.chooseProvider'])}
</span>
<button
type="button"
disabled={disabled}
className="rounded-sm system-xs-medium text-text-accent outline-hidden hover:text-text-accent-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
onClick={showBackendBoundary}
>
{t(($) => $['newKnowledge.moreProviders'])}
</button>
</div>
<div className={cn('grid grid-cols-2 gap-2', 'sm:grid-cols-3')}>
{providers[sourceType].map((provider) => {
return (
<label
key={provider.label}
className={cn(
'flex min-h-10 items-center gap-2 rounded-lg border bg-background-default px-3 system-xs-medium outline-hidden has-focus-visible:ring-2 has-focus-visible:ring-state-accent-solid',
activeProvider === provider.label
? 'border-components-option-card-option-selected-border text-text-primary'
: 'cursor-pointer border-divider-subtle text-text-secondary hover:bg-state-base-hover',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<input
type="radio"
name="create-source-provider"
value={provider.label}
checked={activeProvider === provider.label}
disabled={disabled}
onChange={() => selectProvider(provider.label)}
className="sr-only"
/>
<span aria-hidden className={`${provider.icon} size-4 shrink-0`} />
<span className="truncate">{provider.label}</span>
</label>
)
})}
</div>
</fieldset>
{draft.sourceType === 'websiteCrawl' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.rootUrl'])}
<input
type="url"
inputMode="url"
autoComplete="off"
name="rootUrl"
disabled={disabled}
maxLength={NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH}
value={draft.rootUrl}
placeholder={t(($) => $['newKnowledge.rootUrlPlaceholder'])}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid disabled:text-text-disabled"
onChange={(event) => {
updateDraft({ ...draft, rootUrl: event.target.value })
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</label>
<label className="block system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.sourceName'])}
<input
type="text"
autoComplete="off"
name="sourceName"
disabled={disabled}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={draft.sourceName}
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid disabled:text-text-disabled"
onChange={(event) => {
updateDraft({ ...draft, sourceName: event.target.value })
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</label>
</div>
<div className="overflow-hidden rounded-lg bg-background-section">
<button
type="button"
aria-label={t(($) => $['newKnowledge.crawlOptions'])}
aria-expanded={optionsExpanded}
disabled={disabled}
className="flex min-h-9 w-full items-center gap-2 px-3 text-left system-xs-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed"
onClick={() => setOptionsExpanded((value) => !value)}
>
<span
aria-hidden
className={cn(
'size-4 transition-transform motion-reduce:transition-none',
optionsExpanded ? 'i-ri-arrow-down-s-line' : 'i-ri-arrow-right-s-line',
)}
/>
{t(($) => $['newKnowledge.crawlOptions'])}
{!optionsExpanded && (
<span className="ml-auto system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.usingDefaults'])}
</span>
)}
</button>
{optionsExpanded && (
<fieldset
disabled={disabled}
className="grid grid-cols-1 gap-3 px-3 pb-3 sm:grid-cols-2"
>
<label className="flex items-center gap-2 system-xs-regular text-text-secondary">
<input
type="checkbox"
name="includeSubpages"
checked={draft.includeSubpages}
onChange={(event) =>
updateDraft({ ...draft, includeSubpages: event.target.checked })
}
/>
{t(($) => $['newKnowledge.includeSubpages'])}
</label>
<label className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.maxPages'])}
<input
type="number"
name="maxPages"
min={1}
max={200}
value={draft.maxPages}
className="mt-1.5 h-9 w-full rounded-lg border-0 bg-components-input-bg-normal px-3 system-sm-regular text-text-primary outline-hidden focus:ring-2 focus:ring-state-accent-solid"
onChange={(event) =>
updateDraft({ ...draft, maxPages: event.target.valueAsNumber || 0 })
}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</label>
</fieldset>
)}
</div>
<Button
type="button"
variant="primary"
className="w-full"
disabled={disabled || !previewReady}
onClick={showBackendBoundary}
>
{t(($) => $['newKnowledge.crawlAndPreview'])}
</Button>
<section
aria-label={t(($) => $['newKnowledge.crawlPreview'])}
className="flex min-h-40 flex-col items-center justify-center rounded-lg 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>
</section>
</div>
)}
{draft.sourceType !== 'websiteCrawl' && (
<div className="space-y-3">
{draft.sourceType === 'onlineDocuments' && activeProvider === 'Notion' && (
<section className="rounded-lg border border-divider-subtle bg-background-default p-4">
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
'mt-0.5 size-5 shrink-0',
availableProviders.find((provider) => provider.label === activeProvider)?.icon,
)}
/>
<div className="min-w-0 flex-1">
<p className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.notionNotConnected'])}
</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.notionNotConnectedDescription'])}
</p>
</div>
<button
type="button"
disabled={disabled}
className="h-8 shrink-0 rounded-lg bg-components-button-primary-bg px-3 system-xs-medium text-components-button-primary-text outline-hidden hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
onClick={showBackendBoundary}
>
{t(($) => $['newKnowledge.connectNotion'])}
</button>
</div>
</section>
)}
<ConnectedSourceConfiguration
disabled={disabled}
draft={draft}
onDraftChange={updateDraft}
/>
</div>
)}
{backendBoundaryVisible && (
<p
role="alert"
className="rounded-md bg-components-badge-status-light-warning-bg px-3 py-2 system-xs-regular text-text-warning"
>
{t(($) => $['newKnowledge.sourceSetupBackendDependency'])}
</p>
)}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { v4 as uuidV4 } from 'uuid'
export function createRequestId() {
return globalThis.crypto?.randomUUID?.() ?? uuidV4()
}
+186 -2
View File
@@ -1,4 +1,179 @@
export type NewKnowledgeStartMode = 'empty' | 'source' | 'upload'
export type NewKnowledgeSourceType = 'onlineDocuments' | 'onlineDrive' | 'websiteCrawl'
type NewKnowledgeSyncPolicy = 'daily' | 'manual' | 'provider'
export type NewKnowledgeWebsiteProvider = 'Firecrawl' | 'Jina Reader' | 'WaterCrawl'
export type NewKnowledgeOnlineDocumentsProvider = 'Confluence' | 'Google Docs' | 'Notion'
export type NewKnowledgeOnlineDriveProvider = 'Amazon S3' | 'Google Drive' | 'OneDrive'
type NewKnowledgeSourceDraftBase = {
sourceName: string
syncPolicy: NewKnowledgeSyncPolicy
}
export type NewKnowledgeWebsiteSourceDraft = NewKnowledgeSourceDraftBase & {
includeSubpages: boolean
maxPages: number
provider: NewKnowledgeWebsiteProvider
rootUrl: string
sourceType: 'websiteCrawl'
}
export type NewKnowledgeOnlineDocumentsSourceDraft = NewKnowledgeSourceDraftBase & {
provider: NewKnowledgeOnlineDocumentsProvider
sourceType: 'onlineDocuments'
}
export type NewKnowledgeOnlineDriveSourceDraft = NewKnowledgeSourceDraftBase & {
provider: NewKnowledgeOnlineDriveProvider
sourceType: 'onlineDrive'
}
export type NewKnowledgeSourceDraft =
| NewKnowledgeOnlineDocumentsSourceDraft
| NewKnowledgeOnlineDriveSourceDraft
| NewKnowledgeWebsiteSourceDraft
export const NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH = 200
export const NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH = 2048
const NEW_KNOWLEDGE_SOURCE_DRAFT_STORAGE_PREFIX = 'new-knowledge-source-draft:'
export function createNewKnowledgeSourceDraft(
sourceType: NewKnowledgeSourceType,
): NewKnowledgeSourceDraft {
if (sourceType === 'onlineDocuments')
return {
provider: 'Notion',
sourceName: '',
sourceType,
syncPolicy: 'provider',
}
if (sourceType === 'onlineDrive')
return {
provider: 'Google Drive',
sourceName: '',
sourceType,
syncPolicy: 'provider',
}
return {
includeSubpages: true,
maxPages: 100,
provider: 'Firecrawl',
rootUrl: '',
sourceName: '',
sourceType,
syncPolicy: 'provider',
}
}
export function normalizeWebsiteSourceUrl(value: string) {
if (value.length > NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH) return undefined
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
}
}
export function isValidWebsiteSourceDraft(
draft: NewKnowledgeWebsiteSourceDraft,
{ allowEmpty = false }: { allowEmpty?: boolean } = {},
) {
const hasInput = Boolean(
draft.rootUrl.length ||
draft.sourceName.length ||
!draft.includeSubpages ||
draft.maxPages !== 100,
)
if (allowEmpty && !hasInput) return true
const sourceName = draft.sourceName.trim()
return Boolean(
normalizeWebsiteSourceUrl(draft.rootUrl) &&
sourceName &&
sourceName.length <= NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH &&
Number.isInteger(draft.maxPages) &&
draft.maxPages > 0 &&
draft.maxPages <= 200,
)
}
export function newKnowledgeSourceDraftStorageKey(draftKey: string) {
return `${NEW_KNOWLEDGE_SOURCE_DRAFT_STORAGE_PREFIX}${draftKey}`
}
export function parseNewKnowledgeSourceDraft(value: string): NewKnowledgeSourceDraft | undefined {
try {
const draft: unknown = JSON.parse(value)
if (!draft || typeof draft !== 'object') return undefined
const candidate = draft as Record<string, unknown>
const syncPolicy = ['daily', 'manual', 'provider'].includes(String(candidate.syncPolicy))
? (candidate.syncPolicy as NewKnowledgeSyncPolicy)
: candidate.syncPolicy === undefined
? 'provider'
: undefined
if (
typeof candidate.sourceName !== 'string' ||
candidate.sourceName.length > NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH ||
!syncPolicy
)
return undefined
if (candidate.sourceType === 'onlineDocuments') {
if (!['Confluence', 'Google Docs', 'Notion'].includes(String(candidate.provider)))
return undefined
return {
provider: candidate.provider as NewKnowledgeOnlineDocumentsProvider,
sourceName: candidate.sourceName,
sourceType: candidate.sourceType,
syncPolicy,
}
}
if (candidate.sourceType === 'onlineDrive') {
if (!['Amazon S3', 'Google Drive', 'OneDrive'].includes(String(candidate.provider)))
return undefined
return {
provider: candidate.provider as NewKnowledgeOnlineDriveProvider,
sourceName: candidate.sourceName,
sourceType: candidate.sourceType,
syncPolicy,
}
}
if (
(candidate.sourceType !== undefined && candidate.sourceType !== 'websiteCrawl') ||
!['Firecrawl', 'Jina Reader', 'WaterCrawl'].includes(String(candidate.provider)) ||
typeof candidate.includeSubpages !== 'boolean' ||
typeof candidate.maxPages !== 'number' ||
!Number.isInteger(candidate.maxPages) ||
candidate.maxPages < 1 ||
candidate.maxPages > 200 ||
typeof candidate.rootUrl !== 'string' ||
candidate.rootUrl.length > NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH
)
return undefined
return {
includeSubpages: candidate.includeSubpages,
maxPages: candidate.maxPages,
provider: candidate.provider as NewKnowledgeWebsiteProvider,
rootUrl: candidate.rootUrl,
sourceName: candidate.sourceName,
sourceType: 'websiteCrawl',
syncPolicy,
}
} catch {
return undefined
}
}
export function singleSearchParam(value: string | string[] | undefined) {
return typeof value === 'string' ? value : undefined
}
const newKnowledgeCreatePath = '/datasets/new/create'
@@ -13,5 +188,14 @@ export const newKnowledgeDetailPath = (knowledgeSpaceId: string) =>
export const newKnowledgeDocumentsPath = (knowledgeSpaceId: string) =>
`/datasets/new/${knowledgeSpaceId}/documents`
export const newKnowledgeAddSourcePath = (knowledgeSpaceId: string) =>
`/datasets/new/${knowledgeSpaceId}/sources/new`
export const newKnowledgeAddSourcePath = (
knowledgeSpaceId: string,
sourceType?: NewKnowledgeSourceType,
draftKey?: string,
) => {
const searchParams = new URLSearchParams()
if (sourceType) searchParams.set('type', sourceType)
if (draftKey) searchParams.set('draft', draftKey)
const query = searchParams.toString()
return `/datasets/new/${knowledgeSpaceId}/sources/new${query ? `?${query}` : ''}`
}
+29 -9
View File
@@ -55,6 +55,10 @@ function metadataString(metadata: Source['metadata'], key: string) {
return typeof value === 'string' && value.trim() ? value : undefined
}
function isPreviewDraft(source: Source) {
return source.metadata.preview === true && source.status === 'disabled'
}
function createIdempotencyKey() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`
}
@@ -404,15 +408,15 @@ function SourcesEmpty({
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 py-16 text-center">
<div aria-hidden className="flex items-center gap-3">
<span data-brand="firecrawl" className="i-ri-fire-fill size-8 text-orange-500" />
<span data-brand="jina" className="i-custom-public-llm-jina size-8" />
<span data-brand="firecrawl" className="i-custom-public-common-firecrawl size-5" />
<span data-brand="jina" className="i-custom-public-llm-jina size-5" />
<span
data-brand="notion"
className="i-custom-public-common-notion size-8 text-text-primary"
className="i-custom-public-common-notion size-5 text-text-primary"
/>
<span data-brand="google-drive" className="i-custom-public-plugins-google size-8" />
<span data-brand="confluence" className="i-custom-public-common-confluence size-8" />
<span data-brand="more" className="i-ri-more-fill size-8 text-text-tertiary" />
<span data-brand="google-drive" className="i-custom-public-common-google-drive size-5" />
<span data-brand="confluence" className="i-custom-public-common-confluence size-5" />
<span data-brand="dropbox" className="i-custom-public-common-dropbox size-5" />
</div>
<h2 className="mt-5 title-xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.sourcesEmptyTitle'])}
@@ -472,7 +476,12 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
() =>
(remoteSources ?? [])
.filter((source) => !removedSourceIds.has(source.id))
.map((source) => getCurrentSource(source, sourceOverrides[source.id])),
.map((source) => getCurrentSource(source, sourceOverrides[source.id]))
.filter((source) => !isPreviewDraft(source))
.sort(
(left, right) =>
right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id),
),
[remoteSources, removedSourceIds, sourceOverrides],
)
const loadedPageCount = sourcesQuery.data?.pages.length ?? 0
@@ -492,8 +501,18 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
const localTransformActive = filter !== 'all' || Boolean(search.trim()) || Boolean(sort)
const canAutoCompleteFilteredResults =
localTransformActive && loadedPageCount < MAX_AUTO_FILTER_PAGES
const latestSourcePage = sourcesQuery.data?.pages.at(-1)
const needsVisibleSource =
latestSourcePage !== undefined &&
latestSourcePage.items.some((source) =>
isPreviewDraft(getCurrentSource(source, sourceOverrides[source.id])),
) &&
!latestSourcePage.items.some((source) => {
if (removedSourceIds.has(source.id)) return false
return !isPreviewDraft(getCurrentSource(source, sourceOverrides[source.id]))
})
const completingFilteredResults =
canAutoCompleteFilteredResults &&
(canAutoCompleteFilteredResults || needsVisibleSource) &&
!sourcesQuery.isFetchNextPageError &&
(sourcesQuery.hasNextPage || sourcesQuery.isFetchingNextPage)
const allFilteredSourcesSelected =
@@ -510,7 +529,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
useEffect(() => {
if (
canAutoCompleteFilteredResults &&
(canAutoCompleteFilteredResults || needsVisibleSource) &&
hasNextSourcePage &&
!isFetchingNextSourcePage &&
!sourcesQuery.isFetchNextPageError
@@ -521,6 +540,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
fetchNextSourcePage,
hasNextSourcePage,
isFetchingNextSourcePage,
needsVisibleSource,
sourcesQuery.isFetchNextPageError,
])
File diff suppressed because it is too large Load Diff