fix(frontend): dedupe KB list to stop empty render with ≥2 knowledge bases

The "All" scope of the knowledge-base list renders every card with
`:key="kb.id"`. The same knowledge base can appear more than once in the
source data — owned *and* shared back to the caller, or shared into their
view through two different organizations (distinct share_id, identical
knowledge_base.id). Duplicate `v-for` keys corrupt Vue's virtual-DOM
patch and the list renders empty/partial. A single KB never collides, so
the page works with one KB and blanks once there are two or more — the
exact behavior reported in #795.

Extract the merge into a pure, unit-tested helper
(mergeAllScopeKnowledgeBases) that de-duplicates by knowledge-base id —
owned rows win over shared duplicates, and repeated shares of one KB keep
the most-privileged permission — while preserving the existing
pinned → mine → teammate → shared(editable-first) ordering.

Tests use the repo's existing node:test convention; a `test` script is
added so `npm test` discovers them (it also wires up the two previously
orphaned *.test.ts files).

Fixes #795

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martín Alcalá Rubí
2026-06-05 17:22:55 -03:00
committed by lyingbug
parent 22349055ec
commit bbf7ac29bf
4 changed files with 331 additions and 45 deletions
+2 -1
View File
@@ -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",
@@ -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(() => {
@@ -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> = {}): OwnedKnowledgeBase {
return { id, creator_id: ME, ...extra }
}
function shared(
kbId: string,
permission: string,
extra: Partial<SharedKnowledgeBaseLike> = {},
): 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)
})
+155
View File
@@ -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<string, unknown> & {
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<string, number> = { 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<string>();
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<string, SharedKnowledgeBaseLike>();
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;
}