refactor: replace message.content with message.parts (#868)

This commit is contained in:
Jeremy 2025-03-16 18:42:29 -07:00 committed by GitHub
parent 553a3d825a
commit 47a630fd53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1311 additions and 311 deletions

View file

@ -1,7 +1,7 @@
import { PreviewMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom';
import { Vote } from '@/lib/db/schema';
import { Message } from 'ai';
import { UIMessage } from 'ai';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { UIArtifact } from './artifact';
@ -11,7 +11,7 @@ interface ArtifactMessagesProps {
chatId: string;
status: UseChatHelpers['status'];
votes: Array<Vote> | undefined;
messages: Array<Message>;
messages: Array<UIMessage>;
setMessages: UseChatHelpers['setMessages'];
reload: UseChatHelpers['reload'];
isReadonly: boolean;

View file

@ -1,4 +1,4 @@
import type { Attachment, Message } from 'ai';
import type { Attachment, UIMessage } from 'ai';
import { formatDistance } from 'date-fns';
import { AnimatePresence, motion } from 'framer-motion';
import {
@ -74,8 +74,8 @@ function PureArtifact({
stop: UseChatHelpers['stop'];
attachments: Array<Attachment>;
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
messages: Array<Message>;
setMessages: Dispatch<SetStateAction<Array<Message>>>;
messages: Array<UIMessage>;
setMessages: UseChatHelpers['setMessages'];
votes: Array<Vote> | undefined;
append: UseChatHelpers['append'];
handleSubmit: UseChatHelpers['handleSubmit'];

View file

@ -1,6 +1,6 @@
'use client';
import type { Attachment, Message } from 'ai';
import type { Attachment, UIMessage } from 'ai';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import useSWR, { useSWRConfig } from 'swr';
@ -22,7 +22,7 @@ export function Chat({
isReadonly,
}: {
id: string;
initialMessages: Array<Message>;
initialMessages: Array<UIMessage>;
selectedChatModel: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;

View file

@ -32,8 +32,6 @@ export function PureMessageActions({
if (isLoading) return null;
if (message.role === 'user') return null;
if (message.toolInvocations && message.toolInvocations.length > 0)
return null;
return (
<TooltipProvider delayDuration={0}>
@ -44,7 +42,18 @@ export function PureMessageActions({
className="py-1 px-2 h-fit text-muted-foreground"
variant="outline"
onClick={async () => {
await copyToClipboard(message.content as string);
const textFromParts = message.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n')
.trim();
if (!textFromParts) {
toast.error("There's no text to copy!");
return;
}
await copyToClipboard(textFromParts);
toast.success('Copied to clipboard!');
}}
>

View file

@ -5,17 +5,13 @@ import { Button } from './ui/button';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { Textarea } from './ui/textarea';
import { deleteTrailingMessages } from '@/app/(chat)/actions';
import { toast } from 'sonner';
import { UseChatHelpers } from '@ai-sdk/react';
export type MessageEditorProps = {
message: Message;
setMode: Dispatch<SetStateAction<'view' | 'edit'>>;
setMessages: (
messages: Message[] | ((messages: Message[]) => Message[]),
) => void;
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
setMessages: UseChatHelpers['setMessages'];
reload: UseChatHelpers['reload'];
};
export function MessageEditor({
@ -79,6 +75,7 @@ export function MessageEditor({
id: message.id,
});
// @ts-expect-error todo: support UIMessage in setMessages
setMessages((messages) => {
const index = messages.findIndex((m) => m.id === message.id);
@ -86,6 +83,7 @@ export function MessageEditor({
const updatedMessage = {
...message,
content: draftContent,
parts: [{ type: 'text', text: draftContent }],
};
return [...messages.slice(0, index), updatedMessage];

View file

@ -1,6 +1,6 @@
'use client';
import type { ChatRequestOptions, Message } from 'ai';
import type { UIMessage } from 'ai';
import cx from 'classnames';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useState } from 'react';
@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview';
import { MessageReasoning } from './message-reasoning';
import { UseChatHelpers } from '@ai-sdk/react';
const PurePreviewMessage = ({
chatId,
@ -29,15 +30,11 @@ const PurePreviewMessage = ({
isReadonly,
}: {
chatId: string;
message: Message;
message: UIMessage;
vote: Vote | undefined;
isLoading: boolean;
setMessages: (
messages: Message[] | ((messages: Message[]) => Message[]),
) => void;
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
setMessages: UseChatHelpers['setMessages'];
reload: UseChatHelpers['reload'];
isReadonly: boolean;
}) => {
const [mode, setMode] = useState<'view' | 'edit'>('view');
@ -83,96 +80,79 @@ const PurePreviewMessage = ({
</div>
)}
{message.reasoning && (
<MessageReasoning
isLoading={isLoading}
reasoning={message.reasoning}
/>
)}
{message.parts?.map((part, index) => {
const { type } = part;
const key = `message-${message.id}-part-${index}`;
{(message.content || message.reasoning) && mode === 'view' && (
<div
data-testid="message-content"
className="flex flex-row gap-2 items-start"
>
{message.role === 'user' && !isReadonly && (
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid={`message-edit`}
variant="ghost"
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
onClick={() => {
setMode('edit');
}}
if (type === 'reasoning') {
return (
<MessageReasoning
key={key}
isLoading={isLoading}
reasoning={part.reasoning}
/>
);
}
if (type === 'text') {
if (mode === 'view') {
return (
<div key={key} className="flex flex-row gap-2 items-start">
{message.role === 'user' && !isReadonly && (
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid="message-edit-button"
variant="ghost"
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
onClick={() => {
setMode('edit');
}}
>
<PencilEditIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Edit message</TooltipContent>
</Tooltip>
)}
<div
data-testid="message-content"
className={cn('flex flex-col gap-4', {
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
message.role === 'user',
})}
>
<PencilEditIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Edit message</TooltipContent>
</Tooltip>
)}
<div
className={cn('flex flex-col gap-4', {
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
message.role === 'user',
})}
>
<Markdown>{message.content as string}</Markdown>
</div>
</div>
)}
{message.content && mode === 'edit' && (
<div className="flex flex-row gap-2 items-start">
<div className="size-8" />
<MessageEditor
key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
reload={reload}
/>
</div>
)}
{message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="flex flex-col gap-4">
{message.toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state, args } = toolInvocation;
if (state === 'result') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
) : toolName === 'createDocument' ? (
<DocumentPreview
isReadonly={isReadonly}
result={result}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolResult
type="update"
result={result}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolResult
type="request-suggestions"
result={result}
isReadonly={isReadonly}
/>
) : (
<pre>{JSON.stringify(result, null, 2)}</pre>
)}
<Markdown>{part.text}</Markdown>
</div>
);
}
</div>
);
}
if (mode === 'edit') {
return (
<div key={key} className="flex flex-row gap-2 items-start">
<div className="size-8" />
<MessageEditor
key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
reload={reload}
/>
</div>
);
}
}
if (type === 'tool-invocation') {
const { toolInvocation } = part;
const { toolName, toolCallId, state } = toolInvocation;
if (state === 'call') {
const { args } = toolInvocation;
return (
<div
key={toolCallId}
@ -199,9 +179,40 @@ const PurePreviewMessage = ({
) : null}
</div>
);
})}
</div>
)}
}
if (state === 'result') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
) : toolName === 'createDocument' ? (
<DocumentPreview
isReadonly={isReadonly}
result={result}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolResult
type="update"
result={result}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolResult
type="request-suggestions"
result={result}
isReadonly={isReadonly}
/>
) : (
<pre>{JSON.stringify(result, null, 2)}</pre>
)}
</div>
);
}
}
})}
{!isReadonly && (
<MessageActions
@ -223,16 +234,8 @@ export const PreviewMessage = memo(
PurePreviewMessage,
(prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.message.reasoning !== nextProps.message.reasoning)
return false;
if (prevProps.message.content !== nextProps.message.content) return false;
if (
!equal(
prevProps.message.toolInvocations,
nextProps.message.toolInvocations,
)
)
return false;
if (prevProps.message.id !== nextProps.message.id) return false;
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
if (!equal(prevProps.vote, nextProps.vote)) return false;
return true;
@ -264,7 +267,7 @@ export const ThinkingMessage = () => {
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-col gap-4 text-muted-foreground">
Thinking...
Hmm...
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { Message } from 'ai';
import { UIMessage } from 'ai';
import { PreviewMessage, ThinkingMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom';
import { Overview } from './overview';
@ -11,7 +11,7 @@ interface MessagesProps {
chatId: string;
status: UseChatHelpers['status'];
votes: Array<Vote> | undefined;
messages: Array<Message>;
messages: Array<UIMessage>;
setMessages: UseChatHelpers['setMessages'];
reload: UseChatHelpers['reload'];
isReadonly: boolean;

View file

@ -1,11 +1,6 @@
'use client';
import type {
Attachment,
ChatRequestOptions,
CreateMessage,
Message,
} from 'ai';
import type { Attachment, Message } from 'ai';
import cx from 'classnames';
import type React from 'react';
import {
@ -21,8 +16,6 @@ import {
import { toast } from 'sonner';
import { useLocalStorage, useWindowSize } from 'usehooks-ts';
import { sanitizeUIMessages } from '@/lib/utils';
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
import { PreviewAttachment } from './preview-attachment';
import { Button } from './ui/button';
@ -324,7 +317,7 @@ function PureStopButton({
onClick={(event) => {
event.preventDefault();
stop();
setMessages((messages) => sanitizeUIMessages(messages));
setMessages((messages) => messages);
}}
>
<StopIcon size={14} />

View file

@ -25,18 +25,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { sanitizeUIMessages } from '@/lib/utils';
import {
ArrowUpIcon,
CodeIcon,
LogsIcon,
MessageIcon,
PenIcon,
SparklesIcon,
StopIcon,
SummarizeIcon,
} from './icons';
import { ArrowUpIcon, StopIcon, SummarizeIcon } from './icons';
import { artifactDefinitions, ArtifactKind } from './artifact';
import { ArtifactToolbarItem } from './create-artifact';
import { UseChatHelpers } from '@ai-sdk/react';
@ -442,7 +432,7 @@ const PureToolbar = ({
className="p-3"
onClick={() => {
stop();
setMessages((messages) => sanitizeUIMessages(messages));
setMessages((messages) => messages);
}}
>
<StopIcon />