diff --git a/frontend/src/components/AgentShareSettings.vue b/frontend/src/components/AgentShareSettings.vue index f97551e2..e4999ac9 100644 --- a/frontend/src/components/AgentShareSettings.vue +++ b/frontend/src/components/AgentShareSettings.vue @@ -122,7 +122,7 @@ import { ref, computed, watch, onMounted } from 'vue' import { MessagePlugin } from 'tdesign-vue-next' import { useI18n } from 'vue-i18n' import { useOrganizationStore } from '@/stores/organization' -import { shareAgent, listAgentShares, removeAgentShare } from '@/api/organization' +import { listAgentShares } from '@/api/organization' import type { AgentShareResponse } from '@/api/organization' import type { CustomAgent } from '@/api/agent' import SpaceAvatar from '@/components/SpaceAvatar.vue' @@ -224,7 +224,7 @@ async function handleShare() { if (!selectedOrgId.value) return submitting.value = true try { - const result = await shareAgent(props.agentId, { + const result = await orgStore.shareAgent(props.agentId, { organization_id: selectedOrgId.value, permission: 'viewer' }) @@ -246,7 +246,11 @@ async function handleShare() { async function handleUnshare(share: AgentShareResponse) { try { - const result = await removeAgentShare(props.agentId, share.id) + const result = await orgStore.unshareAgent( + props.agentId, + share.id, + share.organization_id + ) if (result.success) { MessagePlugin.success(t('organization.share.unshareSuccess')) await loadShares() diff --git a/frontend/src/stores/organization.ts b/frontend/src/stores/organization.ts index 3df7e430..838926cd 100644 --- a/frontend/src/stores/organization.ts +++ b/frontend/src/stores/organization.ts @@ -6,14 +6,25 @@ import type { SharedKnowledgeBase, SharedAgentInfo, OrganizationPreview, - ResourceCountsByOrg + ResourceCountsByOrg, + SearchableOrganizationItem, + UpdateOrganizationRequest, + ShareKnowledgeBaseRequest, + UpdateSharePermissionRequest, + InviteMemberRequest, + ReviewJoinRequestRequest, + RequestRoleUpgradeRequest, + ApiResponse, + KnowledgeBaseShare, + AgentShareResponse } from '@/api/organization' import { listMyOrganizations, createOrganization, - updateOrganization, + updateOrganization as updateOrganizationApi, deleteOrganization, - joinOrganization, + joinOrganization as joinOrganizationApi, + joinOrganizationById as joinOrganizationByIdApi, previewOrganization, leaveOrganization, generateInviteCode, @@ -21,8 +32,19 @@ import { updateMemberRole, removeMember, listSharedKnowledgeBases, - listSharedAgents + listSharedAgents, + searchSearchableOrganizations, + shareKnowledgeBase as shareKnowledgeBaseApi, + removeShare as removeShareApi, + updateSharePermission as updateSharePermissionApi, + shareAgent as shareAgentApi, + removeAgentShare as removeAgentShareApi, + inviteMember as inviteMemberApi, + reviewJoinRequest as reviewJoinRequestApi, + requestRoleUpgrade as requestRoleUpgradeApi } from '@/api/organization' +import { createVersionedRequestCoordinator } from './versionedRequest' +import { applyOrganizationResourceDelta, upsertById } from './organizationState' export const useOrganizationStore = defineStore('organization', () => { // State @@ -31,20 +53,24 @@ export const useOrganizationStore = defineStore('organization', () => { const currentMembers = ref([]) const sharedKnowledgeBases = ref([]) const sharedAgents = ref([]) + const searchableOrganizations = ref([]) const previewData = ref(null) const loading = ref(false) const error = ref(null) /** 各空间内知识库/智能体数量(由 GET /organizations 的 resource_counts 填充,供列表侧栏使用) */ const resourceCounts = ref(null) /** 用于去重:同一时刻只允许一次 GET /organizations 请求 */ - let fetchOrganizationsPromise: Promise | null = null let organizationsLoadedAt = 0 /** 共享资源缓存 TTL,与 chatResources 对齐 */ const SHARED_RESOURCE_TTL_MS = 60_000 + const SEARCHABLE_ORGANIZATION_TTL_MS = 5 * 60_000 let sharedKbLoadedAt = 0 let sharedAgentsLoadedAt = 0 - let fetchSharedKbPromise: Promise | null = null - let fetchSharedAgentsPromise: Promise | null = null + let searchableOrganizationsQuery = '' + const searchableOrganizationCache = new Map< + string, + { data: SearchableOrganizationItem[]; loadedAt: number } + >() // Computed const myOrganizations = computed(() => organizations.value) @@ -68,6 +94,28 @@ export const useOrganizationStore = defineStore('organization', () => { * Fetch all organizations the user belongs to. * 去重 + 短期缓存,列表页与侧栏等多处共用。 */ + const organizationsRequest = createVersionedRequestCoordinator( + async () => { + loading.value = true + error.value = null + try { + return await listMyOrganizations() + } finally { + loading.value = false + } + }, + (response) => { + if (response.success && response.data) { + organizations.value = response.data.organizations + resourceCounts.value = response.data.resource_counts ?? null + organizationsLoadedAt = Date.now() + } else { + resourceCounts.value = null + error.value = response.message || 'Failed to fetch organizations' + } + } + ) + async function fetchOrganizations(options?: { force?: boolean }) { const force = options?.force ?? false if ( @@ -77,29 +125,49 @@ export const useOrganizationStore = defineStore('organization', () => { ) { return } - if (fetchOrganizationsPromise) return fetchOrganizationsPromise - loading.value = true - error.value = null - fetchOrganizationsPromise = (async () => { - try { - const response = await listMyOrganizations() - if (response.success && response.data) { - organizations.value = response.data.organizations - resourceCounts.value = response.data.resource_counts ?? null - organizationsLoadedAt = Date.now() - } else { - resourceCounts.value = null - error.value = response.message || 'Failed to fetch organizations' - } - } catch (e: any) { - error.value = e.message || 'Failed to fetch organizations' - resourceCounts.value = null - } finally { - loading.value = false - fetchOrganizationsPromise = null - } - })() - return fetchOrganizationsPromise + return organizationsRequest.fetch(force) + } + + function patchOrganization(id: string, patch: Partial) { + organizationsRequest.invalidate() + organizationsLoadedAt = 0 + organizations.value = organizations.value.map(org => + org.id === id ? { ...org, ...patch } : org + ) + if (currentOrganization.value?.id === id) { + currentOrganization.value = { ...currentOrganization.value, ...patch } + } + } + + function upsertOrganization(organization: Organization) { + organizationsRequest.invalidate() + organizationsLoadedAt = 0 + organizations.value = upsertById(organizations.value, organization) + if (currentOrganization.value?.id === organization.id) { + currentOrganization.value = organization + } + } + + function adjustOrganizationResourceCount( + organizationId: string, + resource: 'knowledge_bases' | 'agents', + delta: number + ) { + organizationsRequest.invalidate() + organizationsLoadedAt = 0 + const result = applyOrganizationResourceDelta( + organizations.value, + resourceCounts.value, + organizationId, + resource, + delta + ) + organizations.value = result.organizations + resourceCounts.value = result.resourceCounts + if (currentOrganization.value?.id === organizationId) { + currentOrganization.value = + result.organizations.find(org => org.id === organizationId) ?? currentOrganization.value + } } /** @@ -111,10 +179,11 @@ export const useOrganizationStore = defineStore('organization', () => { try { const response = await createOrganization({ name, description }) if (response.success && response.data) { - organizations.value.unshift(response.data) + upsertOrganization(response.data) // 创建成功后重置缓存时间戳,确保后续 fetchOrganizations() 不会被 TTL 缓存跳过, // 从而刷新 resource_counts 等创建接口不返回的聚合字段。 organizationsLoadedAt = 0 + void fetchOrganizations({ force: true }) return response.data } else { error.value = response.message || 'Failed to create organization' @@ -131,19 +200,14 @@ export const useOrganizationStore = defineStore('organization', () => { /** * Update an organization */ - async function update(id: string, name?: string, description?: string) { + async function updateOrganization(id: string, updates: UpdateOrganizationRequest) { loading.value = true error.value = null try { - const response = await updateOrganization(id, { name, description }) + const response = await updateOrganizationApi(id, updates) if (response.success && response.data) { - const index = organizations.value.findIndex(o => o.id === id) - if (index !== -1) { - organizations.value[index] = response.data - } - if (currentOrganization.value?.id === id) { - currentOrganization.value = response.data - } + upsertOrganization(response.data) + void fetchOrganizations({ force: true }) return response.data } else { error.value = response.message || 'Failed to update organization' @@ -157,6 +221,10 @@ export const useOrganizationStore = defineStore('organization', () => { } } + async function update(id: string, name?: string, description?: string) { + return updateOrganization(id, { name, description }) + } + /** * Delete an organization */ @@ -166,6 +234,8 @@ export const useOrganizationStore = defineStore('organization', () => { try { const response = await deleteOrganization(id) if (response.success) { + organizationsRequest.invalidate() + organizationsLoadedAt = 0 organizations.value = organizations.value.filter(o => o.id !== id) if (currentOrganization.value?.id === id) { currentOrganization.value = null @@ -214,13 +284,11 @@ export const useOrganizationStore = defineStore('organization', () => { loading.value = true error.value = null try { - const response = await joinOrganization({ invite_code: inviteCode }) + const response = await joinOrganizationApi({ invite_code: inviteCode }) if (response.success && response.data) { - // Check if already in list - const exists = organizations.value.some(o => o.id === response.data!.id) - if (!exists) { - organizations.value.unshift(response.data) - } + upsertOrganization(response.data) + invalidateSearchableOrganizations(response.data.id) + void fetchOrganizations({ force: true }) return response.data } else { error.value = response.message || 'Failed to join organization' @@ -243,6 +311,8 @@ export const useOrganizationStore = defineStore('organization', () => { try { const response = await leaveOrganization(id) if (response.success) { + organizationsRequest.invalidate() + organizationsLoadedAt = 0 organizations.value = organizations.value.filter(o => o.id !== id) if (currentOrganization.value?.id === id) { currentOrganization.value = null @@ -269,13 +339,8 @@ export const useOrganizationStore = defineStore('organization', () => { try { const response = await generateInviteCode(id) if (response.success && response.data) { - const org = organizations.value.find(o => o.id === id) - if (org) { - org.invite_code = response.data.invite_code - } - if (currentOrganization.value?.id === id) { - currentOrganization.value.invite_code = response.data.invite_code - } + patchOrganization(id, { invite_code: response.data.invite_code }) + void fetchOrganizations({ force: true }) return response.data.invite_code } else { error.value = response.message || 'Failed to generate invite code' @@ -348,6 +413,11 @@ export const useOrganizationStore = defineStore('organization', () => { const response = await removeMember(orgId, tenantId) if (response.success) { currentMembers.value = currentMembers.value.filter(m => m.tenant_id !== tenantId) + const organization = organizations.value.find(org => org.id === orgId) + patchOrganization(orgId, { + member_count: Math.max(0, (organization?.member_count ?? 0) - 1) + }) + void fetchOrganizations({ force: true }) return true } else { error.value = response.message || 'Failed to remove member' @@ -365,6 +435,26 @@ export const useOrganizationStore = defineStore('organization', () => { * Fetch shared knowledge bases. * 去重 + 短期缓存,避免对话页等多处并发重复请求。 */ + const sharedKnowledgeBasesRequest = createVersionedRequestCoordinator( + async () => { + loading.value = true + error.value = null + try { + return await listSharedKnowledgeBases() + } finally { + loading.value = false + } + }, + (response) => { + if (response.success && response.data) { + sharedKnowledgeBases.value = response.data.filter(s => s.knowledge_base != null) + sharedKbLoadedAt = Date.now() + } else { + error.value = response.message || 'Failed to fetch shared knowledge bases' + } + } + ) + async function fetchSharedKnowledgeBases(options?: { force?: boolean }) { const force = options?.force ?? false if ( @@ -374,35 +464,24 @@ export const useOrganizationStore = defineStore('organization', () => { ) { return sharedKnowledgeBases.value } - if (fetchSharedKbPromise) return fetchSharedKbPromise - - loading.value = true - error.value = null - fetchSharedKbPromise = (async () => { - try { - const response = await listSharedKnowledgeBases() - if (response.success && response.data) { - sharedKnowledgeBases.value = response.data.filter(s => s.knowledge_base != null) - sharedKbLoadedAt = Date.now() - return sharedKnowledgeBases.value - } - error.value = response.message || 'Failed to fetch shared knowledge bases' - return [] - } catch (e: any) { - error.value = e.message || 'Failed to fetch shared knowledge bases' - return [] - } finally { - loading.value = false - fetchSharedKbPromise = null - } - })() - return fetchSharedKbPromise + await sharedKnowledgeBasesRequest.fetch(force) + return sharedKnowledgeBases.value } /** * Fetch shared agents (shared to me through organizations). * 去重 + 短期缓存。 */ + const sharedAgentsRequest = createVersionedRequestCoordinator( + listSharedAgents, + (response) => { + if (response.success && response.data) { + sharedAgents.value = response.data.filter(s => s.agent != null) + sharedAgentsLoadedAt = Date.now() + } + } + ) + async function fetchSharedAgents(options?: { force?: boolean }) { const force = options?.force ?? false if ( @@ -412,31 +491,244 @@ export const useOrganizationStore = defineStore('organization', () => { ) { return sharedAgents.value } - if (fetchSharedAgentsPromise) return fetchSharedAgentsPromise + await sharedAgentsRequest.fetch(force) + return sharedAgents.value + } - fetchSharedAgentsPromise = (async () => { - try { - const response = await listSharedAgents() - if (response.success && response.data) { - sharedAgents.value = response.data.filter(s => s.agent != null) - sharedAgentsLoadedAt = Date.now() - return sharedAgents.value - } - return [] - } catch { - return [] - } finally { - fetchSharedAgentsPromise = null + interface InvalidateOrganizationDataOptions { + organizations?: boolean + sharedKnowledgeBases?: boolean + sharedAgents?: boolean + searchableOrganizations?: boolean + excludeSearchableOrganizationId?: string + } + + function invalidateSearchableOrganizations(organizationId?: string) { + searchableOrganizationCache.clear() + if (organizationId) { + searchableOrganizations.value = searchableOrganizations.value.filter( + organization => organization.id !== organizationId + ) + } + } + + function invalidateOrganizationData(options: InvalidateOrganizationDataOptions) { + if (options.organizations) { + organizationsRequest.invalidate() + organizationsLoadedAt = 0 + } + if (options.sharedKnowledgeBases) { + sharedKnowledgeBasesRequest.invalidate() + sharedKbLoadedAt = 0 + } + if (options.sharedAgents) { + sharedAgentsRequest.invalidate() + sharedAgentsLoadedAt = 0 + } + if (options.searchableOrganizations) { + invalidateSearchableOrganizations(options.excludeSearchableOrganizationId) + } + } + + async function fetchSearchableOrganizations( + query: string, + options?: { force?: boolean; limit?: number } + ) { + const normalizedQuery = query.trim() + searchableOrganizationsQuery = normalizedQuery + const cached = searchableOrganizationCache.get(normalizedQuery) + if ( + !options?.force && + cached && + Date.now() - cached.loadedAt < SEARCHABLE_ORGANIZATION_TTL_MS + ) { + searchableOrganizations.value = cached.data + return cached.data + } + + const response = await searchSearchableOrganizations(normalizedQuery, options?.limit ?? 20) + if (response.success && response.data) { + const data = response.data.data.filter(organization => !organization.is_already_member) + searchableOrganizationCache.set(normalizedQuery, { data, loadedAt: Date.now() }) + if (searchableOrganizationsQuery === normalizedQuery) { + searchableOrganizations.value = data } - })() - return fetchSharedAgentsPromise + return data + } + if (searchableOrganizationsQuery === normalizedQuery) { + searchableOrganizations.value = [] + } + return [] + } + + function clearSearchableOrganizations() { + searchableOrganizationsQuery = '' + searchableOrganizations.value = [] + } + + async function joinById( + organizationId: string, + message?: string, + role?: 'admin' | 'editor' | 'viewer', + options?: { requiresApproval?: boolean } + ) { + const response = await joinOrganizationByIdApi(organizationId, message, role) + if (response.success) { + invalidateOrganizationData({ + searchableOrganizations: true, + excludeSearchableOrganizationId: organizationId + }) + if (!options?.requiresApproval && response.data) { + upsertOrganization(response.data) + void fetchOrganizations({ force: true }) + } + } + return response + } + + async function shareKnowledgeBase( + knowledgeBaseId: string, + request: ShareKnowledgeBaseRequest + ): Promise> { + const response = await shareKnowledgeBaseApi(knowledgeBaseId, request) + if (response.success) { + adjustOrganizationResourceCount(request.organization_id, 'knowledge_bases', 1) + invalidateOrganizationData({ sharedKnowledgeBases: true }) + void Promise.all([ + fetchOrganizations({ force: true }), + fetchSharedKnowledgeBases({ force: true }) + ]) + } + return response + } + + async function unshareKnowledgeBase( + knowledgeBaseId: string, + shareId: string, + organizationId: string + ): Promise> { + const response = await removeShareApi(knowledgeBaseId, shareId) + if (response.success) { + adjustOrganizationResourceCount(organizationId, 'knowledge_bases', -1) + invalidateOrganizationData({ sharedKnowledgeBases: true }) + void Promise.all([ + fetchOrganizations({ force: true }), + fetchSharedKnowledgeBases({ force: true }) + ]) + } + return response + } + + async function changeKnowledgeBaseSharePermission( + knowledgeBaseId: string, + shareId: string, + request: UpdateSharePermissionRequest + ): Promise> { + const response = await updateSharePermissionApi(knowledgeBaseId, shareId, request) + if (response.success) { + invalidateOrganizationData({ sharedKnowledgeBases: true }) + void fetchSharedKnowledgeBases({ force: true }) + } + return response + } + + async function shareAgent( + agentId: string, + request: ShareKnowledgeBaseRequest + ): Promise> { + const response = await shareAgentApi(agentId, request) + if (response.success) { + adjustOrganizationResourceCount(request.organization_id, 'agents', 1) + invalidateOrganizationData({ sharedAgents: true, sharedKnowledgeBases: true }) + void Promise.all([ + fetchOrganizations({ force: true }), + fetchSharedAgents({ force: true }), + fetchSharedKnowledgeBases({ force: true }) + ]) + } + return response + } + + async function unshareAgent( + agentId: string, + shareId: string, + organizationId: string + ): Promise> { + const response = await removeAgentShareApi(agentId, shareId) + if (response.success) { + adjustOrganizationResourceCount(organizationId, 'agents', -1) + invalidateOrganizationData({ sharedAgents: true, sharedKnowledgeBases: true }) + void Promise.all([ + fetchOrganizations({ force: true }), + fetchSharedAgents({ force: true }), + fetchSharedKnowledgeBases({ force: true }) + ]) + } + return response + } + + async function inviteOrganizationMember( + organizationId: string, + request: InviteMemberRequest + ): Promise> { + const response = await inviteMemberApi(organizationId, request) + if (response.success) { + const organization = organizations.value.find(org => org.id === organizationId) + patchOrganization(organizationId, { + member_count: (organization?.member_count ?? 0) + 1 + }) + void fetchOrganizations({ force: true }) + } + return response + } + + async function reviewOrganizationJoinRequest( + organizationId: string, + requestId: string, + request: ReviewJoinRequestRequest + ): Promise> { + const response = await reviewJoinRequestApi(organizationId, requestId, request) + if (response.success) { + const organization = organizations.value.find(org => org.id === organizationId) + patchOrganization(organizationId, { + member_count: (organization?.member_count ?? 0) + (request.approved ? 1 : 0), + pending_join_request_count: Math.max( + 0, + (organization?.pending_join_request_count ?? 0) - 1 + ) + }) + void fetchOrganizations({ force: true }) + } + return response + } + + async function requestOrganizationRoleUpgrade( + organizationId: string, + request: RequestRoleUpgradeRequest + ) { + const response = await requestRoleUpgradeApi(organizationId, request) + if (response.success) { + patchOrganization(organizationId, { has_pending_upgrade: true }) + void fetchOrganizations({ force: true }) + } + return response } /** * Set current organization for detail view */ function setCurrentOrganization(org: Organization | null) { - currentOrganization.value = org + if (org) { + upsertOrganization(org) + void fetchOrganizations({ force: true }) + } else { + currentOrganization.value = null + } + } + + function clearCurrentOrganizationContext() { + currentOrganization.value = null + currentMembers.value = [] } /** @@ -477,15 +769,18 @@ export const useOrganizationStore = defineStore('organization', () => { currentMembers.value = [] sharedKnowledgeBases.value = [] sharedAgents.value = [] + searchableOrganizations.value = [] resourceCounts.value = null previewData.value = null error.value = null sharedKbLoadedAt = 0 sharedAgentsLoadedAt = 0 - fetchSharedKbPromise = null - fetchSharedAgentsPromise = null organizationsLoadedAt = 0 - fetchOrganizationsPromise = null + searchableOrganizationsQuery = '' + searchableOrganizationCache.clear() + organizationsRequest.invalidate() + sharedKnowledgeBasesRequest.invalidate() + sharedAgentsRequest.invalidate() } return { @@ -495,6 +790,7 @@ export const useOrganizationStore = defineStore('organization', () => { currentMembers, sharedKnowledgeBases, sharedAgents, + searchableOrganizations, resourceCounts, previewData, loading, @@ -509,6 +805,7 @@ export const useOrganizationStore = defineStore('organization', () => { // Actions fetchOrganizations, create, + updateOrganization, update, remove, preview, @@ -520,7 +817,20 @@ export const useOrganizationStore = defineStore('organization', () => { kickMember, fetchSharedKnowledgeBases, fetchSharedAgents, + fetchSearchableOrganizations, + clearSearchableOrganizations, + invalidateOrganizationData, + joinById, + shareKnowledgeBase, + unshareKnowledgeBase, + changeKnowledgeBaseSharePermission, + shareAgent, + unshareAgent, + inviteOrganizationMember, + reviewOrganizationJoinRequest, + requestOrganizationRoleUpgrade, setCurrentOrganization, + clearCurrentOrganizationContext, getKBPermission, canEditKB, canManageKB, diff --git a/frontend/src/stores/organizationState.test.ts b/frontend/src/stores/organizationState.test.ts new file mode 100644 index 00000000..6447d629 --- /dev/null +++ b/frontend/src/stores/organizationState.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + applyOrganizationResourceDelta, + upsertById +} from './organizationState.ts' + +test('upsert adds a newly joined organization to the front', () => { + const result = upsertById( + [{ id: 'existing', name: 'Existing' }], + { id: 'joined', name: 'Joined' } + ) + + assert.deepEqual(result.map(organization => organization.id), ['joined', 'existing']) +}) + +test('upsert replaces edited organization data without duplicating the card', () => { + const result = upsertById( + [{ id: 'space-1', name: 'Old name' }], + { id: 'space-1', name: 'New name' } + ) + + assert.deepEqual(result, [{ id: 'space-1', name: 'New name' }]) +}) + +test('knowledge base sharing updates card and resource counts immediately', () => { + const result = applyOrganizationResourceDelta( + [{ id: 'space-1', share_count: 2 }], + { + knowledge_bases: { by_organization: { 'space-1': 2 } }, + agents: { by_organization: { 'space-1': 1 } } + }, + 'space-1', + 'knowledge_bases', + 1 + ) + + assert.equal(result.organizations[0].share_count, 3) + assert.equal(result.resourceCounts?.knowledge_bases.by_organization['space-1'], 3) +}) + +test('resource counts never become negative when a share is removed', () => { + const result = applyOrganizationResourceDelta( + [{ id: 'space-1', agent_share_count: 0 }], + { + knowledge_bases: { by_organization: {} }, + agents: { by_organization: { 'space-1': 0 } } + }, + 'space-1', + 'agents', + -1 + ) + + assert.equal(result.organizations[0].agent_share_count, 0) + assert.equal(result.resourceCounts?.agents.by_organization['space-1'], 0) +}) diff --git a/frontend/src/stores/organizationState.ts b/frontend/src/stores/organizationState.ts new file mode 100644 index 00000000..e3ee0fee --- /dev/null +++ b/frontend/src/stores/organizationState.ts @@ -0,0 +1,52 @@ +export function upsertById(items: T[], item: T): T[] { + const exists = items.some(existing => existing.id === item.id) + return exists + ? items.map(existing => existing.id === item.id ? item : existing) + : [item, ...items] +} + +interface OrganizationResourceCounts { + knowledge_bases: { by_organization: Record } + agents: { by_organization: Record } +} + +interface CountedOrganization { + id: string + share_count?: number + agent_share_count?: number +} + +export function applyOrganizationResourceDelta( + organizations: T[], + resourceCounts: OrganizationResourceCounts | null, + organizationId: string, + resource: 'knowledge_bases' | 'agents', + delta: number +): { organizations: T[]; resourceCounts: OrganizationResourceCounts | null } { + const field = resource === 'knowledge_bases' ? 'share_count' : 'agent_share_count' + const organizationsAfterUpdate = organizations.map(organization => { + if (organization.id !== organizationId) return organization + return { + ...organization, + [field]: Math.max(0, (organization[field] ?? 0) + delta) + } + }) + + if (!resourceCounts) { + return { organizations: organizationsAfterUpdate, resourceCounts } + } + + const counts = resourceCounts[resource].by_organization + return { + organizations: organizationsAfterUpdate, + resourceCounts: { + ...resourceCounts, + [resource]: { + by_organization: { + ...counts, + [organizationId]: Math.max(0, (counts[organizationId] ?? 0) + delta) + } + } + } + } +} diff --git a/frontend/src/stores/versionedRequest.test.ts b/frontend/src/stores/versionedRequest.test.ts new file mode 100644 index 00000000..2a627e69 --- /dev/null +++ b/frontend/src/stores/versionedRequest.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createVersionedRequestCoordinator } from './versionedRequest.ts' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +test('force queues a fresh request behind an existing request', async () => { + const first = deferred() + const second = deferred() + const requests = [first, second] + const applied: string[] = [] + let calls = 0 + const coordinator = createVersionedRequestCoordinator( + () => requests[calls++].promise, + value => applied.push(value) + ) + + const initial = coordinator.fetch() + const forced = coordinator.fetch(true) + assert.equal(calls, 1) + + first.resolve('old') + await initial + await new Promise(resolve => queueMicrotask(resolve)) + assert.equal(calls, 2) + + second.resolve('fresh') + await forced + assert.deepEqual(applied, ['old', 'fresh']) +}) + +test('an invalidated older response cannot overwrite newer state', async () => { + const first = deferred() + const second = deferred() + const requests = [first, second] + const applied: string[] = [] + let calls = 0 + const coordinator = createVersionedRequestCoordinator( + () => requests[calls++].promise, + value => applied.push(value) + ) + + const initial = coordinator.fetch() + coordinator.invalidate() + const forced = coordinator.fetch(true) + + first.resolve('stale') + await initial + await new Promise(resolve => queueMicrotask(resolve)) + second.resolve('fresh') + await forced + + assert.deepEqual(applied, ['fresh']) +}) + +test('a newer force request extends an active force queue after invalidation', async () => { + const first = deferred() + const second = deferred() + const third = deferred() + const requests = [first, second, third] + const applied: string[] = [] + let calls = 0 + const coordinator = createVersionedRequestCoordinator( + () => requests[calls++].promise, + value => applied.push(value) + ) + + void coordinator.fetch() + const firstForce = coordinator.fetch(true) + first.resolve('first') + await new Promise(resolve => queueMicrotask(resolve)) + + coordinator.invalidate() + const secondForce = coordinator.fetch(true) + second.resolve('stale-second') + await new Promise(resolve => queueMicrotask(resolve)) + assert.equal(calls, 3) + + third.resolve('fresh-third') + await Promise.all([firstForce, secondForce]) + assert.deepEqual(applied, ['first', 'fresh-third']) +}) diff --git a/frontend/src/stores/versionedRequest.ts b/frontend/src/stores/versionedRequest.ts new file mode 100644 index 00000000..9158990f --- /dev/null +++ b/frontend/src/stores/versionedRequest.ts @@ -0,0 +1,90 @@ +export interface VersionedRequestCoordinator { + fetch: (force?: boolean) => Promise + invalidate: () => void + hasInFlightRequest: () => boolean +} + +/** + * Coordinates cached reads that also need a reliable "force refresh" mode. + * + * A forced read requested while another read is running is queued after the + * current request. Invalidating the coordinator also prevents an older + * response from overwriting state changed by a newer write. + */ +export function createVersionedRequestCoordinator( + request: () => Promise, + apply: (value: T) => void +): VersionedRequestCoordinator { + let revision = 0 + let startedSequence = 0 + let completedSequence = 0 + let forceTargetSequence = 0 + let inFlight: Promise | null = null + let forceQueue: Promise | null = null + + const start = (): Promise => { + const requestRevision = revision + const sequence = ++startedSequence + const current = (async () => { + const value = await request() + if (requestRevision === revision) { + apply(value) + } + })() + + inFlight = current + void current.then( + () => { + completedSequence = Math.max(completedSequence, sequence) + if (inFlight === current) inFlight = null + }, + () => { + completedSequence = Math.max(completedSequence, sequence) + if (inFlight === current) inFlight = null + } + ) + return current + } + + const fetch = (force = false): Promise => { + if (!inFlight) return start() + if (!force) return inFlight + + // A force call always requires at least one request newer than the one + // that was in flight when force was requested. + forceTargetSequence = Math.max(forceTargetSequence, startedSequence + 1) + if (!forceQueue) { + const queued = (async () => { + while (completedSequence < forceTargetSequence) { + if (inFlight) { + try { + await inFlight + } catch { + // The queued forced request must still run after a failed read. + } + } else { + await start() + } + } + })() + forceQueue = queued + void queued.then( + () => { + if (forceQueue === queued) forceQueue = null + }, + () => { + if (forceQueue === queued) forceQueue = null + } + ) + } + return forceQueue + } + + return { + fetch, + invalidate: () => { + revision += 1 + }, + hasInFlightRequest: () => inFlight !== null + } +} diff --git a/frontend/src/views/knowledge/settings/KBShareSettings.vue b/frontend/src/views/knowledge/settings/KBShareSettings.vue index e04d9d0a..6925a567 100644 --- a/frontend/src/views/knowledge/settings/KBShareSettings.vue +++ b/frontend/src/views/knowledge/settings/KBShareSettings.vue @@ -140,7 +140,7 @@ import { ref, computed, watch } from 'vue' import { MessagePlugin } from 'tdesign-vue-next' import { useI18n } from 'vue-i18n' import { useOrganizationStore } from '@/stores/organization' -import { shareKnowledgeBase, listKBShares, removeShare, updateSharePermission } from '@/api/organization' +import { listKBShares } from '@/api/organization' import type { KnowledgeBaseShare } from '@/api/organization' import SpaceAvatar from '@/components/SpaceAvatar.vue' import ShareToSpaceOrgSelect from '@/components/ShareToSpaceOrgSelect.vue' @@ -261,7 +261,7 @@ async function handleShare() { submitting.value = true try { - const result = await shareKnowledgeBase(props.kbId, { + const result = await orgStore.shareKnowledgeBase(props.kbId, { organization_id: selectedOrgId.value, permission: selectedPermission.value }) @@ -286,7 +286,7 @@ async function handleUpdatePermission(share: KnowledgeBaseShare, newPermission: if (share.permission === newPermission) return try { - const result = await updateSharePermission(props.kbId, share.id, { + const result = await orgStore.changeKnowledgeBaseSharePermission(props.kbId, share.id, { permission: newPermission as 'viewer' | 'editor' }) if (result.success) { @@ -303,7 +303,11 @@ async function handleUpdatePermission(share: KnowledgeBaseShare, newPermission: async function handleUnshare(share: KnowledgeBaseShare) { try { - const result = await removeShare(props.kbId, share.id) + const result = await orgStore.unshareKnowledgeBase( + props.kbId, + share.id, + share.organization_id + ) if (result.success) { MessagePlugin.success(t('organization.share.unshareSuccess')) await loadShares() diff --git a/frontend/src/views/organization/OrganizationList.vue b/frontend/src/views/organization/OrganizationList.vue index c5249e3e..48e549e9 100644 --- a/frontend/src/views/organization/OrganizationList.vue +++ b/frontend/src/views/organization/OrganizationList.vue @@ -99,10 +99,10 @@ {{ org.name }} - -
+