From 30c84ce58cbc2fb89f7772fffe6d60b5c0163237 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:14:47 +0800 Subject: [PATCH 1/4] feat(knowledge-fs): support tag management and filtering (WTA-1980) --- .../api/console/knowledge-fs/orpc.gen.ts | 64 ++++-- .../api/console/knowledge-fs/types.gen.ts | 48 +++++ .../api/console/knowledge-fs/zod.gen.ts | 101 ++++++--- .../__tests__/new-knowledge-list.spec.tsx | 70 +++++- .../knowledge-space-card-tags.spec.tsx | 203 ++++++++++++++++++ .../components/knowledge-space-card-tags.tsx | 67 ++++++ .../components/knowledge-space-card.tsx | 67 +++--- web/features/new-rag/new-knowledge-list.tsx | 63 ++++-- .../tag-management/components/tag-filter.tsx | 1 + .../components/tag-search-content.tsx | 11 +- .../components/tag-selector.tsx | 32 ++- web/service/client.spec.ts | 12 ++ web/service/console-openapi-url.ts | 2 +- 13 files changed, 631 insertions(+), 110 deletions(-) create mode 100644 web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx create mode 100644 web/features/new-rag/components/knowledge-space-card-tags.tsx 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'] }, From 2a77d24d44dbb51f96f31a2618e3c0cd643a02b6 Mon Sep 17 00:00:00 2001 From: Jyong Date: Thu, 13 Aug 2026 03:58:34 -0400 Subject: [PATCH 2/4] Supersede pending retrieval candidates on settings updates --- api/knowledge-fs-contract.lock.json | 2 +- .../api/src/knowledge-space-handlers.ts | 131 ++++++++++--- ...dge-space-profile-handler-behavior.test.ts | 184 +++++++++++++++--- ...file-migration-database-repository.test.ts | 36 ++++ ...e-profile-migration-database-repository.ts | 32 +++ ...profile-migration-service-behavior.test.ts | 29 +++ ...owledge-space-profile-migration-service.ts | 14 ++ .../knowledge-space-profile-migration.test.ts | 14 ++ .../src/knowledge-space-profile-migration.ts | 20 ++ 9 files changed, 404 insertions(+), 58 deletions(-) diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index 0d4db2b758e..dddcd62e890 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,6 +1,6 @@ { "schemaVersion": 5, - "subtreeTree": "67bd568555a900179301ec69121b276a88332aa3", + "subtreeTree": "7250e700e493b975bf070a97d5424c5a0c7def62", "openapiSha256": "47936a7d9ffdc27e2b2b8982a90e1936dc3bf59a64c316f452a6912ec1d2fcd6", "capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", diff --git a/knowledge-fs/packages/api/src/knowledge-space-handlers.ts b/knowledge-fs/packages/api/src/knowledge-space-handlers.ts index 86016b41105..a59dd907186 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-handlers.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-handlers.ts @@ -70,6 +70,7 @@ import { isTerminalKnowledgeSpaceProfileMigrationError, } from "./knowledge-space-profile-migration"; import { + type KnowledgeSpaceProfileMigrationPrincipal, type KnowledgeSpaceProfileMigrationService, KnowledgeSpaceProfileMigrationServiceError, toPublicKnowledgeSpaceProfileMigration, @@ -1223,37 +1224,11 @@ export function registerKnowledgeSpaceHandlers({ 409, ); } - const candidateRevision = await nextPublishedRetrievalCandidateRevision(profiles, { - activeRevision: actualRevision, - knowledgeSpaceId, - tenantId: subject.tenantId, - }); - const candidateSnapshot = createKnowledgeSpaceRetrievalProfile( - body.profile, - candidateRevision, - ); const capabilitySnapshot = { reasoning: capabilitySnapshots.reasoning ?? null, rerank: capabilitySnapshots.rerank ?? null, verification: "verified", } as const; - let candidate: KnowledgeSpaceProfileRevision; - try { - candidate = await getOrCreateSettingsProfileCandidate(profiles, { - capabilitySnapshot, - createdBySubjectId: subject.subjectId, - kind: "retrieval", - knowledgeSpaceId, - now: now(), - snapshot: candidateSnapshot, - tenantId: subject.tenantId, - }); - } catch (error) { - if (error instanceof SettingsProfileCandidateConflictError) { - return context.json({ code: error.code, error: error.message }, 409); - } - throw error; - } const authenticatedApiKey = context.get("authenticatedApiKey"); const capabilityGrantId = context.get("capabilityV2Grant")?.grantId; const migrationPrincipal = { @@ -1264,7 +1239,21 @@ export function registerKnowledgeSpaceHandlers({ subject, } as const; try { - let migration = await profileMigrations.request({ + const candidate = await getOrSupersedeRetrievalSettingsCandidate({ + activeRevision: actualRevision, + capabilitySnapshot, + createdBySubjectId: subject.subjectId, + migrationPrincipal, + migrations: profileMigrations, + now, + profile: body.profile, + profiles, + }); + let migration = await profileMigrations.findByCandidate({ + ...migrationPrincipal, + candidateProfileId: candidate.id, + }); + migration ??= await profileMigrations.request({ ...migrationPrincipal, candidateRevision: candidate.revision, changedKind: "retrieval", @@ -1291,6 +1280,9 @@ export function registerKnowledgeSpaceHandlers({ } return context.json(toPublicKnowledgeSpaceProfileMigration(migration), 202); } catch (error) { + if (error instanceof SettingsProfileCandidateConflictError) { + return context.json({ code: error.code, error: error.message }, 409); + } if (error instanceof KnowledgeSpaceProfileMigrationConflictError) { return context.json({ code: error.code, error: error.message }, 409); } @@ -2592,6 +2584,91 @@ class SettingsProfileCandidateConflictError extends Error { } } +async function getOrSupersedeRetrievalSettingsCandidate(input: { + readonly activeRevision: number; + readonly capabilitySnapshot: Readonly>; + readonly createdBySubjectId: string; + readonly migrationPrincipal: KnowledgeSpaceProfileMigrationPrincipal & { + readonly knowledgeSpaceId: string; + }; + readonly migrations: KnowledgeSpaceProfileMigrationService; + readonly now: () => string; + readonly profile: KnowledgeSpaceRetrievalProfileInput; + readonly profiles: KnowledgeSpaceProfileRepository; +}): Promise { + for (;;) { + const candidateRevision = await nextPublishedRetrievalCandidateRevision(input.profiles, { + activeRevision: input.activeRevision, + knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId, + tenantId: input.migrationPrincipal.subject.tenantId, + }); + const candidateInput = { + capabilitySnapshot: input.capabilitySnapshot, + createdBySubjectId: input.createdBySubjectId, + kind: "retrieval" as const, + knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId, + now: input.now(), + snapshot: createKnowledgeSpaceRetrievalProfile(input.profile, candidateRevision), + tenantId: input.migrationPrincipal.subject.tenantId, + }; + try { + return await getOrCreateSettingsProfileCandidate(input.profiles, candidateInput); + } catch (error) { + if (!(error instanceof SettingsProfileCandidateConflictError)) throw error; + const existing = await input.profiles.getRevision({ + kind: "retrieval", + knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId, + revision: candidateRevision, + tenantId: input.migrationPrincipal.subject.tenantId, + }); + if ( + !existing || + existing.state !== "candidate" || + existing.createdBySubjectId !== input.createdBySubjectId || + existing.snapshotDigest === knowledgeSpaceProfileSnapshotDigest(candidateInput.snapshot) + ) { + throw error; + } + + const previousMigration = await input.migrations.findByCandidate({ + ...input.migrationPrincipal, + candidateProfileId: existing.id, + }); + if (previousMigration) { + const canceled = await input.migrations.cancel({ + ...input.migrationPrincipal, + reason: "Superseded by a newer retrieval settings update", + runId: previousMigration.id, + }); + if (!canceled || canceled.runState !== "canceled") throw error; + } else { + try { + await input.profiles.failCandidate({ + errorCode: "PROFILE_SETTINGS_CANDIDATE_SUPERSEDED", + errorMessage: "Superseded by a newer retrieval settings update", + kind: "retrieval", + knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId, + now: input.now(), + revision: existing.revision, + tenantId: input.migrationPrincipal.subject.tenantId, + }); + } catch (retirementError) { + if (retirementError instanceof KnowledgeSpaceProfileTransitionError) throw error; + throw retirementError; + } + } + + const retired = await input.profiles.getRevision({ + kind: "retrieval", + knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId, + revision: existing.revision, + tenantId: input.migrationPrincipal.subject.tenantId, + }); + if (!retired || retired.state === "candidate") throw error; + } + } +} + async function nextPublishedRetrievalCandidateRevision( profiles: KnowledgeSpaceProfileRepository, input: { diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts index 839e6cfac71..fb01ba386dd 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts @@ -188,13 +188,13 @@ function retrievalUpdateBody() { } as const; } -function rerankOnlyRetrievalUpdateBody() { +function rerankOnlyRetrievalUpdateBody(rerankModel = RERANK_V2) { return { expectedRevision: 1, profile: { defaultMode: "deep", reasoningModel: REASONING_V1, - rerank: { enabled: true, model: RERANK_V2 }, + rerank: { enabled: true, model: rerankModel }, scoreThreshold: { enabled: false, stage: "mode-final" }, topK: 8, }, @@ -743,6 +743,7 @@ describe("knowledge-space profile handler behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request, requiresMigration: async () => true, @@ -793,6 +794,7 @@ describe("knowledge-space profile handler behavior", () => { knowledgeSpaceManifests: retrievalManifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request, requiresMigration: async () => true, @@ -852,6 +854,121 @@ describe("knowledge-space profile handler behavior", () => { ).resolves.toMatchObject({ state: "candidate" }); }); + it("supersedes an owned pending retrieval candidate when the rerank model changes", async () => { + const manifests = createInMemoryKnowledgeSpaceManifestRepository({ + maxListLimit: 10, + maxManifests: 10, + }); + const profiles = profileRepository(); + const request = vi.fn( + async (input: { readonly candidateRevision: number }) => + ({ + changedKind: "retrieval", + checkpoint: "queued", + createdAt: NOW, + id: `migration-retrieval-${input.candidateRevision}`, + knowledgeSpaceId: SPACE_ID, + rebuildScope: "clone-publication", + runState: "queued", + updatedAt: NOW, + }) as never, + ); + const cancel = vi.fn(async (input: { readonly runId: string }) => { + expect(input.runId).toBe("migration-retrieval-2"); + await profiles.failCandidate({ + errorCode: "PROFILE_MIGRATION_CANCELED", + errorMessage: "Superseded by a newer retrieval settings update", + kind: "retrieval", + knowledgeSpaceId: SPACE_ID, + now: NOW, + revision: 2, + tenantId: "tenant-1", + }); + return { + changedKind: "retrieval", + checkpoint: "queued", + completedAt: NOW, + createdAt: NOW, + id: input.runId, + knowledgeSpaceId: SPACE_ID, + rebuildScope: "clone-publication", + runState: "canceled", + updatedAt: NOW, + } as never; + }); + const findByCandidate = vi.fn(async (input: { readonly candidateProfileId: string }) => { + const revision = await profiles.getRevision({ + kind: "retrieval", + knowledgeSpaceId: SPACE_ID, + revision: 2, + tenantId: "tenant-1", + }); + if (request.mock.calls.length === 0 || revision?.id !== input.candidateProfileId) return null; + return { + changedKind: "retrieval", + checkpoint: "queued", + createdAt: NOW, + id: "migration-retrieval-2", + knowledgeSpaceId: SPACE_ID, + rebuildScope: "clone-publication", + runState: "queued", + updatedAt: NOW, + } as never; + }); + const app = publishedProfileApp(manifests, profiles, { + cancel, + findByCandidate, + get: async () => null, + request, + requiresMigration: async () => true, + retry: async () => null, + }); + await createSpace(app); + await seedLegacyManifestWithoutRerank(manifests); + + const first = await app.request(`/knowledge-spaces/${SPACE_ID}/retrieval-profile`, { + body: JSON.stringify(rerankOnlyRetrievalUpdateBody(RERANK_V1)), + headers: headers(), + method: "PUT", + }); + expect(first.status).toBe(202); + + const replacement = await app.request(`/knowledge-spaces/${SPACE_ID}/retrieval-profile`, { + body: JSON.stringify(rerankOnlyRetrievalUpdateBody(RERANK_V2)), + headers: headers(), + method: "PUT", + }); + const replacementBody = await replacement.json(); + + expect(replacement.status, JSON.stringify(replacementBody)).toBe(202); + expect(replacementBody).toMatchObject({ + id: "migration-retrieval-3", + runState: "queued", + }); + expect(request).toHaveBeenCalledTimes(2); + expect(findByCandidate).toHaveBeenCalledTimes(3); + expect(cancel).toHaveBeenCalledOnce(); + await expect( + profiles.getRevision({ + kind: "retrieval", + knowledgeSpaceId: SPACE_ID, + revision: 2, + tenantId: "tenant-1", + }), + ).resolves.toMatchObject({ state: "failed" }); + await expect( + profiles.getRevision({ + kind: "retrieval", + knowledgeSpaceId: SPACE_ID, + revision: 3, + tenantId: "tenant-1", + }), + ).resolves.toMatchObject({ + snapshot: { rerank: { enabled: true, model: RERANK_V2 } }, + state: "candidate", + }); + }); + it("retries a nonterminal failed retrieval migration when settings are saved again", async () => { const manifests = createInMemoryKnowledgeSpaceManifestRepository({ maxListLimit: 10, @@ -887,6 +1004,7 @@ describe("knowledge-space profile handler behavior", () => { ); const app = publishedProfileApp(manifests, profiles, { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request, requiresMigration: async () => true, @@ -921,21 +1039,20 @@ describe("knowledge-space profile handler behavior", () => { maxManifests: 10, }); const profiles = profileRepository(); - const request = vi.fn( - async () => - ({ - changedKind: "retrieval", - checkpoint: "queued", - createdAt: NOW, - id: "migration-retrieval", - knowledgeSpaceId: SPACE_ID, - rebuildScope: "clone-publication", - runState: "queued", - updatedAt: NOW, - }) as never, - ); + const migration = { + changedKind: "retrieval", + checkpoint: "queued", + createdAt: NOW, + id: "migration-retrieval", + knowledgeSpaceId: SPACE_ID, + rebuildScope: "clone-publication", + runState: "queued", + updatedAt: NOW, + } as never; + const request = vi.fn(async () => migration); const app = publishedProfileApp(manifests, profiles, { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request, requiresMigration: async () => true, @@ -994,23 +1111,23 @@ describe("knowledge-space profile handler behavior", () => { const profiles = profileRepository(); let checkedAt = NOW; let capabilityDigest = `sha256:${"c".repeat(64)}`; - const request = vi.fn( - async () => - ({ - changedKind: "retrieval", - checkpoint: "queued", - createdAt: NOW, - id: "migration-retrieval", - knowledgeSpaceId: SPACE_ID, - rebuildScope: "clone-publication", - runState: "queued", - updatedAt: NOW, - }) as never, - ); + const migration = { + changedKind: "retrieval", + checkpoint: "queued", + createdAt: NOW, + id: "migration-retrieval", + knowledgeSpaceId: SPACE_ID, + rebuildScope: "clone-publication", + runState: "queued", + updatedAt: NOW, + } as never; + const request = vi.fn(async () => migration); + const findByCandidate = vi.fn(async () => (request.mock.calls.length === 0 ? null : migration)); const app = profileApp({ knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate, get: async () => null, request, requiresMigration: async () => true, @@ -1061,7 +1178,8 @@ describe("knowledge-space profile handler behavior", () => { changedKind: "retrieval", id: "migration-retrieval", }); - expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenCalledOnce(); + expect(findByCandidate).toHaveBeenCalledTimes(2); capabilityDigest = `sha256:${"e".repeat(64)}`; const changedCapability = await app.request(`/knowledge-spaces/${SPACE_ID}/retrieval-profile`, { @@ -1073,7 +1191,7 @@ describe("knowledge-space profile handler behavior", () => { await expect(changedCapability.json()).resolves.toMatchObject({ code: "KNOWLEDGE_SPACE_SETTINGS_CANDIDATE_CONFLICT", }); - expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenCalledOnce(); }); it("carries an integrated settings Capability grant into the durable migration", async () => { @@ -1121,6 +1239,7 @@ describe("knowledge-space profile handler behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request, requiresMigration: async () => true, @@ -1182,6 +1301,7 @@ describe("knowledge-space profile handler behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request, requiresMigration: async () => true, @@ -2403,6 +2523,7 @@ describe("knowledge-space profile failure behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request: async () => ({}) as never, requiresMigration: async () => true, @@ -2539,6 +2660,7 @@ describe("knowledge-space profile failure behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request: async () => ({}) as never, requiresMigration: async () => true, @@ -2604,6 +2726,7 @@ describe("knowledge-space profile failure behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request: async () => ({}) as never, requiresMigration: async () => true, @@ -2653,6 +2776,7 @@ describe("knowledge-space profile failure behavior", () => { knowledgeSpaceManifests: manifests, knowledgeSpaceProfileMigrations: { cancel: async () => null, + findByCandidate: async () => null, get: async () => null, request: async () => ({}) as never, requiresMigration: async () => true, diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts index 2295ae689d0..cd6314379d3 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts @@ -135,6 +135,42 @@ describe.each(["postgres", "tidb"] as const)( ).toBe(true); }); + it("looks up the latest candidate migration independently of authorization provenance", async () => { + const calls: DatabaseExecuteInput[] = []; + const execute = async (input: DatabaseExecuteInput): Promise => { + calls.push(input); + return { rows: [runRow()], rowsAffected: 1 }; + }; + const database = createSchemaDatabaseAdapter({ + executor: execute, + kind: dialect, + transaction: async (callback) => callback({ execute }), + }); + const repository = createDatabaseKnowledgeSpaceProfileMigrationRepository({ + database, + maxClaimBatchSize: 10, + }); + + await expect( + repository.findLatestByCandidate({ + candidateProfileId: candidateId, + knowledgeSpaceId: spaceId, + tenantId, + }), + ).resolves.toMatchObject({ id: runId, runState: "queued" }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.params).toEqual([tenantId, spaceId, candidateId]); + expect(calls[0]?.sql).toContain( + dialect === "postgres" + ? '"candidate_profile_revision_id"' + : "`candidate_profile_revision_id`", + ); + expect(calls[0]?.sql).toContain( + dialect === "postgres" ? '"active_slot" IS NOT NULL' : "`active_slot` IS NOT NULL", + ); + }); + it("rejects malformed admissions and unsafe claim leases before database access", async () => { const execute = async (): Promise => { throw new Error("database should not be called"); diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts index 902dedffdcb..19f4edd8204 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts @@ -234,6 +234,7 @@ export function createDatabaseKnowledgeSpaceProfileMigrationRepository({ get: (id) => getById(database, database, id, false), findByRequest: (input) => getByIdempotency(database, database, input, false), + findLatestByCandidate: (input) => getLatestByCandidate(database, database, input), claim: async (input) => { positiveInteger(input.limit, "claim.limit"); @@ -1135,6 +1136,37 @@ async function getByIdempotency( return replay; } +async function getLatestByCandidate( + database: DatabaseAdapter, + executor: DatabaseExecutor, + input: { + readonly candidateProfileId: string; + readonly knowledgeSpaceId: string; + readonly tenantId: string; + }, +): Promise { + const result = await executor.execute({ + maxRows: 1, + operation: "select", + params: [input.tenantId, input.knowledgeSpaceId, input.candidateProfileId], + sql: `SELECT * FROM ${q(database, runTable)} WHERE ${q(database, "tenant_id")} = ${p( + database, + 1, + )} AND ${q(database, "knowledge_space_id")} = ${p(database, 2)} AND ${q( + database, + "candidate_profile_revision_id", + )} = ${p(database, 3)} ORDER BY CASE WHEN ${q( + database, + "active_slot", + )} IS NOT NULL THEN 0 ELSE 1 END ASC, ${q(database, "created_at")} DESC, ${q( + database, + "id", + )} DESC LIMIT 1;`, + tableName: runTable, + }); + return result.rows[0] ? mapRun(result.rows[0]) : null; +} + function profileMigrationIdempotencyDigest(input: { readonly capabilityGrantId?: string | undefined; readonly idempotencyKey: string; diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts index dbc9960853d..827af0f90eb 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts @@ -114,6 +114,35 @@ describe("knowledge-space profile migration service behavior", () => { ); }); + it("finds and cancels a candidate migration after capability grant rotation", async () => { + const fixture = serviceFixture(); + const run = await fixture.service.request(capabilityRequest()); + const rotatedCapabilityGrantId = "70000000-0000-4000-8000-000000000002"; + + await expect( + fixture.service.findByCandidate({ + callerKind: "agent", + candidateProfileId: run.candidateProfile.id, + capabilityGrantId: rotatedCapabilityGrantId, + knowledgeSpaceId: spaceId, + subject, + }), + ).resolves.toEqual(run); + await expect( + fixture.service.cancel({ + callerKind: "agent", + capabilityGrantId: rotatedCapabilityGrantId, + knowledgeSpaceId: spaceId, + runId: run.id, + subject, + }), + ).resolves.toMatchObject({ + capabilityGrantId: rotatedCapabilityGrantId, + runState: "canceled", + }); + expect(fixture.authorization.authorize).not.toHaveBeenCalled(); + }); + it("classifies retrieval migrations from reasoning-model compatibility", async () => { const compatibleCandidate = profileRevision( "retrieval", diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts index dd0479e9075..2bbb1d14fbf 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts @@ -54,6 +54,12 @@ export interface KnowledgeSpaceProfileMigrationService { readonly runId: string; }, ): Promise; + findByCandidate( + input: KnowledgeSpaceProfileMigrationPrincipal & { + readonly candidateProfileId: string; + readonly knowledgeSpaceId: string; + }, + ): Promise; requiresMigration(input: { readonly knowledgeSpaceId: string; readonly tenantId: string; @@ -241,6 +247,14 @@ export function createKnowledgeSpaceProfileMigrationService({ }); }, get: (input) => getAuthorized(input, input.knowledgeSpaceId, input.runId), + findByCandidate: async (input) => { + await authorize(input, input.knowledgeSpaceId); + return repository.findLatestByCandidate({ + candidateProfileId: input.candidateProfileId, + knowledgeSpaceId: input.knowledgeSpaceId, + tenantId: input.subject.tenantId, + }); + }, cancel: async (input) => { const run = await getAuthorized(input, input.knowledgeSpaceId, input.runId); if (!run) return null; diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts index 37553908e6c..1109d5e35e4 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts @@ -31,6 +31,20 @@ describe("knowledge-space profile migration durable repository", () => { const input = startInput(); const first = await repository.start(input); expect(await repository.start(input)).toEqual(first); + await expect( + repository.findLatestByCandidate({ + candidateProfileId: input.candidateProfile.id, + knowledgeSpaceId: spaceId, + tenantId, + }), + ).resolves.toEqual(first); + await expect( + repository.findLatestByCandidate({ + candidateProfileId: input.candidateProfile.id, + knowledgeSpaceId: spaceId, + tenantId: "tenant-other", + }), + ).resolves.toBeNull(); await expect( repository.start({ ...input, diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts index 4f5e5422a58..2d76beef4ab 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts @@ -149,6 +149,11 @@ export interface KnowledgeSpaceProfileMigrationRepository { readonly requestedBySubjectId?: string | undefined; readonly tenantId: string; }): Promise; + findLatestByCandidate(input: { + readonly candidateProfileId: string; + readonly knowledgeSpaceId: string; + readonly tenantId: string; + }): Promise; get(runId: string): Promise; heartbeat( input: KnowledgeSpaceProfileMigrationFence & { @@ -270,6 +275,21 @@ export function createInMemoryKnowledgeSpaceProfileMigrationRepository({ const id = requestKeys.get(key); return id ? (runs.get(id) ?? null) : null; }, + findLatestByCandidate: async (input) => + [...runs.values()] + .filter( + (run) => + run.tenantId === input.tenantId && + run.knowledgeSpaceId === input.knowledgeSpaceId && + run.candidateProfile.id === input.candidateProfileId, + ) + .sort( + (left, right) => + Number(right.runState === "queued" || right.runState === "running") - + Number(left.runState === "queued" || left.runState === "running") || + right.createdAt.localeCompare(left.createdAt) || + right.id.localeCompare(left.id), + )[0] ?? null, claim: async (raw) => { const now = validDate(raw.now, "claim.now"); const leaseExpiresAt = validDate(raw.leaseExpiresAt, "claim.leaseExpiresAt"); From 126394d1578a5df945aea2f6d160eb6e052231ec Mon Sep 17 00:00:00 2001 From: Jyong Date: Thu, 13 Aug 2026 04:16:41 -0400 Subject: [PATCH 3/4] Show document task failure reasons in status popovers --- .../new-rag/__tests__/documents-page.spec.tsx | 41 ++++++++++ web/features/new-rag/document-list.tsx | 80 +++++++++++++++---- web/features/new-rag/documents-page.tsx | 25 ++++++ 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/web/features/new-rag/__tests__/documents-page.spec.tsx b/web/features/new-rag/__tests__/documents-page.spec.tsx index fa197cb8fab..5a700528c92 100644 --- a/web/features/new-rag/__tests__/documents-page.spec.tsx +++ b/web/features/new-rag/__tests__/documents-page.spec.tsx @@ -199,6 +199,7 @@ const taskApiResponse = vi.hoisted(() => (item: BackgroundTask) => ({ document_revision: item.documentRevision ?? null, error_code: item.errorCode ?? null, error_message: item.errorMessage ?? null, + failure: item.failure ?? null, id: item.id, knowledge_space_id: item.knowledgeSpaceId, operation: item.operation ?? 'document_processing', @@ -1010,6 +1011,46 @@ describe('DocumentsPage', () => { expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument() }) + it('reveals the latest document task failure reason from the failed status', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [{ items: [document({ id: 'failed-document', title: 'Failed report.pdf' })] }], + } + tasksQuery.data = { + pages: [ + { + items: [ + task({ + documentId: 'failed-document', + failure: { + action: 'configure_model', + category: 'configuration', + code: 'MODEL_SELECTION_NOT_FOUND', + message: 'Select another model.', + retryPolicy: 'after_configuration', + }, + id: 'failed-task', + state: 'failed', + }), + ], + }, + ], + } + + render() + + const failedStatus = screen.getByRole('button', { + name: 'dataset.newKnowledge.documentStatus.failed: dataset.newKnowledge.taskFailure.configuration', + }) + expect(screen.queryByText('dataset.newKnowledge.taskFailure.configuration')).toBeNull() + + await user.hover(failedStatus) + + expect( + await screen.findByText('dataset.newKnowledge.taskFailure.configuration'), + ).toBeInTheDocument() + }) + it('downloads the active revision from the document action menu', async () => { const user = userEvent.setup() documentsQuery.data = { diff --git a/web/features/new-rag/document-list.tsx b/web/features/new-rag/document-list.tsx index 57b3a54cbf7..9e22907741b 100644 --- a/web/features/new-rag/document-list.tsx +++ b/web/features/new-rag/document-list.tsx @@ -16,6 +16,12 @@ import { import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { Select, SelectContent, @@ -55,6 +61,59 @@ const statusTextClass: Record = { disabled: 'font-medium text-text-tertiary', } +function DocumentStatus({ + failureReason, + status, +}: { + failureReason?: string + status: DocumentDisplayStatus +}) { + const { t } = useTranslation('dataset') + const statusLabel = t(($) => $[`newKnowledge.documentStatus.${status}`]) + const content = ( + <> + + {statusLabel} + + ) + const className = cn( + 'inline-flex items-center gap-1.5 text-xs leading-4', + statusTextClass[status], + ) + + if (status !== 'failed' || !failureReason) return {content} + + return ( + + + {content} + + } + /> + + + {failureReason} + + + + ) +} + function TaskTrigger({ activeTaskCount, attentionTaskBadge, @@ -113,6 +172,7 @@ const DocumentRow = memo( ({ document, documentHref, + failureReason, formatTimeFromNow, canDownload, onDownload, @@ -135,6 +195,7 @@ const DocumentRow = memo( canDownload: boolean document: LogicalDocument documentHref: string + failureReason?: string formatTimeFromNow: (time: number) => string onDownload: (documentId: string) => Promise onRemove: (documentId: string) => Promise @@ -216,21 +277,7 @@ const DocumentRow = memo( {tCommon(($) => $.loading)} ) : ( - - - {t(($) => $[`newKnowledge.documentStatus.${status}`])} - + )} @@ -325,6 +372,7 @@ export function DocumentsList({ canUpload, completingResults, documents, + failureReasons, filter, getDocumentHref, hasNextPage, @@ -374,6 +422,7 @@ export function DocumentsList({ canUpload: boolean completingResults: boolean documents: LogicalDocument[] + failureReasons: Map filter: DocumentFilter getDocumentHref: (documentId: string) => string hasNextPage: boolean @@ -562,6 +611,7 @@ export function DocumentsList({ canDownload={canDownload} document={document} documentHref={getDocumentHref(document.id)} + failureReason={failureReasons.get(document.id)} formatTimeFromNow={formatTimeFromNow} onDownload={onDownloadDocument} onRemove={onRemoveDocument} diff --git a/web/features/new-rag/documents-page.tsx b/web/features/new-rag/documents-page.tsx index 46a62422a62..650c5e087db 100644 --- a/web/features/new-rag/documents-page.tsx +++ b/web/features/new-rag/documents-page.tsx @@ -59,6 +59,7 @@ import { } from './document-models' import { DocumentUploadForm } from './document-upload-form' import { documentUploadIssue } from './document-upload-policy' +import { knowledgeFsTaskFailureMessageKey } from './knowledge-fs-task-error' import { discardKnowledgeFsStagedUpload, stageKnowledgeFsDocument, @@ -785,6 +786,29 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string } ), [documents, taskByDocument], ) + const documentFailureReasons = useMemo( + () => + new Map( + documents.flatMap((document) => { + if (documentStatuses.get(document.id) !== 'failed') return [] + const task = taskByDocument.get(document.id) + const messageKey = + knowledgeFsTaskFailureMessageKey( + task?.failure, + task?.errorCode ?? (task?.errorMessage ? 'LEGACY_TASK_FAILURE' : undefined), + ) ?? 'newKnowledge.taskFailure.internal' + const message = t(($) => $[messageKey]) + const reference = + task?.failure?.traceId && task.failure.category === 'internal' + ? t(($) => $['newKnowledge.taskFailure.reference'], { + traceId: task.failure.traceId, + }) + : undefined + return [[document.id, reference ? `${message}\n${reference}` : message] as const] + }), + ), + [documentStatuses, documents, t, taskByDocument], + ) const filterActive = filter !== 'all' || Boolean(search.trim()) const statusFilterActive = filter !== 'all' const availableDocumentIds = useMemo( @@ -2849,6 +2873,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string } canUpload={canUpload} completingResults={completingFilteredResults} documents={filteredDocuments} + failureReasons={documentFailureReasons} filter={filter} getDocumentHref={(documentId) => newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentId) From e903772ee00b210d42881f5987c5e59d042f2bec Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:23:14 +0800 Subject: [PATCH 4/4] fix(knowledge-fs): hide idle model validation status --- .../knowledge-settings-form.spec.tsx | 4 +- .../__tests__/retrieval-test-page.spec.tsx | 7 +++- .../knowledge-model-readiness-banner.spec.tsx | 12 +----- .../knowledge-model-readiness-banner.tsx | 24 +++++------ .../knowledge-model-readiness-notice.tsx | 17 +++----- .../knowledge-model-setup-dialog.tsx | 4 +- .../new-rag/knowledge-settings-form.tsx | 41 ++++++++----------- web/i18n/ar-TN/dataset.json | 2 + web/i18n/de-DE/dataset.json | 2 + web/i18n/en-US/dataset.json | 2 + web/i18n/es-ES/dataset.json | 2 + web/i18n/fa-IR/dataset.json | 2 + web/i18n/fr-FR/dataset.json | 2 + web/i18n/hi-IN/dataset.json | 2 + web/i18n/id-ID/dataset.json | 2 + web/i18n/it-IT/dataset.json | 2 + web/i18n/ja-JP/dataset.json | 2 + web/i18n/ko-KR/dataset.json | 2 + web/i18n/lo-LA/dataset.json | 2 + web/i18n/nl-NL/dataset.json | 2 + web/i18n/pl-PL/dataset.json | 2 + web/i18n/pt-BR/dataset.json | 2 + web/i18n/ro-RO/dataset.json | 2 + web/i18n/ru-RU/dataset.json | 2 + web/i18n/sl-SI/dataset.json | 2 + web/i18n/th-TH/dataset.json | 2 + web/i18n/tr-TR/dataset.json | 2 + web/i18n/uk-UA/dataset.json | 2 + web/i18n/vi-VN/dataset.json | 2 + web/i18n/zh-Hans/dataset.json | 2 + web/i18n/zh-Hant/dataset.json | 2 + 31 files changed, 93 insertions(+), 64 deletions(-) diff --git a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx index 56ff4a101b2..2daa9135fab 100644 --- a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx +++ b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx @@ -1264,7 +1264,7 @@ describe('KnowledgeSettingsForm', () => { ).not.toHaveAttribute('aria-disabled', 'true') }) - it('keeps API access available while model validation is pending', () => { + it('keeps API access available without showing an idle pending-validation status', () => { renderForm({ settings: { ...settings, @@ -1280,7 +1280,7 @@ describe('KnowledgeSettingsForm', () => { expect(apiAccessSwitch).toHaveAccessibleDescription( 'dataset.newKnowledge.settings.apiAccessDescription', ) - expect(screen.getByRole('status')).toHaveTextContent('common.provider.validating') + expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('shows a recovery alert when initial model validation fails', () => { diff --git a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx index 222ec83a1d7..3ec9b826a1f 100644 --- a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx +++ b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx @@ -426,7 +426,12 @@ describe('RetrievalTestPage', () => { expect(apiMock.queryAdmission).not.toHaveBeenCalled() expect(apiMock.planResearch).not.toHaveBeenCalled() expect(apiMock.createResearch).not.toHaveBeenCalled() - const dialog = screen.getByRole('dialog', { name: 'common.provider.validating' }) + const dialog = screen.getByRole('dialog', { + name: 'dataset.newKnowledge.overview.attention.modelReadiness.pendingTitle', + }) + expect(dialog).toHaveTextContent( + 'dataset.newKnowledge.overview.attention.modelReadiness.pendingDescription', + ) expect(dialog).not.toHaveTextContent( 'dataset.newKnowledge.overview.attention.modelReadiness.profilesMissing', ) diff --git a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx index 34135b9b22c..68b72975228 100644 --- a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx +++ b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx @@ -118,7 +118,7 @@ describe('KnowledgeModelReadinessBanner', () => { ) }) - it('shows validation progress while keeping the configuration route accessible', () => { + it('stays hidden while model validation is waiting for the first document', () => { queryState.data = { ...queryState.data, configuration_state: 'pending-validation', @@ -127,15 +127,7 @@ describe('KnowledgeModelReadinessBanner', () => { render() - expect(screen.getByRole('status')).toHaveTextContent('common.provider.validating') - expect( - screen.getByRole('link', { - name: 'dataset.newKnowledge.overview.attention.action.configureModels', - }), - ).toHaveAttribute( - 'href', - '/datasets/new/space-1/settings?returnTo=%2Fdatasets%2Fnew%2Fspace-1%2Fdocuments%3Fstatus%3Dfailed', - ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('keeps a readiness fetch failure separate and retryable', async () => { diff --git a/web/features/new-rag/components/knowledge-model-readiness-banner.tsx b/web/features/new-rag/components/knowledge-model-readiness-banner.tsx index d992e04be84..6265d1be107 100644 --- a/web/features/new-rag/components/knowledge-model-readiness-banner.tsx +++ b/web/features/new-rag/components/knowledge-model-readiness-banner.tsx @@ -34,10 +34,12 @@ export function KnowledgeModelReadinessBanner({ }), ) const readiness = query.data + const isPendingValidation = readiness?.configuration_state === 'pending-validation' const requestedCapabilityAvailable = capability !== undefined && readiness?.capabilities[capability] === true if ( query.isPending || + isPendingValidation || (!query.isError && (requestedCapabilityAvailable || (capability === undefined && @@ -46,21 +48,17 @@ export function KnowledgeModelReadinessBanner({ ) return null - const isPendingValidation = readiness?.configuration_state === 'pending-validation' const isFailure = query.isError || readiness?.configuration_state === 'validation-failed' const title = query.isError ? tCommon(($) => $['api.actionFailed']) - : isPendingValidation - ? tCommon(($) => $['provider.validating']) - : readiness?.configuration_state === 'validation-failed' - ? tCommon(($) => $['api.actionFailed']) - : t(($) => $['newKnowledge.overview.attention.modelReadiness.title']) - const description = - query.isError || isPendingValidation - ? undefined - : readiness?.active_profile_available - ? t(($) => $['newKnowledge.overview.attention.modelReadiness.description']) - : t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing']) + : readiness?.configuration_state === 'validation-failed' + ? tCommon(($) => $['api.actionFailed']) + : t(($) => $['newKnowledge.overview.attention.modelReadiness.title']) + const description = query.isError + ? undefined + : readiness?.active_profile_available + ? t(($) => $['newKnowledge.overview.attention.modelReadiness.description']) + : t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing']) return ( ) } diff --git a/web/features/new-rag/components/knowledge-model-readiness-notice.tsx b/web/features/new-rag/components/knowledge-model-readiness-notice.tsx index ca2181b46c4..cc532dac5be 100644 --- a/web/features/new-rag/components/knowledge-model-readiness-notice.tsx +++ b/web/features/new-rag/components/knowledge-model-readiness-notice.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -export type KnowledgeModelReadinessTone = 'destructive' | 'progress' | 'warning' +export type KnowledgeModelReadinessTone = 'destructive' | 'warning' export const knowledgeModelReadinessActionClassName = 'shrink-0 cursor-pointer rounded-sm system-sm-medium text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid' @@ -20,17 +20,12 @@ export function KnowledgeModelReadinessNotice({ tone: KnowledgeModelReadinessTone }) { const isDestructive = tone === 'destructive' - const isProgress = tone === 'progress' return (

    diff --git a/web/features/new-rag/components/knowledge-model-setup-dialog.tsx b/web/features/new-rag/components/knowledge-model-setup-dialog.tsx index 97019f5d31e..6090780e0e4 100644 --- a/web/features/new-rag/components/knowledge-model-setup-dialog.tsx +++ b/web/features/new-rag/components/knowledge-model-setup-dialog.tsx @@ -11,7 +11,7 @@ function readinessTitle( tCommon: ReturnType>['t'], ) { if (readiness?.configuration_state === 'pending-validation') - return tCommon(($) => $['provider.validating']) + return t(($) => $['newKnowledge.overview.attention.modelReadiness.pendingTitle']) if (readiness?.configuration_state === 'validation-failed') return tCommon(($) => $['api.actionFailed']) return t(($) => $['newKnowledge.overview.attention.modelReadiness.title']) @@ -40,7 +40,7 @@ export function KnowledgeModelSetupDialog({ } const pendingValidation = readiness?.configuration_state === 'pending-validation' const description = pendingValidation - ? undefined + ? t(($) => $['newKnowledge.overview.attention.modelReadiness.pendingDescription']) : readiness?.active_profile_available ? t(($) => $['newKnowledge.overview.attention.modelReadiness.description']) : t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing']) diff --git a/web/features/new-rag/knowledge-settings-form.tsx b/web/features/new-rag/knowledge-settings-form.tsx index a07db9f8694..6bd3c516ad7 100644 --- a/web/features/new-rag/knowledge-settings-form.tsx +++ b/web/features/new-rag/knowledge-settings-form.tsx @@ -853,32 +853,25 @@ export function KnowledgeSettingsForm({

    )} - {(settings.configuration_state !== 'active' || settings.issues.length > 0) && ( - 0 - ? settings.issues.map(({ field }) => readinessFieldLabel(field)).join(' · ') - : settings.active_profile_available - ? t(($) => $['newKnowledge.overview.attention.modelReadiness.description']) - : undefined - } - title={ - settings.configuration_state === 'pending-validation' - ? tCommon(($) => $['provider.validating']) - : settings.configuration_state === 'validation-failed' + {settings.configuration_state !== 'pending-validation' && + (settings.configuration_state !== 'active' || settings.issues.length > 0) && ( + 0 + ? settings.issues.map(({ field }) => readinessFieldLabel(field)).join(' · ') + : settings.active_profile_available + ? t(($) => $['newKnowledge.overview.attention.modelReadiness.description']) + : undefined + } + title={ + settings.configuration_state === 'validation-failed' ? tCommon(($) => $['api.actionFailed']) : tCommon(($) => $['modelProvider.toBeConfigured']) - } - tone={ - settings.configuration_state === 'pending-validation' - ? 'progress' - : settings.configuration_state === 'validation-failed' - ? 'destructive' - : 'warning' - } - /> - )} + } + tone={settings.configuration_state === 'validation-failed' ? 'destructive' : 'warning'} + /> + )} {saveErrorSlice && (