feat(knowledge-fs): support tag management and filtering (WTA-1980)

This commit is contained in:
Stephen Zhou 2026-08-13 15:14:47 +08:00
parent 1fe9b401e9
commit 30c84ce58c
No known key found for this signature in database
13 changed files with 631 additions and 110 deletions

View File

@ -151,6 +151,8 @@ import {
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponse,
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPath,
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponse,
zGetKnowledgeFsSpacesByControlSpaceIdTagsPath,
zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse,
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsPath,
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsQuery,
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsResponse,
@ -327,6 +329,9 @@ import {
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyBody,
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyPath,
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse,
zPutKnowledgeFsSpacesByControlSpaceIdTagsBody,
zPutKnowledgeFsSpacesByControlSpaceIdTagsPath,
zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse,
} from './zod.gen'
export const get = oc
@ -2299,6 +2304,38 @@ export const sources = {
}
export const get45 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getKnowledgeFsSpacesByControlSpaceIdTags',
path: '/knowledge-fs/spaces/{control_space_id}/tags',
tags: ['console'],
})
.input(z.object({ params: zGetKnowledgeFsSpacesByControlSpaceIdTagsPath }))
.output(zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse)
export const put5 = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'putKnowledgeFsSpacesByControlSpaceIdTags',
path: '/knowledge-fs/spaces/{control_space_id}/tags',
tags: ['console'],
})
.input(
z.object({
body: zPutKnowledgeFsSpacesByControlSpaceIdTagsBody,
params: zPutKnowledgeFsSpacesByControlSpaceIdTagsPath,
}),
)
.output(zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse)
export const tags = {
get: get45,
put: put5,
}
export const get46 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2315,10 +2352,10 @@ export const get45 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsResponse)
export const conflicts = {
get: get45,
get: get46,
}
export const get46 = oc
export const get47 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2335,10 +2372,10 @@ export const get46 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdEvidenceResponse)
export const evidence = {
get: get46,
get: get47,
}
export const get47 = oc
export const get48 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2355,10 +2392,10 @@ export const get47 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdMissingResponse)
export const missing = {
get: get47,
get: get48,
}
export const get48 = oc
export const get49 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2370,13 +2407,13 @@ export const get48 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdResponse)
export const byTraceId = {
get: get48,
get: get49,
conflicts,
evidence,
missing,
}
export const get49 = oc
export const get50 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2393,7 +2430,7 @@ export const get49 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesResponse)
export const traces = {
get: get49,
get: get50,
byTraceId,
}
@ -2531,7 +2568,7 @@ export const delete13 = oc
.input(z.object({ params: zDeleteKnowledgeFsSpacesByControlSpaceIdPath }))
.output(zDeleteKnowledgeFsSpacesByControlSpaceIdResponse)
export const get50 = oc
export const get51 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2560,7 +2597,7 @@ export const patch9 = oc
export const byControlSpaceId = {
delete: delete13,
get: get50,
get: get51,
patch: patch9,
appBindings,
backgroundTasks,
@ -2584,11 +2621,12 @@ export const byControlSpaceId = {
sourceProviders,
sourceWorkflows,
sources,
tags,
traces,
uploadSessions,
}
export const get51 = oc
export const get52 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2612,7 +2650,7 @@ export const post40 = oc
.output(zPostKnowledgeFsSpacesResponse)
export const spaces = {
get: get51,
get: get52,
post: post40,
byControlSpaceId,
}

View File

@ -1084,6 +1084,14 @@ export type KnowledgeFsSourceWorkflowImportPayload =
kind: 'online-drive-import'
} & KnowledgeFsOnlineDriveWorkflowImportPayload)
export type KnowledgeFsSpaceTagListResponse = {
data: Array<KnowledgeFsSpaceTagResponse>
}
export type KnowledgeFsSpaceTagsReplacePayload = {
tag_ids?: Array<string>
}
export type KnowledgeFsTraceListResponse = {
data: Array<KnowledgeFsTraceResponse>
next_cursor?: string | null
@ -1220,6 +1228,7 @@ export type KnowledgeFsSpaceListItemResponse = {
permission_keys: Array<KnowledgeFsProductPermission>
resource_version: number
state: KnowledgeFsControlSpaceState
tags?: Array<KnowledgeFsSpaceTagResponse>
technical_status: 'available' | 'not_ready' | 'unavailable'
technical_summary?: KnowledgeFsTechnicalSummary | null
updated_at: string
@ -1905,6 +1914,12 @@ export type KnowledgeFsOnlineDriveWorkflowImportPayload = {
kind: 'online-drive-import'
}
export type KnowledgeFsSpaceTagResponse = {
id: string
name: string
type?: 'knowledge'
}
export type KnowledgeFsTraceResponse = {
completed: boolean
created_at: string
@ -2241,6 +2256,7 @@ export type GetKnowledgeFsSpacesData = {
limit?: number
page?: number
query?: string
tag_ids?: Array<string>
}
url: '/knowledge-fs/spaces'
}
@ -4012,6 +4028,38 @@ export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImport
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdTagsData = {
body?: never
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/tags'
}
export type GetKnowledgeFsSpacesByControlSpaceIdTagsResponses = {
200: KnowledgeFsSpaceTagListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdTagsResponse =
GetKnowledgeFsSpacesByControlSpaceIdTagsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdTagsResponses]
export type PutKnowledgeFsSpacesByControlSpaceIdTagsData = {
body: KnowledgeFsSpaceTagsReplacePayload
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/tags'
}
export type PutKnowledgeFsSpacesByControlSpaceIdTagsResponses = {
200: KnowledgeFsSpaceTagListResponse
}
export type PutKnowledgeFsSpacesByControlSpaceIdTagsResponse =
PutKnowledgeFsSpacesByControlSpaceIdTagsResponses[keyof PutKnowledgeFsSpacesByControlSpaceIdTagsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
body?: never
path: {

View File

@ -549,6 +549,13 @@ export const zKnowledgeFsSourceSyncPolicyPayload = z.object({
mode: z.enum(['custom', 'interval', 'manual', 'provider']),
})
/**
* KnowledgeFSSpaceTagsReplacePayload
*/
export const zKnowledgeFsSpaceTagsReplacePayload = z.object({
tag_ids: z.array(z.string()).max(100).optional(),
})
/**
* KnowledgeFSUploadSessionCreatePayload
*/
@ -867,34 +874,6 @@ export const zKnowledgeFsSpaceDetailResponse = z.object({
visibility: zKnowledgeFsControlSpaceVisibility,
})
/**
* KnowledgeFSSpaceListItemResponse
*/
export const zKnowledgeFsSpaceListItemResponse = z.object({
control_space_id: z.string(),
created_at: z.iso.datetime(),
knowledge_space_id: z.string().nullable(),
linked_apps: z.int().gte(0),
owner_account_id: z.string(),
permission_keys: z.array(zKnowledgeFsProductPermission),
resource_version: z.int(),
state: zKnowledgeFsControlSpaceState,
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
updated_at: z.iso.datetime(),
visibility: zKnowledgeFsControlSpaceVisibility,
})
/**
* KnowledgeFSSpaceListResponse
*/
export const zKnowledgeFsSpaceListResponse = z.object({
data: z.array(zKnowledgeFsSpaceListItemResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
})
/**
* KnowledgeFSAppSpaceJoinType
*/
@ -1997,6 +1976,51 @@ export const zKnowledgeFsSourceImportFilesPayload = z.object({
files: z.array(zKnowledgeFsSourceImportFilePayload).min(1).max(200),
})
/**
* KnowledgeFSSpaceTagResponse
*/
export const zKnowledgeFsSpaceTagResponse = z.object({
id: z.string(),
name: z.string(),
type: z.literal('knowledge').optional().default('knowledge'),
})
/**
* KnowledgeFSSpaceTagListResponse
*/
export const zKnowledgeFsSpaceTagListResponse = z.object({
data: z.array(zKnowledgeFsSpaceTagResponse),
})
/**
* KnowledgeFSSpaceListItemResponse
*/
export const zKnowledgeFsSpaceListItemResponse = z.object({
control_space_id: z.string(),
created_at: z.iso.datetime(),
knowledge_space_id: z.string().nullable(),
linked_apps: z.int().gte(0),
owner_account_id: z.string(),
permission_keys: z.array(zKnowledgeFsProductPermission),
resource_version: z.int(),
state: zKnowledgeFsControlSpaceState,
tags: z.array(zKnowledgeFsSpaceTagResponse).optional(),
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
updated_at: z.iso.datetime(),
visibility: zKnowledgeFsControlSpaceVisibility,
})
/**
* KnowledgeFSSpaceListResponse
*/
export const zKnowledgeFsSpaceListResponse = z.object({
data: z.array(zKnowledgeFsSpaceListItemResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
})
/**
* KnowledgeFSAnswerTraceStepResponse
*/
@ -2837,6 +2861,7 @@ export const zGetKnowledgeFsSpacesQuery = z.object({
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
query: z.string().max(255).optional(),
tag_ids: z.array(z.string().min(1).max(255)).max(100).optional(),
})
/**
@ -4182,6 +4207,26 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImpo
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse =
zKnowledgeFsSourceWorkflowResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdTagsPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS space tags
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse = zKnowledgeFsSpaceTagListResponse
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsBody = zKnowledgeFsSpaceTagsReplacePayload
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS space tags replaced
*/
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse = zKnowledgeFsSpaceTagListResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdTracesPath = z.object({
control_space_id: z.string(),
})

View File

@ -18,6 +18,7 @@ type KnowledgeSpaceList = {
revision: number
slug: string
tenantId: string
tags?: Array<{ id: string; name: string }>
updatedAt: string
documentCount?: number
}>
@ -33,6 +34,7 @@ type ListKnowledgeSpacesInfiniteOptions = {
limit: number
page: number
query?: string
tag_ids?: string[]
}
}
}
@ -57,6 +59,7 @@ const knowledgeSpaceApiResponse = vi.hoisted(
permission_keys: space.permissionKeys ?? ['knowledge_space_read'],
resource_version: space.revision,
state: 'active',
tags: space.tags?.map((tag) => ({ ...tag, type: 'knowledge' as const })),
technical_status: 'available',
technical_summary: {
description: space.description ?? null,
@ -213,6 +216,35 @@ vi.mock('@/features/system-features/state', () => ({
knowledgeFsUploadEnabledAtom: systemFeaturesStateMock.knowledgeFsUploadEnabledAtom,
}))
vi.mock('@/features/tag-management/components/tag-filter', () => ({
TagFilter: ({ onChange, value }: { onChange: (value: string[]) => void; value: string[] }) => (
<button
type="button"
aria-label="dataset.newKnowledge.tags"
onClick={() => onChange(value.length ? [] : ['tag-1', 'tag-2'])}
>
dataset.newKnowledge.tags
</button>
),
}))
vi.mock('@/features/tag-management/components/tag-management-modal', () => ({
TagManagementModal: () => null,
}))
vi.mock('../components/knowledge-space-card-tags', () => ({
KnowledgeSpaceCardTags: ({
knowledgeSpace,
}: {
knowledgeSpace: { tags?: Array<{ id: string; name: string }> }
}) => (
<div>
<span>dataset.newKnowledge.tags</span>
{knowledgeSpace.tags?.map((tag) => tag.name).join(', ')}
</div>
),
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({}),
}))
@ -308,6 +340,10 @@ describe('NewKnowledgeList', () => {
name: 'Support knowledge',
revision: 1,
slug: 'support-knowledge',
tags: [
{ id: 'tag-1', name: 'Customer support' },
{ id: 'tag-2', name: 'Public docs' },
],
tenantId: 'tenant-1',
updatedAt: '2026-07-18T00:00:00Z',
},
@ -327,6 +363,8 @@ describe('NewKnowledgeList', () => {
const supportCard = within(list).getByRole('link', {
name: 'Support knowledge',
})
const supportCardItem = supportCard.closest('li')
expect(supportCardItem).not.toBeNull()
expect(supportCard).toHaveAttribute('href', '/datasets/new/space-1')
expect(supportCard).toBeInTheDocument()
expect(
@ -339,7 +377,8 @@ describe('NewKnowledgeList', () => {
expect(within(supportCard).getByLabelText('camera')).toBeInTheDocument()
expect(within(list).getAllByText('dataset.newKnowledge.cardType')).toHaveLength(2)
expect(within(list).getAllByText('dataset.newKnowledge.tags')).toHaveLength(2)
expect(within(supportCard).getByText('12')).toBeInTheDocument()
expect(within(list).getByText('Customer support, Public docs')).toBeInTheDocument()
expect(within(supportCardItem!).getByText('12')).toBeInTheDocument()
expect(supportCard).toHaveAccessibleDescription('dataset.newKnowledge.overview.linkedApps: 0')
expect(
within(list).getByRole('link', { name: 'Engineering handbook' }),
@ -453,7 +492,7 @@ describe('NewKnowledgeList', () => {
expect(toastMock.success).toHaveBeenCalledWith('dataset.datasetDeleted')
})
it('keeps backend-dependent metadata filters interactive and sends search to the collection API', async () => {
it('syncs tag filters to the URL and collection API while keeping search interactive', async () => {
const user = userEvent.setup()
setResolvedPage([
{
@ -493,13 +532,23 @@ describe('NewKnowledgeList', () => {
expect(tags).toBeEnabled()
expect(creators).toBeEnabled()
await user.click(tags)
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tag_ids')).toBe('tag-1;tag-2')
})
let options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options?.input(1)).toEqual({
query: { limit: 30, page: 1, tag_ids: ['tag-1', 'tag-2'] },
})
await user.click(tags)
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('tag_ids')).toBe(false)
})
expect(search).toBeEnabled()
expect(create).toHaveAttribute('href', '/datasets/new/create')
await user.type(search, 'customer support')
await waitFor(() => {
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options?.input(1)).toEqual({
query: { limit: 30, page: 1, query: 'customer support' },
})
@ -507,6 +556,19 @@ describe('NewKnowledgeList', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('query')).toBe('customer support')
})
it('restores tag filters from the URL and sends match-any IDs to the collection API', () => {
setResolvedPage()
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />, {
searchParams: '?tag_ids=tag-1%3Btag-2',
})
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options?.input(1)).toEqual({
query: { limit: 30, page: 1, tag_ids: ['tag-1', 'tag-2'] },
})
})
it('restores server search from the URL and shows its empty state', async () => {
const user = userEvent.setup()
setResolvedPage()

View File

@ -0,0 +1,203 @@
import type { KnowledgeFsSpaceListItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { KnowledgeSpaceCardTags } from '../knowledge-space-card-tags'
const { invalidateQueries, putTags, toastMock } = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
putTags: vi.fn(),
toastMock: {
error: vi.fn(),
success: vi.fn(),
},
}))
const workspacePermissionKeys = vi.hoisted(() => ({
value: ['dataset.tag.manage'] as string[],
}))
const tagQuery = vi.hoisted(() => ({
data: [] as Tag[],
}))
const knowledgeTags: Tag[] = [
{ binding_count: '1', id: 'tag-1', name: 'Frontend', type: 'knowledge' },
{ binding_count: '1', id: 'tag-2', name: 'Backend', type: 'knowledge' },
{ binding_count: '0', id: 'tag-3', name: 'Public docs', type: 'knowledge' },
]
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock }))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
workspacePermissionKeys: workspacePermissionKeys.value,
}))
})
vi.mock('@/features/tag-management/hooks/use-tag-mutations', () => ({
useApplyTagBindingsMutation: () => ({ mutate: vi.fn() }),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useMutation: (options: {
mutationFn?: (input: unknown) => Promise<unknown>
onError?: () => void
onSettled?: () => void
onSuccess?: () => void
}) => ({
isPending: false,
mutate: (input: unknown) => {
Promise.resolve(options.mutationFn?.(input))
.then(
() => options.onSuccess?.(),
() => options.onError?.(),
)
.finally(() => options.onSettled?.())
},
}),
useQuery: () => ({ data: tagQuery.data }),
useQueryClient: () => ({ invalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
knowledgeFs: {
spaces: {
byControlSpaceId: {
tags: {
put: {
mutationOptions: () => ({ mutationFn: putTags }),
},
},
},
get: {
key: () => ['knowledge-fs', 'spaces'],
},
},
},
tags: {
get: {
key: () => ['tags'],
queryOptions: () => ({}),
},
post: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
},
}))
function createKnowledgeSpace(
permissionKeys: KnowledgeFsSpaceListItemResponse['permission_keys'],
): KnowledgeFsSpaceListItemResponse {
return {
control_space_id: 'space-1',
created_at: '2026-08-13T00:00:00Z',
knowledge_space_id: 'knowledge-space-1',
linked_apps: 0,
owner_account_id: 'account-1',
permission_keys: permissionKeys,
resource_version: 1,
state: 'active',
tags: [
{ id: 'tag-1', name: 'Frontend', type: 'knowledge' },
{ id: 'tag-2', name: 'Backend', type: 'knowledge' },
],
technical_status: 'available',
updated_at: '2026-08-13T00:00:00Z',
visibility: 'only_me',
}
}
describe('KnowledgeSpaceCardTags', () => {
beforeEach(() => {
vi.clearAllMocks()
putTags.mockResolvedValue({ data: [] })
invalidateQueries.mockResolvedValue(undefined)
workspacePermissionKeys.value = ['dataset.tag.manage']
tagQuery.data = knowledgeTags
})
it('shows tags returned with the knowledge space', () => {
tagQuery.data = []
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
})
it('submits the final tag set and refreshes the list and binding counts', async () => {
const user = userEvent.setup()
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_edit', 'knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
const trigger = screen.getByRole('combobox', { name: 'Frontend, Backend' })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: 'Frontend' }))
await user.click(screen.getByRole('option', { name: 'Public docs' }))
await user.click(trigger)
await waitFor(() => {
expect(putTags).toHaveBeenCalledWith({
body: { tag_ids: ['tag-2', 'tag-3'] },
params: { control_space_id: 'space-1' },
})
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['knowledge-fs', 'spaces'],
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tags'] })
expect(toastMock.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
})
it('can clear every tag from an editable knowledge space', async () => {
const user = userEvent.setup()
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_edit', 'knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
const trigger = screen.getByRole('combobox', { name: 'Frontend, Backend' })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: 'Frontend' }))
await user.click(screen.getByRole('option', { name: 'Backend' }))
await user.click(trigger)
await waitFor(() => {
expect(putTags).toHaveBeenCalledWith({
body: { tag_ids: [] },
params: { control_space_id: 'space-1' },
})
})
})
it('does not allow binding changes without space edit permission', () => {
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
expect(screen.getByRole('combobox', { name: 'Frontend, Backend' })).toBeDisabled()
expect(putTags).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,67 @@
'use client'
import type { KnowledgeFsSpaceListItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { TagSelector } from '@/features/tag-management/components/tag-selector'
import { consoleQuery } from '@/service/client'
export function KnowledgeSpaceCardTags({
knowledgeSpace,
onOpenTagManagement,
}: {
knowledgeSpace: KnowledgeFsSpaceListItemResponse
onOpenTagManagement: () => void
}) {
const { t } = useTranslation('common')
const queryClient = useQueryClient()
const canEdit = knowledgeSpace.permission_keys.includes('knowledge_space_edit')
const tags = useMemo<Tag[]>(
() =>
(knowledgeSpace.tags ?? []).map((tag) => ({
binding_count: '',
id: tag.id,
name: tag.name,
type: 'knowledge',
})),
[knowledgeSpace.tags],
)
const replaceTagsMutation = useMutation({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.tags.put.mutationOptions(),
onError: () => toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'])),
onSuccess: () => toast.success(t(($) => $['actionMsg.modifiedSuccessfully'])),
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.get.key(),
})
void queryClient.invalidateQueries({
queryKey: consoleQuery.tags.get.key({
type: 'query',
input: { query: { type: 'knowledge' } },
}),
})
},
})
return (
<TagSelector
type="knowledge"
targetId={knowledgeSpace.control_space_id}
value={tags}
canBindOrUnbindTags={canEdit}
requiresTargetEditPermission
showProvidedTagNames
onOpenTagManagement={onOpenTagManagement}
onApplyTags={(tagIds) =>
replaceTagsMutation.mutate({
params: { control_space_id: knowledgeSpace.control_space_id },
body: { tag_ids: tagIds },
})
}
className="relative z-1 mx-3 w-auto"
/>
)
}

View File

@ -5,6 +5,7 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { newKnowledgeOverviewPath } from '../routes'
import { KnowledgeSpaceActions } from './knowledge-space-actions'
import { KnowledgeSpaceCardTags } from './knowledge-space-card-tags'
import { KnowledgeSpaceIcon } from './knowledge-space-icon'
function getBuiltinIconName(iconRef: string | undefined) {
@ -14,13 +15,14 @@ function getBuiltinIconName(iconRef: string | undefined) {
export function KnowledgeSpaceCard({
knowledgeSpace,
onOpenTagManagement,
}: {
knowledgeSpace: KnowledgeFsSpaceListItemResponse
onOpenTagManagement: () => void
}) {
const { t } = useTranslation('dataset')
const { formatTimeFromNow } = useFormatTimeFromNow()
const linkedAppsDescriptionId = useId()
const unavailable = t(($) => $['cornerLabel.unavailable'])
const summary = knowledgeSpace.technical_summary
const name = summary?.name ?? knowledgeSpace.control_space_id
const linkedApps = knowledgeSpace.linked_apps
@ -31,14 +33,14 @@ export function KnowledgeSpaceCard({
: formatTimeFromNow(updatedAt)
return (
<li className="group relative">
<li className="group relative flex h-41.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md motion-reduce:transition-none">
<Link
href={newKnowledgeOverviewPath(knowledgeSpace.control_space_id)}
aria-label={name}
aria-describedby={linkedAppsDescriptionId}
className="relative flex h-41.5 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
className="block outline-hidden after:absolute after:inset-0 after:z-0 after:rounded-xl after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-state-accent-solid"
>
<div className="flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
<div className="relative z-1 flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
<div
aria-label={iconName ?? t(($) => $['newKnowledge.cardType'])}
title={iconName}
@ -53,40 +55,35 @@ export function KnowledgeSpaceCard({
</div>
</div>
</div>
<p className="line-clamp-2 min-h-8 w-full px-4 py-0.5 body-xs-regular text-text-tertiary">
<p className="relative z-1 line-clamp-2 min-h-8 w-full px-4 py-0.5 body-xs-regular text-text-tertiary">
{summary?.description || t(($) => $['newKnowledge.noDescription'])}
</p>
<div
aria-label={`${t(($) => $['newKnowledge.tags'])}. ${unavailable}`}
className="mt-1 flex min-w-0 items-center gap-1 px-4"
>
<span className="rounded-md bg-background-section px-1.5 py-0.5 system-2xs-medium-uppercase text-text-disabled">
{t(($) => $['newKnowledge.tags'])}
</span>
<span className="system-2xs-regular text-text-disabled">{unavailable}</span>
</div>
<div className="mt-auto flex w-full min-w-0 items-center gap-2 px-4 pt-1 pb-2.5 system-xs-regular text-text-tertiary">
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-file-text-line size-3.5" />
<span>{summary?.document_count ?? 0}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-robot-2-line size-3.5" />
<span aria-hidden>{linkedApps}</span>
<span id={linkedAppsDescriptionId} className="sr-only">
{t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps}
</span>
</span>
<span aria-hidden className="text-divider-deep">
/
</span>
<span className="ml-auto min-w-0 truncate text-right">
{t(($) => $['newKnowledge.updated'], {
date: formattedUpdatedAt,
})}
</span>
</div>
</Link>
<KnowledgeSpaceCardTags
knowledgeSpace={knowledgeSpace}
onOpenTagManagement={onOpenTagManagement}
/>
<div className="pointer-events-none relative z-1 mt-auto flex w-full min-w-0 items-center gap-2 px-4 pt-1 pb-2.5 system-xs-regular text-text-tertiary">
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-file-text-line size-3.5" />
<span>{summary?.document_count ?? 0}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-robot-2-line size-3.5" />
<span aria-hidden>{linkedApps}</span>
<span id={linkedAppsDescriptionId} className="sr-only">
{t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps}
</span>
</span>
<span aria-hidden className="text-divider-deep">
/
</span>
<span className="ml-auto min-w-0 truncate text-right">
{t(($) => $['newKnowledge.updated'], {
date: formattedUpdatedAt,
})}
</span>
</div>
<KnowledgeSpaceActions knowledgeSpace={knowledgeSpace} />
</li>
)

View File

@ -1,8 +1,7 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useAtomValue } from 'jotai'
import { createParser, parseAsString, useQueryState } from 'nuqs'
@ -13,6 +12,8 @@ import ExternalAPIPanel from '@/app/components/datasets/external-api/external-ap
import ServiceApi from '@/app/components/datasets/extra-info/service-api'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
@ -32,6 +33,8 @@ import {
} from './components/new-knowledge-list-states'
const PAGE_SIZE = 30
const TAG_FILTER_MAX_ID_LENGTH = 255
const TAG_FILTER_MAX_SELECTION = 100
const searchParser = parseAsString.withDefault('').withOptions({
history: 'replace',
})
@ -51,6 +54,21 @@ const creatorIdsParser = createParser<string[]>({
.withDefault([])
.withOptions({ history: 'push' })
function normalizeTagIds(tagIds: string[]) {
return [...new Set(tagIds)]
.filter((tagId) => tagId.length > 0 && tagId.length <= TAG_FILTER_MAX_ID_LENGTH)
.slice(0, TAG_FILTER_MAX_SELECTION)
}
const tagIdsParser = createParser<string[]>({
eq: (left, right) =>
left.length === right.length && left.every((tagId, index) => tagId === right[index]),
parse: (query) => normalizeTagIds(query.split(';')),
serialize: (tagIds) => normalizeTagIds(tagIds).join(';'),
})
.withDefault([])
.withOptions({ history: 'push' })
function isUnavailableError(error: unknown) {
if (!error || typeof error !== 'object') return false
const status = 'status' in error ? error.status : undefined
@ -62,19 +80,6 @@ function isUnavailableError(error: unknown) {
return dataStatus === 404 || dataStatus === 503
}
function MetadataFilter({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Button
variant="tertiary"
className="gap-0 border-0 bg-components-input-bg-normal px-2 font-normal text-text-tertiary shadow-none hover:bg-components-input-bg-hover"
onClick={onClick}
>
<span className="px-1 system-sm-regular">{label}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
</Button>
)
}
export function NewKnowledgeList({
view,
onViewChange,
@ -84,18 +89,19 @@ export function NewKnowledgeList({
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const [showExternalApiPanel, setShowExternalApiPanel] = useState(false)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
const canCreate = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const canConnect = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
const filtersUnavailable = t(($) => $['newKnowledge.filtersUnavailable'])
const showFilterBoundary = () => toast.info(filtersUnavailable)
const createLabel = tCommon(($) => $['operation.create'])
const [searchValue, setSearchValue] = useQueryState('query', searchParser)
const debouncedSearchValue = useDebounce(searchValue.trim(), { wait: 300 })
const [creatorIds, setCreatorIds] = useQueryState('creator_ids', creatorIdsParser)
const [tagIds, setTagIds] = useQueryState('tag_ids', tagIdsParser)
const knowledgeSpacesQuery = useInfiniteQuery(
consoleQuery.knowledgeFs.spaces.get.infiniteOptions({
input: (pageParam) => ({
@ -103,6 +109,7 @@ export function NewKnowledgeList({
limit: PAGE_SIZE,
page: pageParam,
...(creatorIds.length > 0 ? { creator_ids: creatorIds } : {}),
...(tagIds.length > 0 ? { tag_ids: tagIds } : {}),
...(debouncedSearchValue ? { query: debouncedSearchValue } : {}),
},
}),
@ -145,7 +152,12 @@ export function NewKnowledgeList({
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<MetadataFilter label={t(($) => $['newKnowledge.tags'])} onClick={showFilterBoundary} />
<TagFilter
type="knowledge"
value={tagIds}
onChange={(nextTagIds) => void setTagIds(nextTagIds)}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
<CreatorFilter
value={creatorIds}
onChange={(nextCreatorIds) => void setCreatorIds(nextCreatorIds)}
@ -209,7 +221,7 @@ export function NewKnowledgeList({
}
/>
</div>
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 ? (
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 && tagIds.length === 0 ? (
<NewKnowledgeEmptyState
canConnect={canConnect}
canCreate={canCreate}
@ -218,7 +230,7 @@ export function NewKnowledgeList({
) : knowledgeSpaces.length === 0 ? (
<div className="flex min-h-105 items-center justify-center px-6 text-center text-text-tertiary">
{tCommon(($) => $['operation.noSearchResults'], {
content: t(($) => $['newKnowledge.creators']),
content: t(($) => $.knowledge),
})}
</div>
) : (
@ -228,6 +240,7 @@ export function NewKnowledgeList({
<KnowledgeSpaceCard
key={knowledgeSpace.control_space_id}
knowledgeSpace={knowledgeSpace}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))}
</ul>
@ -251,6 +264,16 @@ export function NewKnowledgeList({
</div>
)}
</div>
<TagManagementModal
type="knowledge"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={() => {
void queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.get.key(),
})
}}
/>
{showExternalApiPanel && canConnect && (
<ExternalAPIPanel
canManageExternalKnowledgeApi={canConnect}

View File

@ -139,6 +139,7 @@ export const TagFilter = ({
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags
onOpenTagManagement={onOpenTagManagement}
onClose={() => setOpen(false)}
/>

View File

@ -26,6 +26,7 @@ type TagSearchContentProps = {
onOpenTagManagement?: () => void
onClose?: () => void
canBindOrUnbindTags?: boolean
requiresTargetEditPermission?: boolean
}
export const TagSearchContent = ({
@ -35,10 +36,14 @@ export const TagSearchContent = ({
onOpenTagManagement,
onClose,
canBindOrUnbindTags = false,
requiresTargetEditPermission = false,
}: TagSearchContentProps) => {
const { t } = useTranslation()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
const canChangeBindings = requiresTargetEditPermission
? canBindOrUnbindTags
: canBindOrUnbindTags || canManageTags
const filteredItems = useComboboxFilteredItems<TagComboboxItem>()
const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length
const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || ''
@ -93,11 +98,7 @@ export const TagSearchContent = ({
}
return (
<ComboboxItem
key={tag.id}
value={tag}
disabled={!canBindOrUnbindTags && !canManageTags}
>
<ComboboxItem key={tag.id} value={tag} disabled={!canChangeBindings}>
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>

View File

@ -64,8 +64,11 @@ export type TagSelectorProps = TagSelectorRootProps &
type: TagType
value: Tag[]
canBindOrUnbindTags?: boolean
requiresTargetEditPermission?: boolean
showProvidedTagNames?: boolean
onOpenTagManagement?: () => void
onTagsChange?: () => void
onApplyTags?: (tagIds: string[]) => void
}
export const TagSelector = ({
@ -73,10 +76,13 @@ export const TagSelector = ({
type,
value,
canBindOrUnbindTags,
requiresTargetEditPermission = false,
showProvidedTagNames = false,
className,
onClick,
onOpenTagManagement = () => {},
onTagsChange,
onApplyTags,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
@ -92,6 +98,9 @@ export const TagSelector = ({
const [inputValue, setInputValue] = useState('')
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
const canChangeBindings = requiresTargetEditPermission
? !!canBindOrUnbindTags
: !!canBindOrUnbindTags || canManageTags
const applyTagBindingsMutation = useApplyTagBindingsMutation()
const { isPending: isCreatingTag, mutate: createTag } = useMutation(
@ -113,10 +122,10 @@ export const TagSelector = ({
const tagNameById = new Map(tagList.map((tag) => [tag.id, tag.name]))
return value.flatMap((tag) => {
const tagName = tagNameById.get(tag.id)
const tagName = tagNameById.get(tag.id) ?? (showProvidedTagNames ? tag.name : undefined)
return tagName ? [tagName] : []
})
}, [tagList, value])
}, [showProvidedTagNames, tagList, value])
const emptyTriggerLabel = canBindOrUnbindTags
? t(($) => $['tag.addTag'], { ns: 'common' })
: t(($) => $['tag.noTag'], { ns: 'common' })
@ -159,6 +168,11 @@ export const TagSelector = ({
if (!tagSelectionChanged) return
if (onApplyTags) {
onApplyTags(draftTagIds)
return
}
const toastId = `tag-bindings-${type}-${targetId}`
applyTagBindingsMutation.mutate(
@ -190,7 +204,16 @@ export const TagSelector = ({
},
},
)
}, [applyTagBindingsMutation, draftTags, onTagsChange, selectedTagIds, t, targetId, type])
}, [
applyTagBindingsMutation,
draftTags,
onApplyTags,
onTagsChange,
selectedTagIds,
t,
targetId,
type,
])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
@ -259,7 +282,7 @@ export const TagSelector = ({
isItemEqualToValue={isSameTag}
>
<ComboboxTrigger
disabled={!canManageTags && !canBindOrUnbindTags}
disabled={!canChangeBindings}
aria-label={triggerLabel}
className={cn(
'group/tag-area relative h-auto w-full cursor-pointer rounded-lg border-0 bg-transparent p-1 hover:bg-state-base-hover focus-visible:bg-transparent data-disabled:bg-transparent data-disabled:opacity-50 data-disabled:hover:bg-transparent data-popup-open:bg-state-base-hover data-popup-open:hover:bg-state-base-hover',
@ -291,6 +314,7 @@ export const TagSelector = ({
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags={canBindOrUnbindTags}
requiresTargetEditPermission={requiresTargetEditPermission}
onOpenTagManagement={onOpenTagManagement}
onClose={() => handleOpenChange(false)}
/>

View File

@ -504,6 +504,18 @@ describe('normalizeConsoleOpenAPIURL', () => {
expect(searchParams.has('creator_ids[0]')).toBe(false)
})
it('should serialize KnowledgeFS list query arrays as repeated params', () => {
const url = normalizeConsoleOpenAPIURL(
'https://example.com/console/api/knowledge-fs/spaces?tag_ids%5B0%5D=tag-1&creator_ids%5B0%5D=user-1',
)
const searchParams = new URL(url).searchParams
expect(searchParams.getAll('tag_ids')).toEqual(['tag-1'])
expect(searchParams.getAll('creator_ids')).toEqual(['user-1'])
expect(searchParams.has('tag_ids[0]')).toBe(false)
expect(searchParams.has('creator_ids[0]')).toBe(false)
})
it('should serialize snippet list query arrays as repeated params', () => {
const url = normalizeConsoleOpenAPIURL(
'https://example.com/console/api/workspaces/current/customized-snippets?tag_ids%5B0%5D=tag-1&creators%5B0%5D=user-1',

View File

@ -15,7 +15,7 @@ const repeatedQueryArrayRules: readonly QueryArrayCompatibilityRule[] = [
{ path: /\/datasets$/, fields: ['ids', 'tag_ids'] },
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segment\/[^/]+$/, fields: ['segment_id'] },
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] },
{ path: /\/knowledge-fs\/spaces$/, fields: ['creator_ids'] },
{ path: /\/knowledge-fs\/spaces$/, fields: ['creator_ids', 'tag_ids'] },
{ path: /\/trial-apps\/[^/]+\/datasets$/, fields: ['ids'] },
{ path: /\/workspaces\/current\/customized-snippets$/, fields: ['tag_ids', 'creators'] },
{ path: /\/workspaces\/current\/plugin\/[^/]+\/list$/, fields: ['tags'] },