mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user