diff --git a/src/pages/global-ai/components/GlobalEntityPicker.vue b/src/pages/global-ai/components/GlobalEntityPicker.vue index 75720cda..7952cb97 100644 --- a/src/pages/global-ai/components/GlobalEntityPicker.vue +++ b/src/pages/global-ai/components/GlobalEntityPicker.vue @@ -1,5 +1,5 @@ @@ -160,7 +210,7 @@ watch(query, () => {

{{ t('ai.global.entityPicker.loadFailed') }}

- + {{ t('common.retry') }}
diff --git a/src/pages/global-ai/index.vue b/src/pages/global-ai/index.vue index d701b812..0c626906 100644 --- a/src/pages/global-ai/index.vue +++ b/src/pages/global-ai/index.vue @@ -38,6 +38,7 @@ const input = ref('') const selectedEntities = ref([]) const isComposing = ref(false) let isUnmounted = false +let conversationSelectionRequestId = 0 function isGlobalPageActive(): boolean { return !isUnmounted && route.name === 'global-ai' @@ -80,18 +81,26 @@ async function loadConversations(): Promise { } async function selectConversation(aiChatId: string): Promise { - if (!(await aiChatStore.loadAIChat(chatKey, aiChatId))) { + const currentRequest = ++conversationSelectionRequestId + const loaded = await aiChatStore.loadAIChat(chatKey, aiChatId) + if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return + + if (!loaded) { toast.fail(t('ai.global.toast.loadFailed')) return } + initializing.value = false selectedEntities.value = [] await syncAIChatIdToRoute(aiChatId) + if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return await nextTick() scrollToBottom(true) } function startNewConversation(): void { + conversationSelectionRequestId++ if (!aiChatStore.startNewAIChat(chatKey)) return + initializing.value = false selectedEntities.value = [] input.value = '' void syncAIChatIdToRoute(null) @@ -175,9 +184,10 @@ onMounted(async () => { await loadConversations() if (!isGlobalPageActive()) return + const currentSelectionRequest = ++conversationSelectionRequestId const preferredAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null const restoredPreferredChat = preferredAIChatId ? await aiChatStore.loadAIChat(chatKey, preferredAIChatId) : false - if (!isGlobalPageActive()) return + if (currentSelectionRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return if (!restoredPreferredChat) { aiChatStore.startNewAIChat(chatKey) @@ -196,6 +206,7 @@ onMounted(async () => { onUnmounted(() => { isUnmounted = true + conversationSelectionRequestId++ }) diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index e523ac38..2b97436f 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -334,6 +334,17 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { const { aiGlobalSettings } = storeToRefs(promptStore) let pendingFocusReturnChatKey: string | null = null + const aiChatSelectionGenerations = new Map() + + function advanceAIChatSelection(chatKey: string): number { + const generation = (aiChatSelectionGenerations.get(chatKey) ?? 0) + 1 + aiChatSelectionGenerations.set(chatKey, generation) + return generation + } + + function isLatestAIChatSelection(chatKey: string, generation: number): boolean { + return aiChatSelectionGenerations.get(chatKey) === generation + } function generateId(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` @@ -550,9 +561,12 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { async function loadAIChat(chatKey: string, aiChatId: string): Promise { const state = getSessionState(chatKey) if (!state) return false + const selectionGeneration = advanceAIChatSelection(chatKey) try { const conversation = await useAIService().getAIChat(aiChatId) + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return false + const belongsToState = conversation?.kind === state.kind && (state.kind === 'global' || conversation.sessionId === state.sessionId) if (!conversation || !belongsToState) return false @@ -564,6 +578,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { useAIService().getMessages(aiChatId), useAIService().getAIChatTokenUsage(aiChatId), ]) + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return false + buffer.messages.splice(0, buffer.messages.length, ...history.map((msg) => toRuntimeMessage(msg))) buffer.sourceMessages.splice(0, buffer.sourceMessages.length) buffer.currentKeywords.splice(0, buffer.currentKeywords.length) @@ -571,11 +587,13 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { buffer.loaded = true } + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return false buffer.assistantId = conversation.assistantId bindDisplayedBuffer(state, aiChatId) applySessionAssistantSelection(chatKey) return true } catch (error) { + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return false console.error('[AI] 加载对话历史失败:', error) return false } @@ -590,6 +608,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { return false } + advanceAIChatSelection(chatKey) bindDisplayedBuffer(state, bufferKey) applySessionAssistantSelection(chatKey) return true @@ -637,6 +656,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { const state = getSessionState(chatKey) if (!state || state.isAIThinking) return false + advanceAIChatSelection(chatKey) const draftBuffer = createAIChatBuffer(state.selectedAssistantId) state.aiChatBuffers[DRAFT_AI_CHAT_KEY] = draftBuffer bindDisplayedBuffer(state, DRAFT_AI_CHAT_KEY) diff --git a/src/stores/aiChatNavigation.test.ts b/src/stores/aiChatNavigation.test.ts index 7c5aa928..8b50e10a 100644 --- a/src/stores/aiChatNavigation.test.ts +++ b/src/stores/aiChatNavigation.test.ts @@ -3,26 +3,31 @@ import test from 'node:test' import { createPinia, setActivePinia } from 'pinia' import { ref } from 'vue' -test('restores requested AI chats without leaking global task navigation state', async (t) => { +test('restores only the latest requested AI chat without leaking navigation state', async (t) => { + const makeConversation = (id: string) => ({ + id, + sessionId: 'session-one', + kind: 'session' as const, + title: 'Saved chat', + assistantId: 'assistant-one', + createdAt: 1, + updatedAt: 1, + }) + let resolveSlowConversation!: (conversation: ReturnType) => void + const slowConversation = new Promise>((resolve) => { + resolveSlowConversation = resolve + }) const aiService = { - getAIChat: async (id: string) => - id === 'chat-one' - ? { - id, - sessionId: 'session-one', - kind: 'session' as const, - title: 'Saved chat', - assistantId: 'assistant-one', - createdAt: 1, - updatedAt: 1, - } - : null, - getMessages: async () => [ + getAIChat: async (id: string) => { + if (id === 'chat-slow') return slowConversation + return id === 'chat-one' || id === 'chat-latest' ? makeConversation(id) : null + }, + getMessages: async (aiChatId: string) => [ { - id: 'message-one', - aiChatId: 'chat-one', + id: `message-${aiChatId}`, + aiChatId, role: 'assistant' as const, - content: 'Saved answer', + content: `Saved answer for ${aiChatId}`, timestamp: 1, }, ], @@ -80,7 +85,7 @@ test('restores requested AI chats without leaking global task navigation state', await store.resetToSelectorOnEnter(first.chatKey, 'chat-one') assert.equal(first.state.currentAIChatId, 'chat-one') - assert.equal(first.state.messages[0]?.content, 'Saved answer') + assert.equal(first.state.messages[0]?.content, 'Saved answer for chat-one') const second = store.ensureSessionState({ sessionId: 'session-two', @@ -93,6 +98,14 @@ test('restores requested AI chats without leaking global task navigation state', assert.equal(second.state.currentAIChatId, null) assert.equal(second.state.messages.length, 0) + const staleLoad = store.loadAIChat(first.chatKey, 'chat-slow') + assert.equal(await store.loadAIChat(first.chatKey, 'chat-latest'), true) + resolveSlowConversation(makeConversation('chat-slow')) + + assert.equal(await staleLoad, false) + assert.equal(first.state.currentAIChatId, 'chat-latest') + assert.equal(first.state.messages[0]?.content, 'Saved answer for chat-latest') + const global = store.ensureGlobalState('zh-CN') store.activeTask = { requestId: 'global-request', @@ -111,5 +124,5 @@ test('restores requested AI chats without leaking global task navigation state', await store.resetToSelectorOnEnter(first.chatKey, 'chat-one') assert.equal(first.state.currentAIChatId, 'chat-one') - assert.equal(first.state.messages[0]?.content, 'Saved answer') + assert.equal(first.state.messages[0]?.content, 'Saved answer for chat-one') })