mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🏷️ fix: Re-attribute Agent Content After In-Thread Steers (#14497)
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
* 🏷️ fix: Re-attribute Agent Content After In-Thread Steers * 🏷️ fix: Attribute Post-Steer Resume to the Active Handed-Off Agent * 🏷️ fix: Re-attribute Post-Steer Resumes in Parallel Sequential Stretches
This commit is contained in:
parent
3edb497502
commit
c4d30a096e
10 changed files with 338 additions and 40 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useRef, useMemo, useCallback } from 'react';
|
||||
import { memo, useRef, useMemo, useCallback, Fragment } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
|
|
@ -6,12 +6,13 @@ import type {
|
|||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ReactNode, ReactElement } from 'react';
|
||||
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
|
||||
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
|
||||
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
|
||||
import { EditTextPart, EmptyText, AgentUpdate } from './Parts';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import PendingSkillCall from './Parts/PendingSkillCall';
|
||||
import { EditTextPart, EmptyText } from './Parts';
|
||||
import ApprovalProvider from './ApprovalContext';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import ToolCallGroup from './ToolCallGroup';
|
||||
|
|
@ -112,6 +113,12 @@ type ContentPartsProps = {
|
|||
manualSkills?: string[];
|
||||
/** ISO timestamp of the parent message, surfaced in parallel column headers. */
|
||||
createdAt?: string | null;
|
||||
/**
|
||||
* Author icon + label node re-rendered before content that resumes after an
|
||||
* inline STEER part — the steer renders as a full user turn inside the
|
||||
* response, so what follows must be visibly re-attributed to the author.
|
||||
*/
|
||||
authorHeader?: ReactNode;
|
||||
conversationId?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
|
|
@ -146,6 +153,7 @@ const ContentParts = memo(function ContentParts({
|
|||
isSubmitting,
|
||||
setSiblingIdx,
|
||||
searchResults,
|
||||
authorHeader,
|
||||
conversationId,
|
||||
isCreatedByUser,
|
||||
isLatestMessage,
|
||||
|
|
@ -302,17 +310,35 @@ const ContentParts = memo(function ContentParts({
|
|||
],
|
||||
);
|
||||
|
||||
const sequentialParts = useMemo<PartWithIndex[]>(() => {
|
||||
/** `postSteerAuthors` marks each part that resumes the response after a
|
||||
* steer block — where attribution is re-rendered. The value is the ACTIVE
|
||||
* agent id when a preceding AGENT_UPDATE handed the run off (the resumed
|
||||
* content belongs to that agent, not the message-level author), undefined
|
||||
* for the top-level `authorHeader`. Read BEFORE applying the current
|
||||
* part's own handoff, so a resume point that IS an agent update keeps the
|
||||
* pre-handoff author and lets the real marker announce the transition. */
|
||||
const { sequentialParts, postSteerAuthors } = useMemo(() => {
|
||||
const parts: PartWithIndex[] = [];
|
||||
const authors = new Map<number, string | undefined>();
|
||||
if (!content) {
|
||||
return [];
|
||||
return { sequentialParts: parts, postSteerAuthors: authors };
|
||||
}
|
||||
const result: PartWithIndex[] = [];
|
||||
let prevType: string | undefined;
|
||||
let activeAgentId: string | undefined;
|
||||
content.forEach((part, idx) => {
|
||||
if (part) {
|
||||
result.push({ part, idx });
|
||||
if (!part) {
|
||||
return;
|
||||
}
|
||||
if (prevType === ContentTypes.STEER && part.type !== ContentTypes.STEER) {
|
||||
authors.set(idx, activeAgentId);
|
||||
}
|
||||
if (part.type === ContentTypes.AGENT_UPDATE) {
|
||||
activeAgentId = part[ContentTypes.AGENT_UPDATE]?.agentId || undefined;
|
||||
}
|
||||
prevType = part.type;
|
||||
parts.push({ part, idx });
|
||||
});
|
||||
return result;
|
||||
return { sequentialParts: parts, postSteerAuthors: authors };
|
||||
}, [content]);
|
||||
|
||||
const groupedParts = useMemo(
|
||||
|
|
@ -332,6 +358,22 @@ const ContentParts = memo(function ContentParts({
|
|||
[sequentialParts, attachmentMap, fallbackScope],
|
||||
);
|
||||
|
||||
/** The re-attribution node for a part resuming after a steer block, shared
|
||||
* by the sequential path and the parallel renderer's sequential stretches. */
|
||||
const renderResumeAttribution = useCallback(
|
||||
(idx: number): ReactElement | null => {
|
||||
if (authorHeader == null || !postSteerAuthors.has(idx)) {
|
||||
return null;
|
||||
}
|
||||
const activeAgentId = postSteerAuthors.get(idx);
|
||||
if (activeAgentId != null) {
|
||||
return <AgentUpdate key={`author-${messageId}-${idx}`} currentAgentId={activeAgentId} />;
|
||||
}
|
||||
return <Fragment key={`author-${messageId}-${idx}`}>{authorHeader}</Fragment>;
|
||||
},
|
||||
[authorHeader, postSteerAuthors, messageId],
|
||||
);
|
||||
|
||||
// Early return: no content to render AND no pending skill cards
|
||||
if (!content && !hasPendingSkills) {
|
||||
return null;
|
||||
|
|
@ -397,6 +439,7 @@ const ContentParts = memo(function ContentParts({
|
|||
searchResults={searchResults}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
renderPart={renderPart}
|
||||
renderResumeAttribution={renderResumeAttribution}
|
||||
/>
|
||||
</ApprovalProvider>
|
||||
);
|
||||
|
|
@ -413,13 +456,20 @@ const ContentParts = memo(function ContentParts({
|
|||
<EmptyText />
|
||||
</Container>
|
||||
)}
|
||||
{groupedParts.map((group) => {
|
||||
{groupedParts.flatMap((group) => {
|
||||
const firstIdx = group.type === 'single' ? group.part.idx : (group.parts[0]?.idx ?? -1);
|
||||
const nodes: ReactElement[] = [];
|
||||
const attribution = renderResumeAttribution(firstIdx);
|
||||
if (attribution != null) {
|
||||
nodes.push(attribution);
|
||||
}
|
||||
if (group.type === 'single') {
|
||||
const { part, idx } = group.part;
|
||||
return renderPart(part, idx, idx === lastContentIdx);
|
||||
nodes.push(renderPart(part, idx, idx === lastContentIdx));
|
||||
return nodes;
|
||||
}
|
||||
const { groupId } = group;
|
||||
return (
|
||||
nodes.push(
|
||||
<ToolCallGroup
|
||||
key={`tool-group-${groupId}`}
|
||||
parts={group.parts}
|
||||
|
|
@ -430,8 +480,9 @@ const ContentParts = memo(function ContentParts({
|
|||
groupAttachments={group.groupAttachments}
|
||||
initialExpansionState={toolGroupExpansionRef.current.get(groupId)}
|
||||
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
return nodes;
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
</ApprovalProvider>
|
||||
|
|
|
|||
|
|
@ -202,6 +202,13 @@ type ParallelContentRendererProps = {
|
|||
searchResults?: { [key: string]: SearchResultData };
|
||||
isSubmitting: boolean;
|
||||
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
|
||||
/**
|
||||
* Author re-attribution for a part that resumes after an inline steer —
|
||||
* returns the header node to render before that part, or null. Only the
|
||||
* sequential before/after stretches consult it: column content already
|
||||
* carries per-agent identity.
|
||||
*/
|
||||
renderResumeAttribution?: (idx: number) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -217,6 +224,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
|||
searchResults,
|
||||
isSubmitting,
|
||||
renderPart,
|
||||
renderResumeAttribution,
|
||||
}: ParallelContentRendererProps) {
|
||||
const { parallelSections, sequentialParts } = useMemo(
|
||||
() => groupParallelContent(content),
|
||||
|
|
@ -249,7 +257,11 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
|||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
|
||||
{/* Sequential content BEFORE parallel sections */}
|
||||
{before.map(({ part, idx }) => renderPart(part, idx, false))}
|
||||
{before.flatMap(({ part, idx }) => {
|
||||
const attribution = renderResumeAttribution?.(idx);
|
||||
const rendered = renderPart(part, idx, false);
|
||||
return attribution != null ? [attribution, rendered] : [rendered];
|
||||
})}
|
||||
|
||||
{/* Parallel sections - each group renders as columns */}
|
||||
{parallelSections.map(({ groupId, columns }) => (
|
||||
|
|
@ -267,7 +279,11 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
|||
))}
|
||||
|
||||
{/* Sequential content AFTER parallel sections */}
|
||||
{after.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
|
||||
{after.flatMap(({ part, idx }) => {
|
||||
const attribution = renderResumeAttribution?.(idx);
|
||||
const rendered = renderPart(part, idx, idx === lastContentIdx);
|
||||
return attribution != null ? [attribution, rendered] : [rendered];
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { memo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* Re-attributes response content to its author mid-message. A `SteerPart`
|
||||
* renders a full user turn inside the response, so the parts that resume
|
||||
* after it need the author's icon and label restated — the message-level
|
||||
* header only renders once, above the first part. Outdented past the icon
|
||||
* column (like `SteerPart`) so it aligns with the top-level header.
|
||||
*/
|
||||
const AuthorHeader = memo(function AuthorHeader({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
return (
|
||||
<div className="relative -ml-9 flex w-[calc(100%+2.25rem)] gap-3" data-testid="author-header">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>{label}</h2>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AuthorHeader;
|
||||
|
|
@ -15,3 +15,4 @@ export { default as FileAuthoringCall } from './FileAuthoringCall';
|
|||
export { default as BashCall } from './BashCall';
|
||||
export { default as SubagentCall } from './SubagentCall';
|
||||
export { default as SteerPart } from './SteerPart';
|
||||
export { default as AuthorHeader } from './AuthorHeader';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Suspense, useMemo } from 'react';
|
||||
import { Suspense, useMemo, Fragment } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { DelayedRender } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
|
|
@ -9,10 +9,12 @@ import type {
|
|||
SearchResultData,
|
||||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ReactNode, ReactElement } from 'react';
|
||||
import { UnfinishedMessage } from './MessageContent';
|
||||
import { cn, mapAttachments } from '~/utils';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import { AgentUpdate } from './Parts';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
|
|
@ -20,10 +22,13 @@ const SearchContent = ({
|
|||
message,
|
||||
attachments,
|
||||
searchResults,
|
||||
authorHeader,
|
||||
}: {
|
||||
message: TMessage;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
/** Author icon + label re-rendered before content that resumes after an inline steer. */
|
||||
authorHeader?: ReactNode;
|
||||
}) => {
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const { messageId } = message;
|
||||
|
|
@ -31,29 +36,51 @@ const SearchContent = ({
|
|||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
const parts = message.content.filter((part): part is TMessageContentParts => part != null);
|
||||
/** Active agent from the latest preceding AGENT_UPDATE: post-steer content
|
||||
* after a handoff belongs to that agent, not the message-level author.
|
||||
* Captured BEFORE the current part's own handoff applies, mirroring
|
||||
* `ContentParts`' `postSteerAuthors` scan. */
|
||||
let activeAgentId: string | undefined;
|
||||
return (
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
{message.content
|
||||
.filter((part: TMessageContentParts | undefined) => part)
|
||||
.map((part: TMessageContentParts | undefined, idx: number) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
return (
|
||||
<Part
|
||||
key={`display-${messageId}-${idx}`}
|
||||
showCursor={false}
|
||||
isSubmitting={false}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
attachments={partAttachments}
|
||||
part={part}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{parts.map((part: TMessageContentParts, idx: number) => {
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
const resumesAfterSteer =
|
||||
authorHeader != null &&
|
||||
idx > 0 &&
|
||||
parts[idx - 1].type === ContentTypes.STEER &&
|
||||
part.type !== ContentTypes.STEER;
|
||||
const resumeAgentId = resumesAfterSteer ? activeAgentId : undefined;
|
||||
if (part.type === ContentTypes.AGENT_UPDATE) {
|
||||
activeAgentId = part[ContentTypes.AGENT_UPDATE]?.agentId || undefined;
|
||||
}
|
||||
const rendered: ReactElement = (
|
||||
<Part
|
||||
key={`display-${messageId}-${idx}`}
|
||||
showCursor={false}
|
||||
isSubmitting={false}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
attachments={partAttachments}
|
||||
part={part}
|
||||
/>
|
||||
);
|
||||
if (!resumesAfterSteer) {
|
||||
return rendered;
|
||||
}
|
||||
return (
|
||||
<Fragment key={`display-${messageId}-${idx}`}>
|
||||
{resumeAgentId != null ? (
|
||||
<AgentUpdate currentAgentId={resumeAgentId} />
|
||||
) : (
|
||||
authorHeader
|
||||
)}
|
||||
{rendered}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{message.unfinished === true && (
|
||||
<Suspense>
|
||||
<DelayedRender delay={250}>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ jest.mock('~/Providers', () => ({
|
|||
jest.mock('../Parts', () => ({
|
||||
EditTextPart: () => <div data-testid="edit-text-part" />,
|
||||
EmptyText: () => <div data-testid="empty-text" />,
|
||||
AgentUpdate: ({ currentAgentId }: { currentAgentId: string }) => (
|
||||
<div data-testid="post-steer-agent-update" data-agent-id={currentAgentId} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../MemoryArtifacts', () => ({
|
||||
|
|
@ -56,7 +59,19 @@ jest.mock('../Part', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('../ParallelContent', () => ({
|
||||
ParallelContentRenderer: () => <div data-testid="parallel-renderer" />,
|
||||
/** Invokes `renderResumeAttribution` per content index like the real
|
||||
* renderer does for its sequential stretches, so the wiring is testable. */
|
||||
ParallelContentRenderer: ({
|
||||
content,
|
||||
renderResumeAttribution,
|
||||
}: {
|
||||
content?: Array<TMessageContentParts | undefined>;
|
||||
renderResumeAttribution?: (idx: number) => React.ReactNode;
|
||||
}) => (
|
||||
<div data-testid="parallel-renderer">
|
||||
{content?.map((_, idx) => renderResumeAttribution?.(idx))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import ContentParts from '../ContentParts';
|
||||
|
|
@ -139,3 +154,108 @@ describe('ContentParts — interim skill cards', () => {
|
|||
expect(skillCard.compareDocumentPosition(textPart)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentParts — post-steer author re-attribution', () => {
|
||||
const steerPart = {
|
||||
type: ContentTypes.STEER,
|
||||
steer: 'go left',
|
||||
} as unknown as TMessageContentParts;
|
||||
const textPart = (text: string) =>
|
||||
({ type: ContentTypes.TEXT, text }) as unknown as TMessageContentParts;
|
||||
const header = <div data-testid="author-header" />;
|
||||
|
||||
it('re-renders the author header between a steer and the content that resumes after it', () => {
|
||||
render(
|
||||
<ContentParts
|
||||
{...baseProps}
|
||||
content={[textPart('a'), steerPart, textPart('b')]}
|
||||
authorHeader={header}
|
||||
/>,
|
||||
);
|
||||
const headers = screen.getAllByTestId('author-header');
|
||||
expect(headers).toHaveLength(1);
|
||||
const steer = screen.getByTestId(`real-part-${ContentTypes.STEER}`);
|
||||
const textParts = screen.getAllByTestId(`real-part-${ContentTypes.TEXT}`);
|
||||
expect(steer.compareDocumentPosition(headers[0])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(headers[0].compareDocumentPosition(textParts[1])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it('renders one header per steer block and none after a trailing steer', () => {
|
||||
render(
|
||||
<ContentParts
|
||||
{...baseProps}
|
||||
content={[textPart('a'), steerPart, steerPart, textPart('b'), steerPart]}
|
||||
authorHeader={header}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips empty content slots when finding the part that resumes after a steer', () => {
|
||||
render(
|
||||
<ContentParts
|
||||
{...baseProps}
|
||||
content={[steerPart, undefined, textPart('b')]}
|
||||
authorHeader={header}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders no header when authorHeader is not provided', () => {
|
||||
render(<ContentParts {...baseProps} content={[textPart('a'), steerPart, textPart('b')]} />);
|
||||
expect(screen.queryByTestId('author-header')).toBeNull();
|
||||
});
|
||||
|
||||
it('re-attributes to the ACTIVE agent when a handoff preceded the steer', () => {
|
||||
const agentUpdate = {
|
||||
type: ContentTypes.AGENT_UPDATE,
|
||||
[ContentTypes.AGENT_UPDATE]: { agentId: 'agent_b', index: 1 },
|
||||
} as unknown as TMessageContentParts;
|
||||
render(
|
||||
<ContentParts
|
||||
{...baseProps}
|
||||
content={[textPart('a'), agentUpdate, textPart('b'), steerPart, textPart('c')]}
|
||||
authorHeader={header}
|
||||
/>,
|
||||
);
|
||||
const marker = screen.getAllByTestId('post-steer-agent-update');
|
||||
expect(marker).toHaveLength(1);
|
||||
expect(marker[0]).toHaveAttribute('data-agent-id', 'agent_b');
|
||||
expect(screen.queryByTestId('author-header')).toBeNull();
|
||||
});
|
||||
|
||||
it('provides resume attribution to the parallel renderer for its sequential stretches', () => {
|
||||
const parallelText = {
|
||||
type: ContentTypes.TEXT,
|
||||
text: 'column',
|
||||
groupId: 1,
|
||||
} as unknown as TMessageContentParts;
|
||||
render(
|
||||
<ContentParts
|
||||
{...baseProps}
|
||||
content={[parallelText, steerPart, textPart('resumed')]}
|
||||
authorHeader={header}
|
||||
/>,
|
||||
);
|
||||
const renderer = screen.getByTestId('parallel-renderer');
|
||||
expect(renderer).toBeTruthy();
|
||||
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the top-level header when the handoff comes AFTER the steer', () => {
|
||||
const agentUpdate = {
|
||||
type: ContentTypes.AGENT_UPDATE,
|
||||
[ContentTypes.AGENT_UPDATE]: { agentId: 'agent_b', index: 2 },
|
||||
} as unknown as TMessageContentParts;
|
||||
render(
|
||||
<ContentParts
|
||||
{...baseProps}
|
||||
content={[textPart('a'), steerPart, agentUpdate, textPart('b')]}
|
||||
authorHeader={header}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('post-steer-agent-update')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getHeaderPrefixForScreenReader,
|
||||
} from '~/utils';
|
||||
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
|
||||
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import ContentParts from './Content/ContentParts';
|
||||
|
|
@ -79,6 +80,17 @@ function MessageParts(props: TMessageProps) {
|
|||
],
|
||||
);
|
||||
|
||||
const authorHeader = useMemo(
|
||||
() =>
|
||||
isCreatedByUser === true ? undefined : (
|
||||
<AuthorHeader
|
||||
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
|
||||
label={name}
|
||||
/>
|
||||
),
|
||||
[isCreatedByUser, iconData, assistant, agent, name],
|
||||
);
|
||||
|
||||
const { hasParallelContent } = useContentMetadata(message);
|
||||
|
||||
if (!message) {
|
||||
|
|
@ -153,6 +165,7 @@ function MessageParts(props: TMessageProps) {
|
|||
searchResults={searchResults}
|
||||
manualSkills={message.manualSkills}
|
||||
messageId={message.messageId}
|
||||
authorHeader={authorHeader}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useAtomValue } from 'jotai';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
|
||||
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import Icon from '~/components/Chat/Messages/MessageIcon';
|
||||
|
|
@ -24,7 +25,7 @@ const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const MessageBody = ({ message, messageLabel, fontSize }) => (
|
||||
const MessageBody = ({ message, messageLabel, fontSize, authorHeader }) => (
|
||||
<div
|
||||
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
|
|
@ -32,7 +33,7 @@ const MessageBody = ({ message, messageLabel, fontSize }) => (
|
|||
{messageLabel}
|
||||
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
|
||||
</div>
|
||||
<SearchContent message={message} />
|
||||
<SearchContent message={message} authorHeader={authorHeader} />
|
||||
<SubRow classes="text-xs">
|
||||
<MinimalHoverButtons message={message} />
|
||||
<SearchButtons message={message} />
|
||||
|
|
@ -124,6 +125,14 @@ function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
|
|||
localize,
|
||||
]);
|
||||
|
||||
const authorHeader = useMemo(
|
||||
() =>
|
||||
message?.isCreatedByUser === true ? undefined : (
|
||||
<AuthorHeader icon={<Icon iconData={iconData} />} label={messageLabel} />
|
||||
),
|
||||
[message?.isCreatedByUser, iconData, messageLabel],
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -133,7 +142,12 @@ function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
|
|||
<div className="m-auto p-4 py-2 md:gap-6">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<MessageAvatar iconData={iconData} />
|
||||
<MessageBody message={message} messageLabel={messageLabel} fontSize={fontSize} />
|
||||
<MessageBody
|
||||
message={message}
|
||||
messageLabel={messageLabel}
|
||||
fontSize={fontSize}
|
||||
authorHeader={authorHeader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getMessageAriaLabel,
|
||||
} from '~/utils';
|
||||
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
|
|
@ -131,6 +132,17 @@ const ContentRender = memo(function ContentRender({
|
|||
],
|
||||
);
|
||||
|
||||
const authorHeader = useMemo(
|
||||
() =>
|
||||
msg?.isCreatedByUser === true ? undefined : (
|
||||
<AuthorHeader
|
||||
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
|
||||
label={messageLabel ?? ''}
|
||||
/>
|
||||
),
|
||||
[msg?.isCreatedByUser, iconData, assistant, agent, messageLabel],
|
||||
);
|
||||
|
||||
const { hasParallelContent } = useContentMetadata(msg);
|
||||
|
||||
if (!msg) {
|
||||
|
|
@ -201,6 +213,7 @@ const ContentRender = memo(function ContentRender({
|
|||
attachments={attachments}
|
||||
searchResults={searchResults}
|
||||
manualSkills={msg.manualSkills}
|
||||
authorHeader={authorHeader}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
isLatestMessage={isLatestMessage}
|
||||
isSubmitting={isSubmitting}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
|
||||
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
|
|
@ -86,6 +87,14 @@ export default function Message(props: TMessageProps) {
|
|||
message={message}
|
||||
attachments={attachments}
|
||||
searchResults={searchResults}
|
||||
authorHeader={
|
||||
isCreatedByUser ? undefined : (
|
||||
<AuthorHeader
|
||||
icon={<Icon message={message} conversation={conversation} />}
|
||||
label={messageLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MessageContent
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue