2025-12-11 09:52:15 -05:00
|
|
|
import { useEffect, useRef } from 'react';
|
2025-12-04 08:57:13 -05:00
|
|
|
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
2025-12-11 09:52:15 -05:00
|
|
|
import { Constants, tMessageSchema } from 'librechat-data-provider';
|
|
|
|
|
import type { TMessage, TConversation, TSubmission, Agents } from 'librechat-data-provider';
|
2025-12-11 21:19:43 -05:00
|
|
|
import { useStreamStatus } from '~/data-provider';
|
2025-12-04 08:57:13 -05:00
|
|
|
import store from '~/store';
|
|
|
|
|
|
2025-12-11 09:52:15 -05:00
|
|
|
/**
|
|
|
|
|
* Build a submission object from resume state for reconnected streams.
|
|
|
|
|
* This provides the minimum data needed for useResumableSSE to subscribe.
|
|
|
|
|
*/
|
|
|
|
|
function buildSubmissionFromResumeState(
|
|
|
|
|
resumeState: Agents.ResumeState,
|
|
|
|
|
streamId: string,
|
|
|
|
|
messages: TMessage[],
|
|
|
|
|
conversationId: string,
|
|
|
|
|
): TSubmission {
|
|
|
|
|
const userMessageData = resumeState.userMessage;
|
|
|
|
|
const responseMessageId =
|
|
|
|
|
resumeState.responseMessageId ?? `${userMessageData?.messageId ?? 'resume'}_`;
|
|
|
|
|
|
|
|
|
|
// Try to find existing user message in the messages array (from database)
|
|
|
|
|
const existingUserMessage = messages.find(
|
|
|
|
|
(m) => m.isCreatedByUser && m.messageId === userMessageData?.messageId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Try to find existing response message in the messages array (from database)
|
|
|
|
|
const existingResponseMessage = messages.find(
|
|
|
|
|
(m) =>
|
|
|
|
|
!m.isCreatedByUser &&
|
|
|
|
|
(m.messageId === responseMessageId || m.parentMessageId === userMessageData?.messageId),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Create or use existing user message
|
|
|
|
|
const userMessage: TMessage =
|
|
|
|
|
existingUserMessage ??
|
|
|
|
|
(userMessageData
|
|
|
|
|
? (tMessageSchema.parse({
|
|
|
|
|
messageId: userMessageData.messageId,
|
|
|
|
|
parentMessageId: userMessageData.parentMessageId ?? Constants.NO_PARENT,
|
|
|
|
|
conversationId: userMessageData.conversationId ?? conversationId,
|
|
|
|
|
text: userMessageData.text ?? '',
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
role: 'user',
|
|
|
|
|
}) as TMessage)
|
|
|
|
|
: (messages[messages.length - 2] ??
|
|
|
|
|
({
|
|
|
|
|
messageId: 'resume_user_msg',
|
|
|
|
|
conversationId,
|
|
|
|
|
text: '',
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
} as TMessage)));
|
|
|
|
|
|
|
|
|
|
// Use existing response from DB if available (preserves already-saved content)
|
|
|
|
|
const initialResponse: TMessage =
|
|
|
|
|
existingResponseMessage ??
|
|
|
|
|
({
|
|
|
|
|
messageId: responseMessageId,
|
|
|
|
|
parentMessageId: userMessage.messageId,
|
|
|
|
|
conversationId,
|
|
|
|
|
text: '',
|
|
|
|
|
content: (resumeState.aggregatedContent as TMessage['content']) ?? [],
|
|
|
|
|
isCreatedByUser: false,
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
} as TMessage);
|
|
|
|
|
|
|
|
|
|
const conversation: TConversation = {
|
|
|
|
|
conversationId,
|
|
|
|
|
title: 'Resumed Chat',
|
|
|
|
|
endpoint: null,
|
|
|
|
|
} as TConversation;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
messages,
|
|
|
|
|
userMessage,
|
|
|
|
|
initialResponse,
|
|
|
|
|
conversation,
|
|
|
|
|
isRegenerate: false,
|
|
|
|
|
isTemporary: false,
|
|
|
|
|
endpointOption: {},
|
2025-12-11 21:19:43 -05:00
|
|
|
// Signal to useResumableSSE to subscribe to existing stream instead of starting new
|
|
|
|
|
resumeStreamId: streamId,
|
|
|
|
|
} as TSubmission & { resumeStreamId: string };
|
2025-12-11 09:52:15 -05:00
|
|
|
}
|
2025-12-04 08:57:13 -05:00
|
|
|
|
|
|
|
|
/**
|
2025-12-11 09:52:15 -05:00
|
|
|
* Hook to resume streaming if navigating to a conversation with active generation.
|
|
|
|
|
* Checks stream status via React Query and sets submission if active job found.
|
|
|
|
|
*
|
|
|
|
|
* This hook:
|
|
|
|
|
* 1. Uses useStreamStatus to check for active jobs on navigation
|
|
|
|
|
* 2. If active job found, builds a submission with streamId and sets it
|
|
|
|
|
* 3. useResumableSSE picks up the submission and subscribes to the stream
|
2025-12-04 08:57:13 -05:00
|
|
|
*/
|
|
|
|
|
export default function useResumeOnLoad(
|
|
|
|
|
conversationId: string | undefined,
|
2025-12-11 09:52:15 -05:00
|
|
|
getMessages: () => TMessage[] | undefined,
|
2025-12-04 08:57:13 -05:00
|
|
|
runIndex = 0,
|
|
|
|
|
) {
|
|
|
|
|
const resumableEnabled = useRecoilValue(store.resumableStreams);
|
2025-12-11 09:52:15 -05:00
|
|
|
const setSubmission = useSetRecoilState(store.submissionByIndex(runIndex));
|
|
|
|
|
const currentSubmission = useRecoilValue(store.submissionByIndex(runIndex));
|
2025-12-11 21:19:43 -05:00
|
|
|
// Track conversations we've already processed (either resumed or skipped)
|
|
|
|
|
const processedConvoRef = useRef<string | null>(null);
|
2025-12-11 09:52:15 -05:00
|
|
|
|
|
|
|
|
// Check for active stream when conversation changes
|
2025-12-11 21:19:43 -05:00
|
|
|
// Only check if resumable is enabled and no active submission
|
|
|
|
|
const shouldCheck =
|
|
|
|
|
resumableEnabled &&
|
|
|
|
|
!currentSubmission &&
|
|
|
|
|
!!conversationId &&
|
|
|
|
|
conversationId !== Constants.NEW_CONVO &&
|
|
|
|
|
processedConvoRef.current !== conversationId; // Don't re-check processed convos
|
|
|
|
|
|
|
|
|
|
const { data: streamStatus, isSuccess } = useStreamStatus(conversationId, shouldCheck);
|
2025-12-11 09:52:15 -05:00
|
|
|
|
2025-12-04 08:57:13 -05:00
|
|
|
useEffect(() => {
|
2025-12-11 21:19:43 -05:00
|
|
|
console.log('[ResumeOnLoad] Effect check', {
|
|
|
|
|
resumableEnabled,
|
|
|
|
|
conversationId,
|
|
|
|
|
hasCurrentSubmission: !!currentSubmission,
|
|
|
|
|
currentSubmissionConvoId: currentSubmission?.conversation?.conversationId,
|
|
|
|
|
isSuccess,
|
|
|
|
|
streamStatusActive: streamStatus?.active,
|
|
|
|
|
streamStatusStreamId: streamStatus?.streamId,
|
|
|
|
|
processedConvoRef: processedConvoRef.current,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!resumableEnabled || !conversationId || conversationId === Constants.NEW_CONVO) {
|
|
|
|
|
console.log('[ResumeOnLoad] Skipping - not enabled or new convo');
|
2025-12-04 08:57:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 21:19:43 -05:00
|
|
|
// Don't resume if we already have an active submission (we started it ourselves)
|
2025-12-11 09:52:15 -05:00
|
|
|
if (currentSubmission) {
|
2025-12-11 21:19:43 -05:00
|
|
|
console.log('[ResumeOnLoad] Skipping - already have active submission, marking as processed');
|
|
|
|
|
// Mark as processed so we don't try again
|
|
|
|
|
processedConvoRef.current = conversationId;
|
2025-12-04 08:57:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 21:19:43 -05:00
|
|
|
// Wait for stream status query to complete
|
|
|
|
|
if (!isSuccess || !streamStatus) {
|
|
|
|
|
console.log('[ResumeOnLoad] Waiting for stream status query');
|
2025-12-11 09:52:15 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 21:19:43 -05:00
|
|
|
// Don't process the same conversation twice
|
|
|
|
|
if (processedConvoRef.current === conversationId) {
|
|
|
|
|
console.log('[ResumeOnLoad] Skipping - already processed this conversation');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-11 09:52:15 -05:00
|
|
|
|
2025-12-11 21:19:43 -05:00
|
|
|
// Mark as processed immediately to prevent race conditions
|
|
|
|
|
processedConvoRef.current = conversationId;
|
2025-12-11 09:52:15 -05:00
|
|
|
|
2025-12-11 21:19:43 -05:00
|
|
|
// Check if there's an active job to resume
|
|
|
|
|
if (!streamStatus.active || !streamStatus.streamId) {
|
|
|
|
|
console.log('[ResumeOnLoad] No active job to resume for:', conversationId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[ResumeOnLoad] Found active job, creating submission...', {
|
|
|
|
|
streamId: streamStatus.streamId,
|
|
|
|
|
status: streamStatus.status,
|
|
|
|
|
resumeState: streamStatus.resumeState,
|
|
|
|
|
});
|
2025-12-11 09:52:15 -05:00
|
|
|
|
|
|
|
|
const messages = getMessages() || [];
|
|
|
|
|
|
2025-12-11 21:19:43 -05:00
|
|
|
// Build submission from resume state if available
|
|
|
|
|
if (streamStatus.resumeState) {
|
|
|
|
|
const submission = buildSubmissionFromResumeState(
|
|
|
|
|
streamStatus.resumeState,
|
|
|
|
|
streamStatus.streamId,
|
|
|
|
|
messages,
|
2025-12-11 09:52:15 -05:00
|
|
|
conversationId,
|
2025-12-11 21:19:43 -05:00
|
|
|
);
|
|
|
|
|
setSubmission(submission);
|
|
|
|
|
} else {
|
|
|
|
|
// Minimal submission without resume state
|
|
|
|
|
const lastUserMessage = [...messages].reverse().find((m) => m.isCreatedByUser);
|
|
|
|
|
const submission = {
|
|
|
|
|
messages,
|
|
|
|
|
userMessage:
|
|
|
|
|
lastUserMessage ?? ({ messageId: 'resume', conversationId, text: '' } as TMessage),
|
|
|
|
|
initialResponse: {
|
|
|
|
|
messageId: 'resume_',
|
|
|
|
|
conversationId,
|
|
|
|
|
text: '',
|
|
|
|
|
content: streamStatus.aggregatedContent ?? [{ type: 'text', text: '' }],
|
|
|
|
|
} as TMessage,
|
|
|
|
|
conversation: { conversationId, title: 'Resumed Chat' } as TConversation,
|
|
|
|
|
isRegenerate: false,
|
|
|
|
|
isTemporary: false,
|
|
|
|
|
endpointOption: {},
|
|
|
|
|
// Signal to useResumableSSE to subscribe to existing stream instead of starting new
|
|
|
|
|
resumeStreamId: streamStatus.streamId,
|
|
|
|
|
} as TSubmission & { resumeStreamId: string };
|
|
|
|
|
setSubmission(submission);
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
conversationId,
|
|
|
|
|
resumableEnabled,
|
|
|
|
|
currentSubmission,
|
|
|
|
|
isSuccess,
|
|
|
|
|
streamStatus,
|
|
|
|
|
getMessages,
|
|
|
|
|
setSubmission,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Reset processedConvoRef when conversation changes to a different one
|
2025-12-11 09:52:15 -05:00
|
|
|
useEffect(() => {
|
2025-12-11 21:19:43 -05:00
|
|
|
if (conversationId && conversationId !== processedConvoRef.current) {
|
|
|
|
|
// Only reset if we're navigating to a DIFFERENT conversation
|
|
|
|
|
// This allows re-checking when navigating back
|
|
|
|
|
processedConvoRef.current = null;
|
2025-12-11 09:52:15 -05:00
|
|
|
}
|
2025-12-04 08:57:13 -05:00
|
|
|
}, [conversationId]);
|
|
|
|
|
}
|