diff --git a/frontend/package.json b/frontend/package.json index 6e3230e2..5827ad80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "build-with-types": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "build-only": "vite build", - "type-check": "vue-tsc --build" + "type-check": "vue-tsc --build", + "test": "node --test" }, "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", diff --git a/frontend/src/views/knowledge/KnowledgeBaseList.vue b/frontend/src/views/knowledge/KnowledgeBaseList.vue index 0d0b63e7..7f6d297d 100644 --- a/frontend/src/views/knowledge/KnowledgeBaseList.vue +++ b/frontend/src/views/knowledge/KnowledgeBaseList.vue @@ -778,6 +778,7 @@ import { useUIStore } from '@/stores/ui' import { useAuthStore } from '@/stores/auth' import { useOrganizationStore } from '@/stores/organization' import { listOrganizationSharedKnowledgeBases, type SharedKnowledgeBase, type OrganizationSharedKnowledgeBaseItem, type SourceFromAgentInfo } from '@/api/organization' +import { mergeAllScopeKnowledgeBases, type OwnedKnowledgeBase, type SharedKnowledgeBaseLike } from './kbListMerge' import KnowledgeBaseEditorModal from './KnowledgeBaseEditorModal.vue' import ShareKnowledgeBaseDialog from '@/components/ShareKnowledgeBaseDialog.vue' import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue' @@ -1153,50 +1154,18 @@ const filteredKnowledgeBases = computed(() => { if (spaceSelection.value !== 'all') { return [] } - const result: Array<(KB & { isMine: true }) | (SharedKnowledgeBase['knowledge_base'] & { isMine: false; permission: string; shared_at: string; share_id: string } & any)> = [] - // 本租户的 KB 分三段渲染:①任何人创建但被当前用户置顶的→「已置顶」组; - // ②我创建的非置顶;③同事创建的非置顶(contributor 视图下挂在「本空间 · - // 仅查看」标题下)。置顶现在是 per-user 维度,必须跨创建者优先级地上浮, - // 否则同事创建但我置顶的 KB 会被错误地沉到底部。 - const pinned: KB[] = [] - const ownMine: KB[] = [] - const teammateMine: KB[] = [] - kbs.value.forEach(kb => { - if (kb.is_pinned) pinned.push(kb) - else if (isMyKb(kb)) ownMine.push(kb) - else teammateMine.push(kb) - }) - pinned.sort((a, b) => { - const at = a.pinned_at ? Date.parse(a.pinned_at as string) : 0 - const bt = b.pinned_at ? Date.parse(b.pinned_at as string) : 0 - return bt - at - }) - pinned.forEach(kb => result.push({ ...kb, isMine: true as const })) - ownMine.forEach(kb => result.push({ ...kb, isMine: true as const })) - teammateMine.forEach(kb => result.push({ ...kb, isMine: true as const })) - // 共享区按 permission 排序:可编辑(admin/editor)在前,仅查看(viewer)在后。 - // 即便当前角色不显示分组标题,排序也保留——展示更可预测,并且让分组开关切换 - // 时不会引起卡片顺序跳变。 - const sortedShared = [...sharedKbs.value].sort((a, b) => { - const aE = isSharedKbEditable(a.permission) ? 0 : 1 - const bE = isSharedKbEditable(b.permission) ? 0 : 1 - return aE - bE - }) - sortedShared.forEach(shared => { - const kb = shared.knowledge_base - if (!kb) return - result.push({ - ...kb, - isMine: false as const, - permission: shared.permission, - shared_at: shared.shared_at, - share_id: shared.share_id, - org_name: shared.org_name, - knowledge_count: kb.knowledge_count, - chunk_count: kb.chunk_count, - } as any) - }) - return result + // The "All" scope merges own + shared KBs. The card template keys each + // row by `kb.id`, so the same KB surfacing twice — owned *and* shared + // back, or shared into the caller's view through two different orgs — + // produced duplicate `v-for` keys and blanked the list once there were + // ≥2 entries (#795). mergeAllScopeKnowledgeBases de-duplicates by KB id + // (owned wins; most-privileged share kept) while preserving the existing + // pinned → mine → teammate → shared(editable-first) ordering. + return mergeAllScopeKnowledgeBases( + kbs.value as unknown as OwnedKnowledgeBase[], + sharedKbs.value as unknown as SharedKnowledgeBaseLike[], + authStore.user?.id, + ) as unknown as Array<(KB & { isMine: true }) | (SharedKnowledgeBase['knowledge_base'] & { isMine: false; permission: string; shared_at: string; share_id: string } & any)> }) const showKbListEmpty = computed(() => { diff --git a/frontend/src/views/knowledge/kbListMerge.test.ts b/frontend/src/views/knowledge/kbListMerge.test.ts new file mode 100644 index 00000000..c35d641c --- /dev/null +++ b/frontend/src/views/knowledge/kbListMerge.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + mergeAllScopeKnowledgeBases, + isSharedKbEditable, + type OwnedKnowledgeBase, + type SharedKnowledgeBaseLike, + type MergedKnowledgeBase, +} from './kbListMerge.ts' + +const ME = 'user-me' + +function owned(id: string, extra: Partial = {}): OwnedKnowledgeBase { + return { id, creator_id: ME, ...extra } +} + +function shared( + kbId: string, + permission: string, + extra: Partial = {}, +): SharedKnowledgeBaseLike { + return { + knowledge_base: { id: kbId }, + permission, + shared_at: '2026-01-01T00:00:00Z', + share_id: `share-${kbId}-${permission}`, + org_name: 'Org', + ...extra, + } +} + +// Render keys must be unique — duplicate `:key` values are exactly what +// blanks the list in #795. +function keys(list: MergedKnowledgeBase[]): string[] { + return list.map((kb) => kb.id) +} + +function hasUniqueKeys(list: MergedKnowledgeBase[]): boolean { + return new Set(keys(list)).size === list.length +} + +test('renders an empty list when there are no knowledge bases', () => { + assert.deepEqual(mergeAllScopeKnowledgeBases([], [], ME), []) +}) + +test('keeps a single owned KB (the case that always worked)', () => { + const result = mergeAllScopeKnowledgeBases([owned('a')], [], ME) + assert.deepEqual(keys(result), ['a']) + assert.equal(result[0].isMine, true) +}) + +test('renders every owned KB once when there are two or more (#795 regression)', () => { + const result = mergeAllScopeKnowledgeBases([owned('a'), owned('b'), owned('c')], [], ME) + assert.deepEqual(keys(result), ['a', 'b', 'c']) + assert.ok(hasUniqueKeys(result)) +}) + +test('does not emit a KB twice when it is both owned and shared back', () => { + // Without de-dup this yields two entries keyed "a" -> duplicate Vue key + // -> blank list. Owned must win. + const result = mergeAllScopeKnowledgeBases([owned('a'), owned('b')], [shared('a', 'viewer')], ME) + assert.deepEqual(keys(result), ['a', 'b']) + assert.ok(hasUniqueKeys(result)) + const a = result.find((kb) => kb.id === 'a')! + assert.equal(a.isMine, true) +}) + +test('renders exactly one card per KB when several owned KBs are also shared back', () => { + // Pre-fix this returned 4 rows (2 owned + 2 shared dups) with colliding + // keys, which is what blanked the page once there were ≥2 KBs. + const result = mergeAllScopeKnowledgeBases( + [owned('a'), owned('b')], + [shared('a', 'viewer'), shared('b', 'editor')], + ME, + ) + assert.equal(result.length, 2) + assert.deepEqual([...keys(result)].sort(), ['a', 'b']) + assert.ok(hasUniqueKeys(result)) +}) + +test('collapses the same KB shared through multiple orgs into one card', () => { + // Two distinct shares (different share_id) but the same knowledge_base.id + // — the real-world trigger for duplicate keys once there are ≥2 rows. + const result = mergeAllScopeKnowledgeBases( + [], + [ + shared('x', 'viewer', { org_name: 'Org A', share_id: 'share-x-A' }), + shared('x', 'editor', { org_name: 'Org B', share_id: 'share-x-B' }), + ], + ME, + ) + assert.deepEqual(keys(result), ['x']) + assert.ok(hasUniqueKeys(result)) +}) + +test('keeps the most-privileged permission when collapsing duplicate shares', () => { + const result = mergeAllScopeKnowledgeBases( + [], + [ + shared('x', 'viewer', { share_id: 's1' }), + shared('x', 'admin', { share_id: 's2' }), + shared('x', 'editor', { share_id: 's3' }), + ], + ME, + ) + assert.equal(result.length, 1) + assert.equal((result[0] as { permission: string }).permission, 'admin') +}) + +test('guarantees unique keys across a mixed owned + shared set', () => { + const result = mergeAllScopeKnowledgeBases( + [owned('a'), owned('b', { creator_id: 'someone-else' })], + [ + shared('a', 'editor'), // overlaps owned -> dropped + shared('c', 'viewer'), + shared('c', 'admin', { share_id: 'share-c-2' }), // duplicate share -> collapsed + shared('d', 'editor'), + ], + ME, + ) + assert.ok(hasUniqueKeys(result)) + assert.deepEqual(new Set(keys(result)), new Set(['a', 'b', 'c', 'd'])) +}) + +test('orders pinned (newest first) → own → teammate → shared(editable first)', () => { + const result = mergeAllScopeKnowledgeBases( + [ + owned('mine'), + owned('teammate', { creator_id: 'someone-else' }), + owned('pin-old', { is_pinned: true, pinned_at: '2026-01-01T00:00:00Z' }), + owned('pin-new', { is_pinned: true, pinned_at: '2026-02-01T00:00:00Z' }), + ], + [shared('view-only', 'viewer'), shared('editable', 'editor')], + ME, + ) + assert.deepEqual(keys(result), [ + 'pin-new', + 'pin-old', + 'mine', + 'teammate', + 'editable', + 'view-only', + ]) +}) + +test('drops shared rows whose knowledge_base is null', () => { + const result = mergeAllScopeKnowledgeBases( + [owned('a')], + [{ knowledge_base: null, permission: 'viewer', shared_at: '', share_id: 's' }], + ME, + ) + assert.deepEqual(keys(result), ['a']) +}) + +test('isSharedKbEditable treats admin/editor as editable, others read-only', () => { + assert.equal(isSharedKbEditable('admin'), true) + assert.equal(isSharedKbEditable('editor'), true) + assert.equal(isSharedKbEditable('viewer'), false) + assert.equal(isSharedKbEditable(undefined), false) +}) diff --git a/frontend/src/views/knowledge/kbListMerge.ts b/frontend/src/views/knowledge/kbListMerge.ts new file mode 100644 index 00000000..437f19f8 --- /dev/null +++ b/frontend/src/views/knowledge/kbListMerge.ts @@ -0,0 +1,155 @@ +// Pure helpers backing the "All" scope of the knowledge-base list. +// +// The list template renders every card with `:key="kb.id"`. Vue requires +// `v-for` keys to be unique within a list — duplicate keys corrupt the +// virtual-DOM patch and the list renders empty or partial. The same +// knowledge base can legitimately show up more than once in the source +// data: +// +// 1. A KB the caller owns can also be shared back to them (e.g. they +// belong to an org the KB was shared into), so it appears in both the +// owned list and `sharedKnowledgeBases`. +// 2. The very same KB can be shared into the caller's view through more +// than one organization. Each share is a distinct row (its own +// `share_id`) but carries the identical `knowledge_base.id`. +// +// Either case yields two entries with the same `kb.id` once there are ≥2 +// knowledge bases, which is the #795 symptom: a single KB renders fine +// (no collision) while two or more blank the page. De-duplicating by KB +// id here keeps the keys unique. Owned rows win over shared ones, and +// among multiple shares of the same KB we keep the most-privileged +// permission so the card stays as capable as the caller's best grant. + +export interface OwnedKnowledgeBase { + id: string; + is_pinned?: boolean; + pinned_at?: string; + created_at?: string; + creator_id?: string; + [key: string]: unknown; +} + +export interface SharedKnowledgeBaseLike { + knowledge_base: { + id: string; + knowledge_count?: number; + chunk_count?: number; + [key: string]: unknown; + } | null; + permission: string; + shared_at: string; + share_id: string; + org_name?: string; + [key: string]: unknown; +} + +export type MergedOwnedKnowledgeBase = OwnedKnowledgeBase & { isMine: true }; +export type MergedSharedKnowledgeBase = Record & { + id: string; + isMine: false; + permission: string; + shared_at: string; + share_id: string; + org_name?: string; +}; +export type MergedKnowledgeBase = + | MergedOwnedKnowledgeBase + | MergedSharedKnowledgeBase; + +// Permissions that grant write access. Mirrors EDITABLE_PERMS in +// KnowledgeBaseList.vue — kept local so this module stays free of any +// component/store imports and remains trivially unit-testable. +const EDITABLE_PERMS = new Set(['admin', 'editor']); + +export function isSharedKbEditable(perm: string | undefined): boolean { + return !!perm && EDITABLE_PERMS.has(perm); +} + +// Higher rank = more privileged. Unknown permissions rank lowest so they +// never shadow a real grant when collapsing duplicate shares. +const PERMISSION_RANK: Record = { admin: 3, editor: 2, viewer: 1 }; + +function permissionRank(perm: string | undefined): number { + return (perm && PERMISSION_RANK[perm]) || 0; +} + +function isMyKb(kb: { creator_id?: string }, currentUserId: string | undefined): boolean { + return !!(kb.creator_id && currentUserId && kb.creator_id === currentUserId); +} + +function pinnedTime(kb: OwnedKnowledgeBase): number { + return kb.pinned_at ? Date.parse(kb.pinned_at) : 0; +} + +/** + * Build the de-duplicated, ordered list rendered by the "All" scope. + * + * Ordering (unchanged from the previous inline logic): + * 1. pinned KBs (any creator the caller pinned), newest pin first + * 2. the caller's own non-pinned KBs + * 3. teammate non-pinned KBs (own tenant, someone else created) + * 4. shared KBs, editable grants before view-only + * + * On top of that, every entry is unique by knowledge-base id: owned rows + * win over shared duplicates, and repeated shares of one KB collapse to + * the most-privileged permission. + */ +export function mergeAllScopeKnowledgeBases( + owned: OwnedKnowledgeBase[], + shared: SharedKnowledgeBaseLike[], + currentUserId: string | undefined, +): MergedKnowledgeBase[] { + const result: MergedKnowledgeBase[] = []; + + const pinned: OwnedKnowledgeBase[] = []; + const ownMine: OwnedKnowledgeBase[] = []; + const teammateMine: OwnedKnowledgeBase[] = []; + const ownedIds = new Set(); + for (const kb of owned) { + ownedIds.add(kb.id); + if (kb.is_pinned) pinned.push(kb); + else if (isMyKb(kb, currentUserId)) ownMine.push(kb); + else teammateMine.push(kb); + } + pinned.sort((a, b) => pinnedTime(b) - pinnedTime(a)); + + for (const kb of pinned) result.push({ ...kb, isMine: true as const }); + for (const kb of ownMine) result.push({ ...kb, isMine: true as const }); + for (const kb of teammateMine) result.push({ ...kb, isMine: true as const }); + + // Collapse the shared rows by KB id, keeping the most-privileged grant, + // and drop any KB the caller already owns. This is what guarantees a + // unique `:key` per rendered card. + const dedupedShared = new Map(); + for (const entry of shared) { + const kb = entry?.knowledge_base; + if (!kb) continue; + if (ownedIds.has(kb.id)) continue; + const existing = dedupedShared.get(kb.id); + if (!existing || permissionRank(entry.permission) > permissionRank(existing.permission)) { + dedupedShared.set(kb.id, entry); + } + } + + const sortedShared = [...dedupedShared.values()].sort((a, b) => { + const aE = isSharedKbEditable(a.permission) ? 0 : 1; + const bE = isSharedKbEditable(b.permission) ? 0 : 1; + return aE - bE; + }); + + for (const shared of sortedShared) { + const kb = shared.knowledge_base!; + result.push({ + ...kb, + isMine: false as const, + permission: shared.permission, + shared_at: shared.shared_at, + share_id: shared.share_id, + org_name: shared.org_name, + knowledge_count: kb.knowledge_count, + chunk_count: kb.chunk_count, + } as MergedSharedKnowledgeBase); + } + + return result; +}