mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
fix(client): keep summary parts stable through failed summarize rounds
Failed summarize completions spliced the in-flight summary part out of the content array, remounting every later index-keyed part (collapsing whatever the user had expanded mid-stream) and breaking the content-position == step-index invariant that streaming writes rely on. Finalize the part in place instead and scope completion to the owning step's slot so back-to-back rounds cannot clobber a newer in-flight part.
This commit is contained in:
parent
4e3f15c683
commit
7ae15aa572
2 changed files with 51 additions and 31 deletions
|
|
@ -2704,7 +2704,7 @@ describe('useStepHandler', () => {
|
|||
expect(summaryPart).toMatchObject({ summarizing: false });
|
||||
});
|
||||
|
||||
it('ON_SUMMARIZE_COMPLETE error removes summarizing parts', () => {
|
||||
it('ON_SUMMARIZE_COMPLETE error finalizes the part in place without splicing it out', () => {
|
||||
mockLastAnnouncementTimeRef.current = Date.now();
|
||||
const responseMessage = createResponseMessage();
|
||||
mockGetMessages.mockReturnValue([responseMessage]);
|
||||
|
|
@ -2775,11 +2775,21 @@ describe('useStepHandler', () => {
|
|||
expect(mockSetMessages).toHaveBeenCalled();
|
||||
const lastCall = mockSetMessages.mock.calls[mockSetMessages.mock.calls.length - 1][0];
|
||||
const responseMsg = lastCall.find((m: TMessage) => m.messageId === 'response-msg-1');
|
||||
/**
|
||||
* Failed rounds must keep their slot: splicing shifts every later part
|
||||
* under the index-keyed renderer and breaks the position == step-index
|
||||
* invariant that updateContent writes rely on. The part is finalized in
|
||||
* place with its streamed content preserved.
|
||||
*/
|
||||
const summaryParts =
|
||||
responseMsg?.content?.filter(
|
||||
(c: TMessageContentParts) => c.type === ContentTypes.SUMMARY,
|
||||
) ?? [];
|
||||
expect(summaryParts).toHaveLength(0);
|
||||
expect(summaryParts).toHaveLength(1);
|
||||
expect((summaryParts[0] as SummaryContentPart).summarizing).toBe(false);
|
||||
expect((summaryParts[0] as SummaryContentPart).content).toEqual([
|
||||
{ type: ContentTypes.TEXT, text: 'partial' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ON_SUMMARIZE_COMPLETE returns early when target message not in messageMap', () => {
|
||||
|
|
|
|||
|
|
@ -1437,37 +1437,47 @@ export default function useStepHandler({
|
|||
return;
|
||||
}
|
||||
|
||||
if (completeData.error) {
|
||||
const filtered = targetMessage.content.filter(
|
||||
(part) =>
|
||||
part?.type !== ContentTypes.SUMMARY || !(part as SummaryContentPart).summarizing,
|
||||
);
|
||||
if (filtered.length !== targetMessage.content.length) {
|
||||
announcePolite({ message: 'summarize_failed', isStatus: true });
|
||||
const cleaned = { ...targetMessage, content: filtered };
|
||||
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
|
||||
messageMap.current.set(completeMessageId, cleaned);
|
||||
setMessages(mergeResponseMessage(currentMessages, cleaned, completeMessageId));
|
||||
}
|
||||
} else {
|
||||
let didFinalize = false;
|
||||
const updatedContent = targetMessage.content.map((part) => {
|
||||
if (part?.type === ContentTypes.SUMMARY && (part as SummaryContentPart).summarizing) {
|
||||
didFinalize = true;
|
||||
if (!completeData.summary) {
|
||||
return { ...part, summarizing: false } as SummaryContentPart;
|
||||
}
|
||||
return { ...completeData.summary, summarizing: false } as SummaryContentPart;
|
||||
}
|
||||
/**
|
||||
* Scoped to the owning step's slot when the step is known: a global
|
||||
* scan finalizes a NEWER round's in-flight part when summarize
|
||||
* cycles run back-to-back (tiny context windows re-trigger
|
||||
* summarization every graph step). Unknown step falls back to
|
||||
* finalizing every in-flight part.
|
||||
*/
|
||||
const completeIndex =
|
||||
completeRunStep != null ? completeRunStep.index + editPrefixOffset : -1;
|
||||
let didFinalize = false;
|
||||
const updatedContent = targetMessage.content.map((part, index) => {
|
||||
if (part?.type !== ContentTypes.SUMMARY || !(part as SummaryContentPart).summarizing) {
|
||||
return part;
|
||||
});
|
||||
if (didFinalize) {
|
||||
announcePolite({ message: 'summarize_completed', isStatus: true });
|
||||
const finalized = { ...targetMessage, content: updatedContent };
|
||||
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
|
||||
messageMap.current.set(completeMessageId, finalized);
|
||||
setMessages(mergeResponseMessage(currentMessages, finalized, completeMessageId));
|
||||
}
|
||||
if (completeIndex >= 0 && index !== completeIndex) {
|
||||
return part;
|
||||
}
|
||||
didFinalize = true;
|
||||
if (!completeData.error && completeData.summary) {
|
||||
return { ...completeData.summary, summarizing: false } as SummaryContentPart;
|
||||
}
|
||||
/**
|
||||
* Failed rounds keep their slot. Splicing the part out shifts every
|
||||
* later part under the index-keyed renderer (remounting rows and
|
||||
* collapsing whatever the user expanded mid-stream) and breaks the
|
||||
* content-position == step-index invariant that `updateContent`
|
||||
* writes rely on. Flipping the flag alone hides an empty row
|
||||
* (`Summary` renders null without text) and matches the persisted
|
||||
* message, which retains the part server-side.
|
||||
*/
|
||||
return { ...part, summarizing: false } as SummaryContentPart;
|
||||
});
|
||||
if (didFinalize) {
|
||||
announcePolite({
|
||||
message: completeData.error ? 'summarize_failed' : 'summarize_completed',
|
||||
isStatus: true,
|
||||
});
|
||||
const finalized = { ...targetMessage, content: updatedContent };
|
||||
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
|
||||
messageMap.current.set(completeMessageId, finalized);
|
||||
setMessages(mergeResponseMessage(currentMessages, finalized, completeMessageId));
|
||||
}
|
||||
} else {
|
||||
const _exhaustive: never = stepEvent;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue