feat(dataset): add document processing tasks (#39326)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou
2026-07-24 12:58:15 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 5277e4b375
commit 799c7eea3a
50 changed files with 12657 additions and 65 deletions
+20 -1
View File
@@ -1,20 +1,39 @@
# New RAG
Renders the KnowledgeFS-backed knowledge list, creation entry points, and first-visit guidance.
Renders the KnowledgeFS-backed knowledge list, creation flows, sources, documents, and processing
tasks.
## Internal Modules
- `add-source-page`
- `components/create-knowledge-dialog-parts`
- `components/create-knowledge-exit-dialog`
- `components/knowledge-space-card`
- `components/knowledge-view-switcher`
- `components/new-knowledge-list-states`
- `crawl-selection-form`
- `create-knowledge-page`
- `create-upload-queue`
- `create-knowledge-workflow`
- `document-list`
- `document-model`
- `document-upload-policy`
- `documents-page`
- `knowledge-route-guard`
- `knowledge-space-shell`
- `new-knowledge-list`
- `processing-tasks-drawer`
- `routes`
- `services/processing-task-events`
- `sources-page`
- `storage`
- `task-event-observer`
- `website-crawl-preview`
## External Modules
- `app/components/base/corner-label`
- `app/components/base/loading`
- `app/components/base/search-input`
- `app/components/base/skeleton`
- `app/components/datasets/external-api/external-api-panel`
@@ -0,0 +1,96 @@
import { act, renderHook } from '@testing-library/react'
import {
createAuxiliaryTaskReadGuard,
useAuxiliaryTaskReadGuard,
} from '../auxiliary-task-read-guard'
describe('createAuxiliaryTaskReadGuard', () => {
it('blocks every auxiliary reader for the denied task version', () => {
const guard = createAuxiliaryTaskReadGuard()
const olderVersion = '2026-07-20T10:01:00Z'
const deniedVersion = '2026-07-20T10:02:00Z'
const newerVersion = '2026-07-20T10:03:00Z'
guard.block('task-1', deniedVersion)
expect(guard.isBlocked('task-1', olderVersion)).toBe(true)
expect(guard.isBlocked('task-1', deniedVersion)).toBe(true)
expect(guard.isBlocked('task-1', newerVersion)).toBe(false)
expect(guard.isBlocked('task-2', deniedVersion)).toBe(false)
guard.block('task-1', olderVersion)
expect(guard.isBlocked('task-1', olderVersion)).toBe(true)
expect(guard.isBlocked('task-1', deniedVersion)).toBe(true)
})
it('retires blocks after task or document authority changes', () => {
const guard = createAuxiliaryTaskReadGuard()
guard.block('task-1', 'version-1')
guard.block('task-2', 'version-1')
guard.retain(new Set(['task-1']))
expect(guard.isBlocked('task-1', 'version-1')).toBe(true)
expect(guard.isBlocked('task-2', 'version-1')).toBe(false)
guard.clearTask('task-1')
expect(guard.isBlocked('task-1', 'version-1')).toBe(false)
guard.block('task-1', 'version-2')
guard.clear()
expect(guard.isBlocked('task-1', 'version-2')).toBe(false)
})
it('retires a block only after an authoritative version catches up', () => {
const guard = createAuxiliaryTaskReadGuard()
guard.block('task-1', '2026-07-20T10:02:00Z')
expect(guard.clearThrough('task-1', '2026-07-20T10:01:00Z')).toBe(false)
expect(guard.isBlocked('task-1', '2026-07-20T10:02:00Z')).toBe(true)
expect(guard.clearThrough('task-1', '2026-07-20T10:02:00Z')).toBe(true)
expect(guard.isBlocked('task-1', '2026-07-20T10:02:00Z')).toBe(false)
})
it('restores the page after authoritative document verification without retrying the task version', async () => {
let resolveRefetch!: (result: { error: null }) => void
const refetchDocuments = vi.fn(
() =>
new Promise<{ error: null }>((resolve) => {
resolveRefetch = resolve
}),
)
const { result } = renderHook(() =>
useAuxiliaryTaskReadGuard({ documentPermissionDenied: false, refetchDocuments }),
)
act(() => result.current.deny('task-1', 'version-1'))
expect(result.current.permissionDenied).toBe(true)
expect(result.current.guard.isBlocked('task-1', 'version-1')).toBe(true)
await act(async () => resolveRefetch({ error: null }))
expect(result.current.permissionDenied).toBe(false)
expect(result.current.guard.isBlocked('task-1', 'version-1')).toBe(true)
})
it('clears local denial and version blocks after document permission returns', async () => {
const refetchDocuments = vi
.fn()
.mockResolvedValue({ error: new Response(null, { status: 403 }) })
const { result, rerender } = renderHook(
({ documentPermissionDenied }) =>
useAuxiliaryTaskReadGuard({ documentPermissionDenied, refetchDocuments }),
{ initialProps: { documentPermissionDenied: false } },
)
act(() => result.current.deny('task-1', 'version-1'))
await act(async () => {})
expect(result.current.permissionDenied).toBe(true)
rerender({ documentPermissionDenied: true })
rerender({ documentPermissionDenied: false })
expect(result.current.permissionDenied).toBe(false)
expect(result.current.guard.isBlocked('task-1', 'version-1')).toBe(false)
expect(result.current.guardRevision).toBe(1)
})
})
@@ -9,6 +9,8 @@ const serviceMock = vi.hoisted(() => ({
create: vi.fn(),
getPolicy: vi.fn(),
patchPolicy: vi.fn(),
upload: vi.fn(),
uploadBulk: vi.fn(),
listKey: vi.fn(() => ['console', 'knowledgeFs', 'listKnowledgeSpaces']),
}))
@@ -54,6 +56,8 @@ vi.mock('@/service/client', () => ({
createKnowledgeSpace: serviceMock.create,
getKnowledgeSpacesByIdAccessPolicy: serviceMock.getPolicy,
patchKnowledgeSpacesByIdAccessPolicy: serviceMock.patchPolicy,
postKnowledgeSpacesByIdDocuments: serviceMock.upload,
postKnowledgeSpacesByIdDocumentsBulk: serviceMock.uploadBulk,
},
},
consoleQuery: {
@@ -120,6 +124,14 @@ describe('CreateKnowledgePage', () => {
revision: 5,
visibility: 'all_members',
})
serviceMock.upload.mockResolvedValue({
id: 'document-1',
})
serviceMock.uploadBulk.mockResolvedValue({
accepted: 2,
excluded: 0,
items: [],
})
permissionStateMock.keys = ['dataset.create_and_management', 'dataset.acl.access_config']
navigationMock.startMode = null
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
@@ -129,6 +141,7 @@ describe('CreateKnowledgePage', () => {
afterEach(() => {
globalThis.sessionStorage.clear()
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -373,24 +386,138 @@ describe('CreateKnowledgePage', () => {
expect(serviceMock.patchPolicy).toHaveBeenCalledOnce()
})
it('integrates source setup while keeping the later upload flow disabled', async () => {
it('keeps every start mode interactive without simulating backend success', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
const startEmpty = screen.getByRole('radio', { name: 'dataset.newKnowledge.startEmpty' })
expect(startEmpty).not.toBeChecked()
expect(startEmpty).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).toBeEnabled()
expect(uploadFiles).toBeEnabled()
await user.click(connectSource)
expect(connectSource).toBeChecked()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' })).toBeChecked()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
for (const unavailableProvider of ['Jina Reader', 'WaterCrawl']) {
await user.click(screen.getByRole('radio', { name: unavailableProvider }))
expect(screen.getByRole('radio', { name: unavailableProvider })).toBeChecked()
}
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.moreProviders' })).toBeEnabled()
expect(screen.getByText('dataset.newKnowledge.crawlOptions')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }),
).toBeDisabled()
expect(screen.getByText('dataset.newKnowledge.pagesAppearTitle')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.usingDefaults')).toBeInTheDocument()
const rootUrl = screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder')
const sourceName = screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder')
expect(rootUrl).toBeEnabled()
expect(sourceName).toBeEnabled()
await user.type(rootUrl, 'https://docs.dify.ai')
await user.type(sourceName, 'Dify docs')
const crawlAndPreview = screen.getByRole('button', {
name: 'dataset.newKnowledge.crawlAndPreview',
})
expect(crawlAndPreview).toBeEnabled()
await user.click(crawlAndPreview)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.sourceSetupBackendDependency',
)
expect(screen.queryByText(/^dataset\.newKnowledge\.crawlingPages/)).not.toBeInTheDocument()
expect(screen.queryByText(/^dataset\.newKnowledge\.pagesCrawled/)).not.toBeInTheDocument()
const onlineDocuments = screen.getByRole('radio', {
name: 'dataset.newKnowledge.onlineDocuments',
})
await user.click(onlineDocuments)
expect(onlineDocuments).toBeChecked()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByText('Notion')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.notionNotConnected')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.connectNotion' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectNotion' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.sourceSetupBackendDependency',
)
await user.click(screen.getByRole('radio', { name: 'Google Docs' }))
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled()
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeEnabled()
await user.click(uploadFiles)
expect(uploadFiles).toBeChecked()
const uploadInput = screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
})
expect(uploadInput).toBeInTheDocument()
expect(uploadInput).not.toHaveAttribute('hidden')
expect(uploadInput.nextElementSibling).toHaveClass('peer-focus-visible:ring-2')
uploadInput.focus()
expect(uploadInput).toHaveFocus()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
})
it('continues from the upload mode after real creation succeeds', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
renderPage()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.uploadFiles' })).toBeChecked()
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
new File(['content'], 'handbook.md', { type: 'text/markdown' }),
)
await fillRequiredFields(user)
await choosePermission(user, 'dataset.newKnowledge.permissionOnlyMe')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() =>
expect(routerMock.replace).toHaveBeenCalledWith(
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/documents',
),
)
expect(serviceMock.upload).toHaveBeenCalledWith({
body: { file: expect.objectContaining({ name: 'handbook.md' }) },
params: { id: createdKnowledge.id },
})
})
it('queues uploads when native random UUID generation is unavailable', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
vi.restoreAllMocks()
const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
Object.defineProperty(globalThis.crypto, 'randomUUID', {
configurable: true,
value: undefined,
})
try {
renderPage()
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
new File(['content'], 'handbook.md', { type: 'text/markdown' }),
)
expect(screen.getByText('handbook.md')).toBeInTheDocument()
} finally {
if (descriptor) Object.defineProperty(globalThis.crypto, 'randomUUID', descriptor)
else Reflect.deleteProperty(globalThis.crypto, 'randomUUID')
}
})
it('hands the configured website draft to the real add-source workflow', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
await fillRequiredFields(user)
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder'),
'https://docs.dify.ai',
@@ -399,11 +526,192 @@ describe('CreateKnowledgePage', () => {
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',
await user.keyboard('{Enter}')
expect(serviceMock.create).not.toHaveBeenCalled()
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('preserves online document configuration across the real navigation boundary', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
await user.click(screen.getByRole('radio', { name: 'Google Docs' }))
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' }),
'Shared product docs',
)
await user.selectOptions(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }),
'daily',
)
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=onlineDocuments&draft=a9c36c57-2d84-44d6-a36d-841f0d92a179',
),
)
expect(
JSON.parse(
globalThis.sessionStorage.getItem(
newKnowledgeSourceDraftStorageKey('a9c36c57-2d84-44d6-a36d-841f0d92a179'),
) ?? '',
),
).toEqual({
provider: 'Google Docs',
sourceName: 'Shared product docs',
sourceType: 'onlineDocuments',
syncPolicy: 'daily',
})
})
it('keeps each source type draft when the user switches between them', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder'),
'https://docs.dify.ai',
)
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Website docs',
)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled()
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' }),
'Notion docs',
)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' }))
expect(screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder')).toHaveValue(
'https://docs.dify.ai',
)
expect(screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder')).toHaveValue(
'Website docs',
)
})
it('uses the same website validation as the add-source workflow', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
renderPage()
await fillRequiredFields(user)
const rootUrl = screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder')
const sourceName = screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder')
expect(rootUrl).toHaveAttribute('maxlength', '2048')
expect(sourceName).toHaveAttribute('maxlength', '200')
await user.type(rootUrl, 'https://user:secret@docs.dify.ai')
await user.type(sourceName, 'Dify docs')
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(serviceMock.create).not.toHaveBeenCalled()
await user.clear(rootUrl)
await user.type(rootUrl, 'https://docs.dify.ai')
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeEnabled()
})
it('keeps an invalid upload visible and prevents creating the knowledge space', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
renderPage()
await fillRequiredFields(user)
const oversizedFile = new File(['content'], 'oversized.pdf', { type: 'application/pdf' })
Object.defineProperty(oversizedFile, 'size', { value: 16 * 1024 * 1024 })
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
oversizedFile,
)
expect(screen.getByText('oversized.pdf')).toBeInTheDocument()
expect(
screen.getByText('dataset.newKnowledge.documentUploadExclusion.fileSize'),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'dataset.newKnowledge.preview' })).toBeNull()
expect(serviceMock.create).not.toHaveBeenCalled()
})
it('shows the real uploading state on each valid file row', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
serviceMock.upload.mockImplementation(() => new Promise(() => {}))
renderPage()
await fillRequiredFields(user)
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
new File(['content'], 'handbook.md', { type: 'text/markdown' }),
)
const queue = screen.getByRole('list', { name: 'dataset.newKnowledge.uploadFiles' })
const preview = within(queue).getByRole('button', { name: 'dataset.newKnowledge.preview' })
expect(preview).toBeDisabled()
expect(preview).toHaveAccessibleDescription('dataset.newKnowledge.previewUnavailable')
expect(screen.getByText('dataset.newKnowledge.previewUnavailable')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await within(queue).findByText('dataset.newKnowledge.uploadingFiles')).toBeVisible()
expect(within(queue).queryByRole('button', { name: 'dataset.newKnowledge.preview' })).toBeNull()
})
it('retries upload without creating a duplicate knowledge space', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
serviceMock.upload.mockRejectedValueOnce(new Error('KnowledgeFS unavailable'))
renderPage()
await fillRequiredFields(user)
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
new File(['content'], 'handbook.md', { type: 'text/markdown' }),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentUploadFailed',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(routerMock.replace).toHaveBeenCalled())
expect(serviceMock.create).toHaveBeenCalledOnce()
expect(serviceMock.upload).toHaveBeenCalledTimes(2)
})
it('hands the configured source draft to the add-source workflow after creation', async () => {
@@ -0,0 +1,118 @@
import type {
DocumentProcessingTask,
LogicalDocument,
} from '@dify/contracts/knowledge-fs/types.gen'
import {
documentDisplayStatus,
newestTaskByDocument,
taskCanRetry,
taskIsActive,
taskNeedsAttention,
taskVersionIsAfter,
} from '../document-model'
const document = (overrides: Partial<LogicalDocument> = {}): LogicalDocument => ({
active: {
contentHash: 'hash',
createdAt: '2026-07-20T10:00:00Z',
documentAssetId: 'asset-1',
documentAssetVersion: 1,
documentId: 'document-1',
knowledgeSpaceId: 'space-1',
mimeType: 'text/plain',
revision: 2,
sizeBytes: 10,
state: 'active',
},
activeRevision: 2,
createdAt: '2026-07-20T10:00:00Z',
id: 'document-1',
knowledgeSpaceId: 'space-1',
rowVersion: 1,
status: 'ready',
title: 'Document',
updatedAt: '2026-07-20T10:00:00Z',
userMetadata: {},
...overrides,
})
const task = (overrides: Partial<DocumentProcessingTask> = {}): DocumentProcessingTask => ({
createdAt: '2026-07-20T10:00:00Z',
documentId: 'document-1',
documentRevision: 3,
id: 'task-1',
knowledgeSpaceId: 'space-1',
progressPercent: 10,
stage: 'queued',
state: 'queued',
updatedAt: '2026-07-20T10:00:00Z',
...overrides,
})
describe('new Knowledge document model', () => {
it('chooses the highest revision before a more recently updated stale task', () => {
const result = newestTaskByDocument([
task({ documentRevision: 2, id: 'stale', updatedAt: '2026-07-20T10:10:00Z' }),
task({ documentRevision: 3, id: 'current', updatedAt: '2026-07-20T10:01:00Z' }),
])
expect(result.get('document-1')?.id).toBe('current')
})
it('compares task versions without losing sub-millisecond precision', () => {
expect(taskVersionIsAfter('2026-07-20T10:00:00.123789Z', '2026-07-20T10:00:00.123456Z')).toBe(
true,
)
expect(taskVersionIsAfter('2026-07-20T10:00:00.000001Z', '2026-07-20T10:00:00Z')).toBe(true)
expect(taskVersionIsAfter('2026-07-20T10:00:00Z', '2026-07-20T10:00:00.000001Z')).toBe(false)
})
it('selects the newest task when versions differ only after milliseconds', () => {
const result = newestTaskByDocument([
task({ id: 'older', updatedAt: '2026-07-20T10:00:00.123456Z' }),
task({ id: 'newer', updatedAt: '2026-07-20T10:00:00.123789Z' }),
])
expect(result.get('document-1')?.id).toBe('newer')
})
it.each([
['dispatch_pending', 'queued'],
['queued', 'queued'],
['retry_wait', 'queued'],
['running', 'processing'],
['failed', 'failed'],
] as const)('maps a latest %s task to %s', (state, expected) => {
expect(documentDisplayStatus(document(), task({ state }))).toBe(expected)
})
it('keeps a last ready revision available after a canceled re-index', () => {
expect(documentDisplayStatus(document(), task({ state: 'canceled' }))).toBe('ready')
expect(
documentDisplayStatus(
document({ active: null, activeRevision: undefined, status: 'pending' }),
task({ state: 'canceled' }),
),
).toBe('failed')
})
it('does not let an older failed task override the active revision', () => {
expect(documentDisplayStatus(document(), task({ documentRevision: 1, state: 'failed' }))).toBe(
'ready',
)
})
it('maps a disabled source and deleting lifecycle to the designed disabled state', () => {
expect(documentDisplayStatus(document(), undefined, true)).toBe('disabled')
expect(documentDisplayStatus(document({ status: 'deleting' }))).toBe('disabled')
})
it('keeps task badge and actions aligned with the contract states', () => {
expect(taskIsActive(task({ state: 'running' }))).toBe(true)
expect(taskNeedsAttention(task({ state: 'failed' }))).toBe(true)
expect(taskCanRetry(task({ state: 'canceled' }))).toBe(false)
expect(taskNeedsAttention(task({ state: 'canceled' }))).toBe(false)
expect(taskNeedsAttention(task({ state: 'succeeded' }))).toBe(false)
expect(taskCanRetry(task({ state: 'superseded' }))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
import type { DocumentProcessingTaskEvent } from '@dify/contracts/knowledge-fs/types.gen'
import { withEventMeta } from '@orpc/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { streamProcessingTaskEvents } from '../services/processing-task-events'
const { mockStreamEvents } = vi.hoisted(() => ({
mockStreamEvents: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents: mockStreamEvents,
},
},
}))
async function* eventIterator(...events: DocumentProcessingTaskEvent[]) {
yield* events
}
describe('KnowledgeFS processing task events', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses the generated streaming client and resumes from the last event id', async () => {
mockStreamEvents.mockResolvedValue(
eventIterator(
withEventMeta(
{
data: {
progressPercent: 45,
stage: 'parsed',
state: 'running',
updatedAt: '2026-07-20T01:02:03Z',
},
event: 'progress',
},
{ id: 'task-1:2026-07-20T01:02:03Z' },
),
withEventMeta(
{
data: { state: 'succeeded' },
event: 'terminal',
},
{ id: 'task-1:terminal' },
),
),
)
const abortController = new AbortController()
const events = []
for await (const event of streamProcessingTaskEvents({
documentId: 'document/1',
knowledgeSpaceId: 'space/1',
lastEventId: 'task-1:previous',
signal: abortController.signal,
taskId: 'task/1',
})) {
events.push(event)
}
expect(events).toEqual([
{
data: {
progressPercent: 45,
stage: 'parsed',
state: 'running',
updatedAt: '2026-07-20T01:02:03Z',
},
event: 'progress',
id: 'task-1:2026-07-20T01:02:03Z',
},
{
data: { state: 'succeeded' },
event: 'terminal',
id: 'task-1:terminal',
},
])
expect(mockStreamEvents).toHaveBeenCalledWith(
{
headers: { 'last-event-id': 'task-1:previous' },
params: {
documentId: 'document/1',
id: 'space/1',
taskId: 'task/1',
},
},
{
context: { silent: true },
signal: abortController.signal,
},
)
})
it('rejects events without a resumable event id', async () => {
mockStreamEvents.mockResolvedValue(
eventIterator({
data: { state: 'failed' },
event: 'terminal',
}),
)
await expect(async () => {
for await (const event of streamProcessingTaskEvents({
documentId: 'document-1',
knowledgeSpaceId: 'space-1',
taskId: 'task-1',
})) {
void event
}
}).rejects.toThrow('missing an event id')
})
})
@@ -0,0 +1,50 @@
import { QueryClient, QueryClientProvider, useInfiniteQuery } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import { useQueryDataUpdateCount } from '../use-query-data-update-count'
const queryKey = ['task-generation'] as const
const sharedPage = { items: ['task-1'] }
function GenerationProbe({ queryClient }: { queryClient: QueryClient }) {
const query = useInfiniteQuery({
queryFn: async () => sharedPage,
initialPageParam: null,
getNextPageParam: () => undefined,
queryKey,
})
const dataUpdateCount = useQueryDataUpdateCount(queryClient, queryKey)
return (
<output>
{query.data ? 'ready' : 'pending'}:{query.dataUpdatedAt}:{dataUpdateCount}
</output>
)
}
describe('useQueryDataUpdateCount', () => {
it('observes a real same-reference refetch when response timestamps collide', async () => {
vi.spyOn(Date, 'now').mockReturnValue(100)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
const rendered = render(
<QueryClientProvider client={queryClient}>
<GenerationProbe queryClient={queryClient} />
</QueryClientProvider>,
)
try {
await screen.findByText('ready:100:1')
const firstData = queryClient.getQueryData(queryKey)
await act(async () => queryClient.refetchQueries({ queryKey }))
await waitFor(() => expect(screen.getByText('ready:100:2')).toBeInTheDocument())
expect(queryClient.getQueryData(queryKey)).toBe(firstData)
} finally {
rendered.unmount()
queryClient.clear()
vi.restoreAllMocks()
}
})
})
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { taskVersionIsAfter } from './document-model'
export type AuxiliaryTaskReadGuard = ReturnType<typeof createAuxiliaryTaskReadGuard>
type RefetchDocuments = (options: { cancelRefetch: true }) => Promise<{ error: unknown }>
export function createAuxiliaryTaskReadGuard() {
const blockedVersions = new Map<string, string>()
return {
block(taskId: string, taskVersion: string) {
const blockedVersion = blockedVersions.get(taskId)
if (blockedVersion && taskVersionIsAfter(blockedVersion, taskVersion)) return
blockedVersions.set(taskId, taskVersion)
},
clear() {
blockedVersions.clear()
},
clearTask(taskId: string) {
blockedVersions.delete(taskId)
},
clearThrough(taskId: string, authoritativeVersion: string) {
const blockedVersion = blockedVersions.get(taskId)
if (!blockedVersion || taskVersionIsAfter(blockedVersion, authoritativeVersion)) return false
blockedVersions.delete(taskId)
return true
},
isBlocked(taskId: string, taskVersion: string) {
const blockedVersion = blockedVersions.get(taskId)
return Boolean(blockedVersion && !taskVersionIsAfter(taskVersion, blockedVersion))
},
retain(taskIds: Set<string>) {
for (const taskId of blockedVersions.keys()) {
if (!taskIds.has(taskId)) blockedVersions.delete(taskId)
}
},
}
}
export function useAuxiliaryTaskReadGuard({
documentPermissionDenied,
refetchDocuments,
}: {
documentPermissionDenied: boolean
refetchDocuments: RefetchDocuments
}) {
const guardRef = useRef<AuxiliaryTaskReadGuard | null>(null)
if (!guardRef.current) guardRef.current = createAuxiliaryTaskReadGuard()
const guard = guardRef.current
const previousDocumentPermissionDeniedRef = useRef(documentPermissionDenied)
const denialGenerationRef = useRef(0)
const [permissionDenied, setPermissionDenied] = useState(false)
const [guardRevision, setGuardRevision] = useState(0)
useEffect(() => {
const wasDenied = previousDocumentPermissionDeniedRef.current
previousDocumentPermissionDeniedRef.current = documentPermissionDenied
if (!wasDenied || documentPermissionDenied) return
guard.clear()
// oxlint-disable-next-line eslint-react/set-state-in-effect -- The authoritative document permission transition retires the local denial.
setPermissionDenied(false)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Guard mutations need one render so blocked readers are reconsidered.
setGuardRevision((current) => current + 1)
}, [documentPermissionDenied, guard])
const retry = useCallback(() => {
const denialGeneration = denialGenerationRef.current + 1
denialGenerationRef.current = denialGeneration
setPermissionDenied(true)
void refetchDocuments({ cancelRefetch: true }).then(
(result) => {
if (denialGenerationRef.current === denialGeneration && !result.error)
setPermissionDenied(false)
},
() => undefined,
)
}, [refetchDocuments])
const deny = useCallback(
(taskId: string, taskVersion: string) => {
guard.block(taskId, taskVersion)
retry()
},
[guard, retry],
)
return { deny, guard, guardRevision, permissionDenied, retry }
}
+90 -11
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 { QueuedUpload } from './create-upload-queue'
import type { NewKnowledgeSourceDraft, NewKnowledgeStartMode } from './routes'
import { Button } from '@langgenius/dify-ui/button'
import {
@@ -38,7 +39,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { consoleClient, consoleQuery } from '@/service/client'
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
import { KnowledgeIllustration, StartMode } from './components/create-knowledge-dialog-parts'
import { CreateKnowledgeExitDialog } from './components/create-knowledge-exit-dialog'
@@ -50,18 +51,38 @@ import {
NAME_MAX_LENGTH,
} from './create-knowledge-workflow'
import { CreateSourceSetup } from './create-source-setup'
import { CreateUploadQueue } from './create-upload-queue'
import { createRequestId } from './request-id'
import {
createNewKnowledgeSourceDraft,
isValidWebsiteSourceDraft,
newKnowledgeAddSourcePath,
newKnowledgeDetailPath,
newKnowledgeDocumentsPath,
newKnowledgeListPath,
newKnowledgeSourceDraftStorageKey,
} from './routes'
function normalizeStartMode(value: string | null): NewKnowledgeStartMode {
return value === 'source' ? value : 'empty'
if (value === 'source' || value === 'upload') return value
return 'empty'
}
async function uploadCreatedDocuments(knowledgeSpaceId: string, files: File[]) {
if (files.length === 1) {
await consoleClient.knowledgeFs.postKnowledgeSpacesByIdDocuments({
body: { file: files[0]! },
params: { id: knowledgeSpaceId },
})
return
}
const result = await consoleClient.knowledgeFs.postKnowledgeSpacesByIdDocumentsBulk({
body: { files },
params: { id: knowledgeSpaceId },
})
if (!result.accepted) throw new Error('No files were accepted')
return result
}
export function CreateKnowledgePage() {
@@ -89,14 +110,20 @@ export function CreateKnowledgePage() {
const sourceDraftsRef = useRef<
Partial<Record<NewKnowledgeSourceDraft['sourceType'], NewKnowledgeSourceDraft>>
>({})
const [uploads, setUploads] = useState<QueuedUpload[]>([])
const [createdKnowledge, setCreatedKnowledge] = useState<KnowledgeSpaceCreationResponse>()
const [submissionLocked, setSubmissionLocked] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState(false)
const [exitReason, setExitReason] = useState<CreateKnowledgeExitReason | null>(null)
const idempotencyKeyRef = useRef<string | undefined>(undefined)
const historyGuardArmedRef = useRef(false)
const browserBackExitRef = useRef(false)
const pendingNavigationRef = useRef<string | undefined>(undefined)
const createMutation = useMutation({ mutationFn: createKnowledge })
const submissionPending = createMutation.isPending || uploading
const uploadSubmissionBlocked =
startMode === 'upload' && (!uploads.length || uploads.some((upload) => upload.issue))
const sourceSubmissionBlocked =
startMode === 'source' &&
(sourceDraft.sourceType === 'websiteCrawl'
@@ -115,6 +142,7 @@ export function CreateKnowledgePage() {
visibility !== defaultVisibility ||
startMode !== initialStartMode ||
sourceDraftChanged ||
uploads.length ||
createdKnowledge,
)
@@ -186,10 +214,11 @@ export function CreateKnowledgePage() {
const resetUnsubmittedError = () => {
if (!submissionLocked) createMutation.reset()
setUploadError(false)
}
const requestClose = () => {
if (createMutation.isPending) return
if (submissionPending) return
if (createdKnowledge) {
setExitReason('partial')
return
@@ -222,7 +251,7 @@ export function CreateKnowledgePage() {
}
const handleSubmit = async () => {
if (createMutation.isPending || sourceSubmissionBlocked) return
if (submissionPending || uploadSubmissionBlocked || sourceSubmissionBlocked) return
const normalizedName = name.trim()
const normalizedDescription = description.trim()
@@ -244,6 +273,33 @@ export function CreateKnowledgePage() {
},
visibility,
})
if (startMode === 'upload') {
setUploading(true)
setUploadError(false)
try {
const result = await uploadCreatedDocuments(
created.id,
uploads.map((upload) => upload.file),
)
if (result?.excluded)
toast.warning(
t(($) => $['newKnowledge.documentUploadPartial'], {
accepted: result.accepted,
details: result.items
.filter((item) => 'reason' in item)
.map((item) => item.filename)
.join(', '),
excluded: result.excluded,
}),
)
} catch {
setUploadError(true)
return
} finally {
setUploading(false)
}
}
if (startMode === 'source') {
try {
const sourceDraftKey = createRequestId()
@@ -259,7 +315,12 @@ export function CreateKnowledgePage() {
}
return
}
replaceAfterHistoryGuard(newKnowledgeDetailPath(created.id))
replaceAfterHistoryGuard(
startMode === 'upload'
? newKnowledgeDocumentsPath(created.id)
: newKnowledgeDetailPath(created.id),
)
} catch (error) {
if (error instanceof KnowledgeCreationError && error.createdKnowledge)
setCreatedKnowledge(error.createdKnowledge)
@@ -294,7 +355,7 @@ export function CreateKnowledgePage() {
aria-label={tCommon(($) => $['operation.close'])}
className="absolute top-3 right-3 z-10 flex size-9 items-center justify-center rounded-xl bg-background-section-burn text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
onClick={requestClose}
disabled={createMutation.isPending}
disabled={submissionPending}
>
<span aria-hidden className="i-ri-close-line size-5" />
</button>
@@ -460,12 +521,22 @@ export function CreateKnowledgePage() {
/>
</StartMode>
<StartMode
disabled
value="upload"
icon="i-ri-file-text-line"
selected={startMode === 'upload'}
title={t(($) => $['newKnowledge.uploadFiles'])}
description={t(($) => $['newKnowledge.uploadFilesDescription'])}
/>
>
<CreateUploadQueue
disabled={submissionPending}
uploads={uploads}
uploading={uploading}
onChange={(value) => {
setUploads(value)
resetUnsubmittedError()
}}
/>
</StartMode>
</RadioGroup>
</fieldset>
@@ -482,18 +553,26 @@ export function CreateKnowledgePage() {
)}
</div>
)}
{uploadError && (
<div
className="mt-5 rounded-lg bg-components-badge-status-light-error-bg px-3 py-2 system-sm-regular text-text-destructive"
role="alert"
>
{t(($) => $['newKnowledge.documentUploadFailed'])}
</div>
)}
</div>
<div className="shrink-0 px-6 pt-5 pb-10 sm:px-10">
<div className="flex justify-end gap-2">
<Button type="button" disabled={createMutation.isPending} onClick={requestClose}>
<Button type="button" disabled={submissionPending} onClick={requestClose}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
type="submit"
variant="primary"
loading={createMutation.isPending}
disabled={sourceSubmissionBlocked}
loading={submissionPending}
disabled={uploadSubmissionBlocked || sourceSubmissionBlocked}
>
{t(($) => $['newKnowledge.createTitle'])}
</Button>
@@ -0,0 +1,213 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DOCUMENT_UPLOAD_ACCEPT,
documentUploadFileExtension,
documentUploadIssue,
} from './document-upload-policy'
import { createRequestId } from './request-id'
const PREVIEWABLE_EXTENSIONS = new Set(['doc', 'docx', 'html', 'markdown', 'md', 'pdf', 'txt'])
export type QueuedUpload = {
file: File
id: string
issue?: 'fileSize' | 'fileType'
}
function createQueuedUpload(file: File): QueuedUpload {
return {
file,
id: `${file.name}:${file.size}:${file.lastModified}:${createRequestId()}`,
issue: documentUploadIssue(file),
}
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function fileBadge(file: File) {
return documentUploadFileExtension(file.name).toLocaleUpperCase() || 'FILE'
}
function mergeFiles(current: QueuedUpload[], files: File[]) {
const fingerprints = new Set(
current.map(({ file }) => `${file.name}:${file.size}:${file.lastModified}`),
)
return [
...current,
...files.flatMap((file) => {
const fingerprint = `${file.name}:${file.size}:${file.lastModified}`
if (fingerprints.has(fingerprint)) return []
fingerprints.add(fingerprint)
return [createQueuedUpload(file)]
}),
]
}
export function CreateUploadQueue({
disabled,
uploading,
uploads,
onChange,
}: {
disabled: boolean
uploading: boolean
uploads: QueuedUpload[]
onChange: (uploads: QueuedUpload[]) => void
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const inputId = useId()
const [dragging, setDragging] = useState(false)
const previewUnavailable = uploads.some(
(upload) =>
!upload.issue &&
!uploading &&
PREVIEWABLE_EXTENSIONS.has(documentUploadFileExtension(upload.file.name)),
)
const addFiles = (files: File[]) => {
if (!disabled && files.length) onChange(mergeFiles(uploads, files))
}
return (
<div className="mx-4 mb-4 space-y-2 border-t border-divider-subtle pt-4">
<input
id={inputId}
className="peer sr-only"
multiple
type="file"
accept={DOCUMENT_UPLOAD_ACCEPT}
aria-label={t(($) => $['newKnowledge.uploadFiles'])}
disabled={disabled}
onChange={(event) => {
addFiles([...(event.currentTarget.files ?? [])])
event.currentTarget.value = ''
}}
/>
<label
htmlFor={inputId}
className={cn(
'flex min-h-20 flex-col items-center justify-center rounded-lg border border-dashed border-divider-regular px-4 py-3 text-center outline-hidden transition-colors motion-reduce:transition-none',
'peer-focus-visible:ring-2 peer-focus-visible:ring-state-accent-solid',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:border-components-option-card-option-selected-border hover:bg-state-base-hover has-focus-visible:ring-2 has-focus-visible:ring-state-accent-solid',
dragging && 'border-components-option-card-option-selected-border bg-state-base-hover',
)}
onDragEnter={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false)
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
addFiles([...event.dataTransfer.files])
}}
>
<span
aria-hidden
className={cn(
'size-5 text-text-tertiary',
uploading
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-upload-cloud-2-line',
)}
/>
<span className="mt-1.5 system-sm-medium text-text-primary">
{uploading
? t(($) => $['newKnowledge.uploadingFiles'])
: t(($) => $['newKnowledge.uploadFiles'])}
</span>
<span className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.documentsDropHint'])}
</span>
</label>
{!!uploads.length && (
<ul className="space-y-1.5" aria-label={t(($) => $['newKnowledge.uploadFiles'])}>
{uploads.map((upload) => (
<li
key={upload.id}
className={cn(
'flex min-w-0 items-center gap-2 rounded-lg border border-divider-subtle bg-background-default px-3 py-2',
upload.issue && 'border-components-option-card-option-border',
)}
>
<span
aria-hidden
className={cn(
'system-2xs-semibold flex h-7 min-w-9 shrink-0 items-center justify-center rounded bg-background-section px-1',
upload.issue ? 'text-text-destructive' : 'text-text-tertiary',
)}
>
{uploading && !upload.issue ? (
<span className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none" />
) : (
fileBadge(upload.file)
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate system-xs-medium text-text-primary">
{upload.file.name}
</span>
<span
className={cn(
'block system-2xs-regular text-text-tertiary',
upload.issue && 'text-text-destructive',
)}
>
{uploading && !upload.issue
? t(($) => $['newKnowledge.uploadingFiles'])
: upload.issue === 'fileSize'
? t(($) => $['newKnowledge.documentUploadExclusion.fileSize'])
: upload.issue === 'fileType'
? t(($) => $['newKnowledge.documentUploadExclusion.fileType'])
: `${formatFileSize(upload.file.size)} · ${t(($) => $['newKnowledge.uploadCharactersUnavailable'])}`}
</span>
</span>
{!uploading &&
!upload.issue &&
PREVIEWABLE_EXTENSIONS.has(documentUploadFileExtension(upload.file.name)) && (
<button
type="button"
disabled
aria-describedby={`${inputId}-preview-unavailable`}
className="h-7 shrink-0 rounded-md px-2 system-xs-medium text-text-disabled"
>
{t(($) => $['newKnowledge.preview'])}
</button>
)}
<button
type="button"
disabled={disabled}
aria-label={`${tCommon(($) => $['operation.remove'])} ${upload.file.name}`}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
onClick={() => onChange(uploads.filter((candidate) => candidate.id !== upload.id))}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
</li>
))}
</ul>
)}
{previewUnavailable && (
<p id={`${inputId}-preview-unavailable`} className="system-2xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.previewUnavailable'])}
</p>
)}
</div>
)
}
+712
View File
@@ -0,0 +1,712 @@
'use client'
import type { LogicalDocument } from '@dify/contracts/knowledge-fs/types.gen'
import type { FocusEventHandler } from 'react'
import type { DocumentDisplayStatus } from './document-model'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { sourceName } from './document-model'
export type DocumentFilter = DocumentDisplayStatus | 'all'
const DOCUMENT_RENDER_BATCH_SIZE = 100
const PARTIAL_RESULTS_DESCRIPTION_ID = 'partial-document-results'
const statusIconClass: Record<DocumentDisplayStatus, string> = {
ready: 'i-ri-check-line text-text-success',
queued: 'i-ri-time-line text-text-tertiary',
processing: 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none',
failed: 'i-ri-error-warning-fill text-text-destructive',
disabled: 'i-ri-indeterminate-circle-line text-text-tertiary',
}
function TaskTrigger({
activeTaskCount,
attentionTaskBadge,
hasTaskError,
onOpenTasks,
tasksButtonLabel,
tasksLiveStatus,
}: {
activeTaskCount: number
attentionTaskBadge?: string
hasTaskError: boolean
onOpenTasks: () => void
tasksButtonLabel: string
tasksLiveStatus: string
}) {
const { t } = useTranslation('dataset')
return (
<>
<Button aria-label={tasksButtonLabel} data-has-error={hasTaskError} onClick={onOpenTasks}>
<span
aria-hidden
className={cn(
'size-4',
activeTaskCount ? 'i-ri-loader-2-line animate-spin' : 'i-ri-task-line',
activeTaskCount && 'motion-reduce:animate-none',
)}
/>
{t(($) => $['newKnowledge.tasks'])}
{attentionTaskBadge && (
<span
aria-hidden
className={cn(
'flex min-w-4 items-center justify-center rounded px-1 system-2xs-medium',
hasTaskError
? 'bg-state-destructive-hover text-text-destructive'
: 'bg-state-accent-hover text-text-accent',
)}
>
{attentionTaskBadge}
</span>
)}
</Button>
<span className="sr-only" role="status" aria-live="polite">
{tasksLiveStatus}
</span>
</>
)
}
const DocumentRow = memo(
({
document,
formatTimeFromNow,
onSelectedChange,
readOnlyReasonId,
selected,
selectionDisabled,
source,
sourcePending,
status,
statusPending,
}: {
document: LogicalDocument
formatTimeFromNow: (time: number) => string
onSelectedChange: (documentId: string) => void
readOnlyReasonId?: string
selected: boolean
selectionDisabled: boolean
source?: string
sourcePending: boolean
status: DocumentDisplayStatus
statusPending: boolean
}) => {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const titleId = `new-document-${document.id}`
const revision = document.activeRevision ?? document.active?.revision
const updatedTime = Date.parse(document.updatedAt)
return (
<tr
className={cn(
'border-t border-divider-subtle',
status === 'disabled' && !statusPending && 'opacity-60',
)}
>
<td className="w-10 py-3 pr-3">
<Checkbox
checked={selected}
disabled={selectionDisabled || status === 'disabled'}
aria-describedby={selectionDisabled ? readOnlyReasonId : undefined}
aria-labelledby={titleId}
onCheckedChange={() => onSelectedChange(document.id)}
/>
</td>
<td className="min-w-0 py-3 pr-3 sm:min-w-72 sm:pr-6">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="i-ri-file-text-line size-[18px] shrink-0 text-text-tertiary"
/>
<span id={titleId} className="truncate system-xs-medium text-text-primary">
{document.title}
</span>
{revision !== undefined && (
<span className="shrink-0 rounded border border-divider-regular px-1 system-2xs-medium text-text-tertiary">
v{revision}
</span>
)}
</div>
</td>
<td className="hidden w-52 py-3 pr-6 system-xs-regular text-text-secondary lg:table-cell">
{sourcePending ? (
<span className="inline-flex items-center gap-2">
<span
aria-hidden
className="h-3 w-24 animate-pulse rounded bg-background-section motion-reduce:animate-none"
/>
<span className="sr-only">{tCommon(($) => $.loading)}</span>
</span>
) : (
<span className="block truncate">
{source ?? t(($) => $['newKnowledge.manualUpload'])}
</span>
)}
</td>
<td className="w-24 py-3 pr-2 sm:w-56 sm:pr-6">
{statusPending ? (
<span className="inline-flex items-center gap-2">
<span
aria-hidden
className="h-3 w-20 animate-pulse rounded bg-background-section motion-reduce:animate-none"
/>
<span className="sr-only">{tCommon(($) => $.loading)}</span>
</span>
) : (
<span
className={cn(
'inline-flex items-center gap-1.5 system-xs-regular',
status === 'failed' ? 'text-text-destructive' : 'text-text-secondary',
)}
>
<span aria-hidden className={cn('size-3.5', statusIconClass[status])} />
{t(($) => $[`newKnowledge.documentStatus.${status}`])}
</span>
)}
</td>
<td className="hidden w-40 py-3 pr-6 system-xs-regular text-text-tertiary lg:table-cell">
{Number.isNaN(updatedTime) ? document.updatedAt : formatTimeFromNow(updatedTime)}
</td>
<td className="w-10 py-3 text-right">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(($) => $['newKnowledge.documentActions'], { name: document.title })}
className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))}
>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.downloadDocuments'])}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['newKnowledge.deleteDocuments'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
},
)
export function DocumentsEmpty({
activeTaskCount,
attentionTaskBadge,
canEdit,
hasTaskError,
onAddDocument,
onDropFiles,
onOpenTasks,
readOnlyReasonId,
tasksButtonLabel,
tasksLiveStatus,
uploading,
}: {
activeTaskCount: number
attentionTaskBadge?: string
canEdit: boolean
hasTaskError: boolean
onAddDocument: () => void
onDropFiles: (files: File[]) => void
onOpenTasks: () => void
readOnlyReasonId?: string
tasksButtonLabel: string
tasksLiveStatus: string
uploading: boolean
}) {
const { t } = useTranslation('dataset')
const tasksVisible = activeTaskCount > 0 || Boolean(attentionTaskBadge) || hasTaskError
return (
<div
className="flex min-h-96 flex-1 flex-col items-center justify-center px-6 text-center"
onDragOver={(event) => {
event.preventDefault()
event.dataTransfer.dropEffect = canEdit ? 'copy' : 'none'
}}
onDrop={(event) => {
event.preventDefault()
if (canEdit) onDropFiles([...event.dataTransfer.files])
}}
>
<span className="flex size-12 items-center justify-center rounded-xl bg-background-section text-text-accent">
<span aria-hidden className="i-ri-file-text-fill size-6" />
</span>
<h2 className="mt-4 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.documentsEmptyTitle'])}
</h2>
<p className="mt-2 max-w-lg system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.documentsEmptyDescription'])}
</p>
<Button
className="mt-4"
variant="primary"
aria-busy={uploading}
disabled={!canEdit}
loading={uploading}
aria-describedby={!canEdit ? readOnlyReasonId : undefined}
onClick={onAddDocument}
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.addDocument'])}
</Button>
{canEdit && (
<p className="mt-2 system-2xs-regular text-text-quaternary">
{t(($) => $['newKnowledge.documentsDropHint'])}
</p>
)}
{tasksVisible && (
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<TaskTrigger
activeTaskCount={activeTaskCount}
attentionTaskBadge={attentionTaskBadge}
hasTaskError={hasTaskError}
onOpenTasks={onOpenTasks}
tasksButtonLabel={tasksButtonLabel}
tasksLiveStatus={tasksLiveStatus}
/>
</div>
)}
</div>
)
}
export function DocumentsList({
activeTaskCount,
allSelected,
attentionTaskBadge,
canEdit,
completingResults,
documents,
filter,
hasNextPage,
hasSelectableDocuments,
hasTaskError,
isFetchNextPageError,
isFetchingNextDocumentPage,
isFetchingNextPage,
onAddDocument,
onFilterChange,
onLoadMore,
onOpenTasks,
onSearchChange,
onSelectAll,
onSelectDocument,
readOnlyReasonId,
resultsIncomplete,
search,
selectionDisabled,
selectedDocumentIds,
someSelected,
sourcesPending,
sourceNames,
statusPending,
statuses,
tasksPending,
tasksButtonLabel,
tasksLiveStatus,
uploading,
}: {
activeTaskCount: number
allSelected: boolean
attentionTaskBadge?: string
canEdit: boolean
completingResults: boolean
documents: LogicalDocument[]
filter: DocumentFilter
hasNextPage: boolean
hasSelectableDocuments: boolean
hasTaskError: boolean
isFetchNextPageError: boolean
isFetchingNextDocumentPage: boolean
isFetchingNextPage: boolean
onAddDocument: () => void
onFilterChange: (filter: DocumentFilter) => void
onLoadMore: () => void
onOpenTasks: () => void
onSearchChange: (search: string) => void
onSelectAll: () => void
onSelectDocument: (documentId: string) => void
readOnlyReasonId?: string
resultsIncomplete: boolean
search: string
selectionDisabled: boolean
selectedDocumentIds: Set<string>
someSelected: boolean
sourcesPending: boolean
sourceNames: Map<string, string>
statusPending: boolean
statuses: Map<string, DocumentDisplayStatus>
tasksPending: boolean
tasksButtonLabel: string
tasksLiveStatus: string
uploading: boolean
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const [visibleDocumentLimit, setVisibleDocumentLimit] = useState(DOCUMENT_RENDER_BATCH_SIZE)
const loadMoreButtonRef = useRef<HTMLButtonElement>(null)
const resultsContainerRef = useRef<HTMLDivElement>(null)
const restoreLoadMoreFocusRef = useRef(false)
const filterActive = filter !== 'all' || Boolean(search.trim())
const visibleDocuments = documents.slice(0, visibleDocumentLimit)
const hasHiddenDocuments = visibleDocuments.length < documents.length
useEffect(() => {
if (isFetchingNextPage || !restoreLoadMoreFocusRef.current) return
if (hasHiddenDocuments || hasNextPage || isFetchNextPageError) {
loadMoreButtonRef.current?.focus()
return
}
restoreLoadMoreFocusRef.current = false
resultsContainerRef.current?.focus()
}, [hasHiddenDocuments, hasNextPage, isFetchNextPageError, isFetchingNextPage])
return (
<>
<div className="mt-5 flex flex-col gap-2 xl:flex-row xl:items-center">
<label className="sr-only" htmlFor="document-filter">
{t(($) => $['newKnowledge.documentFilterLabel'])}
</label>
<select
id="document-filter"
disabled={statusPending}
value={filter}
onChange={(event) => {
setVisibleDocumentLimit(DOCUMENT_RENDER_BATCH_SIZE)
onFilterChange(event.target.value as DocumentFilter)
}}
className="h-8 rounded-lg border-0 bg-components-input-bg-normal px-3 system-xs-regular text-text-secondary outline-hidden focus:ring-2 focus:ring-state-accent-solid xl:w-36"
>
<option value="all">{t(($) => $['newKnowledge.allDocumentStatuses'])}</option>
{(['ready', 'queued', 'processing', 'failed', 'disabled'] as const).map((status) => (
<option key={status} value={status}>
{t(($) => $[`newKnowledge.documentStatus.${status}`])}
</option>
))}
</select>
<label className="relative xl:w-60">
<span className="sr-only">{t(($) => $['newKnowledge.searchDocuments'])}</span>
<span
aria-hidden
className="pointer-events-none absolute top-2 left-2.5 i-ri-search-line size-4 text-text-quaternary"
/>
<input
type="search"
value={search}
onChange={(event) => {
setVisibleDocumentLimit(DOCUMENT_RENDER_BATCH_SIZE)
onSearchChange(event.target.value)
}}
placeholder={t(($) => $['newKnowledge.searchDocuments'])}
className="h-8 w-full rounded-lg border-0 bg-components-input-bg-normal pr-3 pl-8 system-xs-regular text-text-primary outline-hidden placeholder:text-text-quaternary focus:ring-2 focus:ring-state-accent-solid"
/>
</label>
<span className="min-w-0 flex-1" />
<TaskTrigger
activeTaskCount={activeTaskCount}
attentionTaskBadge={attentionTaskBadge}
hasTaskError={hasTaskError}
onOpenTasks={onOpenTasks}
tasksButtonLabel={tasksButtonLabel}
tasksLiveStatus={tasksLiveStatus}
/>
<Button onClick={() => toast.info(t(($) => $['newKnowledge.filtersUnavailable']))}>
<span aria-hidden className="i-ri-price-tag-3-line size-4" />
{t(($) => $['newKnowledge.metadata'])}
</Button>
<Button
variant="primary"
aria-busy={uploading}
disabled={!canEdit}
loading={uploading}
aria-describedby={!canEdit ? readOnlyReasonId : undefined}
onClick={onAddDocument}
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.addDocument'])}
</Button>
</div>
<div
ref={resultsContainerRef}
aria-labelledby="new-knowledge-documents-title"
aria-busy={completingResults || isFetchingNextPage || sourcesPending || tasksPending}
className="mt-4 overflow-x-auto rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
role="region"
tabIndex={-1}
>
<table className="w-full table-fixed border-collapse text-left lg:min-w-[900px] lg:table-auto">
<thead className="system-2xs-medium text-text-tertiary uppercase">
<tr>
<th className="pb-2 font-medium">
<Checkbox
checked={allSelected}
indeterminate={someSelected && !allSelected}
disabled={!canEdit || selectionDisabled || !hasSelectableDocuments}
aria-describedby={
!canEdit
? readOnlyReasonId
: selectionDisabled && resultsIncomplete
? PARTIAL_RESULTS_DESCRIPTION_ID
: undefined
}
aria-label={t(($) => $['newKnowledge.selectAllDocuments'])}
onCheckedChange={onSelectAll}
/>
</th>
<th className="pb-2 font-medium">{t(($) => $['newKnowledge.documentColumn'])}</th>
<th className="hidden pb-2 font-medium lg:table-cell">
{t(($) => $['newKnowledge.sourceColumn'])}
</th>
<th className="pb-2 font-medium">{t(($) => $['newKnowledge.statusColumn'])}</th>
<th className="hidden pb-2 font-medium lg:table-cell">
{t(($) => $['newKnowledge.updatedColumn'])}
</th>
<th aria-label={t(($) => $['newKnowledge.actionsColumn'])} />
</tr>
</thead>
<tbody>
{visibleDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
formatTimeFromNow={formatTimeFromNow}
onSelectedChange={onSelectDocument}
readOnlyReasonId={
!canEdit
? readOnlyReasonId
: selectionDisabled && resultsIncomplete
? PARTIAL_RESULTS_DESCRIPTION_ID
: undefined
}
selected={selectedDocumentIds.has(document.id)}
selectionDisabled={!canEdit || selectionDisabled}
source={
(document.sourceId && sourceNames.get(document.sourceId)) ?? sourceName(document)
}
sourcePending={Boolean(
sourcesPending && document.sourceId && !sourceNames.has(document.sourceId),
)}
status={statuses.get(document.id) ?? 'queued'}
statusPending={Boolean(
tasksPending ||
(statusPending && document.sourceId && !sourceNames.has(document.sourceId)),
)}
/>
))}
</tbody>
</table>
{!documents.length && !completingResults && !isFetchNextPageError && !resultsIncomplete && (
<p
aria-live="polite"
className="py-16 text-center body-sm-regular text-text-tertiary"
role="status"
>
{t(($) => $['newKnowledge.noMatchingDocuments'])}
</p>
)}
{resultsIncomplete && (
<p
id={PARTIAL_RESULTS_DESCRIPTION_ID}
aria-live="polite"
className={cn(
'text-center body-sm-regular text-text-tertiary',
completingResults || isFetchNextPageError
? 'sr-only'
: documents.length
? 'py-4'
: 'py-16',
)}
role="status"
>
{t(($) => $['newKnowledge.partialDocumentResults'])}
</p>
)}
{completingResults && (
<div className="flex min-h-32 items-center justify-center">
<Loading />
</div>
)}
</div>
<p className="mt-3 flex items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-information-2-line size-3.5" />
{t(($) => $['newKnowledge.lastReadyRevisionHint'])}
</p>
{hasHiddenDocuments ? (
<div className="mt-5 flex justify-center">
<Button
ref={loadMoreButtonRef}
onBlur={() => {
restoreLoadMoreFocusRef.current = false
}}
onClick={() => {
restoreLoadMoreFocusRef.current = document.activeElement === loadMoreButtonRef.current
setVisibleDocumentLimit((current) => current + DOCUMENT_RENDER_BATCH_SIZE)
}}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
) : isFetchNextPageError ? (
<div className="mt-5 flex items-center justify-center gap-3" role="alert">
<span className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.documentsErrorDescription'])}
</span>
<Button
ref={loadMoreButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
aria-busy={isFetchingNextDocumentPage}
loading={isFetchingNextDocumentPage}
onBlur={(event) => {
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
}}
onClick={() => {
restoreLoadMoreFocusRef.current = document.activeElement === loadMoreButtonRef.current
onLoadMore()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
) : hasNextPage && (!filterActive || !completingResults) ? (
<div className="mt-5 flex justify-center">
<Button
ref={loadMoreButtonRef}
aria-busy={isFetchingNextPage}
loading={isFetchingNextPage}
onBlur={(event) => {
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
}}
onClick={() => {
restoreLoadMoreFocusRef.current = document.activeElement === loadMoreButtonRef.current
onLoadMore()
}}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
) : null}
</>
)
}
export function DocumentBulkActions({
disabled,
disabledReason,
onClear,
onBlurCapture,
onFocusCapture,
onReindex,
reindexing,
selectedCount,
}: {
disabled: boolean
disabledReason?: string
onClear: () => void
onBlurCapture: FocusEventHandler<HTMLDivElement>
onFocusCapture: FocusEventHandler<HTMLDivElement>
onReindex: () => void
reindexing: boolean
selectedCount: number
}) {
const { t } = useTranslation('dataset')
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(1.75rem+env(safe-area-inset-bottom,0px))] z-20 flex justify-center pr-[calc(1rem+env(safe-area-inset-right,0px))] pl-[calc(1rem+env(safe-area-inset-left,0px))]">
<div
aria-label={t(($) => $['newKnowledge.bulkDocumentActions'])}
className="pointer-events-auto flex max-w-full min-w-0 items-center gap-2 overflow-x-auto rounded-[14px] border border-divider-subtle bg-components-panel-bg px-3 py-2.5 shadow-xl"
role="group"
onBlurCapture={onBlurCapture}
onFocusCapture={onFocusCapture}
>
<Button
aria-describedby={disabled ? 'document-reindex-unavailable' : undefined}
aria-busy={reindexing}
className="shrink-0"
disabled={disabled}
loading={reindexing}
size="small"
onClick={onReindex}
>
{t(($) => $['newKnowledge.reindexDocuments'])}
</Button>
<button
type="button"
aria-label={t(($) => $['newKnowledge.clearDocumentSelection'])}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onClear}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
<span className="shrink-0 px-1 system-xs-medium text-text-primary">
{t(($) => $['newKnowledge.documentsSelected'], { count: selectedCount })}
</span>
{disabled && disabledReason && (
<span
id="document-reindex-unavailable"
className="max-w-44 shrink-0 system-2xs-regular text-text-tertiary"
role="status"
>
{t(($) => $['newKnowledge.reindexDocuments'])}
{' · '}
{disabledReason}
</span>
)}
<span
id="document-actions-unavailable"
className="max-w-44 shrink-0 system-2xs-regular text-text-tertiary"
>
{t(($) => $['newKnowledge.downloadDocuments'])}
{' / '}
{t(($) => $['newKnowledge.deleteDocuments'])}
{' · '}
{t(($) => $['cornerLabel.unavailable'])}
</span>
<Button
aria-describedby="document-actions-unavailable"
className="shrink-0"
size="small"
disabled
>
{t(($) => $['newKnowledge.downloadDocuments'])}
</Button>
<Button
aria-describedby="document-actions-unavailable"
className="shrink-0"
size="small"
disabled
>
{t(($) => $['newKnowledge.deleteDocuments'])}
</Button>
</div>
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
import type {
DocumentProcessingTask,
LogicalDocument,
} from '@dify/contracts/knowledge-fs/types.gen'
export type DocumentDisplayStatus = 'ready' | 'queued' | 'processing' | 'failed' | 'disabled'
export const ACTIVE_TASK_STATES = new Set<DocumentProcessingTask['state']>([
'dispatch_pending',
'queued',
'running',
'retry_wait',
])
const ATTENTION_TASK_STATES = new Set<DocumentProcessingTask['state']>([
...ACTIVE_TASK_STATES,
'failed',
])
function rfc3339Parts(value: string) {
const match = /^(.*:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/.exec(value)
if (!match) return
const epochSecond = Date.parse(`${match[1]}${match[3]}`)
if (Number.isNaN(epochSecond)) return
return { epochSecond, fraction: match[2] ?? '' }
}
export function taskVersionIsAfter(candidate: string, baseline: string) {
const candidateParts = rfc3339Parts(candidate)
const baselineParts = rfc3339Parts(baseline)
if (candidateParts && baselineParts) {
if (candidateParts.epochSecond !== baselineParts.epochSecond)
return candidateParts.epochSecond > baselineParts.epochSecond
const precision = Math.max(candidateParts.fraction.length, baselineParts.fraction.length)
return (
candidateParts.fraction.padEnd(precision, '0') > baselineParts.fraction.padEnd(precision, '0')
)
}
return candidate.localeCompare(baseline) > 0
}
export function sourceName(document: LogicalDocument) {
const value = document.userMetadata.sourceName
if (typeof value === 'string' && value.trim()) return value
return document.sourceId
}
function documentDisabled(document: LogicalDocument, sourceDisabled = false) {
return document.status === 'deleting' || sourceDisabled
}
export function newestTaskByDocument(tasks: DocumentProcessingTask[]) {
const result = new Map<string, DocumentProcessingTask>()
for (const task of tasks) {
const current = result.get(task.documentId)
if (
!current ||
task.documentRevision > current.documentRevision ||
(task.documentRevision === current.documentRevision &&
(taskVersionIsAfter(task.updatedAt, current.updatedAt) ||
(task.updatedAt === current.updatedAt && task.id > current.id)))
)
result.set(task.documentId, task)
}
return result
}
export function documentDisplayStatus(
document: LogicalDocument,
task?: DocumentProcessingTask,
sourceDisabled = false,
): DocumentDisplayStatus {
if (documentDisabled(document, sourceDisabled)) return 'disabled'
const activeRevision = document.activeRevision ?? document.active?.revision ?? 0
const taskRepresentsLatestRevision = task && task.documentRevision >= activeRevision
if (taskRepresentsLatestRevision) {
if (task.state === 'running') return 'processing'
if (task.state === 'dispatch_pending' || task.state === 'queued' || task.state === 'retry_wait')
return 'queued'
if (task.state === 'failed') return 'failed'
if (task.state === 'canceled' && !document.active) return 'failed'
}
if (document.status === 'failed') return 'failed'
if (document.status === 'pending') return 'queued'
return 'ready'
}
export function taskNeedsAttention(task: DocumentProcessingTask) {
return ATTENTION_TASK_STATES.has(task.state)
}
export function taskIsActive(task: DocumentProcessingTask) {
return ACTIVE_TASK_STATES.has(task.state)
}
export function taskCanRetry(task: DocumentProcessingTask) {
return task.state === 'failed'
}
@@ -0,0 +1,42 @@
const DOCUMENT_UPLOAD_MAX_BYTES = 15 * 1024 * 1024
const DOCUMENT_UPLOAD_EXTENSIONS = [
'csv',
'doc',
'docx',
'epub',
'htm',
'html',
'json',
'jsonl',
'md',
'pdf',
'ppt',
'pptx',
'rtf',
'text',
'txt',
'xls',
'xlsx',
] as const
const documentUploadExtensionSet = new Set<string>(DOCUMENT_UPLOAD_EXTENSIONS)
export const DOCUMENT_UPLOAD_ACCEPT = DOCUMENT_UPLOAD_EXTENSIONS.map(
(extension) => `.${extension}`,
).join(',')
export type DocumentUploadIssue = 'fileSize' | 'fileType'
export function documentUploadFileExtension(name: string) {
const normalizedName = name.trim().toLocaleLowerCase()
const dotIndex = normalizedName.lastIndexOf('.')
return dotIndex >= 0 && dotIndex < normalizedName.length - 1
? normalizedName.slice(dotIndex + 1)
: ''
}
export function documentUploadIssue(file: File): DocumentUploadIssue | undefined {
if (file.size > DOCUMENT_UPLOAD_MAX_BYTES) return 'fileSize'
if (!documentUploadExtensionSet.has(documentUploadFileExtension(file.name))) return 'fileType'
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
export function KnowledgeRoutePlaceholder({ type }: { type: 'documents' | 'sources' }) {
const { t } = useTranslation('dataset')
return (
<div className="flex min-h-full flex-col items-center justify-center px-6 py-16 text-center">
<span
aria-hidden
className={`${type === 'sources' ? 'i-ri-links-line' : 'i-ri-file-text-line'} size-7 text-text-tertiary`}
/>
<h2 className="mt-4 title-xl-semi-bold text-text-primary">
{t(($) => (type === 'sources' ? $['newKnowledge.sources'] : $['newKnowledge.documents']))}
</h2>
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.routeUnavailable'])}
</p>
</div>
)
}
@@ -0,0 +1,636 @@
'use client'
import type {
DocumentProcessingTask,
LogicalDocument,
} from '@dify/contracts/knowledge-fs/types.gen'
import type { TaskProgressStore } from './task-progress-store'
import { Button } from '@langgenius/dify-ui/button'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useMutation } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { consoleQuery } from '@/service/client'
import { taskCanRetry, taskIsActive, taskVersionIsAfter } from './document-model'
type TaskAction = 'cancel' | 'retry'
const TASK_DRAWER_LIMIT = 100
const noopSubscribe = () => () => undefined
function taskTime(task: DocumentProcessingTask) {
return task.completedAt ?? task.updatedAt
}
function taskLifecycle(task: DocumentProcessingTask) {
return `${task.updatedAt}:${task.state}`
}
function responseStatus(error: unknown): number | undefined {
if (error instanceof Response) return error.status
if (error && typeof error === 'object' && 'status' in error)
return typeof error.status === 'number' ? error.status : undefined
if (error && typeof error === 'object' && 'data' in error) {
const data = error.data
if (data && typeof data === 'object' && 'status' in data)
return typeof data.status === 'number' ? data.status : undefined
}
}
function compareTaskRecency(left: DocumentProcessingTask, right: DocumentProcessingTask) {
if (taskVersionIsAfter(left.updatedAt, right.updatedAt)) return -1
if (taskVersionIsAfter(right.updatedAt, left.updatedAt)) return 1
return right.id.localeCompare(left.id)
}
function newestTasks(
tasks: DocumentProcessingTask[],
limit: number,
predicate: (task: DocumentProcessingTask) => boolean,
) {
const selected: DocumentProcessingTask[] = []
for (const task of tasks) {
if (!predicate(task)) continue
let low = 0
let high = selected.length
while (low < high) {
const middle = Math.floor((low + high) / 2)
if (compareTaskRecency(task, selected[middle]!) < 0) high = middle
else low = middle + 1
}
selected.splice(low, 0, task)
if (selected.length > limit) selected.pop()
}
return selected
}
export function ProcessingTasksDrawer({
canEdit,
actionResultsValid,
documentQueryError,
documentQueryFetching,
documents,
documentsPending,
hasNextDocumentPage,
hasNextTaskPage,
hasUnresolvedTaskDocuments,
isFetchingNextDocumentPage,
isFetchingNextTaskPage,
knowledgeSpaceId,
onLoadMoreDocuments,
onLoadMoreTasks,
onOpenChange,
onRefreshDocumentsAndTasks,
onRetryPermissionQuery,
onTaskUpdated,
onWritePermissionDenied,
open,
permissionQueryError,
permissionQueryFetching,
permissionQueryPending,
readOnlyReason,
taskQueryError,
taskQueryFetching,
taskQueryPending,
tasks,
taskProgressStore,
onRetryTaskQuery,
onRetryDocumentQuery,
}: {
canEdit: boolean
actionResultsValid: boolean
documentQueryError: boolean
documentQueryFetching: boolean
documents: LogicalDocument[]
documentsPending: boolean
hasNextDocumentPage: boolean
hasNextTaskPage: boolean
hasUnresolvedTaskDocuments: boolean
isFetchingNextDocumentPage: boolean
isFetchingNextTaskPage: boolean
knowledgeSpaceId: string
onLoadMoreDocuments: () => void
onLoadMoreTasks: () => void
onOpenChange: (open: boolean) => void
onRefreshDocumentsAndTasks: () => void
onRetryPermissionQuery: () => void
onTaskUpdated: (task: DocumentProcessingTask) => void
onWritePermissionDenied: () => void
open: boolean
permissionQueryError: boolean
permissionQueryFetching: boolean
permissionQueryPending: boolean
readOnlyReason?: string
taskQueryError: boolean
taskQueryFetching: boolean
taskQueryPending: boolean
tasks: DocumentProcessingTask[]
taskProgressStore: TaskProgressStore
onRetryDocumentQuery: () => void
onRetryTaskQuery: () => void
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const cancelTask = useMutation(
consoleQuery.knowledgeFs.deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId.mutationOptions(),
)
const retryTask = useMutation(
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry.mutationOptions(),
)
const pendingActionsRef = useRef(new Set<string>())
const drawerCloseButtonRef = useRef<HTMLButtonElement>(null)
const taskQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const documentQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const permissionQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const focusedTaskActionRef = useRef<HTMLButtonElement | null>(null)
const loadMoreRequestedRef = useRef(false)
const queryRetryFocusRequestedRef = useRef(false)
const permissionRetryFocusRequestedRef = useRef(false)
const loadMoreButtonRef = useRef<HTMLButtonElement>(null)
const openCycleRef = useRef(0)
const openRef = useRef(open)
const actionResultsValidRef = useRef(actionResultsValid)
const previousOpenRef = useRef(open)
const [pendingActions, setPendingActions] = useState<Set<string>>(() => new Set())
const [actionErrors, setActionErrors] = useState<Record<string, string>>({})
const [visibleTaskLimit, setVisibleTaskLimit] = useState(TASK_DRAWER_LIMIT)
useSyncExternalStore(
open ? taskProgressStore.subscribe : noopSubscribe,
taskProgressStore.getSnapshot,
taskProgressStore.getSnapshot,
)
const documentTitles = useMemo(
() => new Map(documents.map((document) => [document.id, document.title])),
[documents],
)
const orderedBaseTasks = useMemo(() => {
if (!open) return []
const reservedLimit = Math.min(TASK_DRAWER_LIMIT / 2, visibleTaskLimit)
const retryableTasks = newestTasks(tasks, reservedLimit, taskCanRetry)
const activeTasks = newestTasks(tasks, reservedLimit, taskIsActive)
const attentionTaskIds = new Set([
...retryableTasks.map((task) => task.id),
...activeTasks.map((task) => task.id),
])
const remainingAttentionTasks = newestTasks(
tasks,
visibleTaskLimit - attentionTaskIds.size,
(task) => (taskCanRetry(task) || taskIsActive(task)) && !attentionTaskIds.has(task.id),
)
for (const task of remainingAttentionTasks) attentionTaskIds.add(task.id)
const terminalTasks = newestTasks(
tasks,
visibleTaskLimit - attentionTaskIds.size,
(task) => !attentionTaskIds.has(task.id),
)
return [...retryableTasks, ...activeTasks, ...remainingAttentionTasks, ...terminalTasks].sort(
compareTaskRecency,
)
}, [open, tasks, visibleTaskLimit])
const hasMoreTasks =
open &&
(tasks.length > orderedBaseTasks.length ||
hasNextTaskPage ||
(hasUnresolvedTaskDocuments && hasNextDocumentPage))
const orderedTasks = orderedBaseTasks.map((task) => {
const progress = taskProgressStore.get(task.id)
if (!progress || !taskIsActive(task) || taskVersionIsAfter(task.updatedAt, progress.updatedAt))
return task
return {
...task,
errorCode: undefined,
errorMessage: undefined,
...progress,
}
})
const activeActionCount = orderedTasks.filter(taskIsActive).length
const retryActionCount = orderedTasks.filter(taskCanRetry).length
const taskLifecycles = useMemo(
() => new Map(tasks.map((task) => [task.id, taskLifecycle(task)])),
[tasks],
)
const taskLifecycleGenerationsRef = useRef(
new Map<string, { generation: number; lifecycle: string }>(),
)
useLayoutEffect(() => {
actionResultsValidRef.current = actionResultsValid
const currentTaskIds = new Set(tasks.map((task) => task.id))
for (const task of tasks) {
const lifecycle = taskLifecycle(task)
const previous = taskLifecycleGenerationsRef.current.get(task.id)
if (previous?.lifecycle === lifecycle) continue
taskLifecycleGenerationsRef.current.set(task.id, {
generation: (previous?.generation ?? 0) + 1,
lifecycle,
})
}
for (const taskId of taskLifecycleGenerationsRef.current.keys()) {
if (!currentTaskIds.has(taskId)) taskLifecycleGenerationsRef.current.delete(taskId)
}
}, [actionResultsValid, tasks])
useEffect(() => {
openRef.current = open
const wasOpen = previousOpenRef.current
previousOpenRef.current = open
if (!wasOpen || open) return
openCycleRef.current += 1
loadMoreRequestedRef.current = false
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Every committed controlled close resets drawer-local pagination and errors.
setVisibleTaskLimit(TASK_DRAWER_LIMIT)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Every committed controlled close starts a fresh action-error cycle.
setActionErrors({})
}, [open])
useEffect(() => {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Task lifecycle changes retire action errors from older task versions.
setActionErrors((current) => {
const staleTaskIds = Object.keys(current).filter(
(taskId) => taskLifecycles.get(taskId) !== current[taskId],
)
if (!staleTaskIds.length) return current
const next = { ...current }
for (const taskId of staleTaskIds) delete next[taskId]
return next
})
}, [taskLifecycles])
useEffect(() => {
if (!open) return
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A newly opened drawer starts a fresh action-error cycle.
setActionErrors({})
}, [open])
useEffect(() => {
if (!open || hasMoreTasks || !loadMoreRequestedRef.current) return
loadMoreRequestedRef.current = false
drawerCloseButtonRef.current?.focus()
}, [hasMoreTasks, open])
useEffect(() => {
const queryRetryVisible = taskQueryError || (documentQueryError && hasUnresolvedTaskDocuments)
if (!open || !queryRetryFocusRequestedRef.current) return
if (queryRetryVisible) {
if (taskQueryError) taskQueryRetryButtonRef.current?.focus()
else documentQueryRetryButtonRef.current?.focus()
return
}
queryRetryFocusRequestedRef.current = false
drawerCloseButtonRef.current?.focus()
}, [documentQueryError, hasUnresolvedTaskDocuments, open, taskQueryError])
useEffect(() => {
if (!open || !permissionRetryFocusRequestedRef.current) return
if (permissionQueryError) {
permissionQueryRetryButtonRef.current?.focus()
return
}
permissionRetryFocusRequestedRef.current = false
drawerCloseButtonRef.current?.focus()
}, [open, permissionQueryError])
useEffect(() => {
const focusedAction = focusedTaskActionRef.current
if (!open || !focusedAction || focusedAction.isConnected) return
focusedTaskActionRef.current = null
drawerCloseButtonRef.current?.focus()
}, [open, orderedTasks])
const performAction = async (task: DocumentProcessingTask, action: TaskAction) => {
if (!canEdit || pendingActionsRef.current.has(task.id)) return
pendingActionsRef.current.add(task.id)
const actionOpenCycle = openCycleRef.current
const actionLifecycleGeneration = taskLifecycleGenerationsRef.current.get(task.id)?.generation
const actionFocusTarget = document.activeElement
setPendingActions((current) => new Set(current).add(task.id))
setActionErrors((current) => {
const next = { ...current }
delete next[task.id]
return next
})
try {
const input = {
params: {
documentId: task.documentId,
id: knowledgeSpaceId,
taskId: task.id,
},
}
const updated =
action === 'cancel'
? await cancelTask.mutateAsync(input)
: await retryTask.mutateAsync(input)
if (
!actionResultsValidRef.current ||
taskLifecycleGenerationsRef.current.get(task.id)?.generation !== actionLifecycleGeneration
)
return
onTaskUpdated(updated)
setActionErrors((current) => {
const next = { ...current }
delete next[task.id]
return next
})
if (
actionResultsValidRef.current &&
openRef.current &&
openCycleRef.current === actionOpenCycle &&
document.activeElement === actionFocusTarget
)
drawerCloseButtonRef.current?.focus()
} catch (error) {
const permissionDenied = responseStatus(error) === 403
if (permissionDenied) onWritePermissionDenied()
if (
!permissionDenied &&
actionResultsValidRef.current &&
openRef.current &&
openCycleRef.current === actionOpenCycle &&
taskLifecycleGenerationsRef.current.get(task.id)?.generation === actionLifecycleGeneration
)
setActionErrors((current) => ({ ...current, [task.id]: taskLifecycle(task) }))
} finally {
pendingActionsRef.current.delete(task.id)
setPendingActions((current) => {
const next = new Set(current)
next.delete(task.id)
return next
})
if (actionResultsValidRef.current) onRefreshDocumentsAndTasks()
}
}
return (
<Drawer
open={open}
modal
swipeDirection="right"
onOpenChange={(nextOpen) => {
openRef.current = nextOpen
onOpenChange(nextOpen)
}}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:w-[440px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]">
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
<header className="shrink-0 pt-[calc(1.25rem+env(safe-area-inset-top,0px))] pr-[calc(1.5rem+env(safe-area-inset-right,0px))] pb-4 pl-[calc(1.5rem+env(safe-area-inset-left,0px))]">
<div className="flex items-center justify-between gap-3">
<DrawerTitle className="system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.backgroundTasks'])}
</DrawerTitle>
<DrawerCloseButton
ref={drawerCloseButtonRef}
aria-label={tCommon(($) => $['operation.close'])}
className="size-6 rounded-md"
/>
</div>
<DrawerDescription className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.backgroundTasksDescription'])}
</DrawerDescription>
{readOnlyReason && (
<p
className="mt-2 inline-flex items-center gap-1.5 system-xs-regular text-text-warning"
role="status"
>
<span aria-hidden className="i-ri-lock-line size-3.5" />
{readOnlyReason}
</p>
)}
{permissionQueryPending && (
<p className="mt-2 system-xs-regular text-text-tertiary" role="status">
{t(($) => $['newKnowledge.permission'])}
{' · '}
{tCommon(($) => $.loading)}
</p>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto pr-[calc(1.5rem+env(safe-area-inset-right,0px))] pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pl-[calc(1.5rem+env(safe-area-inset-left,0px))]">
{permissionQueryError && (
<div className="mb-3 rounded-xl border border-divider-regular p-4" role="alert">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.permissionLoadFailed'])}
</p>
<Button
ref={permissionQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
aria-busy={permissionQueryFetching}
className="mt-3"
loading={permissionQueryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) permissionRetryFocusRequestedRef.current = false
}}
onClick={() => {
permissionRetryFocusRequestedRef.current = true
onRetryPermissionQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{taskQueryError && (
<div className="mb-3 rounded-xl border border-divider-regular p-4" role="alert">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.tasksErrorDescription'])}
</p>
<Button
ref={taskQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.tasksErrorDescription'])}`}
aria-busy={taskQueryFetching}
className="mt-3"
loading={taskQueryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) queryRetryFocusRequestedRef.current = false
}}
onClick={() => {
queryRetryFocusRequestedRef.current = true
onRetryTaskQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{documentQueryError && hasUnresolvedTaskDocuments && (
<div className="mb-3 rounded-xl border border-divider-regular p-4" role="alert">
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.documentsErrorDescription'])}
</p>
<Button
ref={documentQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
aria-busy={documentQueryFetching}
className="mt-3"
loading={documentQueryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) queryRetryFocusRequestedRef.current = false
}}
onClick={() => {
queryRetryFocusRequestedRef.current = true
onRetryDocumentQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{taskQueryPending && !orderedTasks.length ? (
<div className="flex min-h-40 items-center justify-center">
<Loading />
</div>
) : orderedTasks.length ? (
<ul className="divide-y divide-divider-subtle">
{orderedTasks.map((task) => {
const title =
documentTitles.get(task.documentId) ??
(documentsPending
? t(($) => $['newKnowledge.documentColumn'])
: task.documentId)
const timestamp = Date.parse(
taskIsActive(task) ? task.createdAt : taskTime(task),
)
const taskError = task.errorMessage ?? task.errorCode
const actionTarget = `${documentTitles.get(task.documentId) ?? task.documentId} · ${task.id}`
return (
<li key={task.id} className="flex min-h-[62px] items-center gap-2.5 py-3.5">
<span
aria-hidden
className={
task.state === 'failed'
? 'i-ri-error-warning-fill size-4 shrink-0 text-text-destructive'
: taskIsActive(task)
? 'i-ri-loader-2-line size-4 shrink-0 animate-spin text-text-accent motion-reduce:animate-none'
: task.state === 'succeeded'
? 'i-ri-check-line size-4 shrink-0 text-text-success'
: 'i-ri-indeterminate-circle-line size-4 shrink-0 text-text-tertiary'
}
/>
<div className="min-w-0 flex-1">
<p className="truncate system-xs-medium text-text-primary">
{t(($) => $['newKnowledge.processDocument'], { name: title })}
</p>
<p className="mt-0.5 truncate system-2xs-regular text-text-tertiary">
{t(($) => $[`newKnowledge.processingTaskState.${task.state}`], {
progress: task.progressPercent,
})}
{!Number.isNaN(timestamp) && (
<>
<span aria-hidden> · </span>
{formatTimeFromNow(timestamp)}
</>
)}
</p>
{taskError && (
<p className="mt-1 system-2xs-regular break-words whitespace-pre-wrap text-text-destructive">
{taskError}
</p>
)}
{actionErrors[task.id] === taskLifecycle(task) && (
<p
className="mt-1 system-2xs-regular text-text-destructive"
role="alert"
>
{t(($) => $['newKnowledge.taskActionFailed'])}
</p>
)}
</div>
{canEdit && taskIsActive(task) ? (
<Button
aria-label={
activeActionCount > 1
? `${t(($) => $['newKnowledge.interruptTask'])} · ${actionTarget}`
: undefined
}
size="small"
aria-busy={pendingActions.has(task.id)}
disabled={pendingActions.has(task.id)}
loading={pendingActions.has(task.id)}
onBlur={(event) => {
if (event.relatedTarget) focusedTaskActionRef.current = null
}}
onFocus={(event) => {
focusedTaskActionRef.current = event.currentTarget
}}
onClick={() => void performAction(task, 'cancel')}
>
{t(($) => $['newKnowledge.interruptTask'])}
</Button>
) : canEdit && taskCanRetry(task) ? (
<Button
aria-label={
retryActionCount > 1
? `${t(($) => $['newKnowledge.retryTask'])} · ${actionTarget}`
: undefined
}
size="small"
aria-busy={pendingActions.has(task.id)}
disabled={pendingActions.has(task.id)}
loading={pendingActions.has(task.id)}
onBlur={(event) => {
if (event.relatedTarget) focusedTaskActionRef.current = null
}}
onFocus={(event) => {
focusedTaskActionRef.current = event.currentTarget
}}
onClick={() => void performAction(task, 'retry')}
>
{t(($) => $['newKnowledge.retryTask'])}
</Button>
) : null}
</li>
)
})}
</ul>
) : !taskQueryError && !hasMoreTasks ? (
<p className="py-16 text-center system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.noBackgroundTasks'])}
</p>
) : null}
{hasMoreTasks && (
<div className="mt-4 flex justify-center">
<Button
ref={loadMoreButtonRef}
aria-busy={isFetchingNextTaskPage || isFetchingNextDocumentPage}
loading={isFetchingNextTaskPage || isFetchingNextDocumentPage}
onBlur={() => {
loadMoreRequestedRef.current = false
}}
onClick={() => {
loadMoreRequestedRef.current =
document.activeElement === loadMoreButtonRef.current
if (tasks.length <= orderedBaseTasks.length && hasNextTaskPage)
onLoadMoreTasks()
if (hasUnresolvedTaskDocuments && hasNextDocumentPage) onLoadMoreDocuments()
setVisibleTaskLimit((current) => current + TASK_DRAWER_LIMIT)
}}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
@@ -0,0 +1,51 @@
import type { DocumentProcessingTaskEvent } from '@dify/contracts/knowledge-fs/types.gen'
import { getEventMeta } from '@orpc/client'
import { consoleClient } from '@/service/client'
type ProcessingTaskEventWithId<T extends DocumentProcessingTaskEvent> = T & {
id: string
}
export type ProcessingTaskProgressEvent = ProcessingTaskEventWithId<
Extract<DocumentProcessingTaskEvent, { event: 'progress' }>
>
type ProcessingTaskTerminalEvent = ProcessingTaskEventWithId<
Extract<DocumentProcessingTaskEvent, { event: 'terminal' }>
>
export type ProcessingTaskEvent = ProcessingTaskProgressEvent | ProcessingTaskTerminalEvent
type StreamProcessingTaskEventsInput = {
documentId: string
knowledgeSpaceId: string
lastEventId?: string
signal?: AbortSignal
taskId: string
}
export async function* streamProcessingTaskEvents(
input: StreamProcessingTaskEventsInput,
): AsyncGenerator<ProcessingTaskEvent> {
const events =
await consoleClient.knowledgeFs.getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents(
{
headers: input.lastEventId ? { 'last-event-id': input.lastEventId } : undefined,
params: {
documentId: input.documentId,
id: input.knowledgeSpaceId,
taskId: input.taskId,
},
},
{
context: { silent: true },
signal: input.signal,
},
)
for await (const event of events) {
const id = getEventMeta(event)?.id
if (!id) throw new Error('KnowledgeFS processing task event is missing an event id')
yield { ...event, id }
}
}
@@ -0,0 +1,128 @@
'use client'
import type { ProcessingTaskEvent } from './services/processing-task-events'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { taskVersionIsAfter } from './document-model'
import { streamProcessingTaskEvents } from './services/processing-task-events'
const TASK_EVENT_RECONNECT_DELAY = 1000
const TASK_EVENT_MAX_RECONNECT_DELAY = 30000
function waitForTaskEventReconnect(signal: AbortSignal, delay: number) {
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
let settled = false
const timeout = window.setTimeout(finish, delay)
signal.addEventListener('abort', finish, { once: true })
if (signal.aborted) finish()
function finish() {
if (settled) return
settled = true
window.clearTimeout(timeout)
signal.removeEventListener('abort', finish)
resolve()
}
})
}
function responseStatus(error: unknown): number | undefined {
if (error instanceof Response) return error.status
if (error && typeof error === 'object' && 'status' in error)
return typeof error.status === 'number' ? error.status : undefined
if (error && typeof error === 'object' && 'data' in error) {
const data = error.data
if (data && typeof data === 'object' && 'status' in data)
return typeof data.status === 'number' ? data.status : undefined
}
}
export function TaskEventObserver({
documentId,
knowledgeSpaceId,
lastEventId,
onEvent,
onLastEventIdChange,
onPermissionDenied,
taskId,
taskVersion,
}: {
documentId: string
knowledgeSpaceId: string
lastEventId?: string
onEvent: (taskId: string, taskVersion: string, event: ProcessingTaskEvent) => boolean
onLastEventIdChange: (taskId: string, eventId?: string) => void
onPermissionDenied: (taskId: string, taskVersion: string) => void
taskId: string
taskVersion: string
}) {
const resumeEventIdRef = useRef(lastEventId)
useLayoutEffect(() => {
resumeEventIdRef.current = lastEventId
}, [lastEventId])
const latestTaskVersionRef = useRef(taskVersion)
const streamTaskVersionRef = useRef(taskVersion)
useLayoutEffect(() => {
if (taskVersionIsAfter(taskVersion, latestTaskVersionRef.current))
latestTaskVersionRef.current = taskVersion
}, [taskVersion])
useEffect(() => {
const controller = new AbortController()
void (async () => {
let reconnectDelay = TASK_EVENT_RECONNECT_DELAY
while (!controller.signal.aborted) {
if (taskVersionIsAfter(latestTaskVersionRef.current, streamTaskVersionRef.current))
streamTaskVersionRef.current = latestTaskVersionRef.current
try {
for await (const event of streamProcessingTaskEvents({
documentId,
knowledgeSpaceId,
lastEventId: resumeEventIdRef.current,
signal: controller.signal,
taskId,
})) {
if (controller.signal.aborted) return
resumeEventIdRef.current = event.id
onLastEventIdChange(taskId, event.id)
const acceptedTaskVersion = streamTaskVersionRef.current
const eventTaskVersion =
event.event === 'progress' ? event.data.updatedAt : acceptedTaskVersion
const accepted = onEvent(taskId, eventTaskVersion, event)
if (!accepted) {
resumeEventIdRef.current = undefined
onLastEventIdChange(taskId)
streamTaskVersionRef.current = taskVersionIsAfter(
latestTaskVersionRef.current,
acceptedTaskVersion,
)
? latestTaskVersionRef.current
: acceptedTaskVersion
break
}
streamTaskVersionRef.current = eventTaskVersion
reconnectDelay = TASK_EVENT_RECONNECT_DELAY
if (event.event === 'terminal') {
resumeEventIdRef.current = undefined
onLastEventIdChange(taskId)
return
}
}
} catch (error) {
if (controller.signal.aborted) return
if (responseStatus(error) === 403) {
if (taskVersionIsAfter(latestTaskVersionRef.current, streamTaskVersionRef.current))
streamTaskVersionRef.current = latestTaskVersionRef.current
onPermissionDenied(taskId, streamTaskVersionRef.current)
return
}
}
await waitForTaskEventReconnect(controller.signal, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, TASK_EVENT_MAX_RECONNECT_DELAY)
}
})()
return () => controller.abort()
}, [documentId, knowledgeSpaceId, onEvent, onLastEventIdChange, onPermissionDenied, taskId])
return null
}
@@ -0,0 +1,53 @@
import type { ProcessingTaskProgressEvent } from './services/processing-task-events'
import { taskVersionIsAfter } from './document-model'
type TaskProgress = ProcessingTaskProgressEvent['data']
type Listener = () => void
export type TaskProgressStore = {
delete: (taskId: string) => void
get: (taskId: string) => TaskProgress | undefined
getSnapshot: () => number
retain: (taskIds: Set<string>) => void
set: (taskId: string, progress: TaskProgress) => void
subscribe: (listener: Listener) => () => void
}
export function createTaskProgressStore(): TaskProgressStore {
const progressByTaskId = new Map<string, TaskProgress>()
const listeners = new Set<Listener>()
let revision = 0
const emit = () => {
revision += 1
for (const listener of listeners) listener()
}
return {
delete(taskId) {
if (!progressByTaskId.delete(taskId)) return
emit()
},
get: (taskId) => progressByTaskId.get(taskId),
getSnapshot: () => revision,
retain(taskIds) {
let changed = false
for (const taskId of progressByTaskId.keys()) {
if (taskIds.has(taskId)) continue
progressByTaskId.delete(taskId)
changed = true
}
if (changed) emit()
},
set(taskId, progress) {
const current = progressByTaskId.get(taskId)
if (current && taskVersionIsAfter(current.updatedAt, progress.updatedAt)) return
progressByTaskId.set(taskId, progress)
emit()
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
@@ -0,0 +1,26 @@
'use client'
import type { QueryClient, QueryKey } from '@tanstack/react-query'
import { hashKey } from '@tanstack/react-query'
import { useCallback, useSyncExternalStore } from 'react'
export function useQueryDataUpdateCount(queryClient: QueryClient, queryKey: QueryKey) {
const queryHash = hashKey(queryKey)
const subscribe = useCallback(
(onStoreChange: () => void) =>
queryClient.getQueryCache().subscribe((event) => {
if (
event.type === 'updated' &&
event.action.type === 'success' &&
event.query.queryHash === queryHash
)
onStoreChange()
}),
[queryClient, queryHash],
)
const getSnapshot = useCallback(
() => queryClient.getQueryState(queryKey)?.dataUpdateCount ?? 0,
[queryClient, queryKey],
)
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}