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:
committed by
lyingbug
parent
22349055ec
commit
bbf7ac29bf
@@ -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)
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user