mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
💬 feat: Interim Progress Card for Streaming Q&A Calls (#14576)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 💬 feat: Interim Progress Card for Streaming ask_user_question Calls * 🔍 fix: Match Progress Card Against Every Live Ask Pause, Not Newest Only * ⏳ feat: Hold Streaming Cursor Under Answered Question While Resume Is In Flight
This commit is contained in:
parent
59395a6bf0
commit
2fb03118bb
7 changed files with 370 additions and 43 deletions
|
|
@ -1,6 +1,9 @@
|
|||
import { MessageCircleQuestion, TriangleAlert } from 'lucide-react';
|
||||
import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval';
|
||||
import AskUserQuestionProgress from './AskUserQuestionProgress';
|
||||
import EmptyText from './Parts/EmptyText';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
|
||||
/**
|
||||
* Static rendering of a COMPLETED (or abandoned) `ask_user_question` tool call —
|
||||
|
|
@ -15,12 +18,14 @@ export default function AskUserQuestionCall({
|
|||
toolCallId,
|
||||
isSubmitting = false,
|
||||
failed = false,
|
||||
showCursor = false,
|
||||
}: {
|
||||
args: string | Record<string, unknown> | undefined;
|
||||
output: string;
|
||||
toolCallId?: string;
|
||||
isSubmitting?: boolean;
|
||||
failed?: boolean;
|
||||
showCursor?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const question = parseAskUserQuestionArgs(args);
|
||||
|
|
@ -37,33 +42,51 @@ export default function AskUserQuestionCall({
|
|||
* While the turn is live and unanswered, the INTERACTIVE card (rendered from
|
||||
* the pendingAction's synthetic part) owns the question UI — rendering the
|
||||
* durable record too would duplicate it with a misleading "no answer" line.
|
||||
* Once the user answers, the submit handler stamps `output` onto this part,
|
||||
* so the record takes over immediately; an abandoned pause only shows its
|
||||
* "no answer" state after the turn is no longer submitting.
|
||||
* Until that pause actually starts (args still streaming, interrupt not yet
|
||||
* delivered) the progress card fills the gap. Once the user answers, the
|
||||
* submit handler stamps `output` onto this part, so the record takes over
|
||||
* immediately; an abandoned pause only shows its "no answer" state after
|
||||
* the turn is no longer submitting.
|
||||
*/
|
||||
if (!answered && !failed && isSubmitting) {
|
||||
return null;
|
||||
return <AskUserQuestionProgress args={args} toolCallId={toolCallId} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The run resumes the moment the pause resolves (an answer submits, or a
|
||||
* schema-rejected call auto-retries), but its first token takes a beat to
|
||||
* arrive — hold the streaming cursor under the settled card so the turn
|
||||
* never looks stalled between the answer and the resumed text.
|
||||
*/
|
||||
const resumingCursor =
|
||||
isSubmitting && showCursor ? (
|
||||
<Container>
|
||||
<EmptyText />
|
||||
</Container>
|
||||
) : null;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-warning">
|
||||
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_question_failed')}
|
||||
</div>
|
||||
{question?.question != null && (
|
||||
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
|
||||
{question.question}
|
||||
<>
|
||||
<div
|
||||
className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-warning">
|
||||
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_question_failed')}
|
||||
</div>
|
||||
{question?.question != null && (
|
||||
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
|
||||
{question.question}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_question_failed_description')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_question_failed_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{resumingCursor}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -88,29 +111,34 @@ export default function AskUserQuestionCall({
|
|||
const answerLabel = exactLabel ?? mappedMultiLabel ?? 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">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-secondary">
|
||||
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
|
||||
{answered ? localize('com_ui_asked') : localize('com_ui_asking')}
|
||||
<>
|
||||
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-secondary">
|
||||
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
|
||||
{answered ? localize('com_ui_asked') : localize('com_ui_asking')}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
|
||||
{question?.question ?? (answered ? localize('com_ui_asked') : localize('com_ui_asking'))}
|
||||
</p>
|
||||
{question?.description != null && question.description.length > 0 && (
|
||||
<p className="text-sm text-text-secondary [overflow-wrap:anywhere]">
|
||||
{question.description}
|
||||
</p>
|
||||
)}
|
||||
{answered ? (
|
||||
<p className="text-sm text-text-primary [overflow-wrap:anywhere]">
|
||||
<span className="font-medium text-text-secondary">
|
||||
{localize('com_ui_you_answered')}
|
||||
</span>{' '}
|
||||
{answerLabel}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-text-secondary">
|
||||
{localize('com_ui_question_unanswered')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
|
||||
{question?.question ?? (answered ? localize('com_ui_asked') : localize('com_ui_asking'))}
|
||||
</p>
|
||||
{question?.description != null && question.description.length > 0 && (
|
||||
<p className="text-sm text-text-secondary [overflow-wrap:anywhere]">
|
||||
{question.description}
|
||||
</p>
|
||||
)}
|
||||
{answered ? (
|
||||
<p className="text-sm text-text-primary [overflow-wrap:anywhere]">
|
||||
<span className="font-medium text-text-secondary">{localize('com_ui_you_answered')}</span>{' '}
|
||||
{answerLabel}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-text-secondary">
|
||||
{localize('com_ui_question_unanswered')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{resumingCursor}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { useContext } from 'react';
|
||||
import { MessageCircleQuestion } from 'lucide-react';
|
||||
import { collectLiveAskToolCallIds } from '~/utils/approval';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import { ChatContext } from '~/Providers/ChatContext';
|
||||
import parseJsonField from './Parts/parseJsonField';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Interim card for an `ask_user_question` call whose pause hasn't gone
|
||||
* interactive yet: the window between the model starting the call and the
|
||||
* interrupt's synthetic part arriving (which hands the UI to the popover /
|
||||
* interactive card). Without it a long question, many options, or several
|
||||
* parallel questions leave a dead gap after the last streamed token.
|
||||
*
|
||||
* The question text streams in live: `question` is the schema's first (and
|
||||
* only required) property, so providers emit it as the first args key and
|
||||
* `parseJsonField`'s partial-JSON path can render it delta by delta.
|
||||
*
|
||||
* Mounted only for a live, unanswered call ({@link AskUserQuestionCall}
|
||||
* gates on `isSubmitting`), so the live-ask subscription below never runs
|
||||
* for the settled cards in history.
|
||||
*/
|
||||
export default function AskUserQuestionProgress({
|
||||
args,
|
||||
toolCallId,
|
||||
}: {
|
||||
args: string | Record<string, unknown> | undefined;
|
||||
toolCallId?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const conversationId = useContext(ChatContext)?.conversation?.conversationId;
|
||||
const enabled = conversationId != null && conversationId !== 'new';
|
||||
const { data: livePauses } = useGetMessagesByConvoId(enabled ? conversationId : '', {
|
||||
enabled,
|
||||
select: collectLiveAskToolCallIds,
|
||||
});
|
||||
const question = parseJsonField(args, 'question');
|
||||
|
||||
/**
|
||||
* THIS call's pause went interactive: the popover (or the interactive card)
|
||||
* now owns the question's UI. Checked against every live pause, not just the
|
||||
* newest, so a sibling pause arriving later can never resurrect this card
|
||||
* next to its own interactive one. An unattributed live ask (no
|
||||
* `tool_call_id` on older payloads) can't be matched to a call, so treat it
|
||||
* as owning every placeholder rather than duplicating the question on screen.
|
||||
*/
|
||||
const interactive =
|
||||
livePauses != null &&
|
||||
(livePauses.hasUnattributed || (toolCallId != null && livePauses.ids.includes(toolCallId)));
|
||||
if (interactive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs font-medium text-text-secondary"
|
||||
role="status"
|
||||
>
|
||||
<MessageCircleQuestion className="h-4 w-4 animate-pulse" aria-hidden="true" />
|
||||
<span className="shimmer">{localize('com_ui_asking')}</span>
|
||||
</div>
|
||||
{question.length > 0 ? (
|
||||
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">{question}</p>
|
||||
) : (
|
||||
<div className="h-4 w-2/5 animate-pulse rounded bg-surface-tertiary" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -238,6 +238,7 @@ const Part = memo(function Part({
|
|||
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
|
||||
toolCallId={toolCall.id}
|
||||
isSubmitting={isSubmitting}
|
||||
showCursor={showCursor}
|
||||
failed={'inputValidationError' in toolCall && toolCall.inputValidationError === true}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,12 +26,35 @@ jest.mock('~/utils/approval', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock('../AskUserQuestionProgress', () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const { createElement } = jest.requireActual<typeof React>('react');
|
||||
return createElement('div', { 'data-testid': 'ask-progress' });
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../Container', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => {
|
||||
const { createElement } = jest.requireActual<typeof React>('react');
|
||||
return createElement('div', null, children);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AskUserQuestionCall', () => {
|
||||
const args = JSON.stringify({
|
||||
question: 'How would you like me to get the data?',
|
||||
options: [{ label: 'Use public data', value: 'public' }],
|
||||
});
|
||||
|
||||
test('renders the progress card while the call is live and unanswered', () => {
|
||||
render(<AskUserQuestionCall args={args} output="" toolCallId="call_1" isSubmitting />);
|
||||
|
||||
expect(screen.getByTestId('ask-progress')).toBeInTheDocument();
|
||||
expect(screen.queryByText('You answered:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a successful tool result as the user answer', () => {
|
||||
render(<AskUserQuestionCall args={args} output="public" />);
|
||||
|
||||
|
|
@ -39,6 +62,31 @@ describe('AskUserQuestionCall', () => {
|
|||
expect(screen.getByText('Use public data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('holds the streaming cursor under the answered card while the resume is in flight', () => {
|
||||
const { container } = render(
|
||||
<AskUserQuestionCall
|
||||
args={args}
|
||||
output="public"
|
||||
toolCallId="call_1"
|
||||
isSubmitting
|
||||
showCursor
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('You answered:')).toBeInTheDocument();
|
||||
expect(container.querySelector('.result-thinking')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('shows no cursor once the record is not the streaming tail', () => {
|
||||
const settled = render(<AskUserQuestionCall args={args} output="public" showCursor />);
|
||||
expect(settled.container.querySelector('.result-thinking')).toBeNull();
|
||||
|
||||
const midStream = render(
|
||||
<AskUserQuestionCall args={args} output="public" toolCallId="call_1" isSubmitting />,
|
||||
);
|
||||
expect(midStream.container.querySelector('.result-thinking')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders schema rejection as an internal question failure, not a user answer', () => {
|
||||
const output =
|
||||
'Error processing tool: Received tool input did not match expected schema ' +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AskUserQuestionProgress from '../AskUserQuestionProgress';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_asking: 'Asking',
|
||||
};
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => translations[key] ?? key,
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/ChatContext', () => {
|
||||
const { createContext } = jest.requireActual<typeof React>('react');
|
||||
return {
|
||||
ChatContext: createContext({ conversation: { conversationId: 'convo-1' } }),
|
||||
};
|
||||
});
|
||||
|
||||
let mockLivePauses: { ids: string[]; hasUnattributed: boolean } = {
|
||||
ids: [],
|
||||
hasUnattributed: false,
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetMessagesByConvoId: () => ({ data: mockLivePauses }),
|
||||
}));
|
||||
|
||||
describe('AskUserQuestionProgress', () => {
|
||||
beforeEach(() => {
|
||||
mockLivePauses = { ids: [], hasUnattributed: false };
|
||||
});
|
||||
|
||||
test('streams the question text from partial args', () => {
|
||||
render(
|
||||
<AskUserQuestionProgress
|
||||
args={'{"question":"Which environment should I dep'}
|
||||
toolCallId="call_1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Asking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Which environment should I dep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('decodes JSON escapes in the streaming question', () => {
|
||||
render(
|
||||
<AskUserQuestionProgress args={'{"question":"Caf\\u00e9 or \\"bar\\"'} toolCallId="call_1" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Café or "bar"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a skeleton line before any question text streams', () => {
|
||||
render(<AskUserQuestionProgress args="" toolCallId="call_1" />);
|
||||
|
||||
expect(screen.getByText('Asking')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides once the interactive pause for this call is live', () => {
|
||||
mockLivePauses = { ids: ['call_1'], hasUnattributed: false };
|
||||
|
||||
const { container } = render(
|
||||
<AskUserQuestionProgress args={'{"question":"Ready?"}'} toolCallId="call_1" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('hides for an unattributed live pause (no tool_call_id on the payload)', () => {
|
||||
mockLivePauses = { ids: [], hasUnattributed: true };
|
||||
|
||||
const { container } = render(
|
||||
<AskUserQuestionProgress args={'{"question":"Ready?"}'} toolCallId="call_1" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test("stays visible while a DIFFERENT call's pause is interactive", () => {
|
||||
mockLivePauses = { ids: ['call_1'], hasUnattributed: false };
|
||||
|
||||
render(
|
||||
<AskUserQuestionProgress args={'{"question":"Second question?"}'} toolCallId="call_2" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Second question?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides when its own pause is live alongside a newer sibling pause', () => {
|
||||
mockLivePauses = { ids: ['call_1', 'call_2'], hasUnattributed: false };
|
||||
|
||||
const { container } = render(
|
||||
<AskUserQuestionProgress args={'{"question":"First question?"}'} toolCallId="call_1" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
resolveAskUserQuestionPart,
|
||||
getSubmittedAskAnswer,
|
||||
findLiveAskUserQuestion,
|
||||
collectLiveAskToolCallIds,
|
||||
isAnsweredAskUserQuestionPart,
|
||||
splitOtherOption,
|
||||
} from './approval';
|
||||
|
|
@ -477,6 +478,47 @@ describe('findLiveAskUserQuestion', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('collectLiveAskToolCallIds', () => {
|
||||
const attributedAsk = (actionId: string, toolCallId: string) =>
|
||||
askAction({
|
||||
actionId,
|
||||
payload: {
|
||||
type: 'ask_user_question',
|
||||
question: { question: 'Q?' },
|
||||
tool_call_id: toolCallId,
|
||||
},
|
||||
});
|
||||
|
||||
it('collects every live pause id, not just the newest, and drops answered ones', () => {
|
||||
const first = applyPendingAction(msg({ content: [] }), attributedAsk('a-first', 'call_1'));
|
||||
const both = applyPendingAction(first, attributedAsk('a-second', 'call_2'));
|
||||
|
||||
expect(collectLiveAskToolCallIds([both])).toEqual({
|
||||
ids: ['call_1', 'call_2'],
|
||||
hasUnattributed: false,
|
||||
});
|
||||
|
||||
resolveAskUserQuestionPart(both, 'a-first', 'Ada');
|
||||
|
||||
// `both` is the pre-answer copy — the answered pause must still drop out.
|
||||
expect(collectLiveAskToolCallIds([both])).toEqual({
|
||||
ids: ['call_2'],
|
||||
hasUnattributed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags unattributed pauses and handles non-array input', () => {
|
||||
const unattributed = applyPendingAction(
|
||||
msg({ content: [] }),
|
||||
askAction({ actionId: 'a-unattributed' }),
|
||||
);
|
||||
|
||||
expect(collectLiveAskToolCallIds([unattributed])).toEqual({ ids: [], hasUnattributed: true });
|
||||
expect(collectLiveAskToolCallIds(null)).toEqual({ ids: [], hasUnattributed: false });
|
||||
expect(collectLiveAskToolCallIds(undefined)).toEqual({ ids: [], hasUnattributed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAnsweredAskUserQuestionPart', () => {
|
||||
it('marks only cards whose question was actually answered', () => {
|
||||
const live = applyPendingAction(msg({ content: [] }), askAction({ actionId: 'a-open' }));
|
||||
|
|
|
|||
|
|
@ -447,6 +447,43 @@ export function findLiveAskUserQuestion(
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY live (unanswered) ask pause across the conversation, as the set of
|
||||
* tool_call_ids their synthetic parts attribute, plus whether any live part
|
||||
* lacks attribution (older payloads). Unlike {@link findLiveAskUserQuestion}
|
||||
* (newest-only, the popover's signal), this lets a per-call surface — the
|
||||
* streaming progress card — test whether ITS OWN pause is live even when a
|
||||
* newer sibling pause exists.
|
||||
*/
|
||||
export function collectLiveAskToolCallIds(messages: TMessage[] | null | undefined): {
|
||||
ids: string[];
|
||||
hasUnattributed: boolean;
|
||||
} {
|
||||
const ids: string[] = [];
|
||||
let hasUnattributed = false;
|
||||
if (!Array.isArray(messages)) {
|
||||
return { ids, hasUnattributed };
|
||||
}
|
||||
for (const message of messages) {
|
||||
const content = message?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
for (const part of content) {
|
||||
if (!isAskUserQuestionPart(part) || isAnsweredAskUserQuestionPart(part)) {
|
||||
continue;
|
||||
}
|
||||
const toolCallId = (part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION].tool_call_id;
|
||||
if (toolCallId == null) {
|
||||
hasUnattributed = true;
|
||||
} else {
|
||||
ids.push(toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ids, hasUnattributed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a {@link Agents.PendingAction} onto the target response message,
|
||||
* dispatching on the interrupt type. Pure — returns a new message only when the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue