mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-24 08:55:59 +00:00
fix(frontend): hide copy for streaming assistant turn (#3176)
This commit is contained in:
@@ -16,13 +16,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractPresentFilesFromMessage,
|
extractPresentFilesFromMessage,
|
||||||
extractReasoningContentFromMessage,
|
|
||||||
extractTextFromMessage,
|
extractTextFromMessage,
|
||||||
|
getAssistantTurnCopyData,
|
||||||
getAssistantTurnUsageMessages,
|
getAssistantTurnUsageMessages,
|
||||||
getMessageGroups,
|
getMessageGroups,
|
||||||
|
getStreamingMessageLookup,
|
||||||
hasContent,
|
hasContent,
|
||||||
hasPresentFiles,
|
hasPresentFiles,
|
||||||
hasReasoning,
|
hasReasoning,
|
||||||
|
isAssistantMessageGroupStreaming,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
@@ -184,16 +186,19 @@ export function MessageList({
|
|||||||
() => buildTokenDebugSteps(messages, t),
|
() => buildTokenDebugSteps(messages, t),
|
||||||
[messages, t],
|
[messages, t],
|
||||||
);
|
);
|
||||||
|
const streamingMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
getStreamingMessageLookup(
|
||||||
|
messages,
|
||||||
|
thread.isLoading,
|
||||||
|
thread.getMessagesMetadata,
|
||||||
|
),
|
||||||
|
[messages, thread.getMessagesMetadata, thread.isLoading],
|
||||||
|
);
|
||||||
|
|
||||||
const renderAssistantCopyButton = useCallback((messages: Message[]) => {
|
const renderAssistantCopyButton = useCallback(
|
||||||
const clipboardData = [...messages]
|
(messages: Message[], isStreaming: boolean) => {
|
||||||
.reverse()
|
const clipboardData = getAssistantTurnCopyData(messages, { isStreaming });
|
||||||
.filter((message) => message.type === "ai")
|
|
||||||
.map((message) => {
|
|
||||||
const content = extractContentFromMessage(message);
|
|
||||||
return content ?? extractReasoningContentFromMessage(message) ?? "";
|
|
||||||
})
|
|
||||||
.find((content) => content.length > 0);
|
|
||||||
|
|
||||||
if (!clipboardData) {
|
if (!clipboardData) {
|
||||||
return null;
|
return null;
|
||||||
@@ -204,7 +209,9 @@ export function MessageList({
|
|||||||
<CopyButton clipboardData={clipboardData} />
|
<CopyButton clipboardData={clipboardData} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const renderTokenUsage = useCallback(
|
const renderTokenUsage = useCallback(
|
||||||
({
|
({
|
||||||
@@ -294,7 +301,13 @@ export function MessageList({
|
|||||||
turnUsageMessages,
|
turnUsageMessages,
|
||||||
})}
|
})}
|
||||||
{group.type === "assistant" &&
|
{group.type === "assistant" &&
|
||||||
renderAssistantCopyButton(group.messages)}
|
renderAssistantCopyButton(
|
||||||
|
group.messages,
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
group.messages,
|
||||||
|
streamingMessages,
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (group.type === "assistant:clarification") {
|
} else if (group.type === "assistant:clarification") {
|
||||||
|
|||||||
@@ -170,6 +170,86 @@ export function getAssistantTurnUsageMessages(groups: MessageGroup[]) {
|
|||||||
return usageMessagesByGroupIndex;
|
return usageMessagesByGroupIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessageMetadataLookup = (
|
||||||
|
message: Message,
|
||||||
|
index: number,
|
||||||
|
) => { streamMetadata?: Record<string, unknown> } | undefined;
|
||||||
|
|
||||||
|
export type StreamingMessageLookup = {
|
||||||
|
ids: ReadonlySet<string>;
|
||||||
|
messages: ReadonlySet<Message>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStreamingMessageLookup(
|
||||||
|
messages: Message[],
|
||||||
|
isStreaming: boolean,
|
||||||
|
getMessagesMetadata?: MessageMetadataLookup,
|
||||||
|
): StreamingMessageLookup {
|
||||||
|
const streamingMessageIds = new Set<string>();
|
||||||
|
const streamingMessages = new Set<Message>();
|
||||||
|
|
||||||
|
if (!isStreaming) {
|
||||||
|
return {
|
||||||
|
ids: streamingMessageIds,
|
||||||
|
messages: streamingMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.forEach((message, index) => {
|
||||||
|
if (!getMessagesMetadata?.(message, index)?.streamMetadata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message.id === "string" && message.id.length > 0) {
|
||||||
|
streamingMessageIds.add(message.id);
|
||||||
|
}
|
||||||
|
streamingMessages.add(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ids: streamingMessageIds,
|
||||||
|
messages: streamingMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAssistantMessageGroupStreaming(
|
||||||
|
groupMessages: Message[],
|
||||||
|
streamingMessages: StreamingMessageLookup,
|
||||||
|
) {
|
||||||
|
return groupMessages.some((message) => {
|
||||||
|
if (message.type !== "ai") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(typeof message.id === "string" &&
|
||||||
|
message.id.length > 0 &&
|
||||||
|
streamingMessages.ids.has(message.id)) ||
|
||||||
|
streamingMessages.messages.has(message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssistantTurnCopyData(
|
||||||
|
messages: Message[],
|
||||||
|
{ isStreaming = false }: { isStreaming?: boolean } = {},
|
||||||
|
) {
|
||||||
|
if (isStreaming) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
[...messages]
|
||||||
|
.reverse()
|
||||||
|
.filter((message) => message.type === "ai")
|
||||||
|
.map((message) => {
|
||||||
|
const content = extractContentFromMessage(message);
|
||||||
|
return content ?? extractReasoningContentFromMessage(message) ?? "";
|
||||||
|
})
|
||||||
|
.find((content) => content.length > 0) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractTextFromMessage(message: Message) {
|
export function extractTextFromMessage(message: Message) {
|
||||||
if (typeof message.content === "string") {
|
if (typeof message.content === "string") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getAssistantTurnCopyData,
|
||||||
getAssistantTurnUsageMessages,
|
getAssistantTurnUsageMessages,
|
||||||
getMessageGroups,
|
getMessageGroups,
|
||||||
|
getStreamingMessageLookup,
|
||||||
|
isAssistantMessageGroupStreaming,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
|
|
||||||
test("aggregates token usage messages once per assistant turn", () => {
|
test("aggregates token usage messages once per assistant turn", () => {
|
||||||
@@ -97,3 +100,272 @@ test("hides internal todo reminder messages from message groups", () => {
|
|||||||
groups.flatMap((group) => group.messages).map((message) => message.id),
|
groups.flatMap((group) => group.messages).map((message) => message.id),
|
||||||
).toEqual(["human-1", "ai-1"]);
|
).toEqual(["human-1", "ai-1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hides assistant copy data while that turn is streaming", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Partial answer",
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
|
||||||
|
expect(getAssistantTurnCopyData(messages)).toBe("Partial answer");
|
||||||
|
expect(getAssistantTurnCopyData(messages, { isStreaming: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks the latest assistant message as streaming", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Still generating",
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndex = groups.findIndex(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true, () => ({
|
||||||
|
streamMetadata: { langgraph_node: "agent" },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, false, () => ({
|
||||||
|
streamMetadata: { langgraph_node: "agent" },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps previous assistant copyable while waiting for a new visible answer", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Completed answer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opt-human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Continue",
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndex = groups.findIndex(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps previous assistant copyable while a hidden send is starting", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Completed answer",
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndex = groups.findIndex(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps previous assistant copyable after a hidden send is appended", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Completed answer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "human-hidden",
|
||||||
|
type: "human",
|
||||||
|
content: "Save this agent",
|
||||||
|
additional_kwargs: { hide_from_ui: true },
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndex = groups.findIndex(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses stream metadata to identify an assistant before optimistic input", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Completed answer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-2",
|
||||||
|
type: "ai",
|
||||||
|
content: "Still generating",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opt-human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Continue",
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const assistantGroups = getMessageGroups(messages).filter(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndexes = groups
|
||||||
|
.map((group, index) => (group.type === "assistant" ? index : -1))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndexes[0] ?? -1]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true, (message) =>
|
||||||
|
message.id === "ai-2"
|
||||||
|
? { streamMetadata: { langgraph_node: "agent" } }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndexes[1] ?? -1]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true, (message) =>
|
||||||
|
message.id === "ai-2"
|
||||||
|
? { streamMetadata: { langgraph_node: "agent" } }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(assistantGroups.map((group) => group.id)).toEqual(["ai-1", "ai-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not mark a completed assistant group streaming from a later processing group", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Visible answer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-2",
|
||||||
|
type: "ai",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{ id: "tool-1", name: "web_search", args: {} }],
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndex = groups.findIndex(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.type)).toEqual([
|
||||||
|
"human",
|
||||||
|
"assistant",
|
||||||
|
"assistant:processing",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true, (message) =>
|
||||||
|
message.id === "ai-2"
|
||||||
|
? { streamMetadata: { langgraph_node: "agent" } }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps streaming assistant hidden when a hidden control message follows it", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "human-1",
|
||||||
|
type: "human",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-1",
|
||||||
|
type: "ai",
|
||||||
|
content: "Still generating",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "human-hidden",
|
||||||
|
type: "human",
|
||||||
|
content: "Save this agent",
|
||||||
|
additional_kwargs: { hide_from_ui: true },
|
||||||
|
},
|
||||||
|
] as Message[];
|
||||||
|
const groups = getMessageGroups(messages);
|
||||||
|
const assistantGroupIndex = groups.findIndex(
|
||||||
|
(group) => group.type === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantMessageGroupStreaming(
|
||||||
|
groups[assistantGroupIndex]?.messages ?? [],
|
||||||
|
getStreamingMessageLookup(messages, true, (message) =>
|
||||||
|
message.id === "ai-1"
|
||||||
|
? { streamMetadata: { langgraph_node: "agent" } }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user