feat: enhance message display

This commit is contained in:
Henry Li
2026-01-18 11:25:46 +08:00
parent 59683fc12e
commit 23dc64fab1
3 changed files with 116 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { memo } from "react"; import { memo, useMemo } from "react";
import { import {
Message as AIElementMessage, Message as AIElementMessage,
@@ -9,25 +9,20 @@ import {
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { import {
extractContentFromMessage, extractContentFromMessage,
hasReasoning, extractReasoningContentFromMessage,
hasToolCalls,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button"; import { CopyButton } from "../copy-button";
import { MessageGroup } from "./message-group";
export function MessageListItem({ export function MessageListItem({
className, className,
message, message,
messagesInGroup,
isLoading, isLoading,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
messagesInGroup: Message[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
return ( return (
@@ -38,7 +33,6 @@ export function MessageListItem({
<MessageContent <MessageContent
className={message.type === "human" ? "w-fit" : "w-full"} className={message.type === "human" ? "w-fit" : "w-full"}
message={message} message={message}
messagesInGroup={messagesInGroup}
isLoading={isLoading} isLoading={isLoading}
/> />
<MessageToolbar <MessageToolbar
@@ -49,7 +43,13 @@ export function MessageListItem({
)} )}
> >
<div className="flex gap-1"> <div className="flex gap-1">
<CopyButton clipboardData={extractContentFromMessage(message)} /> <CopyButton
clipboardData={
extractContentFromMessage(message)
? extractContentFromMessage(message)
: (extractReasoningContentFromMessage(message) ?? "")
}
/>
</div> </div>
</MessageToolbar> </MessageToolbar>
</AIElementMessage> </AIElementMessage>
@@ -59,26 +59,26 @@ export function MessageListItem({
function MessageContent_({ function MessageContent_({
className, className,
message, message,
messagesInGroup,
isLoading = false, isLoading = false,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
messagesInGroup: Message[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const content = useMemo(() => {
const reasoningContent = extractReasoningContentFromMessage(message);
const content = extractContentFromMessage(message);
if (!isLoading && reasoningContent && !content) {
return reasoningContent;
}
return content;
}, [isLoading, message]);
return ( return (
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{hasReasoning(message) && (
<MessageGroup messages={messagesInGroup} isLoading={isLoading} />
)}
<AIElementMessageResponse rehypePlugins={rehypePlugins}> <AIElementMessageResponse rehypePlugins={rehypePlugins}>
{extractContentFromMessage(message)} {content}
</AIElementMessageResponse> </AIElementMessageResponse>
{hasToolCalls(message) && (
<MessageGroup messages={messagesInGroup} isLoading={isLoading} />
)}
</AIElementMessageContent> </AIElementMessageContent>
); );
} }

View File

@@ -7,7 +7,6 @@ import {
import { import {
extractPresentFilesFromMessage, extractPresentFilesFromMessage,
groupMessages, groupMessages,
hasContent,
hasPresentFiles, hasPresentFiles,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
@@ -39,28 +38,26 @@ export function MessageList({
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-10 pt-12"> <ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-10 pt-12">
{groupMessages( {groupMessages(
thread.messages, thread.messages,
(groupedMessages) => { (group) => {
if (groupedMessages[0] && hasContent(groupedMessages[0])) { if (group.type === "human" || group.type === "assistant") {
const message = groupedMessages[0];
return ( return (
<MessageListItem <MessageListItem
key={message.id} key={group.id}
message={message} message={group.messages[0]!}
messagesInGroup={groupedMessages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
/> />
); );
} }
if (groupedMessages[0] && hasPresentFiles(groupedMessages[0])) { if (group.type === "assistant:present-files") {
const files = []; const files = [];
for (const message of groupedMessages) { for (const message of group.messages) {
if (hasPresentFiles(message)) { if (hasPresentFiles(message)) {
files.push(...extractPresentFilesFromMessage(message)); files.push(...extractPresentFilesFromMessage(message));
} }
} }
return ( return (
<ArtifactFileList <ArtifactFileList
key={groupedMessages[0].id} key={group.id}
files={files} files={files}
threadId={threadId} threadId={threadId}
/> />
@@ -68,8 +65,8 @@ export function MessageList({
} }
return ( return (
<MessageGroup <MessageGroup
key={groupedMessages[0]!.id} key={group.id}
messages={groupedMessages} messages={group.messages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
/> />
); );

View File

@@ -1,58 +1,110 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
interface GenericMessageGroup<T = string> {
type: T;
id: string | undefined;
messages: Message[];
}
interface HumanMessageGroup extends GenericMessageGroup<"human"> {}
interface AssistantProcessingGroup extends GenericMessageGroup<"assistant:processing"> {}
interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup;
export function groupMessages<T>( export function groupMessages<T>(
messages: Message[], messages: Message[],
mapper: (groupedMessages: Message[]) => T, mapper: (group: MessageGroup) => T,
isLoading = false, isLoading = false,
): T[] { ): T[] {
if (messages.length === 0) { if (messages.length === 0) {
return []; return [];
} }
const groups: Message[][] = []; const groups: MessageGroup[] = [];
let currentGroup: Message[] = [];
const yieldCurrentGroup = () => {
if (currentGroup.length > 0) {
groups.push(currentGroup);
currentGroup = [];
}
};
let messageIndex = 0;
for (const message of messages) { for (const message of messages) {
const lastGroup = groups[groups.length - 1];
if (message.type === "human") { if (message.type === "human") {
// Human messages are always shown as a individual group groups.push({
yieldCurrentGroup(); id: message.id,
currentGroup.push(message); type: "human",
yieldCurrentGroup(); messages: [message],
});
} else if (message.type === "tool") { } else if (message.type === "tool") {
// Tool messages are always shown with the assistant messages that contains the tool calls
currentGroup.push(message);
} else if (message.type === "ai") {
if ( if (
hasToolCalls(message) || lastGroup &&
(extractTextFromMessage(message) === "" && lastGroup.type !== "human" &&
extractReasoningContentFromMessage(message) !== "" && lastGroup.type !== "assistant"
messageIndex === messages.length - 1 &&
isLoading)
) { ) {
if (message.tool_calls?.[0]?.name === "present_files") { lastGroup.messages.push(message);
// When `present_files` called, put them into an individual group
yieldCurrentGroup();
currentGroup.push(message);
} else { } else {
// Assistant messages without any content are folded into the previous group throw new Error(
// Normally, these are tool calls (with or without thinking) "Tool message must be matched with a previous assistant message with tool calls",
currentGroup.push(message); );
} }
} else if (message.type === "ai") {
if (hasReasoning(message) || hasToolCalls(message)) {
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
type: "assistant:present-files",
messages: [message],
});
} else { } else {
// Assistant messages with content (text or images) are shown as a group if they have content if (lastGroup?.type !== "assistant:processing") {
// No matter whether it has tool calls or not groups.push({
yieldCurrentGroup(); id: message.id,
currentGroup.push(message); type: "assistant:processing",
messages: [],
});
}
const currentGroup = groups[groups.length - 1];
if (currentGroup?.type === "assistant:processing") {
currentGroup.messages.push(message);
} else {
throw new Error(
"Assistant message with reasoning or tool calls must be preceded by a processing group",
);
}
}
}
if (hasContent(message) && !hasToolCalls(message)) {
groups.push({
id: message.id,
type: "assistant",
messages: [message],
});
}
}
}
if (!isLoading) {
const lastGroup: MessageGroup | undefined = groups[groups.length - 1];
if (
lastGroup?.type === "assistant:processing" &&
lastGroup.messages.length > 0
) {
const reasoningContent = extractReasoningContentFromMessage(
lastGroup.messages[lastGroup.messages.length - 1]!,
);
const content = extractContentFromMessage(
lastGroup.messages[lastGroup.messages.length - 1]!,
);
if (reasoningContent && !content) {
const group = groups.pop()!;
group.type = "assistant";
groups.push(group);
} }
} }
messageIndex++;
} }
yieldCurrentGroup();
const resultsOfGroups: T[] = []; const resultsOfGroups: T[] = [];
for (const group of groups) { for (const group of groups) {