From 862b39fb1b9f83d25ab84656edb27def42cff426 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:04:27 +0800 Subject: [PATCH] fix(ai): keep latest session request owner --- .../src/blocks/AI/index.tsx | 52 +++++++-- .../src/blocks/AI/retiredAiSurface.test.ts | 1 + .../AI/sessionRequestCoordinator.test.ts | 106 ++++++++++++++++++ .../blocks/AI/sessionRequestCoordinator.ts | 71 ++++++++++++ 4 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.test.ts create mode 100644 chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.ts diff --git a/chat2db-community-client/src/blocks/AI/index.tsx b/chat2db-community-client/src/blocks/AI/index.tsx index 89c6caf687..4b939eb4e8 100644 --- a/chat2db-community-client/src/blocks/AI/index.tsx +++ b/chat2db-community-client/src/blocks/AI/index.tsx @@ -56,6 +56,7 @@ import { buildUserMessageNavigationItems } from './messageNavigation'; import { Pencil } from 'lucide-react'; import MessageNavigationRail from './components/MessageNavigationRail'; import InlineRenameInput from '@/components/InlineRenameInput'; +import { AiSessionRequestCoordinator, type AiSessionRequestOwner } from './sessionRequestCoordinator'; /** detects unclosed text in flowing text ```chart block, return chart and whether there are any unfinished diagrams */ function splitIncompleteChartBlock(text: string): { textBeforeChart: string; hasIncompleteChart: boolean } { @@ -558,6 +559,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession const currentSessionIdRef = useRef(null); const currentSessionTitleRef = useRef(''); const messagesRef = useRef([]); + const sessionRequestCoordinatorRef = useRef(new AiSessionRequestCoordinator()); const currentRoundUserMessageIdRef = useRef(null); const statusRef = useRef(SSERequestStatus.IDLE); const inProgressSessionRef = useRef(null); @@ -1235,7 +1237,9 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession // Start a new conversation. const handleNewChat = useCallback(() => { + const newSessionOwner = sessionRequestCoordinatorRef.current.beginNewSession(); stop(); + setSessionLoading(false); setAutoFollow(true); chatInputRef.current?.resetAttachments(); pendingViewportAnchorRef.current = null; @@ -1280,6 +1284,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession clearChatIdFromPath(); } onSessionChange?.(); + return newSessionOwner; }, [isPanel, clearChatIdFromPath, onSessionChange, stop]); const startPanelHistoryRename = useCallback((session: IChatSession) => { @@ -1342,6 +1347,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession const handleLoadSessionById = useCallback( async (sessionId: string, title?: string) => { + const loadOwner = sessionRequestCoordinatorRef.current.beginSessionLoad(sessionId); const isGenerating = statusRef.current === SSERequestStatus.LOADING; if (isGenerating) { const activeSessionId = currentSessionIdRef.current || ''; @@ -1417,12 +1423,18 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession currentSessionTitleRef.current = inProgressSession.title; } inProgressSessionRef.current = null; + if (sessionRequestCoordinatorRef.current.finishSessionLoad(loadOwner)) { + setSessionLoading(false); + } return; } setSessionLoading(true); try { const msgs = (await aiStreamService.getChatMessages({ sessionId })) || []; + if (!sessionRequestCoordinatorRef.current.isCurrent(loadOwner)) { + return; + } const chatItems: IChatItem[] = msgs.map((m: IChatMessage) => ({ id: m.id, role: m.role as ChatRole, @@ -1465,6 +1477,9 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession if (!title) { try { const sessions = (await aiStreamService.getChatSessions(undefined as void)) || []; + if (!sessionRequestCoordinatorRef.current.isCurrent(loadOwner)) { + return; + } const found = sessions.find((s) => s.id === sessionId); setCurrentSessionTitle(found?.title || ''); currentSessionTitleRef.current = found?.title || ''; @@ -1473,9 +1488,13 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession } } } catch { - feedback.error(i18n('stream.error.loadSessionMessages')); + if (sessionRequestCoordinatorRef.current.isCurrent(loadOwner)) { + feedback.error(i18n('stream.error.loadSessionMessages')); + } } finally { - setSessionLoading(false); + if (sessionRequestCoordinatorRef.current.finishSessionLoad(loadOwner)) { + setSessionLoading(false); + } } }, [onSessionChange, stop], @@ -1543,7 +1562,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession // Send a message. const handleSend = useCallback( - async (params: SendParams) => { + async (params: SendParams, sessionOwner?: AiSessionRequestOwner) => { const content = (params.input || '').trim(); if (!content) return; @@ -1557,6 +1576,14 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession feedback.warning(i18n('stream.warning.invalidModel')); return; } + const sessionContext = sessionRequestCoordinatorRef.current.resolveSendContext( + sessionOwner, + currentSessionIdRef.current, + messagesRef.current, + ); + if (!sessionContext) { + return; + } setStreamTraceEntries([]); streamTraceEntriesRef.current = []; @@ -1601,9 +1628,9 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession }); // Let the backend load history for an existing session; otherwise send local history. - const historyPayload = currentSessionId + const historyPayload = sessionContext.sessionId ? [] - : messages + : sessionContext.history .slice(-MAX_HISTORY_ROUNDS * 2) .filter((item) => item.content?.trim()) .map((item) => ({ role: item.role, content: item.content })); @@ -1613,10 +1640,13 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession feedback.warning(i18n('stream.warning.invalidModel')); return; } + if (sessionOwner && !sessionRequestCoordinatorRef.current.isCurrent(sessionOwner)) { + return; + } console.log('[AI stream] sending request', { inputPreview: content.slice(0, 200), - sessionId: currentSessionId || undefined, + sessionId: sessionContext.sessionId, dataSourceId: params.dataSourceId, databaseName: params.databaseName, schemaName: params.schemaName, @@ -1631,10 +1661,10 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession })), }); - const isNewSession = !currentSessionId; + const isNewSession = !sessionContext.sessionId; const requestPromise = request({ input: content, - sessionId: currentSessionId || undefined, + sessionId: sessionContext.sessionId, history: historyPayload, enableTools: true, ...modelRequestPayload, @@ -1658,9 +1688,7 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession await requestPromise; }, [ - currentSessionId, isCurrentRoundOverflowingViewport, - messages, modelOptionMap, selectedModel?.value, request, @@ -1678,10 +1706,10 @@ export default function AI({ variant = 'page', onTableClick, onPinSql, onSession const params = (e as CustomEvent).detail as SendParams; if (params) { // Start a new conversation before sending to avoid mixing old context. - handleNewChat(); + const newSessionOwner = handleNewChat(); // Wait for handleNewChat state cleanup before sending. setTimeout(() => { - handleSend(params); + handleSend(params, newSessionOwner); }, 0); } }; diff --git a/chat2db-community-client/src/blocks/AI/retiredAiSurface.test.ts b/chat2db-community-client/src/blocks/AI/retiredAiSurface.test.ts index 0644a4498f..baff1adc1a 100644 --- a/chat2db-community-client/src/blocks/AI/retiredAiSurface.test.ts +++ b/chat2db-community-client/src/blocks/AI/retiredAiSurface.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; +import './sessionRequestCoordinator.test'; const sourceRoot = path.resolve('src'); const retiredFiles = [ diff --git a/chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.test.ts b/chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.test.ts new file mode 100644 index 0000000000..4e5895d7d8 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { AiSessionRequestCoordinator } from './sessionRequestCoordinator'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +async function run() { + const externalCoordinator = new AiSessionRequestCoordinator(); + const externalNewSessionOwner = externalCoordinator.beginNewSession(); + const externalSendWithOldSession = externalCoordinator.resolveSendContext( + externalNewSessionOwner, + 'stale-session', + [{ role: 'user', content: 'stale history' }], + ); + assert.deepEqual( + externalSendWithOldSession, + { sessionId: undefined, history: [] }, + 'an external new-session send must not inherit the render closure session', + ); + const externalSendWithOldHistory = externalCoordinator.resolveSendContext( + externalNewSessionOwner, + null, + [{ role: 'user', content: 'stale history' }], + ); + assert.deepEqual( + externalSendWithOldHistory, + { sessionId: undefined, history: [] }, + 'an external new-session send must not inherit the render closure history', + ); + + const coordinator = new AiSessionRequestCoordinator(); + const committedSessions: string[] = []; + let loading = false; + + const loadSession = async (sessionId: string, response: Promise) => { + const owner = coordinator.beginSessionLoad(sessionId); + loading = true; + try { + const resolvedSession = await response; + if (coordinator.isCurrent(owner)) { + committedSessions.push(resolvedSession); + } + } finally { + if (coordinator.finishSessionLoad(owner)) { + loading = false; + } + } + }; + + const first = deferred(); + const second = deferred(); + const firstLoad = loadSession('session-a', first.promise); + const secondLoad = loadSession('session-b', second.promise); + + first.resolve('session-a'); + await firstLoad; + assert.equal(loading, true, 'an obsolete finally must not clear the latest loading state'); + assert.deepEqual(committedSessions, [], 'an obsolete response must not commit while the latest request is pending'); + + second.resolve('session-b'); + await secondLoad; + assert.equal(loading, false); + assert.deepEqual(committedSessions, ['session-b']); + + const reverseCoordinator = new AiSessionRequestCoordinator(); + const reverseCommits: string[] = []; + const resolveReverseLoad = async (sessionId: string, response: Promise) => { + const owner = reverseCoordinator.beginSessionLoad(sessionId); + const resolvedSession = await response; + if (reverseCoordinator.isCurrent(owner)) { + reverseCommits.push(resolvedSession); + } + reverseCoordinator.finishSessionLoad(owner); + }; + const reverseFirst = deferred(); + const reverseSecond = deferred(); + const reverseFirstLoad = resolveReverseLoad('session-a', reverseFirst.promise); + const reverseSecondLoad = resolveReverseLoad('session-b', reverseSecond.promise); + reverseSecond.resolve('session-b'); + await reverseSecondLoad; + reverseFirst.resolve('session-a'); + await reverseFirstLoad; + assert.deepEqual(reverseCommits, ['session-b'], 'B must remain visible when A resolves after B'); + + const pendingLoad = coordinator.beginSessionLoad('session-c'); + const newSessionOwner = coordinator.beginNewSession(); + assert.equal(coordinator.isCurrent(pendingLoad), false, 'new chat must invalidate an outstanding session load'); + assert.equal(coordinator.finishSessionLoad(pendingLoad), false); + assert.equal(coordinator.isCurrent(newSessionOwner), true); + + const nextLoad = coordinator.beginSessionLoad('session-d'); + assert.equal(coordinator.resolveSendContext(newSessionOwner, 'stale-session', ['stale']), null); + assert.equal(coordinator.isCurrent(nextLoad), true); + + console.log('AI session request coordinator tests passed.'); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.ts b/chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.ts new file mode 100644 index 0000000000..f581c4d1e8 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/sessionRequestCoordinator.ts @@ -0,0 +1,71 @@ +export interface AiSessionRequestOwner { + generation: number; + sessionId: string | null; +} + +export interface AiSessionSendContext { + sessionId: string | undefined; + history: T[]; +} + +export class AiSessionRequestCoordinator { + private generation = 0; + + private currentOwner: AiSessionRequestOwner | null = null; + + private activeLoadGeneration: number | null = null; + + beginSessionLoad(sessionId: string): AiSessionRequestOwner { + const owner = this.advance(sessionId); + this.activeLoadGeneration = owner.generation; + return owner; + } + + beginNewSession(): AiSessionRequestOwner { + const owner = this.advance(null); + this.activeLoadGeneration = null; + return owner; + } + + isCurrent(owner: AiSessionRequestOwner): boolean { + return ( + this.currentOwner?.generation === owner.generation && this.currentOwner.sessionId === owner.sessionId + ); + } + + finishSessionLoad(owner: AiSessionRequestOwner): boolean { + if (!this.isCurrent(owner) || this.activeLoadGeneration !== owner.generation) { + return false; + } + this.activeLoadGeneration = null; + return true; + } + + resolveSendContext( + owner: AiSessionRequestOwner | undefined, + currentSessionId: string | null, + history: readonly T[], + ): AiSessionSendContext | null { + if (owner) { + if (!this.isCurrent(owner)) { + return null; + } + if (owner.sessionId === null) { + return { sessionId: undefined, history: [] }; + } + } + + const sessionId = currentSessionId || undefined; + return { + sessionId, + history: sessionId ? [] : [...history], + }; + } + + private advance(sessionId: string | null): AiSessionRequestOwner { + this.generation += 1; + const owner = { generation: this.generation, sessionId }; + this.currentOwner = owner; + return owner; + } +}