mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-13 20:26:51 +08:00
fix(chat): preserve in-flight SSE stream when switching conversations (#18037)
This commit is contained in:
@@ -42,6 +42,7 @@ export function SingleChatBox({
|
||||
handleUploadFile,
|
||||
removeFile,
|
||||
setDerivedMessages,
|
||||
activeStreamsRef,
|
||||
} = useSendMessage(controller);
|
||||
const { data: userInfo } = useFetchUserInfo();
|
||||
const { data: currentDialog } = useFetchChat();
|
||||
@@ -56,6 +57,12 @@ export function SingleChatBox({
|
||||
const showInternet = useShowInternet();
|
||||
|
||||
useEffect(() => {
|
||||
// Don't let backend data overwrite local streaming state while SSE is
|
||||
// in-flight for this conversation. The server hasn't persisted the latest
|
||||
// answer yet, and applying conversation.messages would discard the
|
||||
// in-progress answer.
|
||||
if (activeStreamsRef.current.has(conversationId)) return;
|
||||
|
||||
const messages = conversation?.messages;
|
||||
if (Array.isArray(messages)) {
|
||||
setDerivedMessages((prevMessages) => {
|
||||
@@ -76,7 +83,7 @@ export function SingleChatBox({
|
||||
}));
|
||||
});
|
||||
}
|
||||
}, [conversation?.messages, setDerivedMessages]);
|
||||
}, [conversation?.messages, conversationId, setDerivedMessages, activeStreamsRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear the message list after deleting the conversation.
|
||||
|
||||
@@ -15,9 +15,15 @@ export function useHandleClickConversationCard() {
|
||||
const handleConversationCardClick = useCallback(
|
||||
(conversationId: string, isNew: boolean) => {
|
||||
setConversationBoth(conversationId, isNew ? 'true' : '');
|
||||
stopOutputMessage();
|
||||
// Switch to a fresh controller WITHOUT aborting the previous one.
|
||||
// Aborting the in-flight completion request can cancel server-side
|
||||
// persistence of the just-asked question, so switching conversations
|
||||
// right after asking would lose the question. Let the old SSE finish
|
||||
// in the background; the unmounted SingleChatBox's setState becomes
|
||||
// a no-op and the server keeps the message.
|
||||
setController(new AbortController());
|
||||
},
|
||||
[setConversationBoth, stopOutputMessage],
|
||||
[setConversationBoth],
|
||||
);
|
||||
|
||||
return { controller, handleConversationCardClick, stopOutputMessage };
|
||||
|
||||
@@ -7,16 +7,60 @@ import {
|
||||
useSendMessageWithSse,
|
||||
} from '@/hooks/logic-hooks';
|
||||
import { useGetChatSearchParams } from '@/hooks/use-chat-request';
|
||||
import { IMessage } from '@/interfaces/database/chat';
|
||||
import { IAnswer, IMessage } from '@/interfaces/database/chat';
|
||||
import api from '@/utils/api';
|
||||
import { trim } from 'lodash';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { buildMessageUuid } from '@/utils/chat';
|
||||
import { omit, trim } from 'lodash';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useCreateConversationBeforeSendMessage } from './use-chat-url';
|
||||
import { useFindPrologueFromDialogList } from './use-select-conversation-list';
|
||||
import { useUploadFile } from './use-upload-file';
|
||||
|
||||
// Append an answer to a messages array (mirrors addNewestAnswer logic in
|
||||
// useSelectDerivedMessages) for updating a background conversation's cache
|
||||
// without touching the currently displayed derivedMessages.
|
||||
function appendAnswerToMessages(
|
||||
messages: IMessage[],
|
||||
answer: IAnswer,
|
||||
): IMessage[] {
|
||||
return [
|
||||
...(messages.slice(0, -1) ?? []),
|
||||
{
|
||||
role: MessageType.Assistant,
|
||||
content: answer.answer,
|
||||
reference: answer.reference,
|
||||
id: buildMessageUuid({ id: answer.id, role: MessageType.Assistant }),
|
||||
prompt: answer.prompt,
|
||||
audio_binary: answer.audio_binary,
|
||||
...omit(answer, 'reference'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Append a user question + empty assistant placeholder to a messages array
|
||||
// (mirrors addNewestQuestion logic) for updating a background conversation's
|
||||
// cache when the user asks a question right before switching conversations.
|
||||
function appendQuestionToMessages(
|
||||
messages: IMessage[],
|
||||
message: IMessage,
|
||||
): IMessage[] {
|
||||
return [
|
||||
...messages,
|
||||
{
|
||||
...message,
|
||||
id: buildMessageUuid(message),
|
||||
},
|
||||
{
|
||||
role: MessageType.Assistant,
|
||||
content: '',
|
||||
conversationId: message.conversationId,
|
||||
id: buildMessageUuid({ ...message, role: MessageType.Assistant }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const useSelectNextMessages = () => {
|
||||
const {
|
||||
scrollRef,
|
||||
@@ -33,6 +77,26 @@ export const useSelectNextMessages = () => {
|
||||
const { id: dialogId } = useParams();
|
||||
const prologue = useFindPrologueFromDialogList();
|
||||
|
||||
// Per-conversation message cache so switching conversations preserves the
|
||||
// local derivedMessages (including in-flight SSE answers) instead of wiping
|
||||
// them. Keyed by conversationId.
|
||||
const messagesCacheRef = useRef<Record<string, IMessage[]>>({});
|
||||
// Tracks conversations with an in-flight SSE stream so the backend sync
|
||||
// effect in SingleChatBox can avoid overwriting local streaming state.
|
||||
const activeStreamsRef = useRef<Set<string>>(new Set());
|
||||
// Tracks which conversationId the current derivedMessages belongs to, so
|
||||
// the cache-write effect doesn't mistakenly write the previous conversation's
|
||||
// messages into the newly switched conversation's cache entry during the
|
||||
// render where conversationId has changed but derivedMessages hasn't yet.
|
||||
const messagesConversationIdRef = useRef(conversationId);
|
||||
|
||||
// On conversation switch, restore cached messages (or start empty).
|
||||
useEffect(() => {
|
||||
messagesConversationIdRef.current = conversationId;
|
||||
const cached = messagesCacheRef.current[conversationId];
|
||||
setDerivedMessages(cached ?? []);
|
||||
}, [conversationId, setDerivedMessages]);
|
||||
|
||||
const addPrologue = useCallback(() => {
|
||||
if (dialogId !== '' && isNew === 'true') {
|
||||
const nextMessage = {
|
||||
@@ -50,6 +114,13 @@ export const useSelectNextMessages = () => {
|
||||
addPrologue();
|
||||
}, [addPrologue]);
|
||||
|
||||
// Persist current derivedMessages back into the cache for the conversation
|
||||
// they actually belong to (not necessarily the URL's current conversationId
|
||||
// during a switch).
|
||||
useEffect(() => {
|
||||
messagesCacheRef.current[messagesConversationIdRef.current] = derivedMessages;
|
||||
}, [derivedMessages]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
messageContainerRef,
|
||||
@@ -60,6 +131,8 @@ export const useSelectNextMessages = () => {
|
||||
removeMessageById,
|
||||
removeMessagesAfterCurrentMessage,
|
||||
setDerivedMessages,
|
||||
messagesCacheRef,
|
||||
activeStreamsRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,6 +155,8 @@ export const useSendMessage = (controller: AbortController) => {
|
||||
removeMessageById,
|
||||
removeMessagesAfterCurrentMessage,
|
||||
setDerivedMessages,
|
||||
messagesCacheRef,
|
||||
activeStreamsRef,
|
||||
} = useSelectNextMessages();
|
||||
|
||||
const sendMessage = useCallback(
|
||||
@@ -97,29 +172,34 @@ export const useSendMessage = (controller: AbortController) => {
|
||||
messages?: IMessage[];
|
||||
} & NextMessageInputOnPressEnterParameter) => {
|
||||
const sessionId = currentConversationId ?? conversationId;
|
||||
const res = await send(
|
||||
api.completionUrl,
|
||||
{
|
||||
chat_id: chatId,
|
||||
session_id: sessionId,
|
||||
// An explicitly provided list is authoritative, even when empty
|
||||
// (e.g. regenerating the first question must truncate history).
|
||||
messages: [
|
||||
...(Array.isArray(messages) ? messages : (derivedMessages ?? [])),
|
||||
message,
|
||||
],
|
||||
pass_all_history_messages: true,
|
||||
reasoning: Number(enableThinking),
|
||||
internet: enableInternet,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
activeStreamsRef.current.add(sessionId);
|
||||
try {
|
||||
const res = await send(
|
||||
api.completionUrl,
|
||||
{
|
||||
chat_id: chatId,
|
||||
session_id: sessionId,
|
||||
// An explicitly provided list is authoritative, even when empty
|
||||
// (e.g. regenerating the first question must truncate history).
|
||||
messages: [
|
||||
...(Array.isArray(messages) ? messages : (derivedMessages ?? [])),
|
||||
message,
|
||||
],
|
||||
pass_all_history_messages: true,
|
||||
reasoning: Number(enableThinking),
|
||||
internet: enableInternet,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
|
||||
if (res && (res?.response.status !== 200 || res?.data?.code !== 0)) {
|
||||
// cancel loading
|
||||
setValue(message.content);
|
||||
console.info('removeLatestMessage111');
|
||||
removeLatestMessage();
|
||||
if (res && (res?.response.status !== 200 || res?.data?.code !== 0)) {
|
||||
// cancel loading
|
||||
setValue(message.content);
|
||||
console.info('removeLatestMessage111');
|
||||
removeLatestMessage();
|
||||
}
|
||||
} finally {
|
||||
activeStreamsRef.current.delete(sessionId);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -130,6 +210,7 @@ export const useSendMessage = (controller: AbortController) => {
|
||||
setValue,
|
||||
send,
|
||||
controller,
|
||||
activeStreamsRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -159,13 +240,23 @@ export const useSendMessage = (controller: AbortController) => {
|
||||
|
||||
const id = uuid();
|
||||
|
||||
addNewestQuestion({
|
||||
const questionMessage = {
|
||||
content: value,
|
||||
files: files,
|
||||
id,
|
||||
role: MessageType.User,
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
};
|
||||
// The await on createConversationBeforeSendMessage above can span a
|
||||
// conversation switch. Route the question to the conversation it was
|
||||
// asked in, not whichever is currently displayed.
|
||||
if (targetConversationId === conversationId) {
|
||||
addNewestQuestion(questionMessage);
|
||||
} else {
|
||||
const cached = messagesCacheRef.current[targetConversationId] ?? [];
|
||||
messagesCacheRef.current[targetConversationId] =
|
||||
appendQuestionToMessages(cached, questionMessage);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
setValue('');
|
||||
@@ -209,15 +300,30 @@ export const useSendMessage = (controller: AbortController) => {
|
||||
setValue,
|
||||
sendMessage,
|
||||
messageContainerRef,
|
||||
conversationId,
|
||||
messagesCacheRef,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// #1289
|
||||
if (answer.answer && conversationId && isNew !== 'true') {
|
||||
if (!answer.answer || isNew === 'true') return;
|
||||
// Route the answer to the conversation it belongs to. answer.conversationId
|
||||
// comes from the request body's session_id (set in useSendMessageWithSse.send),
|
||||
// NOT from the URL, so it stays correct even after switching conversations.
|
||||
// This prevents a background stream's answer from leaking into the currently
|
||||
// displayed conversation, and keeps the background conversation's cache
|
||||
// updating so switching back shows the streamed answer.
|
||||
const targetId = answer.conversationId;
|
||||
if (!targetId) return;
|
||||
if (targetId === conversationId) {
|
||||
addNewestAnswer(answer);
|
||||
} else {
|
||||
const cached = messagesCacheRef.current[targetId] ?? [];
|
||||
messagesCacheRef.current[targetId] =
|
||||
appendAnswerToMessages(cached, answer);
|
||||
}
|
||||
}, [answer, addNewestAnswer, conversationId, isNew]);
|
||||
}, [answer, addNewestAnswer, conversationId, isNew, messagesCacheRef]);
|
||||
|
||||
return {
|
||||
handlePressEnter,
|
||||
@@ -234,5 +340,7 @@ export const useSendMessage = (controller: AbortController) => {
|
||||
isUploading,
|
||||
removeFile,
|
||||
setDerivedMessages,
|
||||
messagesCacheRef,
|
||||
activeStreamsRef,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user