新增 TurnList 组件,优化消息渲染逻辑
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const renderCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("framer-motion", () => ({
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
motion: {
|
||||||
|
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./GlobalChatbox.parts", () => ({
|
||||||
|
TypingIndicator: () => <div>typing</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentTurn", () => ({
|
||||||
|
AgentTurn: ({ message }: { message: Message }) => {
|
||||||
|
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||||
|
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentWorkspace", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
branchGroups: [],
|
||||||
|
branchTransition: null,
|
||||||
|
bottomRef: { current: null },
|
||||||
|
speakingMessageId: null,
|
||||||
|
speechState: "idle" as const,
|
||||||
|
onSpeak: jest.fn(),
|
||||||
|
onPauseSpeech: jest.fn(),
|
||||||
|
onResumeSpeech: jest.fn(),
|
||||||
|
onStopSpeech: jest.fn(),
|
||||||
|
isTtsSupported: false,
|
||||||
|
onRegenerate: jest.fn(),
|
||||||
|
onEditResubmit: jest.fn(),
|
||||||
|
onCycleBranch: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderCounts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: "user-1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
};
|
||||||
|
const assistantHistoryMessage: Message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "stable answer",
|
||||||
|
};
|
||||||
|
const streamingMessage: Message = {
|
||||||
|
id: "assistant-2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "partial",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedStreamingMessage: Message = {
|
||||||
|
...streamingMessage,
|
||||||
|
content: "partial with more tokens",
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renderCounts.get("user-1")).toBe(1);
|
||||||
|
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||||
|
expect(renderCounts.get("assistant-2")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { AgentTurn } from "./AgentTurn";
|
|||||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||||
import type {
|
import type {
|
||||||
BranchGroup,
|
BranchGroup,
|
||||||
|
BranchState,
|
||||||
BranchTransition,
|
BranchTransition,
|
||||||
Message,
|
Message,
|
||||||
SpeechState,
|
SpeechState,
|
||||||
@@ -36,6 +37,96 @@ type AgentWorkspaceProps = {
|
|||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TurnListProps = {
|
||||||
|
messages: Message[];
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
speakingMessageId: string | null;
|
||||||
|
speechState: SpeechState;
|
||||||
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
|
onPauseSpeech: () => void;
|
||||||
|
onResumeSpeech: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((message, index) => message === right[index]);
|
||||||
|
|
||||||
|
const TurnListInner = ({
|
||||||
|
messages,
|
||||||
|
branchGroups,
|
||||||
|
speakingMessageId,
|
||||||
|
speechState,
|
||||||
|
onSpeak,
|
||||||
|
onPauseSpeech,
|
||||||
|
onResumeSpeech,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onRegenerate,
|
||||||
|
onEditResubmit,
|
||||||
|
onCycleBranch,
|
||||||
|
}: TurnListProps) => {
|
||||||
|
const branchStateByRootId = React.useMemo(() => {
|
||||||
|
const next = new Map<string, BranchState>();
|
||||||
|
branchGroups.forEach((group) => {
|
||||||
|
if (group.branches.length > 1) {
|
||||||
|
next.set(group.rootMessageId, {
|
||||||
|
activeIndex: group.activeIndex,
|
||||||
|
total: group.branches.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}, [branchGroups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => {
|
||||||
|
const rootMessageId = message.branchRootId ?? message.id;
|
||||||
|
return (
|
||||||
|
<AgentTurn
|
||||||
|
key={rootMessageId}
|
||||||
|
message={message}
|
||||||
|
branchState={branchStateByRootId.get(rootMessageId)}
|
||||||
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPause={onPauseSpeech}
|
||||||
|
onResume={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TurnList = React.memo(
|
||||||
|
TurnListInner,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
|
prevProps.branchGroups === nextProps.branchGroups &&
|
||||||
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
|
prevProps.speechState === nextProps.speechState &&
|
||||||
|
prevProps.onSpeak === nextProps.onSpeak &&
|
||||||
|
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
|
||||||
|
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||||
|
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||||
|
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||||
|
prevProps.onRegenerate === nextProps.onRegenerate &&
|
||||||
|
prevProps.onEditResubmit === nextProps.onEditResubmit &&
|
||||||
|
prevProps.onCycleBranch === nextProps.onCycleBranch,
|
||||||
|
);
|
||||||
|
|
||||||
|
TurnList.displayName = "TurnList";
|
||||||
|
|
||||||
const EmptyState = () => {
|
const EmptyState = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
@@ -182,37 +273,12 @@ export const AgentWorkspace = ({
|
|||||||
const transitionMessages = branchTransition
|
const transitionMessages = branchTransition
|
||||||
? messages.slice(branchTransition.parentCount)
|
? messages.slice(branchTransition.parentCount)
|
||||||
: [];
|
: [];
|
||||||
|
const streamingMessage =
|
||||||
const renderTurn = (message: Message) => {
|
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
const rootMessageId = message.branchRootId ?? message.id;
|
? messages.at(-1)
|
||||||
const branchGroup = branchGroups.find(
|
: undefined;
|
||||||
(group) => group.rootMessageId === rootMessageId,
|
const historyMessages =
|
||||||
);
|
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
|
||||||
|
|
||||||
return (
|
|
||||||
<AgentTurn
|
|
||||||
key={rootMessageId}
|
|
||||||
message={message}
|
|
||||||
branchState={
|
|
||||||
branchGroup && branchGroup.branches.length > 1
|
|
||||||
? {
|
|
||||||
activeIndex: branchGroup.activeIndex,
|
|
||||||
total: branchGroup.branches.length,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
|
||||||
onSpeak={onSpeak}
|
|
||||||
onPause={onPauseSpeech}
|
|
||||||
onResume={onResumeSpeech}
|
|
||||||
onStopSpeech={onStopSpeech}
|
|
||||||
isTtsSupported={isTtsSupported}
|
|
||||||
onRegenerate={onRegenerate}
|
|
||||||
onEditResubmit={onEditResubmit}
|
|
||||||
onCycleBranch={onCycleBranch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -232,7 +298,37 @@ export const AgentWorkspace = ({
|
|||||||
|
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
{stableMessages.map(renderTurn)}
|
<TurnList
|
||||||
|
messages={historyMessages}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{streamingMessage ? (
|
||||||
|
<TurnList
|
||||||
|
messages={[streamingMessage]}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{branchTransition ? (
|
{branchTransition ? (
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
@@ -244,7 +340,20 @@ export const AgentWorkspace = ({
|
|||||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||||
>
|
>
|
||||||
{transitionMessages.map(renderTurn)}
|
<TurnList
|
||||||
|
messages={transitionMessages}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user