fix(ai): stabilize global workspace interactions

Poll contact preparation only while the contact picker is active and refresh the current query when returning from group search. Guard conversation loading with per-session generations so stale async completions cannot replace the latest selection.
This commit is contained in:
digua committed 2026-08-22 18:01:22 +08:00
1 parent 5c40a37572
commit f03b203d39
4 files changed
+125 -31

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AIEntityRef, ContactListItem, ContactsResponse } from '@openchatlab/shared-types'
import { useDataService } from '@/services'
@@ -25,7 +25,9 @@ const contactResponses = ref<ContactsResponse[]>([])
const loadingContacts = ref(false)
const contactError = ref('')
let searchTimer: ReturnType<typeof setTimeout> | null = null
let pollTimer: ReturnType<typeof setTimeout> | null = null
let requestId = 0
const CONTACTS_POLL_INTERVAL_MS = 1500
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase())
const groups = computed(() => {
@@ -70,12 +72,39 @@ function remove(entity: AIEntityRef): void {
)
}
async function loadContacts(): Promise<void> {
function stopContactsPolling(): void {
if (!pollTimer) return
clearTimeout(pollTimer)
pollTimer = null
}
function scheduleContactsPolling(responses: ContactsResponse[]): void {
stopContactsPolling()
if (!open.value || activeType.value !== 'contacts') return
if (!responses.some((response) => response.task?.status === 'running')) return
pollTimer = setTimeout(() => {
pollTimer = null
void loadContacts({ silent: true })
}, CONTACTS_POLL_INTERVAL_MS)
}
function stopPendingContactWork(): void {
requestId++
loadingContacts.value = false
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
stopContactsPolling()
}
async function loadContacts(loadOptions?: { silent?: boolean }): Promise<void> {
const currentRequest = ++requestId
loadingContacts.value = true
if (!loadOptions?.silent) loadingContacts.value = true
contactError.value = ''
try {
const options = {
const requestOptions = {
acceptStale: true,
timeRangePreset: 'all',
page: 1,
@@ -83,8 +112,8 @@ async function loadContacts(): Promise<void> {
query: query.value.trim() || undefined,
} as const
const responses = await Promise.all([
dataService.getContacts({ ...options, pool: 'friend' }),
dataService.getContacts({ ...options, pool: 'non_friend' }),
dataService.getContacts({ ...requestOptions, pool: 'friend' }),
dataService.getContacts({ ...requestOptions, pool: 'non_friend' }),
])
if (currentRequest !== requestId) return
@@ -92,24 +121,45 @@ async function loadContacts(): Promise<void> {
responses.flatMap((response) => response.contacts).forEach((contact) => byKey.set(contact.key, contact))
contacts.value = [...byKey.values()]
contactResponses.value = responses
scheduleContactsPolling(responses)
} catch (error) {
if (currentRequest !== requestId) return
stopContactsPolling()
contactError.value = error instanceof Error ? error.message : String(error)
contacts.value = []
contactResponses.value = []
} finally {
if (currentRequest === requestId) loadingContacts.value = false
if (currentRequest === requestId && !loadOptions?.silent) loadingContacts.value = false
}
}
watch(open, (isOpen) => {
if (isOpen) void loadContacts()
if (isOpen) {
void loadContacts()
} else {
stopPendingContactWork()
}
})
watch(activeType, (type) => {
if (!open.value) return
stopPendingContactWork()
if (type === 'contacts') void loadContacts()
})
watch(query, () => {
if (!open.value || activeType.value !== 'contacts') return
requestId++
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => void loadContacts(), 250)
stopContactsPolling()
searchTimer = setTimeout(() => {
searchTimer = null
void loadContacts()
}, 250)
})
onUnmounted(() => {
stopPendingContactWork()
})
</script>
@@ -160,7 +210,7 @@ watch(query, () => {
</div>
<div v-else-if="contactError" class="px-3 py-8 text-center text-xs text-red-500">
<p>{{ t('ai.global.entityPicker.loadFailed') }}</p>
<UButton size="xs" variant="link" class="mt-1" @click="loadContacts">
<UButton size="xs" variant="link" class="mt-1" @click="loadContacts()">
{{ t('common.retry') }}
</UButton>
</div>
+13 -2
View File
@@ -38,6 +38,7 @@ const input = ref('')
const selectedEntities = ref<AIEntityRef[]>([])
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<void> {
}
async function selectConversation(aiChatId: string): Promise<void> {
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++
})
</script>
+20
View File
@@ -334,6 +334,17 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const { aiGlobalSettings } = storeToRefs(promptStore)
let pendingFocusReturnChatKey: string | null = null
const aiChatSelectionGenerations = new Map<string, number>()
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<boolean> {
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)
+32 -19
View File
@@ -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<typeof makeConversation>) => void
const slowConversation = new Promise<ReturnType<typeof makeConversation>>((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')
})