mirror of
https://github.com/langgenius/dify.git
synced 2026-10-29 18:07:39 +08:00
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:
co-authored by
autofix-ci[bot]
parent
5277e4b375
commit
799c7eea3a
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user