mirror of
https://github.com/langgenius/dify.git
synced 2026-09-04 07:53:20 +08:00
feat(dataset): support New RAG document metadata
This commit is contained in:
parent
4fd80c8d59
commit
c758131fb9
@ -100,6 +100,15 @@ const permissionState = vi.hoisted(() => ({
|
||||
}))
|
||||
const reindexMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
|
||||
const cancelMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
|
||||
const patchDocumentMetadata = vi.hoisted(() => vi.fn())
|
||||
const listLogicalDocuments = vi.hoisted(() => vi.fn())
|
||||
const metadataDocumentsQuery = vi.hoisted(() => ({
|
||||
data: [] as LogicalDocument[] | undefined,
|
||||
error: null as unknown,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}))
|
||||
const routerMock = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
const settingsState = vi.hoisted(() => ({
|
||||
configurationState: 'active' as 'active' | 'setup-required',
|
||||
@ -335,7 +344,8 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
},
|
||||
useMutation: (options: { mutationKind?: string }) =>
|
||||
options.mutationKind === 'cancel' ? cancelMutation : reindexMutation,
|
||||
useQuery: (options: { queryKind?: string }) => {
|
||||
useQuery: (options: { queryKey?: readonly unknown[]; queryKind?: string }) => {
|
||||
if (options.queryKey?.includes('document-metadata-documents')) return metadataDocumentsQuery
|
||||
if (options.queryKind === 'settings')
|
||||
return {
|
||||
data: {
|
||||
@ -354,6 +364,22 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
knowledgeFs: {
|
||||
spaces: {
|
||||
byControlSpaceId: {
|
||||
documents: {
|
||||
byDocumentId: {
|
||||
patch: patchDocumentMetadata,
|
||||
},
|
||||
},
|
||||
logicalDocuments: {
|
||||
get: listLogicalDocuments,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
spaces: {
|
||||
@ -556,6 +582,10 @@ describe('DocumentDetailPage', () => {
|
||||
tasksQuery.isFetchNextPageError = false
|
||||
tasksQuery.isFetchingNextPage = false
|
||||
tasksQuery.isPending = false
|
||||
metadataDocumentsQuery.data = []
|
||||
metadataDocumentsQuery.error = null
|
||||
metadataDocumentsQuery.isFetching = false
|
||||
metadataDocumentsQuery.isPending = false
|
||||
permissionState.refresh.mockResolvedValue({
|
||||
data: { dataset: { default_permission_keys: ['dataset.acl.edit'] } },
|
||||
error: null,
|
||||
@ -572,6 +602,13 @@ describe('DocumentDetailPage', () => {
|
||||
}))
|
||||
reindexMutation.mutateAsync.mockResolvedValue(queuedReindexResult())
|
||||
cancelMutation.mutateAsync.mockResolvedValue(taskApiResponse(task({ state: 'canceled' })))
|
||||
patchDocumentMetadata.mockImplementation(async () =>
|
||||
logicalDocumentApiResponse(logicalDocument({ rowVersion: 3 })),
|
||||
)
|
||||
listLogicalDocuments.mockResolvedValue({
|
||||
data: [logicalDocumentApiResponse(logicalDocument())],
|
||||
next_cursor: null,
|
||||
})
|
||||
queryClient.invalidateQueries.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
@ -751,11 +788,395 @@ describe('DocumentDetailPage', () => {
|
||||
})
|
||||
expect(startLabeling).toBeEnabled()
|
||||
await user.click(startLabeling)
|
||||
expect(toastState.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'dataset.metadata.addMetadata' }),
|
||||
).toBeInTheDocument()
|
||||
expect(toastState.info).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('chunk-content-scroll')).toBe(previousContentScroller)
|
||||
expect(screen.getByRole('heading', { name: 'Setup requirements' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates document metadata through the KnowledgeFS metadata endpoint', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentQuery.data = logicalDocument({
|
||||
knowledgeSpaceId: 'remote-space-1',
|
||||
userMetadata: { category: 'support', sourceName: 'Notion support SOP' },
|
||||
})
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.getByText('category')).toBeInTheDocument()
|
||||
expect(screen.queryByText('sourceName')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
const valueInput = await screen.findByRole('textbox', { name: 'category' })
|
||||
await user.clear(valueInput)
|
||||
await user.type(valueInput, 'security')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(patchDocumentMetadata).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 2, patch: { category: 'security' } },
|
||||
params: { control_space_id: 'space-1', document_id: 'document-1' },
|
||||
})
|
||||
await waitFor(() => expect(toastState.success).toHaveBeenCalledWith('common.api.actionSuccess'))
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['knowledge-fs', 'document'],
|
||||
})
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['knowledge-fs', 'documents'],
|
||||
})
|
||||
})
|
||||
|
||||
it('lets users choose the type of a new document metadata field', async () => {
|
||||
const user = userEvent.setup()
|
||||
metadataDocumentsQuery.data = [
|
||||
logicalDocument(),
|
||||
logicalDocument({
|
||||
id: 'document-2',
|
||||
rowVersion: 4,
|
||||
userMetadata: { priority: 0 },
|
||||
}),
|
||||
]
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(await screen.findByRole('option', { name: /priority/ }))
|
||||
const valueInput = screen.getByRole('spinbutton', { name: 'priority' })
|
||||
await user.clear(valueInput)
|
||||
await user.type(valueInput, '42')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(patchDocumentMetadata).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 2, patch: { priority: 42 } },
|
||||
params: { control_space_id: 'space-1', document_id: 'document-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps selected number and time metadata empty until the user enters a value', async () => {
|
||||
const user = userEvent.setup()
|
||||
metadataDocumentsQuery.data = [
|
||||
logicalDocument(),
|
||||
logicalDocument({
|
||||
id: 'document-2',
|
||||
userMetadata: {
|
||||
priority: 7,
|
||||
reviewed_at: '2026-08-04T10:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(await screen.findByRole('option', { name: /priority/ }))
|
||||
expect(screen.getByLabelText('priority')).toHaveValue(null)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(await screen.findByRole('option', { name: /reviewed_at/ }))
|
||||
expect(screen.getByLabelText('reviewed_at')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('preserves a time field editor when this document has an empty value', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentQuery.data = logicalDocument({ userMetadata: { reviewed_at: '' } })
|
||||
metadataDocumentsQuery.data = [
|
||||
logicalDocument({ userMetadata: { reviewed_at: '' } }),
|
||||
logicalDocument({
|
||||
id: 'document-2',
|
||||
userMetadata: { reviewed_at: '2026-08-04T10:00:00.000Z' },
|
||||
}),
|
||||
]
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(screen.getByLabelText('reviewed_at')).toHaveAttribute('type', 'datetime-local')
|
||||
})
|
||||
|
||||
it('keeps the edit action busy while resolving metadata types', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveMetadataRefetch!: (value: { data: LogicalDocument[] }) => void
|
||||
documentQuery.data = logicalDocument({ userMetadata: { category: '' } })
|
||||
metadataDocumentsQuery.data = undefined
|
||||
metadataDocumentsQuery.refetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveMetadataRefetch = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
const editButton = screen.getByRole('button', { name: 'common.operation.edit' })
|
||||
await user.click(editButton)
|
||||
|
||||
expect(editButton).toHaveAttribute('aria-disabled', 'true')
|
||||
await user.click(editButton)
|
||||
expect(metadataDocumentsQuery.refetch).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
resolveMetadataRefetch({ data: [logicalDocument({ userMetadata: { category: '' } })] })
|
||||
})
|
||||
expect(await screen.findByLabelText('category')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('converts UTC metadata timestamps to local datetime input values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const getTimezoneOffset = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-480)
|
||||
documentQuery.data = logicalDocument({
|
||||
userMetadata: { reviewed_at: '2026-08-04T10:00:00.000Z' },
|
||||
})
|
||||
metadataDocumentsQuery.data = [documentQuery.data]
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(screen.getByLabelText('reviewed_at')).toHaveValue('2026-08-04T18:00')
|
||||
getTimezoneOffset.mockRestore()
|
||||
})
|
||||
|
||||
it('creates a reusable metadata field across KnowledgeFS documents', async () => {
|
||||
const user = userEvent.setup()
|
||||
const secondDocument = logicalDocument({ id: 'document-2', rowVersion: 4 })
|
||||
listLogicalDocuments.mockResolvedValue({
|
||||
data: [
|
||||
logicalDocumentApiResponse(logicalDocument()),
|
||||
logicalDocumentApiResponse(secondDocument),
|
||||
],
|
||||
next_cursor: null,
|
||||
})
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'number' }))
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }),
|
||||
'priority',
|
||||
)
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(listLogicalDocuments).toHaveBeenCalledWith({
|
||||
params: { control_space_id: 'space-1' },
|
||||
query: {},
|
||||
})
|
||||
expect(patchDocumentMetadata).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 2, patch: { priority: 0 } },
|
||||
params: { control_space_id: 'space-1', document_id: 'document-1' },
|
||||
})
|
||||
expect(patchDocumentMetadata).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 4, patch: { priority: 0 } },
|
||||
params: { control_space_id: 'space-1', document_id: 'document-2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a newly created field in the current document draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const currentDocument = logicalDocument()
|
||||
const secondDocument = logicalDocument({ id: 'document-2', rowVersion: 4 })
|
||||
const documents = [currentDocument, secondDocument]
|
||||
documentQuery.data = currentDocument
|
||||
metadataDocumentsQuery.data = documents
|
||||
listLogicalDocuments.mockImplementation(async () => ({
|
||||
data: documents.map(logicalDocumentApiResponse),
|
||||
next_cursor: null,
|
||||
}))
|
||||
patchDocumentMetadata.mockImplementation(
|
||||
async ({
|
||||
body,
|
||||
params,
|
||||
}: {
|
||||
body: { patch: Record<string, unknown> }
|
||||
params: { document_id: string }
|
||||
}) => {
|
||||
const index = documents.findIndex((candidate) => candidate.id === params.document_id)
|
||||
if (index < 0) throw new Error(`Unknown document ${params.document_id}`)
|
||||
const candidate = documents[index]
|
||||
if (!candidate) throw new Error(`Unknown document ${params.document_id}`)
|
||||
const updated = logicalDocument({
|
||||
...candidate,
|
||||
id: candidate.id,
|
||||
rowVersion: candidate.rowVersion + 1,
|
||||
userMetadata: { ...candidate.userMetadata, ...body.patch },
|
||||
})
|
||||
documents[index] = updated
|
||||
if (updated.id === currentDocument.id) documentQuery.data = updated
|
||||
return logicalDocumentApiResponse(updated)
|
||||
},
|
||||
)
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'number' }))
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }),
|
||||
'priority',
|
||||
)
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(screen.getByLabelText('priority')).toHaveValue(0))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(patchDocumentMetadata).toHaveBeenCalledTimes(2)
|
||||
expect(patchDocumentMetadata).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: expect.objectContaining({ patch: { priority: null } }) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps metadata creation unavailable while the full field list is loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
metadataDocumentsQuery.isPending = true
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps the metadata create form open when creation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
patchDocumentMetadata.mockRejectedValueOnce(new Error('metadata update failed'))
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
|
||||
)
|
||||
const nameInput = screen.getByRole('textbox', {
|
||||
name: 'dataset.metadata.createMetadata.name',
|
||||
})
|
||||
const createDialog = nameInput.closest<HTMLElement>('[role="dialog"]')!
|
||||
await user.type(nameInput, 'category')
|
||||
await user.click(within(createDialog).getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(nameInput).toHaveValue('category')
|
||||
expect(nameInput).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(toastState.error).toHaveBeenCalledWith('dataset.newKnowledge.settings.saveFailed'),
|
||||
)
|
||||
})
|
||||
|
||||
it('validates a new metadata name before submitting it', async () => {
|
||||
const user = userEvent.setup()
|
||||
metadataDocumentsQuery.data = [
|
||||
logicalDocument({ id: 'document-2', userMetadata: { existing_field: '' } }),
|
||||
]
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const nameInput = within(dialog).getByRole('textbox', {
|
||||
name: 'dataset.metadata.createMetadata.name',
|
||||
})
|
||||
const save = within(dialog).getByRole('button', { name: 'common.operation.save' })
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.type(nameInput, 'Priority')
|
||||
expect(nameInput).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.invalid',
|
||||
)
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'existing_field')
|
||||
expect(nameInput).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.duplicate',
|
||||
)
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'a'.repeat(256))
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.tooLong',
|
||||
)
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'sourceName')
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.invalid',
|
||||
)
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'priority_1')
|
||||
expect(nameInput).not.toHaveAttribute('aria-invalid')
|
||||
expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(save).toBeEnabled()
|
||||
})
|
||||
|
||||
it('opens the New RAG metadata manager from the document picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.documentMetadata.startLabeling',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }),
|
||||
)
|
||||
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/documents?metadata=1')
|
||||
})
|
||||
|
||||
it('supports tree keyboard navigation, collapse, and selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
chunksQuery.data = {
|
||||
|
||||
@ -63,6 +63,14 @@ const retryMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
|
||||
const reindexMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
|
||||
const removeDocumentMutation = vi.hoisted(() => vi.fn())
|
||||
const renameDocumentMutation = vi.hoisted(() => vi.fn())
|
||||
const listLogicalDocuments = vi.hoisted(() => vi.fn())
|
||||
const metadataDocumentsQuery = vi.hoisted(() => ({
|
||||
data: undefined as LogicalDocument[] | undefined,
|
||||
error: null as unknown,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}))
|
||||
const updateSourceMutation = vi.hoisted(() => vi.fn())
|
||||
const uploadMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
|
||||
const bulkUploadMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
|
||||
@ -344,15 +352,25 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
if (options.mutationKind === 'bulk-upload') return bulkUploadMutation
|
||||
return uploadMutation
|
||||
},
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
configuration_state: settingsState.configurationState,
|
||||
embedding: null,
|
||||
retrieval: null,
|
||||
revision: 1,
|
||||
},
|
||||
refetch: settingsState.refetch,
|
||||
}),
|
||||
useQuery: (options: { queryKey?: readonly unknown[] }) => {
|
||||
if (options.queryKey?.includes('document-metadata-documents'))
|
||||
return {
|
||||
...metadataDocumentsQuery,
|
||||
data:
|
||||
metadataDocumentsQuery.data ??
|
||||
documentsQuery.data?.pages.flatMap((page) => page.items) ??
|
||||
[],
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
configuration_state: settingsState.configurationState,
|
||||
embedding: null,
|
||||
retrieval: null,
|
||||
revision: 1,
|
||||
},
|
||||
refetch: settingsState.refetch,
|
||||
}
|
||||
},
|
||||
useQueryClient: () => queryClient,
|
||||
}
|
||||
})
|
||||
@ -404,6 +422,7 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
},
|
||||
logicalDocuments: {
|
||||
get: listLogicalDocuments,
|
||||
byDocumentId: {
|
||||
delete: removeDocumentMutation,
|
||||
},
|
||||
@ -618,6 +637,11 @@ describe('DocumentsPage', () => {
|
||||
documentsQuery.isPending = false
|
||||
documentsQuery.isRefetching = false
|
||||
documentsQuery.refetch.mockResolvedValue({ error: null })
|
||||
metadataDocumentsQuery.data = undefined
|
||||
metadataDocumentsQuery.error = null
|
||||
metadataDocumentsQuery.isFetching = false
|
||||
metadataDocumentsQuery.isPending = false
|
||||
metadataDocumentsQuery.refetch.mockResolvedValue({ error: null })
|
||||
tasksQuery.data = { pages: [{ items: [] }] }
|
||||
tasksQuery.dataUpdatedAt = 0
|
||||
tasksQuery.dataUpdateCount = 0
|
||||
@ -682,15 +706,21 @@ describe('DocumentsPage', () => {
|
||||
job: { id: 'delete-1', state: 'accepted' },
|
||||
status_url: '/delete-1',
|
||||
})
|
||||
renameDocumentMutation.mockImplementation(async ({ body }: { body: { patch: unknown } }) =>
|
||||
documentApiResponse(
|
||||
document({
|
||||
rowVersion: 2,
|
||||
userMetadata: {
|
||||
displayName: String((body.patch as { displayName: string }).displayName),
|
||||
},
|
||||
}),
|
||||
listLogicalDocuments.mockImplementation(async () => ({
|
||||
data: (documentsQuery.data?.pages.flatMap((page) => page.items) ?? []).map(
|
||||
documentApiResponse,
|
||||
),
|
||||
next_cursor: null,
|
||||
}))
|
||||
renameDocumentMutation.mockImplementation(
|
||||
async ({ body }: { body: { patch: Record<string, unknown> } }) => {
|
||||
const userMetadata = { ...document().userMetadata }
|
||||
for (const [name, value] of Object.entries(body.patch)) {
|
||||
if (value === null) delete userMetadata[name]
|
||||
else userMetadata[name] = value
|
||||
}
|
||||
return documentApiResponse(document({ rowVersion: 2, userMetadata }))
|
||||
},
|
||||
)
|
||||
updateSourceMutation.mockImplementation(
|
||||
async ({ body }: { body: { status: Source['status'] } }) =>
|
||||
@ -783,7 +813,26 @@ describe('DocumentsPage', () => {
|
||||
const metadata = screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })
|
||||
expect(metadata).toBeEnabled()
|
||||
await user.click(metadata)
|
||||
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'dataset.metadata.metadata' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'dataset.metadata.datasetMetadata.addMetaData',
|
||||
}),
|
||||
).toBeEnabled()
|
||||
expect(screen.queryByText('sourceName')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('document_name')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('uploader')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('upload_date')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('last_update_date')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('source')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'dataset.metadata.metadata' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
const rowActions = screen.getByRole('button', {
|
||||
name: /dataset\.newKnowledge\.documentActions/,
|
||||
})
|
||||
@ -808,6 +857,378 @@ describe('DocumentsPage', () => {
|
||||
expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates metadata through the KnowledgeFS document metadata endpoint', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }],
|
||||
}
|
||||
listLogicalDocuments
|
||||
.mockResolvedValueOnce({
|
||||
data: [documentApiResponse(document({ id: 'one', title: 'One.pdf' }))],
|
||||
next_cursor: 'next-page',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
documentApiResponse(
|
||||
document({ id: 'two', rowVersion: 4, title: 'Two.pdf', userMetadata: {} }),
|
||||
),
|
||||
],
|
||||
next_cursor: null,
|
||||
})
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'dataset.metadata.datasetMetadata.addMetaData',
|
||||
}),
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }),
|
||||
'category',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(renameDocumentMutation).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 1, patch: { category: '' } },
|
||||
params: { control_space_id: 'space-1', document_id: 'one' },
|
||||
})
|
||||
expect(renameDocumentMutation).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 4, patch: { category: '' } },
|
||||
params: { control_space_id: 'space-1', document_id: 'two' },
|
||||
})
|
||||
expect(listLogicalDocuments).toHaveBeenNthCalledWith(1, {
|
||||
params: { control_space_id: 'space-1' },
|
||||
query: {},
|
||||
})
|
||||
expect(listLogicalDocuments).toHaveBeenNthCalledWith(2, {
|
||||
params: { control_space_id: 'space-1' },
|
||||
query: { cursor: 'next-page' },
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes a partially failed metadata creation without rewriting completed documents', async () => {
|
||||
const user = userEvent.setup()
|
||||
const currentDocuments = Array.from({ length: 6 }, (_, index) =>
|
||||
document({
|
||||
id: `document-${index + 1}`,
|
||||
rowVersion: index + 1,
|
||||
title: `Document ${index + 1}.pdf`,
|
||||
userMetadata: {},
|
||||
}),
|
||||
)
|
||||
documentsQuery.data = { pages: [{ items: currentDocuments }] }
|
||||
listLogicalDocuments.mockImplementation(async () => ({
|
||||
data: currentDocuments.map(documentApiResponse),
|
||||
next_cursor: null,
|
||||
}))
|
||||
let rejectSecondDocument = true
|
||||
renameDocumentMutation.mockImplementation(
|
||||
async ({
|
||||
body,
|
||||
params,
|
||||
}: {
|
||||
body: { patch: Record<string, unknown> }
|
||||
params: { document_id: string }
|
||||
}) => {
|
||||
if (params.document_id === 'document-2' && rejectSecondDocument) {
|
||||
rejectSecondDocument = false
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const index = currentDocuments.findIndex((candidate) => candidate.id === params.document_id)
|
||||
if (index < 0) throw new Error(`Unknown document ${params.document_id}`)
|
||||
const candidate = currentDocuments[index]
|
||||
if (!candidate) throw new Error(`Unknown document ${params.document_id}`)
|
||||
const updatedMetadata = { ...candidate.userMetadata }
|
||||
for (const [name, value] of Object.entries(body.patch)) {
|
||||
if (value === null) delete updatedMetadata[name]
|
||||
else updatedMetadata[name] = value
|
||||
}
|
||||
const updated = document({
|
||||
...candidate,
|
||||
id: candidate.id,
|
||||
rowVersion: candidate.rowVersion + 1,
|
||||
userMetadata: updatedMetadata,
|
||||
})
|
||||
currentDocuments[index] = updated
|
||||
return documentApiResponse(updated)
|
||||
},
|
||||
)
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'dataset.metadata.datasetMetadata.addMetaData',
|
||||
}),
|
||||
)
|
||||
const nameInput = screen.getByRole('textbox', {
|
||||
name: 'dataset.metadata.createMetadata.name',
|
||||
})
|
||||
await user.type(nameInput, 'category')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => expect(toastMock.error).toHaveBeenCalled())
|
||||
expect(nameInput).toBeInTheDocument()
|
||||
expect(renameDocumentMutation).toHaveBeenCalledTimes(6)
|
||||
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => expect(nameInput).not.toBeInTheDocument())
|
||||
expect(renameDocumentMutation).toHaveBeenCalledTimes(7)
|
||||
expect(
|
||||
renameDocumentMutation.mock.calls.filter(
|
||||
([request]) => request.params.document_id === 'document-1',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
renameDocumentMutation.mock.calls.filter(
|
||||
([request]) => request.params.document_id === 'document-2',
|
||||
),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('disables metadata creation until the full document metadata query completes', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }],
|
||||
}
|
||||
metadataDocumentsQuery.isPending = true
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'dataset.metadata.datasetMetadata.addMetaData',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('lets users retry when the metadata document query fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }],
|
||||
}
|
||||
metadataDocumentsQuery.error = new Error('metadata query failed')
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
expect(
|
||||
await screen.findByText('dataset.newKnowledge.documentLoadErrorDescription'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
expect(metadataDocumentsQuery.refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('validates a metadata name in the metadata drawer before submitting it', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
document({
|
||||
id: 'one',
|
||||
title: 'One.pdf',
|
||||
userMetadata: { existing_field: 'support' },
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'dataset.metadata.datasetMetadata.addMetaData',
|
||||
}),
|
||||
)
|
||||
const nameInput = screen.getByRole('textbox', {
|
||||
name: 'dataset.metadata.createMetadata.name',
|
||||
})
|
||||
const createDialog = nameInput.closest<HTMLElement>('[role="dialog"]')!
|
||||
const save = within(createDialog).getByRole('button', { name: 'common.operation.save' })
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.type(nameInput, '11')
|
||||
expect(nameInput).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(within(createDialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.invalid',
|
||||
)
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'existing_field')
|
||||
expect(within(createDialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.duplicate',
|
||||
)
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'a'.repeat(256))
|
||||
expect(within(createDialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.tooLong',
|
||||
)
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'displayName')
|
||||
expect(within(createDialog).getByRole('alert')).toHaveTextContent(
|
||||
'dataset.metadata.checkName.invalid',
|
||||
)
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'priority_1')
|
||||
expect(nameInput).not.toHaveAttribute('aria-invalid')
|
||||
expect(within(createDialog).queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(save).toBeEnabled()
|
||||
})
|
||||
|
||||
it('renames metadata through the KnowledgeFS document metadata endpoint', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
document({
|
||||
id: 'one',
|
||||
title: 'One.pdf',
|
||||
userMetadata: { category: 'support', sourceName: 'Notion support SOP' },
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
const nameInput = screen.getByRole('textbox', {
|
||||
name: 'dataset.metadata.datasetMetadata.name',
|
||||
})
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'topic')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(renameDocumentMutation).toHaveBeenCalledWith({
|
||||
body: {
|
||||
expectedRowVersion: 1,
|
||||
patch: { category: null, topic: 'support' },
|
||||
},
|
||||
params: { control_space_id: 'space-1', document_id: 'one' },
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes a partially failed metadata rename from the open dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const currentDocuments = [
|
||||
document({ id: 'one', userMetadata: { category: 'support' } }),
|
||||
document({ id: 'two', rowVersion: 2, userMetadata: { category: 'sales' } }),
|
||||
]
|
||||
documentsQuery.data = { pages: [{ items: currentDocuments }] }
|
||||
listLogicalDocuments.mockImplementation(async () => ({
|
||||
data: currentDocuments.map(documentApiResponse),
|
||||
next_cursor: null,
|
||||
}))
|
||||
let rejectSecondDocument = true
|
||||
renameDocumentMutation.mockImplementation(
|
||||
async ({
|
||||
body,
|
||||
params,
|
||||
}: {
|
||||
body: { patch: Record<string, unknown> }
|
||||
params: { document_id: string }
|
||||
}) => {
|
||||
if (params.document_id === 'two' && rejectSecondDocument) {
|
||||
rejectSecondDocument = false
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const index = currentDocuments.findIndex((candidate) => candidate.id === params.document_id)
|
||||
if (index < 0) throw new Error(`Unknown document ${params.document_id}`)
|
||||
const candidate = currentDocuments[index]
|
||||
if (!candidate) throw new Error(`Unknown document ${params.document_id}`)
|
||||
const updatedMetadata = { ...candidate.userMetadata }
|
||||
for (const [name, value] of Object.entries(body.patch)) {
|
||||
if (value === null) delete updatedMetadata[name]
|
||||
else updatedMetadata[name] = value
|
||||
}
|
||||
const updated = document({
|
||||
...candidate,
|
||||
id: candidate.id,
|
||||
rowVersion: candidate.rowVersion + 1,
|
||||
userMetadata: updatedMetadata,
|
||||
})
|
||||
currentDocuments[index] = updated
|
||||
return documentApiResponse(updated)
|
||||
},
|
||||
)
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
const nameInput = screen.getByRole('textbox', {
|
||||
name: 'dataset.metadata.datasetMetadata.name',
|
||||
})
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'topic')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => expect(toastMock.error).toHaveBeenCalled())
|
||||
expect(nameInput).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => expect(nameInput).not.toBeInTheDocument())
|
||||
expect(renameDocumentMutation).toHaveBeenCalledTimes(3)
|
||||
expect(
|
||||
renameDocumentMutation.mock.calls.filter(([request]) => request.params.document_id === 'one'),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
renameDocumentMutation.mock.calls.filter(([request]) => request.params.document_id === 'two'),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deletes metadata through the KnowledgeFS document metadata endpoint', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
document({
|
||||
id: 'one',
|
||||
title: 'One.pdf',
|
||||
userMetadata: { category: 'support', sourceName: 'Notion support SOP' },
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
render(<DocumentsPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.remove' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(renameDocumentMutation).toHaveBeenCalledWith({
|
||||
body: { expectedRowVersion: 1, patch: { category: null } },
|
||||
params: { control_space_id: 'space-1', document_id: 'one' },
|
||||
})
|
||||
})
|
||||
|
||||
it('starts re-indexing from a document row action', async () => {
|
||||
const user = userEvent.setup()
|
||||
documentsQuery.data = {
|
||||
|
||||
@ -10,6 +10,7 @@ import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { chunkCharacterCount, chunkContentParts } from './document-detail-model'
|
||||
import { DocumentMetadataCard } from './document-metadata-card'
|
||||
|
||||
function formatBytes(bytes: number, locale: string) {
|
||||
const numberFormat = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })
|
||||
@ -52,6 +53,8 @@ function ChunkMarker({ label }: { label: string }) {
|
||||
}
|
||||
|
||||
export function DocumentChunkDetail({
|
||||
canEdit,
|
||||
controlSpaceId,
|
||||
document,
|
||||
chunks,
|
||||
chunksComplete,
|
||||
@ -60,6 +63,8 @@ export function DocumentChunkDetail({
|
||||
revision,
|
||||
selectedChunkId,
|
||||
}: {
|
||||
canEdit: boolean
|
||||
controlSpaceId: string
|
||||
document: LogicalDocument
|
||||
chunks: DocumentRevisionChunk[]
|
||||
chunksComplete: boolean
|
||||
@ -180,20 +185,12 @@ export function DocumentChunkDetail({
|
||||
</article>
|
||||
|
||||
<aside className="min-w-0 space-y-6 xl:pt-3 xl:pl-6">
|
||||
<section className="flex flex-col items-start gap-2.5 rounded-xl bg-background-default-subtle p-4">
|
||||
<h2 className="system-sm-semibold text-text-primary">
|
||||
{t(($) => $['metadata.metadata'])}
|
||||
</h2>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.documentOverviewDescription'])}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => toast.info(t(($) => $['newKnowledge.filtersUnavailable']))}
|
||||
variant="primary"
|
||||
>
|
||||
{t(($) => $['metadata.documentMetadata.startLabeling'])}
|
||||
</Button>
|
||||
</section>
|
||||
<DocumentMetadataCard
|
||||
canEdit={canEdit}
|
||||
controlSpaceId={controlSpaceId}
|
||||
document={document}
|
||||
locale={locale}
|
||||
/>
|
||||
<section>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
|
||||
@ -351,6 +351,7 @@ export function DocumentDetailPage({
|
||||
|
||||
<DocumentRevisionContent
|
||||
key={effectiveRevision ?? 'missing'}
|
||||
canEdit={canEdit}
|
||||
document={document}
|
||||
documentId={documentId}
|
||||
effectiveRevision={effectiveRevision}
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
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'
|
||||
@ -332,6 +331,7 @@ export function DocumentsList({
|
||||
onAddDocument,
|
||||
onFilterChange,
|
||||
onLoadMore,
|
||||
onOpenMetadata,
|
||||
onOpenTasks,
|
||||
onRemoveDocument,
|
||||
onRenameDocument,
|
||||
@ -377,6 +377,7 @@ export function DocumentsList({
|
||||
onAddDocument: () => void
|
||||
onFilterChange: (filter: DocumentFilter) => void
|
||||
onLoadMore: () => void
|
||||
onOpenMetadata: () => void
|
||||
onOpenTasks: () => void
|
||||
onRemoveDocument: (documentId: string) => Promise<boolean>
|
||||
onRenameDocument: (documentId: string, title: string) => Promise<boolean>
|
||||
@ -481,10 +482,7 @@ export function DocumentsList({
|
||||
tasksLiveStatus={tasksLiveStatus}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="gap-1 pl-3"
|
||||
onClick={() => toast.info(t(($) => $['newKnowledge.filtersUnavailable']))}
|
||||
>
|
||||
<Button className="gap-1 pl-3" onClick={onOpenMetadata}>
|
||||
<span aria-hidden className="i-ri-file-text-line size-4" />
|
||||
{t(($) => $['newKnowledge.metadata'])}
|
||||
</Button>
|
||||
|
||||
427
web/features/new-rag/document-metadata-card.tsx
Normal file
427
web/features/new-rag/document-metadata-card.tsx
Normal file
@ -0,0 +1,427 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { DocumentMetadataType } from './document-metadata-model'
|
||||
import type { LogicalDocument } from './document-models'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import {
|
||||
documentMetadataDefaultValue,
|
||||
documentMetadataDocumentsQueryOptions,
|
||||
documentMetadataFieldsFromDocuments,
|
||||
documentMetadataNameError,
|
||||
documentMetadataType,
|
||||
editableDocumentMetadataEntries,
|
||||
listAllLogicalDocuments,
|
||||
patchDocumentMetadataTargets,
|
||||
} from './document-metadata-model'
|
||||
import { DocumentMetadataPicker } from './document-metadata-picker'
|
||||
import { newKnowledgeDocumentsPath } from './routes'
|
||||
|
||||
type MetadataDraft = {
|
||||
id: string
|
||||
name: string
|
||||
type: DocumentMetadataType
|
||||
value: string
|
||||
}
|
||||
|
||||
function metadataValueForInput(value: unknown, type: DocumentMetadataType) {
|
||||
if (type === 'time' && typeof value === 'string') {
|
||||
const date = new Date(value)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
|
||||
return localDate.toISOString().slice(0, 16)
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number') return String(value)
|
||||
if (value === undefined || value === null) return ''
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function metadataValueFromInput(value: string, type: DocumentMetadataType) {
|
||||
if (!value) return ''
|
||||
if (type === 'number') return Number(value)
|
||||
if (type === 'time') {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function metadataDisplayValue(value: unknown, locale: string) {
|
||||
if (typeof value === 'string') {
|
||||
const type = documentMetadataType(value)
|
||||
if (type === 'time') {
|
||||
const date = new Date(value)
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
return value || '—'
|
||||
}
|
||||
if (typeof value === 'number') return new Intl.NumberFormat(locale).format(value)
|
||||
if (value === undefined || value === null) return '—'
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function metadataDrafts(
|
||||
document: LogicalDocument,
|
||||
fields: readonly { name: string; type: DocumentMetadataType }[],
|
||||
): MetadataDraft[] {
|
||||
const fieldTypes = new Map(fields.map((field) => [field.name, field.type]))
|
||||
return editableDocumentMetadataEntries(document.userMetadata)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, value]) => {
|
||||
const type = fieldTypes.get(name) ?? documentMetadataType(value)
|
||||
return {
|
||||
id: `field-${name}`,
|
||||
name,
|
||||
type,
|
||||
value: metadataValueForInput(value, type),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function DocumentMetadataCard({
|
||||
canEdit,
|
||||
controlSpaceId,
|
||||
document,
|
||||
locale,
|
||||
}: {
|
||||
canEdit: boolean
|
||||
controlSpaceId: string
|
||||
document: LogicalDocument
|
||||
locale: string
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const [drafts, setDrafts] = useState<MetadataDraft[]>([])
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [retryableCreateName, setRetryableCreateName] = useState<string>()
|
||||
const [editBaseline, setEditBaseline] = useState(() => ({
|
||||
metadata: document.userMetadata,
|
||||
rowVersion: document.rowVersion,
|
||||
}))
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
editableDocumentMetadataEntries(document.userMetadata).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
[document.userMetadata],
|
||||
)
|
||||
const metadataDocumentsQuery = useQuery({
|
||||
...documentMetadataDocumentsQueryOptions(controlSpaceId),
|
||||
enabled: editing,
|
||||
})
|
||||
const fields = useMemo(
|
||||
() => documentMetadataFieldsFromDocuments(metadataDocumentsQuery.data ?? [document]),
|
||||
[document, metadataDocumentsQuery.data],
|
||||
)
|
||||
const renderedItems = useMemo(
|
||||
() =>
|
||||
editing
|
||||
? drafts.map((draft) => ({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
type: draft.type,
|
||||
value: draft.value,
|
||||
}))
|
||||
: entries.map(([name, value]) => ({
|
||||
id: `field-${name}`,
|
||||
name,
|
||||
type: documentMetadataType(value),
|
||||
value,
|
||||
})),
|
||||
[drafts, editing, entries],
|
||||
)
|
||||
|
||||
const invalidateMetadataQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.logicalDocuments.byDocumentId.get.key(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.logicalDocuments.get.key(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: documentMetadataDocumentsQueryOptions(controlSpaceId).queryKey,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
const startEditing = async () => {
|
||||
if (!canEdit || preparing) return
|
||||
setPreparing(true)
|
||||
try {
|
||||
let availableFields = fields
|
||||
if (
|
||||
!metadataDocumentsQuery.data &&
|
||||
editableDocumentMetadataEntries(document.userMetadata).some(([, value]) => value === '')
|
||||
) {
|
||||
const result = await metadataDocumentsQuery.refetch()
|
||||
availableFields = documentMetadataFieldsFromDocuments(result.data ?? [document])
|
||||
}
|
||||
setEditBaseline({ metadata: document.userMetadata, rowVersion: document.rowVersion })
|
||||
setDrafts(metadataDrafts(document, availableFields))
|
||||
setEditing(true)
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setDrafts([])
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const createField = async (rawName: string, type: DocumentMetadataType) => {
|
||||
if (!canEdit || creating) return
|
||||
const name = rawName.trim()
|
||||
const nameError = documentMetadataNameError(name, fields, retryableCreateName)
|
||||
if (nameError) {
|
||||
toast.error(t(($) => $[`metadata.checkName.${nameError}`], { max: 255 }))
|
||||
throw new Error(`metadata name is ${nameError}`)
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
const listedDocuments = await listAllLogicalDocuments(controlSpaceId)
|
||||
const documents = listedDocuments.some((candidate) => candidate.id === document.id)
|
||||
? listedDocuments
|
||||
: [document, ...listedDocuments]
|
||||
const defaultValue = documentMetadataDefaultValue(type)
|
||||
const targets = documents.flatMap((candidate) =>
|
||||
name in candidate.userMetadata
|
||||
? []
|
||||
: [{ document: candidate, patch: { [name]: defaultValue } }],
|
||||
)
|
||||
const result = await patchDocumentMetadataTargets(controlSpaceId, targets)
|
||||
const failure = result.failures[0]
|
||||
if (failure) {
|
||||
setRetryableCreateName(name)
|
||||
throw failure.reason
|
||||
}
|
||||
const currentDocument =
|
||||
result.updatedDocuments.get(document.id) ??
|
||||
documents.find((candidate) => candidate.id === document.id) ??
|
||||
document
|
||||
setEditBaseline({
|
||||
metadata: { ...currentDocument.userMetadata, [name]: defaultValue },
|
||||
rowVersion: currentDocument.rowVersion,
|
||||
})
|
||||
setDrafts((current) => {
|
||||
if (current.some((draft) => draft.name === name)) return current
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: `field-${name}`,
|
||||
name,
|
||||
type,
|
||||
value: metadataValueForInput(defaultValue, type),
|
||||
},
|
||||
]
|
||||
})
|
||||
setRetryableCreateName(undefined)
|
||||
await invalidateMetadataQueries()
|
||||
toast.success(tCommon(($) => $['api.actionSuccess']))
|
||||
} catch (error) {
|
||||
toast.error(t(($) => $['newKnowledge.settings.saveFailed']))
|
||||
throw error
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!canEdit || saving) return
|
||||
const patch: Record<string, unknown> = {}
|
||||
const original = new Map(editableDocumentMetadataEntries(editBaseline.metadata))
|
||||
const nextNames = new Set(drafts.map((draft) => draft.name))
|
||||
for (const name of original.keys()) {
|
||||
if (!nextNames.has(name)) patch[name] = null
|
||||
}
|
||||
for (const draft of drafts) {
|
||||
const value = metadataValueFromInput(draft.value, draft.type)
|
||||
if (!original.has(draft.name) || !Object.is(original.get(draft.name), value))
|
||||
patch[draft.name] = value
|
||||
}
|
||||
|
||||
if (!Object.keys(patch).length) {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await consoleClient.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.patch({
|
||||
body: { expectedRowVersion: editBaseline.rowVersion, patch },
|
||||
params: {
|
||||
control_space_id: controlSpaceId,
|
||||
document_id: document.id,
|
||||
},
|
||||
})
|
||||
await invalidateMetadataQueries()
|
||||
cancelEditing()
|
||||
toast.success(tCommon(($) => $['api.actionSuccess']))
|
||||
} catch {
|
||||
toast.error(t(($) => $['newKnowledge.settings.saveFailed']))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing && !entries.length)
|
||||
return (
|
||||
<section className="rounded-xl bg-linear-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4 pt-3">
|
||||
<h2 className="text-xs/5 font-semibold text-text-secondary">
|
||||
{t(($) => $['metadata.metadata'])}
|
||||
</h2>
|
||||
<p className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['metadata.documentMetadata.metadataToolTip'])}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-2"
|
||||
disabled={!canEdit}
|
||||
loading={preparing}
|
||||
onClick={() => void startEditing()}
|
||||
variant="primary"
|
||||
>
|
||||
{t(($) => $['metadata.documentMetadata.startLabeling'])}
|
||||
<span aria-hidden className="ml-1 i-ri-arrow-right-line size-4" />
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="system-md-semibold text-text-secondary">
|
||||
{t(($) => $['metadata.metadata'])}
|
||||
</h2>
|
||||
{!editing && canEdit && (
|
||||
<Button
|
||||
loading={preparing}
|
||||
onClick={() => void startEditing()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
>
|
||||
<span aria-hidden className="mr-1 i-ri-edit-line size-3.5" />
|
||||
{tCommon(($) => $['operation.edit'])}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="mt-3">
|
||||
<DocumentMetadataPicker
|
||||
allowedExistingName={retryableCreateName}
|
||||
creating={creating}
|
||||
error={Boolean(metadataDocumentsQuery.error)}
|
||||
fields={fields}
|
||||
loading={metadataDocumentsQuery.isPending || metadataDocumentsQuery.isFetching}
|
||||
onCreate={createField}
|
||||
onManage={() => router.push(`${newKnowledgeDocumentsPath(controlSpaceId)}?metadata=1`)}
|
||||
onRetry={() => void metadataDocumentsQuery.refetch()}
|
||||
onSelect={(field) => {
|
||||
setDrafts((current) => {
|
||||
if (current.some((draft) => draft.name === field.name)) return current
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: `field-${field.name}`,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{drafts.length > 0 && <div className="my-3 h-px bg-divider-subtle" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mt-3 space-y-1">
|
||||
{renderedItems.map((item) => {
|
||||
return (
|
||||
<div key={item.id} className="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-2">
|
||||
<dt className="truncate system-xs-medium text-text-secondary" title={item.name}>
|
||||
{item.name}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Input
|
||||
aria-label={item.name}
|
||||
className="h-6 min-w-0 flex-1"
|
||||
type={
|
||||
item.type === 'number'
|
||||
? 'number'
|
||||
: item.type === 'time'
|
||||
? 'datetime-local'
|
||||
: 'text'
|
||||
}
|
||||
value={String(item.value)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setDrafts((current) =>
|
||||
current.map((draft) =>
|
||||
draft.id === item.id ? { ...draft, value: event.target.value } : draft,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${tCommon(($) => $['operation.remove'])} ${item.name}`}
|
||||
className="shrink-0 cursor-pointer rounded-md border-0 bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() =>
|
||||
setDrafts((current) => current.filter((draft) => draft.id !== item.id))
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 system-xs-regular wrap-break-word text-text-secondary">
|
||||
{metadataDisplayValue(item.value, locale)}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
|
||||
{editing && (
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button disabled={saving || creating} onClick={cancelEditing} size="small">
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={creating}
|
||||
loading={saving}
|
||||
onClick={() => void save()}
|
||||
size="small"
|
||||
variant="primary"
|
||||
>
|
||||
{tCommon(($) => $['operation.save'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
145
web/features/new-rag/document-metadata-create-form.tsx
Normal file
145
web/features/new-rag/document-metadata-create-form.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import type { FormEvent } from 'react'
|
||||
import type { DocumentMetadataField, DocumentMetadataType } from './document-metadata-model'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { documentMetadataNameError } from './document-metadata-model'
|
||||
|
||||
const metadataTypes: readonly DocumentMetadataType[] = ['string', 'number', 'time']
|
||||
|
||||
export function DocumentMetadataCreateForm({
|
||||
allowedExistingName,
|
||||
fields,
|
||||
pending,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
allowedExistingName?: string
|
||||
fields: readonly DocumentMetadataField[]
|
||||
pending: boolean
|
||||
onClose: () => void
|
||||
onCreate: (name: string, type: DocumentMetadataType) => Promise<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [name, setName] = useState('')
|
||||
const [nameTouched, setNameTouched] = useState(false)
|
||||
const [type, setType] = useState<DocumentMetadataType>('string')
|
||||
const nameErrorId = useId()
|
||||
const nameErrorKind = useMemo(
|
||||
() => documentMetadataNameError(name, fields, allowedExistingName),
|
||||
[allowedExistingName, fields, name],
|
||||
)
|
||||
const nameError = useMemo(() => {
|
||||
if (!nameErrorKind) return undefined
|
||||
return t(($) => $[`metadata.checkName.${nameErrorKind}`], { max: 255 })
|
||||
}, [nameErrorKind, t])
|
||||
|
||||
const close = () => {
|
||||
setName('')
|
||||
setNameTouched(false)
|
||||
setType('string')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
if (pending) return
|
||||
setNameTouched(true)
|
||||
if (nameError) return
|
||||
try {
|
||||
if (await onCreate(name.trim(), type)) close()
|
||||
} catch {
|
||||
// The workflow owner reports the error. Keep the form open so the user can retry.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="px-3 pt-3.5 pb-4"
|
||||
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
void create()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="relative -left-1 mb-1 flex cursor-pointer items-center gap-1 border-0 bg-transparent px-0 py-1 text-text-accent"
|
||||
onClick={close}
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-line size-4" />
|
||||
<span className="system-xs-semibold-uppercase">
|
||||
{t(($) => $['metadata.createMetadata.back'])}
|
||||
</span>
|
||||
</button>
|
||||
<h3 className="mb-1 flex h-6 items-center system-xl-semibold text-text-primary">
|
||||
{t(($) => $['metadata.createMetadata.title'])}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<fieldset>
|
||||
<legend className="py-1 system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['metadata.createMetadata.type'])}
|
||||
</legend>
|
||||
<div className="mt-1 grid grid-cols-3 gap-2">
|
||||
{metadataTypes.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
aria-pressed={type === candidate}
|
||||
className={cn(
|
||||
'h-8 cursor-pointer rounded-md border px-2 system-sm-regular text-text-secondary capitalize focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
|
||||
type === candidate
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
)}
|
||||
onClick={() => setType(candidate)}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label className="block">
|
||||
<span className="block py-1 system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['metadata.createMetadata.name'])}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
aria-label={t(($) => $['metadata.createMetadata.name'])}
|
||||
aria-describedby={nameTouched && nameError ? nameErrorId : undefined}
|
||||
aria-invalid={nameTouched && Boolean(nameError) ? true : undefined}
|
||||
disabled={pending}
|
||||
onBlur={() => setNameTouched(true)}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value)
|
||||
setNameTouched(true)
|
||||
}}
|
||||
placeholder={t(($) => $['metadata.createMetadata.namePlaceholder'])}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
{nameTouched && nameError && (
|
||||
<span
|
||||
id={nameErrorId}
|
||||
role="alert"
|
||||
className="mt-1 block system-xs-regular text-text-destructive"
|
||||
>
|
||||
{nameError}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button className="mr-2" disabled={pending} onClick={close}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</Button>
|
||||
<Button disabled={Boolean(nameError)} loading={pending} type="submit" variant="primary">
|
||||
{tCommon(($) => $['operation.save'])}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
416
web/features/new-rag/document-metadata-drawer.tsx
Normal file
416
web/features/new-rag/document-metadata-drawer.tsx
Normal file
@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DocumentMetadataField, DocumentMetadataType } from './document-metadata-model'
|
||||
import type { LogicalDocument } from './document-models'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useHover } from 'ahooks'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DocumentMetadataCreateForm } from './document-metadata-create-form'
|
||||
import {
|
||||
documentMetadataDefaultValue,
|
||||
documentMetadataDocumentsQueryOptions,
|
||||
documentMetadataFieldsFromDocuments,
|
||||
documentMetadataNameError,
|
||||
listAllLogicalDocuments,
|
||||
patchDocumentMetadataTargets,
|
||||
} from './document-metadata-model'
|
||||
|
||||
const metadataTypeIconClass: Record<DocumentMetadataType, string> = {
|
||||
number: 'i-ri-hashtag',
|
||||
string: 'i-ri-text-snippet',
|
||||
time: 'i-ri-time-line',
|
||||
}
|
||||
|
||||
function Field({ children, label }: { children: ReactNode; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="py-1 system-sm-semibold text-text-secondary">{label}</div>
|
||||
<div className="mt-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateMetadataPopover({
|
||||
allowedExistingName,
|
||||
disabled,
|
||||
fields,
|
||||
pending,
|
||||
onCreate,
|
||||
}: {
|
||||
allowedExistingName?: string
|
||||
disabled: boolean
|
||||
fields: DocumentMetadataField[]
|
||||
pending: boolean
|
||||
onCreate: (name: string, type: DocumentMetadataType) => Promise<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="primary" className="mt-3" disabled={disabled || pending}>
|
||||
<span aria-hidden className="mr-1 i-ri-add-line size-4" />
|
||||
{t(($) => $['metadata.datasetMetadata.addMetaData'], { ns: 'dataset' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="left-start"
|
||||
sideOffset={20}
|
||||
alignOffset={-38}
|
||||
popupClassName="w-[320px]"
|
||||
>
|
||||
<DocumentMetadataCreateForm
|
||||
allowedExistingName={allowedExistingName}
|
||||
fields={fields}
|
||||
pending={pending}
|
||||
onClose={() => setOpen(false)}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataItem({
|
||||
busy,
|
||||
canEdit,
|
||||
field,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: {
|
||||
busy: boolean
|
||||
canEdit: boolean
|
||||
field: DocumentMetadataField
|
||||
onDelete: (field: DocumentMetadataField) => Promise<boolean>
|
||||
onRename: (field: DocumentMetadataField, name: string) => Promise<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [name, setName] = useState(field.name)
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const isDeleteHovering = useHover(deleteButtonRef)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
canEdit && 'hover:shadow-xs',
|
||||
'rounded-md border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg',
|
||||
isDeleteHovering && 'border border-state-destructive-border bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 items-center justify-between px-2">
|
||||
<div className="flex h-full min-w-0 items-center space-x-1 text-text-tertiary">
|
||||
<span
|
||||
className={cn(metadataTypeIconClass[field.type], 'size-4 shrink-0')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="max-w-62.5 truncate system-sm-medium text-text-primary">{field.name}</div>
|
||||
<div className="shrink-0 system-xs-regular">{field.type}</div>
|
||||
</div>
|
||||
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['metadata.datasetMetadata.values'], {
|
||||
ns: 'dataset',
|
||||
num: field.count,
|
||||
})}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="ml-2 flex shrink-0 items-center space-x-1 text-text-tertiary">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
|
||||
className="cursor-pointer rounded-md border-none bg-transparent p-0.5 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
setName(field.name)
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-edit-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={deleteButtonRef}
|
||||
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
|
||||
className="cursor-pointer rounded-md border-none bg-transparent p-0.5 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<span className="i-ri-delete-bin-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||
<DialogContent className="overflow-hidden! border-none text-left align-middle">
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (!name.trim() || busy) return
|
||||
if (await onRename(field, name.trim())) setRenameOpen(false)
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['metadata.datasetMetadata.rename'], { ns: 'dataset' })}
|
||||
</DialogTitle>
|
||||
<div className="mt-4">
|
||||
<Field label={t(($) => $['metadata.datasetMetadata.name'], { ns: 'dataset' })}>
|
||||
<Input
|
||||
aria-label={t(($) => $['metadata.datasetMetadata.name'], { ns: 'dataset' })}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={t(($) => $['metadata.datasetMetadata.namePlaceholder'], {
|
||||
ns: 'dataset',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button className="mr-2" onClick={() => setRenameOpen(false)}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button disabled={!name.trim()} loading={busy} type="submit" variant="primary">
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['metadata.datasetMetadata.deleteTitle'], { ns: 'dataset' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $['metadata.datasetMetadata.deleteContent'], {
|
||||
ns: 'dataset',
|
||||
name: field.name,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (await onDelete(field)) setDeleteOpen(false)
|
||||
}}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentMetadataDrawer({
|
||||
documents,
|
||||
knowledgeSpaceId,
|
||||
onOpenChange,
|
||||
open,
|
||||
readOnly,
|
||||
}: {
|
||||
documents: LogicalDocument[]
|
||||
knowledgeSpaceId: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
readOnly: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [retryableCreateName, setRetryableCreateName] = useState<string>()
|
||||
const [retryableRename, setRetryableRename] = useState<{
|
||||
sourceName: string
|
||||
targetName: string
|
||||
}>()
|
||||
const metadataDocumentsQuery = useQuery({
|
||||
...documentMetadataDocumentsQueryOptions(knowledgeSpaceId),
|
||||
enabled: open,
|
||||
})
|
||||
const metadataDocuments = metadataDocumentsQuery.data ?? documents
|
||||
const fields = useMemo(
|
||||
() => documentMetadataFieldsFromDocuments(metadataDocuments),
|
||||
[metadataDocuments],
|
||||
)
|
||||
|
||||
const nameErrorMessage = (error: ReturnType<typeof documentMetadataNameError>) => {
|
||||
if (!error) return undefined
|
||||
return t(($) => $[`metadata.checkName.${error}`], { max: 255, ns: 'dataset' })
|
||||
}
|
||||
|
||||
const mutateDocuments = async (
|
||||
patchForDocument: (document: LogicalDocument) => Record<string, unknown> | undefined,
|
||||
) => {
|
||||
if (readOnly || pending) return false
|
||||
setPending(true)
|
||||
try {
|
||||
const currentDocuments = await listAllLogicalDocuments(knowledgeSpaceId)
|
||||
const targets = currentDocuments.flatMap((document) => {
|
||||
const patch = patchForDocument(document)
|
||||
return patch ? [{ document, patch }] : []
|
||||
})
|
||||
const result = await patchDocumentMetadataTargets(knowledgeSpaceId, targets)
|
||||
const failure = result.failures[0]
|
||||
if (failure) throw failure.reason
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.logicalDocuments.get.key(),
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: documentMetadataDocumentsQueryOptions(knowledgeSpaceId).queryKey,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
toast.error(t(($) => $['newKnowledge.settings.saveFailed'], { ns: 'dataset' }))
|
||||
return false
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createMetadata = async (name: string, type: DocumentMetadataType) => {
|
||||
const error = nameErrorMessage(documentMetadataNameError(name, fields, retryableCreateName))
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return false
|
||||
}
|
||||
const defaultValue = documentMetadataDefaultValue(type)
|
||||
const success = await mutateDocuments((document) =>
|
||||
name in document.userMetadata ? undefined : { [name]: defaultValue },
|
||||
)
|
||||
setRetryableCreateName(success ? undefined : name)
|
||||
return success
|
||||
}
|
||||
|
||||
const renameMetadata = async (field: DocumentMetadataField, name: string) => {
|
||||
const retryingSameRename =
|
||||
retryableRename?.sourceName === field.name && retryableRename.targetName === name
|
||||
const error = nameErrorMessage(
|
||||
documentMetadataNameError(name, fields, retryingSameRename ? name : field.name),
|
||||
)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
return false
|
||||
}
|
||||
if (name === field.name) return true
|
||||
const success = await mutateDocuments((document) => {
|
||||
if (!(field.name in document.userMetadata)) return undefined
|
||||
return { [field.name]: null, [name]: document.userMetadata[field.name] }
|
||||
})
|
||||
setRetryableRename(success ? undefined : { sourceName: field.name, targetName: name })
|
||||
return success
|
||||
}
|
||||
|
||||
const deleteMetadata = (field: DocumentMetadataField) =>
|
||||
mutateDocuments((document) =>
|
||||
field.name in document.userMetadata ? { [field.name]: null } : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer open={open} modal swipeDirection="right" onOpenChange={onOpenChange}>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup className="data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-105">
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
|
||||
<div className="flex shrink-0 justify-between px-4 pt-6 pb-4">
|
||||
<DrawerTitle className="text-lg/6 font-medium text-text-primary">
|
||||
{t(($) => $['metadata.metadata'], { ns: 'dataset' })}
|
||||
</DrawerTitle>
|
||||
<DrawerCloseButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
className="size-6 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['metadata.datasetMetadata.description'], { ns: 'dataset' })}
|
||||
</div>
|
||||
<CreateMetadataPopover
|
||||
allowedExistingName={retryableCreateName}
|
||||
disabled={
|
||||
readOnly ||
|
||||
metadataDocuments.length === 0 ||
|
||||
metadataDocumentsQuery.isPending ||
|
||||
metadataDocumentsQuery.isFetching ||
|
||||
Boolean(metadataDocumentsQuery.error)
|
||||
}
|
||||
fields={fields}
|
||||
pending={pending}
|
||||
onCreate={createMetadata}
|
||||
/>
|
||||
|
||||
{metadataDocumentsQuery.error && !metadataDocumentsQuery.isFetching && (
|
||||
<div className="mt-3 flex items-center justify-between gap-2 rounded-lg bg-background-section-burn px-3 py-2">
|
||||
<span className="min-w-0 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.documentLoadErrorDescription'], {
|
||||
ns: 'dataset',
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => void metadataDocumentsQuery.refetch()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{fields.map((field) => (
|
||||
<MetadataItem
|
||||
key={field.name}
|
||||
busy={pending}
|
||||
canEdit={!readOnly}
|
||||
field={field}
|
||||
onDelete={deleteMetadata}
|
||||
onRename={renameMetadata}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
146
web/features/new-rag/document-metadata-model.ts
Normal file
146
web/features/new-rag/document-metadata-model.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import type { LogicalDocument } from './document-models'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { logicalDocumentFromApi, logicalDocumentListFromApi } from './document-models'
|
||||
|
||||
export type DocumentMetadataType = 'string' | 'number' | 'time'
|
||||
export type DocumentMetadataNameError = 'duplicate' | 'empty' | 'invalid' | 'tooLong'
|
||||
|
||||
export type DocumentMetadataField = {
|
||||
count: number
|
||||
name: string
|
||||
type: DocumentMetadataType
|
||||
}
|
||||
|
||||
const reservedDocumentMetadataNames = new Set(['displayName', 'retrievalCount', 'sourceName'])
|
||||
|
||||
export function isEditableDocumentMetadata(name: string) {
|
||||
return !reservedDocumentMetadataNames.has(name)
|
||||
}
|
||||
|
||||
export function documentMetadataNameError(
|
||||
name: string,
|
||||
fields: readonly DocumentMetadataField[],
|
||||
currentName?: string,
|
||||
): DocumentMetadataNameError | undefined {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) return 'empty'
|
||||
if (trimmedName.length > 255) return 'tooLong'
|
||||
if (reservedDocumentMetadataNames.has(trimmedName)) return 'invalid'
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(trimmedName)) return 'invalid'
|
||||
if (trimmedName !== currentName && fields.some((field) => field.name === trimmedName))
|
||||
return 'duplicate'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function documentMetadataType(value: unknown): DocumentMetadataType {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value))
|
||||
return 'time'
|
||||
return typeof value === 'number' ? 'number' : 'string'
|
||||
}
|
||||
|
||||
export function documentMetadataDefaultValue(type: DocumentMetadataType): string | number {
|
||||
if (type === 'number') return 0
|
||||
if (type === 'time') return new Date().toISOString()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function editableDocumentMetadataEntries(metadata: Record<string, unknown>) {
|
||||
return Object.entries(metadata).filter(([name]) => isEditableDocumentMetadata(name))
|
||||
}
|
||||
|
||||
export function documentMetadataFieldsFromDocuments(
|
||||
documents: LogicalDocument[],
|
||||
): DocumentMetadataField[] {
|
||||
const fields = new Map<
|
||||
string,
|
||||
DocumentMetadataField & {
|
||||
typeInferredFromEmptyValue: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [name, value] of editableDocumentMetadataEntries(document.userMetadata)) {
|
||||
if (value === null || value === undefined) continue
|
||||
const current = fields.get(name)
|
||||
const typeInferredFromEmptyValue = value === ''
|
||||
fields.set(name, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
name,
|
||||
type:
|
||||
current && !current.typeInferredFromEmptyValue
|
||||
? current.type
|
||||
: documentMetadataType(value),
|
||||
typeInferredFromEmptyValue:
|
||||
Boolean(current?.typeInferredFromEmptyValue ?? true) && typeInferredFromEmptyValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...fields.values()]
|
||||
.map(({ count, name, type }) => ({ count, name, type }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
export async function listAllLogicalDocuments(knowledgeSpaceId: string) {
|
||||
const documents: LogicalDocument[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const response = await consoleClient.knowledgeFs.spaces.byControlSpaceId.logicalDocuments.get({
|
||||
params: { control_space_id: knowledgeSpaceId },
|
||||
query: cursor ? { cursor } : {},
|
||||
})
|
||||
const page = logicalDocumentListFromApi(response)
|
||||
documents.push(...page.items)
|
||||
cursor = page.nextCursor
|
||||
} while (cursor)
|
||||
|
||||
return documents
|
||||
}
|
||||
|
||||
export type DocumentMetadataPatchTarget = {
|
||||
document: LogicalDocument
|
||||
patch: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function patchDocumentMetadataTargets(
|
||||
controlSpaceId: string,
|
||||
targets: DocumentMetadataPatchTarget[],
|
||||
) {
|
||||
const failures: Array<{ document: LogicalDocument; reason: unknown }> = []
|
||||
const updatedDocuments = new Map<string, LogicalDocument>()
|
||||
|
||||
for (let index = 0; index < targets.length; index += 5) {
|
||||
const batch = targets.slice(index, index + 5)
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(({ document, patch }) =>
|
||||
consoleClient.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.patch({
|
||||
body: { expectedRowVersion: document.rowVersion, patch },
|
||||
params: {
|
||||
control_space_id: controlSpaceId,
|
||||
document_id: document.id,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
results.forEach((result, resultIndex) => {
|
||||
const target = batch[resultIndex]
|
||||
if (!target) return
|
||||
const { document } = target
|
||||
if (result.status === 'fulfilled')
|
||||
updatedDocuments.set(document.id, logicalDocumentFromApi(result.value))
|
||||
else failures.push({ document, reason: result.reason })
|
||||
})
|
||||
}
|
||||
|
||||
return { failures, updatedDocuments }
|
||||
}
|
||||
|
||||
export function documentMetadataDocumentsQueryOptions(knowledgeSpaceId: string) {
|
||||
return {
|
||||
queryFn: () => listAllLogicalDocuments(knowledgeSpaceId),
|
||||
queryKey: ['new-rag', 'document-metadata-documents', knowledgeSpaceId] as const,
|
||||
staleTime: 30_000,
|
||||
}
|
||||
}
|
||||
205
web/features/new-rag/document-metadata-picker.tsx
Normal file
205
web/features/new-rag/document-metadata-picker.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { DocumentMetadataField, DocumentMetadataType } from './document-metadata-model'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxClear,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DocumentMetadataCreateForm } from './document-metadata-create-form'
|
||||
|
||||
const metadataTypeIcon = {
|
||||
number: 'i-ri-hashtag',
|
||||
string: 'i-ri-text-snippet',
|
||||
time: 'i-ri-time-line',
|
||||
} satisfies Record<DocumentMetadataType, string>
|
||||
|
||||
export function DocumentMetadataPicker({
|
||||
allowedExistingName,
|
||||
creating,
|
||||
error,
|
||||
fields,
|
||||
loading,
|
||||
onCreate,
|
||||
onManage,
|
||||
onRetry,
|
||||
onSelect,
|
||||
}: {
|
||||
allowedExistingName?: string
|
||||
creating: boolean
|
||||
error: boolean
|
||||
fields: DocumentMetadataField[]
|
||||
loading: boolean
|
||||
onCreate: (name: string, type: DocumentMetadataType) => Promise<void>
|
||||
onManage: () => void
|
||||
onRetry: () => void
|
||||
onSelect: (field: DocumentMetadataField) => void
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<'create' | 'select'>('select')
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const reset = () => {
|
||||
setView('select')
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-label={t(($) => $['metadata.addMetadata'])}
|
||||
className="flex h-6 w-full cursor-pointer items-center justify-center rounded-md border-0 bg-components-button-tertiary-bg px-2 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="mr-1 i-ri-add-line size-3.5" />
|
||||
<span className="truncate system-xs-medium">{t(($) => $['metadata.addMetadata'])}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
alignOffset={4}
|
||||
placement="left-start"
|
||||
popupClassName="w-[320px] bg-components-panel-bg-blur backdrop-blur-[5px]"
|
||||
sideOffset={-38}
|
||||
>
|
||||
{view === 'select' ? (
|
||||
<Combobox<DocumentMetadataField>
|
||||
filter={(field, input) => field.name.toLowerCase().includes(input.toLowerCase())}
|
||||
inputValue={query}
|
||||
isItemEqualToValue={(field, value) => field.name === value.name}
|
||||
items={fields}
|
||||
itemToStringLabel={(field) => field.name}
|
||||
itemToStringValue={(field) => field.name}
|
||||
onInputValueChange={(value, details: ComboboxChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press') setQuery(value)
|
||||
}}
|
||||
onValueChange={(field) => {
|
||||
if (!field) return
|
||||
onSelect(field)
|
||||
setOpen(false)
|
||||
reset()
|
||||
}}
|
||||
value={null}
|
||||
>
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup>
|
||||
<span aria-hidden className="ml-2 i-ri-search-line size-4 text-text-tertiary" />
|
||||
<ComboboxInput
|
||||
aria-label={t(($) => $['metadata.selectMetadata.search'])}
|
||||
className="pl-2"
|
||||
placeholder={t(($) => $['metadata.selectMetadata.search'])}
|
||||
/>
|
||||
{query && <ComboboxClear aria-label={tCommon(($) => $['operation.clear'])} />}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<ComboboxList<DocumentMetadataField>>
|
||||
{(field) => (
|
||||
<ComboboxItem key={field.name} value={field}>
|
||||
<ComboboxItemText className="flex min-w-0 items-center gap-1.5 px-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(metadataTypeIcon[field.type], 'size-3.5 shrink-0')}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate">{field.name}</span>
|
||||
</ComboboxItemText>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{field.type}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex h-20 items-center justify-center gap-2 system-xs-regular text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin" />
|
||||
{tCommon(($) => $.loading)}
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="flex h-20 flex-col items-center justify-center gap-1 px-3 text-center">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.documentLoadErrorDescription'])}
|
||||
</span>
|
||||
<Button onClick={onRetry} size="small" variant="ghost">
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && <ComboboxEmpty>{tCommon(($) => $.noData)}</ComboboxEmpty>}
|
||||
<ComboboxSeparator />
|
||||
<div className="flex items-center justify-between p-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || error}
|
||||
className="flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-lg border-0 bg-transparent px-2 text-text-secondary hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setView('create')
|
||||
setQuery('')
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-4 text-text-tertiary" />
|
||||
<span className="truncate system-sm-medium">
|
||||
{t(($) => $['metadata.selectMetadata.newAction'])}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex h-8 shrink-0 items-center">
|
||||
<div className="mx-1 h-3 w-px bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 cursor-pointer items-center gap-1 rounded-lg border-0 bg-transparent px-2 text-text-secondary hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
onManage()
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-medium">
|
||||
{t(($) => $['metadata.selectMetadata.manageAction'])}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-arrow-right-up-line size-4 text-text-tertiary"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Combobox>
|
||||
) : (
|
||||
<DocumentMetadataCreateForm
|
||||
allowedExistingName={allowedExistingName}
|
||||
fields={fields}
|
||||
pending={creating}
|
||||
onClose={reset}
|
||||
onCreate={async (name, type) => {
|
||||
await onCreate(name, type)
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@ -13,6 +13,7 @@ import { documentChunksQueryOptions } from './document-detail-queries'
|
||||
import { documentChunkListFromApi } from './document-models'
|
||||
|
||||
export function DocumentRevisionContent({
|
||||
canEdit,
|
||||
document,
|
||||
documentId,
|
||||
effectiveRevision,
|
||||
@ -23,6 +24,7 @@ export function DocumentRevisionContent({
|
||||
revisionHistoryPending,
|
||||
retryRevisionHistory,
|
||||
}: {
|
||||
canEdit: boolean
|
||||
document: LogicalDocument
|
||||
documentId: string
|
||||
effectiveRevision?: number
|
||||
@ -74,6 +76,7 @@ export function DocumentRevisionContent({
|
||||
|
||||
return (
|
||||
<LoadedDocumentRevisionContent
|
||||
canEdit={canEdit}
|
||||
document={document}
|
||||
documentId={documentId}
|
||||
effectiveRevision={effectiveRevision}
|
||||
@ -85,6 +88,7 @@ export function DocumentRevisionContent({
|
||||
}
|
||||
|
||||
function LoadedDocumentRevisionContent({
|
||||
canEdit,
|
||||
document,
|
||||
documentId,
|
||||
effectiveRevision,
|
||||
@ -92,6 +96,7 @@ function LoadedDocumentRevisionContent({
|
||||
locale,
|
||||
revision,
|
||||
}: {
|
||||
canEdit: boolean
|
||||
document: LogicalDocument
|
||||
documentId: string
|
||||
effectiveRevision: number
|
||||
@ -133,6 +138,8 @@ function LoadedDocumentRevisionContent({
|
||||
/>
|
||||
|
||||
<DocumentChunkDetail
|
||||
canEdit={canEdit}
|
||||
controlSpaceId={knowledgeSpaceId}
|
||||
chunks={chunks}
|
||||
chunksComplete={
|
||||
Boolean(chunksQuery.data) &&
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
DocumentsEmpty,
|
||||
DocumentsList,
|
||||
} from './document-list'
|
||||
import { DocumentMetadataDrawer } from './document-metadata-drawer'
|
||||
import {
|
||||
ACTIVE_TASK_STATES,
|
||||
documentDisplayStatus,
|
||||
@ -86,6 +87,9 @@ const documentSearchParser = parseAsString.withDefault('').withOptions({
|
||||
const documentUploadParser = parseAsStringLiteral(['1'] as const).withOptions({
|
||||
history: 'replace',
|
||||
})
|
||||
const documentMetadataParser = parseAsStringLiteral(['1'] as const).withOptions({
|
||||
history: 'replace',
|
||||
})
|
||||
|
||||
const uploadExclusionReasonKey = {
|
||||
batch_byte_limit_exceeded: 'batchLimit',
|
||||
@ -244,6 +248,14 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
const [filter, setFilter] = useQueryState('status', documentFilterParser)
|
||||
const [search, setSearch] = useQueryState('query', documentSearchParser)
|
||||
const [uploadRequest, setUploadRequest] = useQueryState('upload', documentUploadParser)
|
||||
const [metadataRequest, setMetadataRequest] = useQueryState('metadata', documentMetadataParser)
|
||||
const metadataOpen = metadataRequest === '1'
|
||||
const setMetadataOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
void setMetadataRequest(open ? '1' : null)
|
||||
},
|
||||
[setMetadataRequest],
|
||||
)
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(() => new Set())
|
||||
const [uploadFormInitialFiles, setUploadFormInitialFiles] = useState<File[]>([])
|
||||
const [isFileDragActive, setIsFileDragActive] = useState(false)
|
||||
@ -2472,6 +2484,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
onAddDocument={() => openUploadForm()}
|
||||
onFilterChange={setFilter}
|
||||
onLoadMore={loadMoreResults}
|
||||
onOpenMetadata={() => setMetadataOpen(true)}
|
||||
onOpenTasks={() => setTasksOpen(true)}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onRenameDocument={handleRenameDocument}
|
||||
@ -2567,6 +2580,13 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
|
||||
taskProgressStore={taskProgressStore}
|
||||
tasks={drawerTasks}
|
||||
/>
|
||||
<DocumentMetadataDrawer
|
||||
documents={documents}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
onOpenChange={setMetadataOpen}
|
||||
open={metadataOpen && !permissionDenied}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<KnowledgeModelSetupDialog
|
||||
open={modelSetupDialogOpen}
|
||||
onOpenChange={setModelSetupDialogOpen}
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "تعديل {{num}} مستندات",
|
||||
"metadata.batchEditMetadata.editMetadata": "تعديل البيانات الوصفية",
|
||||
"metadata.batchEditMetadata.multipleValue": "قيمة متعددة",
|
||||
"metadata.checkName.duplicate": "يوجد حقل بيانات وصفية بهذا الاسم بالفعل",
|
||||
"metadata.checkName.empty": "لا يمكن أن يكون اسم البيانات الوصفية فارغًا",
|
||||
"metadata.checkName.invalid": "يمكن أن يحتوي اسم البيانات الوصفية فقط على أحرف صغيرة وأرقام وشرطات سفلية ويجب أن يبدأ بحرف صغير",
|
||||
"metadata.checkName.tooLong": "لا يمكن أن يتجاوز اسم البيانات الوصفية {{max}} حرفًا",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Bearbeiten von {{num}} Dokumenten",
|
||||
"metadata.batchEditMetadata.editMetadata": "Metadaten bearbeiten",
|
||||
"metadata.batchEditMetadata.multipleValue": "Mehrwert",
|
||||
"metadata.checkName.duplicate": "Ein Metadatenfeld mit diesem Namen existiert bereits.",
|
||||
"metadata.checkName.empty": "Der Metadatenname darf nicht leer sein.",
|
||||
"metadata.checkName.invalid": "Der Metadatenname darf nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und muss mit einem Kleinbuchstaben beginnen.",
|
||||
"metadata.checkName.tooLong": "Der Metadatenname darf {{max}} Zeichen nicht überschreiten.",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Editing {{num}} documents",
|
||||
"metadata.batchEditMetadata.editMetadata": "Edit Metadata",
|
||||
"metadata.batchEditMetadata.multipleValue": "Multiple Value",
|
||||
"metadata.checkName.duplicate": "A metadata field with this name already exists",
|
||||
"metadata.checkName.empty": "Metadata name cannot be empty",
|
||||
"metadata.checkName.invalid": "Metadata name can only contain lowercase letters, numbers, and underscores and must start with a lowercase letter",
|
||||
"metadata.checkName.tooLong": "Metadata name cannot exceed {{max}} characters",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Editando {{num}} documentos",
|
||||
"metadata.batchEditMetadata.editMetadata": "Editar Metadatos",
|
||||
"metadata.batchEditMetadata.multipleValue": "Valor Múltiple",
|
||||
"metadata.checkName.duplicate": "Ya existe un campo de metadatos con este nombre.",
|
||||
"metadata.checkName.empty": "El nombre de metadatos no puede estar vacío",
|
||||
"metadata.checkName.invalid": "El nombre de los metadatos solo puede contener letras minúsculas, números y guiones bajos, y debe comenzar con una letra minúscula.",
|
||||
"metadata.checkName.tooLong": "El nombre de los metadatos no puede exceder {{max}} caracteres.",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "ویرایش {{num}} سند",
|
||||
"metadata.batchEditMetadata.editMetadata": "ویرایش متا داده ها",
|
||||
"metadata.batchEditMetadata.multipleValue": "چندین ارزش",
|
||||
"metadata.checkName.duplicate": "یک فیلد فراداده با این نام از قبل وجود دارد",
|
||||
"metadata.checkName.empty": "نام فراداده نمیتواند خالی باشد",
|
||||
"metadata.checkName.invalid": "نام متاداده فقط میتواند شامل حروف کوچک، اعداد و زیرخطها باشد و باید با یک حرف کوچک آغاز شود.",
|
||||
"metadata.checkName.tooLong": "نام متا داده نمیتواند بیشتر از {{max}} کاراکتر باشد",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Édition de {{num}} documents",
|
||||
"metadata.batchEditMetadata.editMetadata": "Modifier les métadonnées",
|
||||
"metadata.batchEditMetadata.multipleValue": "Valeur multiple",
|
||||
"metadata.checkName.duplicate": "Un champ de métadonnées portant ce nom existe déjà.",
|
||||
"metadata.checkName.empty": "Le nom des métadonnées ne peut pas être vide",
|
||||
"metadata.checkName.invalid": "Le nom des métadonnées ne peut contenir que des lettres minuscules, des chiffres et des tirets bas et doit commencer par une lettre minuscule.",
|
||||
"metadata.checkName.tooLong": "Le nom des métadonnées ne peut pas dépasser {{max}} caractères",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "संपादित कर रहे हैं {{num}} दस्तावेज़",
|
||||
"metadata.batchEditMetadata.editMetadata": "मेटाडेटा संपादित करें",
|
||||
"metadata.batchEditMetadata.multipleValue": "कई मान",
|
||||
"metadata.checkName.duplicate": "इस नाम वाला मेटाडेटा फ़ील्ड पहले से मौजूद है",
|
||||
"metadata.checkName.empty": "मेटाडाटा का नाम खाली नहीं हो सकता",
|
||||
"metadata.checkName.invalid": "मेटाडेटा नाम में केवल छोटे अक्षर, संख्या और अंडरस्कोर शामिल हो सकते हैं और इसे छोटे अक्षर से शुरू होना चाहिए।",
|
||||
"metadata.checkName.tooLong": "मेटाडेटा नाम {{max}} वर्णों से अधिक नहीं हो सकता",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Mengedit dokumen {{num}}",
|
||||
"metadata.batchEditMetadata.editMetadata": "Edit Metadata",
|
||||
"metadata.batchEditMetadata.multipleValue": "Beberapa Nilai",
|
||||
"metadata.checkName.duplicate": "Kolom metadata dengan nama ini sudah ada.",
|
||||
"metadata.checkName.empty": "Nama metadata tidak boleh kosong",
|
||||
"metadata.checkName.invalid": "Nama metadata hanya dapat berisi huruf kecil, angka, dan garis bawah dan harus dimulai dengan huruf kecil",
|
||||
"metadata.checkName.tooLong": "Nama metadata tidak boleh melebihi {{max}} karakter",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Modifica {{num}} documenti",
|
||||
"metadata.batchEditMetadata.editMetadata": "Modifica metadati",
|
||||
"metadata.batchEditMetadata.multipleValue": "Valore Multiplo",
|
||||
"metadata.checkName.duplicate": "Esiste già un campo di metadati con questo nome.",
|
||||
"metadata.checkName.empty": "Il nome dei metadati non può essere vuoto",
|
||||
"metadata.checkName.invalid": "Il nome dei metadati può contenere solo lettere minuscole, numeri e underscore e deve iniziare con una lettera minuscola.",
|
||||
"metadata.checkName.tooLong": "Il nome dei metadati non può superare {{max}} caratteri.",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "{{num}}件のドキュメントを編集",
|
||||
"metadata.batchEditMetadata.editMetadata": "メタデータを編集",
|
||||
"metadata.batchEditMetadata.multipleValue": "複数の値",
|
||||
"metadata.checkName.duplicate": "この名前のメタデータフィールドはすでに存在します",
|
||||
"metadata.checkName.empty": "メタデータ名を入力してください",
|
||||
"metadata.checkName.invalid": "メタデータ名は小文字、数字、アンダースコアのみを使用し、小文字で始める必要があります",
|
||||
"metadata.checkName.tooLong": "メタデータ名は {{max}} 文字を超えることはできません",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "{{num}} 개 문서 편집 중",
|
||||
"metadata.batchEditMetadata.editMetadata": "메타데이터 편집",
|
||||
"metadata.batchEditMetadata.multipleValue": "다중 값",
|
||||
"metadata.checkName.duplicate": "이 이름의 메타데이터 필드가 이미 존재합니다.",
|
||||
"metadata.checkName.empty": "메타데이터 이름은 비어 있을 수 없습니다.",
|
||||
"metadata.checkName.invalid": "메타데이터 이름은 소문자, 숫자 및 밑줄만 포함할 수 있으며 소문자로 시작해야 합니다.",
|
||||
"metadata.checkName.tooLong": "메타데이터 이름은 {{max}}자를 초과할 수 없습니다.",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Editing {{num}} documents",
|
||||
"metadata.batchEditMetadata.editMetadata": "Edit Metadata",
|
||||
"metadata.batchEditMetadata.multipleValue": "Multiple Value",
|
||||
"metadata.checkName.duplicate": "Er bestaat al een metadataveld met deze naam.",
|
||||
"metadata.checkName.empty": "Metadata name cannot be empty",
|
||||
"metadata.checkName.invalid": "Metadata name can only contain lowercase letters, numbers, and underscores and must start with a lowercase letter",
|
||||
"metadata.checkName.tooLong": "Metadata name cannot exceed {{max}} characters",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Edycja {{num}} dokumentów",
|
||||
"metadata.batchEditMetadata.editMetadata": "Edytuj metadane",
|
||||
"metadata.batchEditMetadata.multipleValue": "Wielokrotna wartość",
|
||||
"metadata.checkName.duplicate": "Pole metadanych o tej nazwie już istnieje.",
|
||||
"metadata.checkName.empty": "Nazwa metadanych nie może być pusta",
|
||||
"metadata.checkName.invalid": "Nazwa metadanych może zawierać tylko małe litery, cyfry i podkreślenia oraz musi zaczynać się od małej litery",
|
||||
"metadata.checkName.tooLong": "Nazwa metadanych nie może przekraczać {{max}} znaków",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Editando {{num}} documentos",
|
||||
"metadata.batchEditMetadata.editMetadata": "Editar Metadados",
|
||||
"metadata.batchEditMetadata.multipleValue": "Múltiplos Valores",
|
||||
"metadata.checkName.duplicate": "Já existe um campo de metadados com este nome.",
|
||||
"metadata.checkName.empty": "O nome dos metadados não pode estar vazio",
|
||||
"metadata.checkName.invalid": "O nome de metadata só pode conter letras minúsculas, números e sublinhados e deve começar com uma letra minúscula.",
|
||||
"metadata.checkName.tooLong": "O nome dos metadados não pode exceder {{max}} caracteres.",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Editarea {{num}} documente",
|
||||
"metadata.batchEditMetadata.editMetadata": "Editează metadatele",
|
||||
"metadata.batchEditMetadata.multipleValue": "Valoare multiplă",
|
||||
"metadata.checkName.duplicate": "Există deja un câmp de metadate cu acest nume.",
|
||||
"metadata.checkName.empty": "Numele metadatelor nu poate fi gol",
|
||||
"metadata.checkName.invalid": "Numele metadatelor poate conține doar litere mici, cifre și underscore și trebuie să înceapă cu o literă mică.",
|
||||
"metadata.checkName.tooLong": "Numele metadatelor nu poate depăși {{max}} caractere",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Редактирование {{num}} документов",
|
||||
"metadata.batchEditMetadata.editMetadata": "Редактировать метаданные",
|
||||
"metadata.batchEditMetadata.multipleValue": "Множественное значение",
|
||||
"metadata.checkName.duplicate": "Поле метаданных с таким именем уже существует.",
|
||||
"metadata.checkName.empty": "Имя метаданных не может быть пустым",
|
||||
"metadata.checkName.invalid": "Имя метаданных может содержать только строчные буквы, цифры и знаки нижнего подчеркивания и должно начинаться со строчной буквы.",
|
||||
"metadata.checkName.tooLong": "Имя метаданных не может превышать {{max}} символов",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Urejanje {{num}} dokumentov",
|
||||
"metadata.batchEditMetadata.editMetadata": "Uredi metapodatke",
|
||||
"metadata.batchEditMetadata.multipleValue": "Več vrednosti",
|
||||
"metadata.checkName.duplicate": "Polje metapodatkov s tem imenom že obstaja.",
|
||||
"metadata.checkName.empty": "Ime metapodatkov ne more biti prazno",
|
||||
"metadata.checkName.invalid": "Ime metapodatkov lahko vsebuje samo male črke, številke in podčrtaje ter se mora začeti z malo črko.",
|
||||
"metadata.checkName.tooLong": "Ime metapodatkov ne sme presegati {{max}} znakov",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "การแก้ไขเอกสาร {{num}} ฉบับ",
|
||||
"metadata.batchEditMetadata.editMetadata": "แก้ไขข้อมูลเมตา",
|
||||
"metadata.batchEditMetadata.multipleValue": "หลายค่า",
|
||||
"metadata.checkName.duplicate": "มีฟิลด์ข้อมูลเมตาชื่อนี้อยู่แล้ว",
|
||||
"metadata.checkName.empty": "ชื่อข้อมูลเมตาไม่สามารถเป็นค่าแEmpty",
|
||||
"metadata.checkName.invalid": "ชื่อเมตาดาต้าต้องประกอบด้วยตัวอักษรตัวเล็กเท่านั้น เลข และขีดล่าง และต้องเริ่มต้นด้วยตัวอักษรตัวเล็ก",
|
||||
"metadata.checkName.tooLong": "ชื่อเมตาดาต้าไม่สามารถเกิน {{max}} ตัวอักษร",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "{{num}} belge düzenleniyor",
|
||||
"metadata.batchEditMetadata.editMetadata": "Meta Verileri Düzenle",
|
||||
"metadata.batchEditMetadata.multipleValue": "Birden Fazla Değer",
|
||||
"metadata.checkName.duplicate": "Bu ada sahip bir meta veri alanı zaten mevcut.",
|
||||
"metadata.checkName.empty": "Meta veri adı boş olamaz",
|
||||
"metadata.checkName.invalid": "Meta verisi adı yalnızca küçük harfler, sayılar ve alt çizgiler içerebilir ve küçük bir harfle başlamalıdır.",
|
||||
"metadata.checkName.tooLong": "Meta veri adı {{max}} karakteri geçemez",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Редагування {{num}} документів",
|
||||
"metadata.batchEditMetadata.editMetadata": "Редагувати метадані",
|
||||
"metadata.batchEditMetadata.multipleValue": "Кілька значень",
|
||||
"metadata.checkName.duplicate": "Поле метаданих із такою назвою вже існує.",
|
||||
"metadata.checkName.empty": "Ім'я метаданих не може бути порожнім",
|
||||
"metadata.checkName.invalid": "Ім'я метаданих може містити лише малі літери, цифри та підкреслення, і повинно починатися з малої літери",
|
||||
"metadata.checkName.tooLong": "Назва метаданих не може перевищувати {{max}} символів",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "Chỉnh sửa {{num}} tài liệu",
|
||||
"metadata.batchEditMetadata.editMetadata": "Chỉnh sửa siêu dữ liệu",
|
||||
"metadata.batchEditMetadata.multipleValue": "Nhiều giá trị",
|
||||
"metadata.checkName.duplicate": "Trường siêu dữ liệu có tên này đã tồn tại.",
|
||||
"metadata.checkName.empty": "Tên siêu dữ liệu không được để trống",
|
||||
"metadata.checkName.invalid": "Tên siêu dữ liệu chỉ có thể chứa chữ cái thường, số và dấu gạch dưới, và phải bắt đầu bằng một chữ cái thường.",
|
||||
"metadata.checkName.tooLong": "Tên siêu dữ liệu không được vượt quá {{max}} ký tự",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "编辑 {{num}} 个文档",
|
||||
"metadata.batchEditMetadata.editMetadata": "编辑元数据",
|
||||
"metadata.batchEditMetadata.multipleValue": "多个值",
|
||||
"metadata.checkName.duplicate": "已存在同名的元数据字段",
|
||||
"metadata.checkName.empty": "元数据名称不能为空",
|
||||
"metadata.checkName.invalid": "元数据名称只能包含小写字母、数字和下划线,并且必须以小写字母开头",
|
||||
"metadata.checkName.tooLong": "元数据名称不得超过{{max}}个字符",
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
"metadata.batchEditMetadata.editDocumentsNum": "編輯 {{num}} 份文件",
|
||||
"metadata.batchEditMetadata.editMetadata": "編輯元資料",
|
||||
"metadata.batchEditMetadata.multipleValue": "多重價值",
|
||||
"metadata.checkName.duplicate": "已存在同名的中繼資料欄位",
|
||||
"metadata.checkName.empty": "元數據名稱不能為空",
|
||||
"metadata.checkName.invalid": "元數據名稱只能包含小寫字母、數字和底線,並且必須以小寫字母開頭",
|
||||
"metadata.checkName.tooLong": "元數據名稱不能超過 {{max}} 個字符",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user