diff --git a/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts index 26b6f7ddb7b..3c01e40dc55 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts @@ -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, } diff --git a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts index 14cf22ada5a..75ca2570d25 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts @@ -1084,6 +1084,14 @@ export type KnowledgeFsSourceWorkflowImportPayload = kind: 'online-drive-import' } & KnowledgeFsOnlineDriveWorkflowImportPayload) +export type KnowledgeFsSpaceTagListResponse = { + data: Array +} + +export type KnowledgeFsSpaceTagsReplacePayload = { + tag_ids?: Array +} + export type KnowledgeFsTraceListResponse = { data: Array next_cursor?: string | null @@ -1220,6 +1228,7 @@ export type KnowledgeFsSpaceListItemResponse = { permission_keys: Array resource_version: number state: KnowledgeFsControlSpaceState + tags?: Array 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 } 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: { diff --git a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts index c123bdf5564..c16d1879c0f 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts @@ -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(), }) diff --git a/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx b/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx index 1daf17dbe47..108f9536f8a 100644 --- a/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx +++ b/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx @@ -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[] }) => ( + + ), +})) + +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 }> } + }) => ( +
+ dataset.newKnowledge.tags + {knowledgeSpace.tags?.map((tag) => tag.name).join(', ')} +
+ ), +})) + 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(, { + 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() diff --git a/web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx new file mode 100644 index 00000000000..c6089b1dfd5 --- /dev/null +++ b/web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx @@ -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() + return { + ...original, + useMutation: (options: { + mutationFn?: (input: unknown) => Promise + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByRole('combobox', { name: 'Frontend, Backend' })).toBeDisabled() + expect(putTags).not.toHaveBeenCalled() + }) +}) diff --git a/web/features/new-rag/components/knowledge-space-card-tags.tsx b/web/features/new-rag/components/knowledge-space-card-tags.tsx new file mode 100644 index 00000000000..15f36a57c97 --- /dev/null +++ b/web/features/new-rag/components/knowledge-space-card-tags.tsx @@ -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( + () => + (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 ( + + replaceTagsMutation.mutate({ + params: { control_space_id: knowledgeSpace.control_space_id }, + body: { tag_ids: tagIds }, + }) + } + className="relative z-1 mx-3 w-auto" + /> + ) +} diff --git a/web/features/new-rag/components/knowledge-space-card.tsx b/web/features/new-rag/components/knowledge-space-card.tsx index c3c9b98bb23..4b8e91b40c0 100644 --- a/web/features/new-rag/components/knowledge-space-card.tsx +++ b/web/features/new-rag/components/knowledge-space-card.tsx @@ -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 ( -
  • +
  • -
    +
    $['newKnowledge.cardType'])} title={iconName} @@ -53,40 +55,35 @@ export function KnowledgeSpaceCard({
    -

    +

    {summary?.description || t(($) => $['newKnowledge.noDescription'])}

    -
    $['newKnowledge.tags'])}. ${unavailable}`} - className="mt-1 flex min-w-0 items-center gap-1 px-4" - > - - {t(($) => $['newKnowledge.tags'])} - - {unavailable} -
    -
    - - - {summary?.document_count ?? 0} - - - - {linkedApps} - - {t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps} - - - - / - - - {t(($) => $['newKnowledge.updated'], { - date: formattedUpdatedAt, - })} - -
    + +
    + + + {summary?.document_count ?? 0} + + + + {linkedApps} + + {t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps} + + + + / + + + {t(($) => $['newKnowledge.updated'], { + date: formattedUpdatedAt, + })} + +
  • ) diff --git a/web/features/new-rag/new-knowledge-list.tsx b/web/features/new-rag/new-knowledge-list.tsx index 8d056dc5866..f1f5a2e2c73 100644 --- a/web/features/new-rag/new-knowledge-list.tsx +++ b/web/features/new-rag/new-knowledge-list.tsx @@ -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({ .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({ + 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 ( - - ) -} - 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({
    - $['newKnowledge.tags'])} onClick={showFilterBoundary} /> + void setTagIds(nextTagIds)} + onOpenTagManagement={() => setShowTagManagementModal(true)} + /> void setCreatorIds(nextCreatorIds)} @@ -209,7 +221,7 @@ export function NewKnowledgeList({ } />
    - ) : knowledgeSpaces.length === 0 && creatorIds.length === 0 ? ( + ) : knowledgeSpaces.length === 0 && creatorIds.length === 0 && tagIds.length === 0 ? ( {tCommon(($) => $['operation.noSearchResults'], { - content: t(($) => $['newKnowledge.creators']), + content: t(($) => $.knowledge), })}
    ) : ( @@ -228,6 +240,7 @@ export function NewKnowledgeList({ setShowTagManagementModal(true)} /> ))} @@ -251,6 +264,16 @@ export function NewKnowledgeList({ )} + setShowTagManagementModal(false)} + onTagsChange={() => { + void queryClient.invalidateQueries({ + queryKey: consoleQuery.knowledgeFs.spaces.get.key(), + }) + }} + /> {showExternalApiPanel && canConnect && ( setOpen(false)} /> diff --git a/web/features/tag-management/components/tag-search-content.tsx b/web/features/tag-management/components/tag-search-content.tsx index 2604609230d..0b3067ac3cb 100644 --- a/web/features/tag-management/components/tag-search-content.tsx +++ b/web/features/tag-management/components/tag-search-content.tsx @@ -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() const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || '' @@ -93,11 +98,7 @@ export const TagSearchContent = ({ } return ( - + {tag.name} diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index 9373debe15e..291792a10d0 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -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} > handleOpenChange(false)} /> diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index fdcbd6a3d4f..fb8db728935 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -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', diff --git a/web/service/console-openapi-url.ts b/web/service/console-openapi-url.ts index cb6f2c5826a..c3d60d5ad43 100644 --- a/web/service/console-openapi-url.ts +++ b/web/service/console-openapi-url.ts @@ -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'] },