mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: keep the Q&A record visible while the resumed segment streams
The optimistic output stamp lives in the message store, but the SSE step handler evolves its own cached copy of the streaming message (created at turn start) — the first resumed event overwrites the store with that copy, wiping the stamp, so the Q&A card blinked out during streaming and only returned at finalize. Render-layer fallback instead of fighting the handler's copy: submitted answers are recorded by ask tool_call id when resolveAskUserQuestionPart stamps the part, and AskUserQuestionCall reads the recorded answer whenever the part's own output is missing — the record survives any message-copy churn until finalize delivers the server-stamped part.
This commit is contained in:
parent
054cd67139
commit
fc08aeac93
4 changed files with 40 additions and 3 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { MessageCircleQuestion } from 'lucide-react';
|
||||
import { parseAskUserQuestionArgs } from '~/utils/approval';
|
||||
import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
|
|
@ -12,15 +12,24 @@ import { useLocalize } from '~/hooks';
|
|||
export default function AskUserQuestionCall({
|
||||
args,
|
||||
output,
|
||||
toolCallId,
|
||||
isSubmitting = false,
|
||||
}: {
|
||||
args: string | Record<string, unknown> | undefined;
|
||||
output: string;
|
||||
toolCallId?: string;
|
||||
isSubmitting?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const question = parseAskUserQuestionArgs(args);
|
||||
const answered = output.length > 0;
|
||||
/**
|
||||
* The part's own output arrives from the server only at finalize, and the
|
||||
* streaming handler's message copy can overwrite the optimistic store stamp
|
||||
* mid-stream — fall back to the locally-recorded submitted answer so the
|
||||
* Q&A record never blinks out while the resumed segment streams.
|
||||
*/
|
||||
const effectiveOutput = output.length > 0 ? output : (getSubmittedAskAnswer(toolCallId) ?? '');
|
||||
const answered = effectiveOutput.length > 0;
|
||||
|
||||
/**
|
||||
* While the turn is live and unanswered, the INTERACTIVE card (rendered from
|
||||
|
|
@ -35,7 +44,8 @@ export default function AskUserQuestionCall({
|
|||
}
|
||||
|
||||
/** Prefer the picked option's label over its wire value when they differ. */
|
||||
const answerLabel = question?.options?.find((option) => option.value === output)?.label ?? output;
|
||||
const answerLabel =
|
||||
question?.options?.find((option) => option.value === effectiveOutput)?.label ?? effectiveOutput;
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ const Part = memo(function Part({
|
|||
<AskUserQuestionCall
|
||||
args={toolCall.args}
|
||||
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
|
||||
toolCallId={toolCall.id}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
removeAskUserQuestionPart,
|
||||
parseAskUserQuestionArgs,
|
||||
resolveAskUserQuestionPart,
|
||||
getSubmittedAskAnswer,
|
||||
} from './approval';
|
||||
|
||||
const toolCallPart = (id: string, extra: Record<string, unknown> = {}): TMessageContentParts =>
|
||||
|
|
@ -322,6 +323,13 @@ describe('resolveAskUserQuestionPart', () => {
|
|||
const plain = msg({ content: [textPart('hi')] });
|
||||
expect(resolveAskUserQuestionPart(plain, 'a1', 'x')).toBe(plain);
|
||||
});
|
||||
|
||||
it('records the submitted answer by tool_call id (render-layer fallback for mid-stream copies)', () => {
|
||||
resolveAskUserQuestionPart(withCardAndToolCall(), 'a1', 'purple');
|
||||
expect(getSubmittedAskAnswer('tc1')).toBe('purple');
|
||||
expect(getSubmittedAskAnswer('unknown')).toBeUndefined();
|
||||
expect(getSubmittedAskAnswer(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPendingAction — unsupported type', () => {
|
||||
|
|
|
|||
|
|
@ -234,6 +234,21 @@ export function removeAskUserQuestionPart(message: TMessage, actionId: string):
|
|||
return { ...message, content: nextContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-scoped record of answers the user has submitted, keyed by the ask
|
||||
* tool_call id. Render-layer fallback for `AskUserQuestionCall`: the SSE
|
||||
* step handler evolves its own cached copy of the streaming message, so a
|
||||
* store-level `output` stamp can be overwritten by the next streamed event —
|
||||
* this survives any message-copy churn until finalize delivers the
|
||||
* server-stamped part. Written by {@link resolveAskUserQuestionPart}.
|
||||
*/
|
||||
const submittedAskAnswers = new Map<string, string>();
|
||||
|
||||
/** The locally-submitted answer for an ask tool_call, if any. */
|
||||
export function getSubmittedAskAnswer(toolCallId: string | undefined): string | undefined {
|
||||
return toolCallId ? submittedAskAnswers.get(toolCallId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an answered ask-user-question pause on the client, mirroring the
|
||||
* server's resume-time stamp so the durable Q&A card shows the answer the
|
||||
|
|
@ -294,6 +309,9 @@ export function resolveAskUserQuestionPart(
|
|||
progress: 1,
|
||||
},
|
||||
} as TMessageContentParts;
|
||||
if (typeof toolCall.id === 'string' && toolCall.id.length > 0) {
|
||||
submittedAskAnswers.set(toolCall.id, answer);
|
||||
}
|
||||
patched = true;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue