feat(dataset): support New RAG document metadata

This commit is contained in:
Stephen Zhou 2026-08-04 19:32:42 +08:00
parent 4fd80c8d59
commit c758131fb9
No known key found for this signature in database
35 changed files with 2266 additions and 39 deletions

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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">

View File

@ -351,6 +351,7 @@ export function DocumentDetailPage({
<DocumentRevisionContent
key={effectiveRevision ?? 'missing'}
canEdit={canEdit}
document={document}
documentId={documentId}
effectiveRevision={effectiveRevision}

View File

@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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,
}
}

View 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>
)
}

View File

@ -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) &&

View File

@ -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}

View File

@ -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}} حرفًا",

View File

@ -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.",

View File

@ -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",

View File

@ -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.",

View File

@ -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}} کاراکتر باشد",

View File

@ -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",

View File

@ -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}} वर्णों से अधिक नहीं हो सकता",

View File

@ -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",

View File

@ -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.",

View File

@ -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}} 文字を超えることはできません",

View File

@ -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}}자를 초과할 수 없습니다.",

View File

@ -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",

View File

@ -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",

View File

@ -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.",

View File

@ -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",

View File

@ -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}} символов",

View File

@ -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",

View File

@ -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}} ตัวอักษร",

View File

@ -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",

View File

@ -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}} символів",

View File

@ -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ự",

View File

@ -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}}个字符",

View File

@ -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}} 個字符",