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,5 +1,6 @@
import { import {
type Message, UIMessage,
appendResponseMessages,
createDataStreamResponse, createDataStreamResponse,
smoothStream, smoothStream,
streamText, streamText,
@ -15,7 +16,7 @@ import {
import { import {
generateUUID, generateUUID,
getMostRecentUserMessage, getMostRecentUserMessage,
sanitizeResponseMessages, getTrailingMessageId,
} from '@/lib/utils'; } from '@/lib/utils';
import { generateTitleFromUserMessage } from '../../actions'; import { generateTitleFromUserMessage } from '../../actions';
import { createDocument } from '@/lib/ai/tools/create-document'; import { createDocument } from '@/lib/ai/tools/create-document';
@ -23,7 +24,6 @@ import { updateDocument } from '@/lib/ai/tools/update-document';
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions'; import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
import { getWeather } from '@/lib/ai/tools/get-weather'; import { getWeather } from '@/lib/ai/tools/get-weather';
import { isProductionEnvironment } from '@/lib/constants'; import { isProductionEnvironment } from '@/lib/constants';
import { NextResponse } from 'next/server';
import { myProvider } from '@/lib/ai/providers'; import { myProvider } from '@/lib/ai/providers';
export const maxDuration = 60; export const maxDuration = 60;
@ -36,7 +36,7 @@ export async function POST(request: Request) {
selectedChatModel, selectedChatModel,
}: { }: {
id: string; id: string;
messages: Array<Message>; messages: Array<UIMessage>;
selectedChatModel: string; selectedChatModel: string;
} = await request.json(); } = await request.json();
@ -67,7 +67,16 @@ export async function POST(request: Request) {
} }
await saveMessages({ await saveMessages({
messages: [{ ...userMessage, createdAt: new Date(), chatId: id }], messages: [
{
chatId: id,
id: userMessage.id,
role: 'user',
parts: userMessage.parts,
attachments: userMessage.experimental_attachments ?? [],
createdAt: new Date(),
},
],
}); });
return createDataStreamResponse({ return createDataStreamResponse({
@ -97,24 +106,36 @@ export async function POST(request: Request) {
dataStream, dataStream,
}), }),
}, },
onFinish: async ({ response, reasoning }) => { onFinish: async ({ response }) => {
if (session.user?.id) { if (session.user?.id) {
try { try {
const sanitizedResponseMessages = sanitizeResponseMessages({ const assistantId = getTrailingMessageId({
messages: response.messages, messages: response.messages.filter(
reasoning, (message) => message.role === 'assistant',
),
});
if (!assistantId) {
throw new Error('No assistant message found!');
}
const [, assistantMessage] = appendResponseMessages({
messages: [userMessage],
responseMessages: response.messages,
}); });
await saveMessages({ await saveMessages({
messages: sanitizedResponseMessages.map((message) => { messages: [
return { {
id: message.id, id: assistantId,
chatId: id, chatId: id,
role: message.role, role: assistantMessage.role,
content: message.content, parts: assistantMessage.parts,
attachments:
assistantMessage.experimental_attachments ?? [],
createdAt: new Date(), createdAt: new Date(),
}; },
}), ],
}); });
} catch (error) { } catch (error) {
console.error('Failed to save chat'); console.error('Failed to save chat');
@ -138,7 +159,9 @@ export async function POST(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
return NextResponse.json({ error }, { status: 400 }); return new Response('An error occurred while processing your request!', {
status: 404,
});
} }
} }
@ -167,7 +190,7 @@ export async function DELETE(request: Request) {
return new Response('Chat deleted', { status: 200 }); return new Response('Chat deleted', { status: 200 });
} catch (error) { } catch (error) {
return new Response('An error occurred while processing your request', { return new Response('An error occurred while processing your request!', {
status: 500, status: 500,
}); });
} }

View file

@ -4,9 +4,10 @@ import { notFound } from 'next/navigation';
import { auth } from '@/app/(auth)/auth'; import { auth } from '@/app/(auth)/auth';
import { Chat } from '@/components/chat'; import { Chat } from '@/components/chat';
import { getChatById, getMessagesByChatId } from '@/lib/db/queries'; import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
import { convertToUIMessages } from '@/lib/utils';
import { DataStreamHandler } from '@/components/data-stream-handler'; import { DataStreamHandler } from '@/components/data-stream-handler';
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
import { DBMessage } from '@/lib/db/schema';
import { Attachment, UIMessage } from 'ai';
export default async function Page(props: { params: Promise<{ id: string }> }) { export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params; const params = await props.params;
@ -33,6 +34,19 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
id, id,
}); });
function convertToUIMessages(messages: Array<DBMessage>): Array<UIMessage> {
return messages.map((message) => ({
id: message.id,
parts: message.parts as UIMessage['parts'],
role: message.role as UIMessage['role'],
// Note: content will soon be deprecated in @ai-sdk/react
content: '',
createdAt: message.createdAt,
experimental_attachments:
(message.attachments as Array<Attachment>) ?? [],
}));
}
const cookieStore = await cookies(); const cookieStore = await cookies();
const chatModelFromCookie = cookieStore.get('chat-model'); const chatModelFromCookie = cookieStore.get('chat-model');

View file

@ -56,7 +56,7 @@ export default async function RootLayout({
}} }}
/> />
</head> </head>
<body className="antialiased"> <body className="">
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"

View file

@ -1,7 +1,7 @@
import { PreviewMessage } from './message'; import { PreviewMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom'; import { useScrollToBottom } from './use-scroll-to-bottom';
import { Vote } from '@/lib/db/schema'; import { Vote } from '@/lib/db/schema';
import { Message } from 'ai'; import { UIMessage } from 'ai';
import { memo } from 'react'; import { memo } from 'react';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
import { UIArtifact } from './artifact'; import { UIArtifact } from './artifact';
@ -11,7 +11,7 @@ interface ArtifactMessagesProps {
chatId: string; chatId: string;
status: UseChatHelpers['status']; status: UseChatHelpers['status'];
votes: Array<Vote> | undefined; votes: Array<Vote> | undefined;
messages: Array<Message>; messages: Array<UIMessage>;
setMessages: UseChatHelpers['setMessages']; setMessages: UseChatHelpers['setMessages'];
reload: UseChatHelpers['reload']; reload: UseChatHelpers['reload'];
isReadonly: boolean; 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 { formatDistance } from 'date-fns';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
@ -74,8 +74,8 @@ function PureArtifact({
stop: UseChatHelpers['stop']; stop: UseChatHelpers['stop'];
attachments: Array<Attachment>; attachments: Array<Attachment>;
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>; setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
messages: Array<Message>; messages: Array<UIMessage>;
setMessages: Dispatch<SetStateAction<Array<Message>>>; setMessages: UseChatHelpers['setMessages'];
votes: Array<Vote> | undefined; votes: Array<Vote> | undefined;
append: UseChatHelpers['append']; append: UseChatHelpers['append'];
handleSubmit: UseChatHelpers['handleSubmit']; handleSubmit: UseChatHelpers['handleSubmit'];

View file

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

View file

@ -32,8 +32,6 @@ export function PureMessageActions({
if (isLoading) return null; if (isLoading) return null;
if (message.role === 'user') return null; if (message.role === 'user') return null;
if (message.toolInvocations && message.toolInvocations.length > 0)
return null;
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
@ -44,7 +42,18 @@ export function PureMessageActions({
className="py-1 px-2 h-fit text-muted-foreground" className="py-1 px-2 h-fit text-muted-foreground"
variant="outline" variant="outline"
onClick={async () => { 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!'); 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 { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { Textarea } from './ui/textarea'; import { Textarea } from './ui/textarea';
import { deleteTrailingMessages } from '@/app/(chat)/actions'; import { deleteTrailingMessages } from '@/app/(chat)/actions';
import { toast } from 'sonner'; import { UseChatHelpers } from '@ai-sdk/react';
export type MessageEditorProps = { export type MessageEditorProps = {
message: Message; message: Message;
setMode: Dispatch<SetStateAction<'view' | 'edit'>>; setMode: Dispatch<SetStateAction<'view' | 'edit'>>;
setMessages: ( setMessages: UseChatHelpers['setMessages'];
messages: Message[] | ((messages: Message[]) => Message[]), reload: UseChatHelpers['reload'];
) => void;
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
}; };
export function MessageEditor({ export function MessageEditor({
@ -79,6 +75,7 @@ export function MessageEditor({
id: message.id, id: message.id,
}); });
// @ts-expect-error todo: support UIMessage in setMessages
setMessages((messages) => { setMessages((messages) => {
const index = messages.findIndex((m) => m.id === message.id); const index = messages.findIndex((m) => m.id === message.id);
@ -86,6 +83,7 @@ export function MessageEditor({
const updatedMessage = { const updatedMessage = {
...message, ...message,
content: draftContent, content: draftContent,
parts: [{ type: 'text', text: draftContent }],
}; };
return [...messages.slice(0, index), updatedMessage]; return [...messages.slice(0, index), updatedMessage];

View file

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

View file

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

View file

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

View file

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

243
docs/04-migrate-to-parts.md Normal file
View file

@ -0,0 +1,243 @@
# Migrate to Message Parts
The release of [`@ai-sdk/react@1.1.10`](https://github.com/vercel/ai/pull/4670) introduced a new property called `parts` to messages in the `useChat` hook. We recommend rendering the messages using the `parts` property instead of the `content` property. The parts property supports different message types, including text, tool invocation, and tool result, and allows for more flexible and complex chat and agent-like user interfaces.
You can read the API reference for the `parts` property [here](https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat#messages.ui-message.parts).
## Migrating your existing project to use the `parts` property
Your existing project must already have messages stored in the database. To migrate your messages to use `parts`, you will have to create new tables for `Message` and `Vote`, backfill the new tables with transformed messages, and delete (optional) the old tables.
These are the following steps:
1. Create tables `Message_v2` and `Vote_v2` with updated schemas at `/lib/db/schema.ts`
2. Update the `Message` component at `/components/message.tsx` to use parts and render content.
3. Run migration script at `src/lib/db/helpers/01-migrate-to-parts.ts`
### 1. Creating Tables with Updated Schemas
You will mark the earlier tables as deprecated and create two new tables, `Message_v2` and `Vote_v2`. Table `Message_v2` contains the updated schema that includes the `parts` property. Table `Vote_v2` does not contain schema changes but will contain votes that point to the transformed messages in `Message_v2`.
Before creating the new tables, you will need to update the variables of existing tables to indicate that they are deprecated.
```tsx title="/lib/db/schema.ts"
export const messageDeprecated = pgTable("Message", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
chatId: uuid("chatId")
.notNull()
.references(() => chat.id),
role: varchar("role").notNull(),
content: json("content").notNull(),
createdAt: timestamp("createdAt").notNull(),
});
export type MessageDeprecated = InferSelectModel<typeof messageDeprecated>;
export const voteDeprecated = pgTable(
"Vote",
{
chatId: uuid("chatId")
.notNull()
.references(() => chat.id),
messageId: uuid("messageId")
.notNull()
.references(() => messageDeprecated.id),
isUpvoted: boolean("isUpvoted").notNull(),
},
(table) => {
return {
pk: primaryKey({ columns: [table.chatId, table.messageId] }),
};
},
);
export type VoteDeprecated = InferSelectModel<typeof voteDeprecated>;
```
After deprecating the current table schemas, you can now proceed to create schemas for the new tables.
```ts title="/lib/db/schema.ts"
export const message = pgTable("Message_v2", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
chatId: uuid("chatId")
.notNull()
.references(() => chat.id),
role: varchar("role").notNull(),
parts: json("parts").notNull(),
attachments: json("attachments").notNull(),
createdAt: timestamp("createdAt").notNull(),
});
export type Message = InferSelectModel<typeof message>;
export const vote = pgTable(
"Vote_v2",
{
chatId: uuid("chatId")
.notNull()
.references(() => chat.id),
messageId: uuid("messageId")
.notNull()
.references(() => message.id),
isUpvoted: boolean("isUpvoted").notNull(),
},
(table) => {
return {
pk: primaryKey({ columns: [table.chatId, table.messageId] }),
};
},
);
export type Vote = InferSelectModel<typeof vote>;
```
### 2. Updating the Message Component
Previously you were using content types to render messages and tool invocations. Now you will use the `parts` property to render messages and tool invocations.
```tsx title="components/message.tsx"
{message.parts?.map((part, index) => {
const { type } = part;
const key = `message-${message.id}-part-${index}`;
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",
})}
>
<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}
className={cx({
skeleton: ["getWeather"].includes(toolName),
})}
>
{toolName === "getWeather" ? (
<Weather />
) : toolName === "createDocument" ? (
<DocumentPreview isReadonly={isReadonly} args={args} />
) : toolName === "updateDocument" ? (
<DocumentToolCall
type="update"
args={args}
isReadonly={isReadonly}
/>
) : toolName === "requestSuggestions" ? (
<DocumentToolCall
type="request-suggestions"
args={args}
isReadonly={isReadonly}
/>
) : null}
</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>
);
}
}
})}
```
### 3. Running the Migration Script
At this point, you can deploy your application so new messages can be stored in the new format.
To restore messages of previous chat conversations, you can run the following script that applies a transformation to the old messages and stores them in the new format.
```zsh title="shell"
pnpm exec tsx lib/db/helpers/01-core-to-parts.ts
```
This script will take some time to complete based on the number of messages to be migrated. After completion, you can verify that the messages have been successfully migrated by checking the database.

View file

@ -0,0 +1,196 @@
import { config } from 'dotenv';
import postgres from 'postgres';
import {
chat,
message,
messageDeprecated,
vote,
voteDeprecated,
} from '../schema';
import { drizzle } from 'drizzle-orm/postgres-js';
import { inArray } from 'drizzle-orm';
import { appendResponseMessages, UIMessage } from 'ai';
config({
path: '.env.local',
});
if (!process.env.POSTGRES_URL) {
throw new Error('POSTGRES_URL environment variable is not set');
}
const client = postgres(process.env.POSTGRES_URL);
const db = drizzle(client);
const BATCH_SIZE = 50; // Process 10 chats at a time
const INSERT_BATCH_SIZE = 100; // Insert 100 messages at a time
type NewMessageInsert = {
id: string;
chatId: string;
parts: any[];
role: string;
attachments: any[];
createdAt: Date;
};
type NewVoteInsert = {
messageId: string;
chatId: string;
isUpvoted: boolean;
};
async function createNewTable() {
const chats = await db.select().from(chat);
let processedCount = 0;
// Process chats in batches
for (let i = 0; i < chats.length; i += BATCH_SIZE) {
const chatBatch = chats.slice(i, i + BATCH_SIZE);
const chatIds = chatBatch.map((chat) => chat.id);
// Fetch all messages and votes for the current batch of chats in bulk
const allMessages = await db
.select()
.from(messageDeprecated)
.where(inArray(messageDeprecated.chatId, chatIds));
const allVotes = await db
.select()
.from(voteDeprecated)
.where(inArray(voteDeprecated.chatId, chatIds));
// Prepare batches for insertion
const newMessagesToInsert: NewMessageInsert[] = [];
const newVotesToInsert: NewVoteInsert[] = [];
// Process each chat in the batch
for (const chat of chatBatch) {
processedCount++;
console.info(`Processed ${processedCount}/${chats.length} chats`);
// Filter messages and votes for this specific chat
const messages = allMessages.filter((msg) => msg.chatId === chat.id);
const votes = allVotes.filter((v) => v.chatId === chat.id);
// Group messages into sections
const messageSection: Array<UIMessage> = [];
const messageSections: Array<Array<UIMessage>> = [];
for (const message of messages) {
const { role } = message;
if (role === 'user' && messageSection.length > 0) {
messageSections.push([...messageSection]);
messageSection.length = 0;
}
// @ts-expect-error message.content has different type
messageSection.push(message);
}
if (messageSection.length > 0) {
messageSections.push([...messageSection]);
}
// Process each message section
for (const section of messageSections) {
const [userMessage, ...assistantMessages] = section;
const [firstAssistantMessage] = assistantMessages;
try {
const uiSection = appendResponseMessages({
messages: [userMessage],
// @ts-expect-error: message.content has different type
responseMessages: assistantMessages,
_internal: {
currentDate: () => firstAssistantMessage.createdAt ?? new Date(),
},
});
const projectedUISection = uiSection
.map((message) => {
if (message.role === 'user') {
return {
id: message.id,
chatId: chat.id,
parts: [{ type: 'text', text: message.content }],
role: message.role,
createdAt: message.createdAt,
attachments: [],
} as NewMessageInsert;
} else if (message.role === 'assistant') {
return {
id: message.id,
chatId: chat.id,
parts: message.parts || [],
role: message.role,
createdAt: message.createdAt,
attachments: [],
} as NewMessageInsert;
}
return null;
})
.filter((msg): msg is NewMessageInsert => msg !== null);
// Add messages to batch
for (const msg of projectedUISection) {
newMessagesToInsert.push(msg);
if (msg.role === 'assistant') {
const voteByMessage = votes.find((v) => v.messageId === msg.id);
if (voteByMessage) {
newVotesToInsert.push({
messageId: msg.id,
chatId: msg.chatId,
isUpvoted: voteByMessage.isUpvoted,
});
}
}
}
} catch (error) {
console.error(`Error processing chat ${chat.id}: ${error}`);
}
}
}
// Batch insert messages
for (let j = 0; j < newMessagesToInsert.length; j += INSERT_BATCH_SIZE) {
const messageBatch = newMessagesToInsert.slice(j, j + INSERT_BATCH_SIZE);
if (messageBatch.length > 0) {
// Ensure all required fields are present
const validMessageBatch = messageBatch.map((msg) => ({
id: msg.id,
chatId: msg.chatId,
parts: msg.parts,
role: msg.role,
attachments: msg.attachments,
createdAt: msg.createdAt,
}));
await db.insert(message).values(validMessageBatch);
}
}
// Batch insert votes
for (let j = 0; j < newVotesToInsert.length; j += INSERT_BATCH_SIZE) {
const voteBatch = newVotesToInsert.slice(j, j + INSERT_BATCH_SIZE);
if (voteBatch.length > 0) {
await db.insert(vote).values(voteBatch);
}
}
}
console.info(`Migration completed: ${processedCount} chats processed`);
}
createNewTable()
.then(() => {
console.info('Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS "Message_v2" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"chatId" uuid NOT NULL,
"role" varchar NOT NULL,
"parts" json NOT NULL,
"attachments" json NOT NULL,
"createdAt" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "Vote_v2" (
"chatId" uuid NOT NULL,
"messageId" uuid NOT NULL,
"isUpvoted" boolean NOT NULL,
CONSTRAINT "Vote_v2_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Message_v2" ADD CONSTRAINT "Message_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,515 @@
{
"id": "c6c102e6-b64e-4f0c-a7a6-32df758de437",
"prevId": "30ad8233-1432-428b-93fc-2bb1ba867ff1",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Chat": {
"name": "Chat",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"visibility": {
"name": "visibility",
"type": "varchar",
"primaryKey": false,
"notNull": true,
"default": "'private'"
}
},
"indexes": {},
"foreignKeys": {
"Chat_userId_User_id_fk": {
"name": "Chat_userId_User_id_fk",
"tableFrom": "Chat",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Document": {
"name": "Document",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"text": {
"name": "text",
"type": "varchar",
"primaryKey": false,
"notNull": true,
"default": "'text'"
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Document_userId_User_id_fk": {
"name": "Document_userId_User_id_fk",
"tableFrom": "Document",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Document_id_createdAt_pk": {
"name": "Document_id_createdAt_pk",
"columns": [
"id",
"createdAt"
]
}
},
"uniqueConstraints": {}
},
"public.Message_v2": {
"name": "Message_v2",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"parts": {
"name": "parts",
"type": "json",
"primaryKey": false,
"notNull": true
},
"attachments": {
"name": "attachments",
"type": "json",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Message_v2_chatId_Chat_id_fk": {
"name": "Message_v2_chatId_Chat_id_fk",
"tableFrom": "Message_v2",
"tableTo": "Chat",
"columnsFrom": [
"chatId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Message": {
"name": "Message",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "json",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Message_chatId_Chat_id_fk": {
"name": "Message_chatId_Chat_id_fk",
"tableFrom": "Message",
"tableTo": "Chat",
"columnsFrom": [
"chatId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Suggestion": {
"name": "Suggestion",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"documentId": {
"name": "documentId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"documentCreatedAt": {
"name": "documentCreatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"originalText": {
"name": "originalText",
"type": "text",
"primaryKey": false,
"notNull": true
},
"suggestedText": {
"name": "suggestedText",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isResolved": {
"name": "isResolved",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Suggestion_userId_User_id_fk": {
"name": "Suggestion_userId_User_id_fk",
"tableFrom": "Suggestion",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": {
"name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk",
"tableFrom": "Suggestion",
"tableTo": "Document",
"columnsFrom": [
"documentId",
"documentCreatedAt"
],
"columnsTo": [
"id",
"createdAt"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Suggestion_id_pk": {
"name": "Suggestion_id_pk",
"columns": [
"id"
]
}
},
"uniqueConstraints": {}
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Vote_v2": {
"name": "Vote_v2",
"schema": "",
"columns": {
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"messageId": {
"name": "messageId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"isUpvoted": {
"name": "isUpvoted",
"type": "boolean",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Vote_v2_chatId_Chat_id_fk": {
"name": "Vote_v2_chatId_Chat_id_fk",
"tableFrom": "Vote_v2",
"tableTo": "Chat",
"columnsFrom": [
"chatId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Vote_v2_messageId_Message_v2_id_fk": {
"name": "Vote_v2_messageId_Message_v2_id_fk",
"tableFrom": "Vote_v2",
"tableTo": "Message_v2",
"columnsFrom": [
"messageId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Vote_v2_chatId_messageId_pk": {
"name": "Vote_v2_chatId_messageId_pk",
"columns": [
"chatId",
"messageId"
]
}
},
"uniqueConstraints": {}
},
"public.Vote": {
"name": "Vote",
"schema": "",
"columns": {
"chatId": {
"name": "chatId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"messageId": {
"name": "messageId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"isUpvoted": {
"name": "isUpvoted",
"type": "boolean",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Vote_chatId_Chat_id_fk": {
"name": "Vote_chatId_Chat_id_fk",
"tableFrom": "Vote",
"tableTo": "Chat",
"columnsFrom": [
"chatId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Vote_messageId_Message_id_fk": {
"name": "Vote_messageId_Message_id_fk",
"tableFrom": "Vote",
"tableTo": "Message",
"columnsFrom": [
"messageId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Vote_chatId_messageId_pk": {
"name": "Vote_chatId_messageId_pk",
"columns": [
"chatId",
"messageId"
]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -36,6 +36,13 @@
"when": 1733945232355, "when": 1733945232355,
"tag": "0004_odd_slayback", "tag": "0004_odd_slayback",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1741934630596,
"tag": "0005_wooden_whistler",
"breakpoints": true
} }
] ]
} }

View file

@ -12,9 +12,9 @@ import {
document, document,
type Suggestion, type Suggestion,
suggestion, suggestion,
type Message,
message, message,
vote, vote,
type DBMessage,
} from './schema'; } from './schema';
import { ArtifactKind } from '@/components/artifact'; import { ArtifactKind } from '@/components/artifact';
@ -104,7 +104,11 @@ export async function getChatById({ id }: { id: string }) {
} }
} }
export async function saveMessages({ messages }: { messages: Array<Message> }) { export async function saveMessages({
messages,
}: {
messages: Array<DBMessage>;
}) {
try { try {
return await db.insert(message).values(messages); return await db.insert(message).values(messages);
} catch (error) { } catch (error) {

View file

@ -33,7 +33,9 @@ export const chat = pgTable('Chat', {
export type Chat = InferSelectModel<typeof chat>; export type Chat = InferSelectModel<typeof chat>;
export const message = pgTable('Message', { // DEPRECATED: The following schema is deprecated and will be removed in the future.
// Read the migration guide at https://github.com/vercel/ai-chatbot/blob/main/docs/04-migrate-to-parts.md
export const messageDeprecated = pgTable('Message', {
id: uuid('id').primaryKey().notNull().defaultRandom(), id: uuid('id').primaryKey().notNull().defaultRandom(),
chatId: uuid('chatId') chatId: uuid('chatId')
.notNull() .notNull()
@ -43,10 +45,45 @@ export const message = pgTable('Message', {
createdAt: timestamp('createdAt').notNull(), createdAt: timestamp('createdAt').notNull(),
}); });
export type Message = InferSelectModel<typeof message>; export type MessageDeprecated = InferSelectModel<typeof messageDeprecated>;
export const message = pgTable('Message_v2', {
id: uuid('id').primaryKey().notNull().defaultRandom(),
chatId: uuid('chatId')
.notNull()
.references(() => chat.id),
role: varchar('role').notNull(),
parts: json('parts').notNull(),
attachments: json('attachments').notNull(),
createdAt: timestamp('createdAt').notNull(),
});
export type DBMessage = InferSelectModel<typeof message>;
// DEPRECATED: The following schema is deprecated and will be removed in the future.
// Read the migration guide at https://github.com/vercel/ai-chatbot/blob/main/docs/04-migrate-to-parts.md
export const voteDeprecated = pgTable(
'Vote',
{
chatId: uuid('chatId')
.notNull()
.references(() => chat.id),
messageId: uuid('messageId')
.notNull()
.references(() => messageDeprecated.id),
isUpvoted: boolean('isUpvoted').notNull(),
},
(table) => {
return {
pk: primaryKey({ columns: [table.chatId, table.messageId] }),
};
},
);
export type VoteDeprecated = InferSelectModel<typeof voteDeprecated>;
export const vote = pgTable( export const vote = pgTable(
'Vote', 'Vote_v2',
{ {
chatId: uuid('chatId') chatId: uuid('chatId')
.notNull() .notNull()

View file

@ -5,11 +5,12 @@ import type {
TextStreamPart, TextStreamPart,
ToolInvocation, ToolInvocation,
ToolSet, ToolSet,
UIMessage,
} from 'ai'; } from 'ai';
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import type { Message as DBMessage, Document } from '@/lib/db/schema'; import type { DBMessage, Document } from '@/lib/db/schema';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -85,52 +86,6 @@ function addToolMessageToChat({
}); });
} }
export function convertToUIMessages(
messages: Array<DBMessage>,
): Array<Message> {
return messages.reduce((chatMessages: Array<Message>, message) => {
if (message.role === 'tool') {
return addToolMessageToChat({
toolMessage: message as CoreToolMessage,
messages: chatMessages,
});
}
let textContent = '';
let reasoning: string | undefined = undefined;
const toolInvocations: Array<ToolInvocation> = [];
if (typeof message.content === 'string') {
textContent = message.content;
} else if (Array.isArray(message.content)) {
for (const content of message.content) {
if (content.type === 'text') {
textContent += content.text;
} else if (content.type === 'tool-call') {
toolInvocations.push({
state: 'call',
toolCallId: content.toolCallId,
toolName: content.toolName,
args: content.args,
});
} else if (content.type === 'reasoning') {
reasoning = content.reasoning;
}
}
}
chatMessages.push({
id: message.id,
role: message.role as Message['role'],
content: textContent,
reasoning,
toolInvocations,
});
return chatMessages;
}, []);
}
type ResponseMessageWithoutId = CoreToolMessage | CoreAssistantMessage; type ResponseMessageWithoutId = CoreToolMessage | CoreAssistantMessage;
type ResponseMessage = ResponseMessageWithoutId & { id: string }; type ResponseMessage = ResponseMessageWithoutId & { id: string };
@ -182,40 +137,7 @@ export function sanitizeResponseMessages({
); );
} }
export function sanitizeUIMessages(messages: Array<Message>): Array<Message> { export function getMostRecentUserMessage(messages: Array<UIMessage>) {
const messagesBySanitizedToolInvocations = messages.map((message) => {
if (message.role !== 'assistant') return message;
if (!message.toolInvocations) return message;
const toolResultIds: Array<string> = [];
for (const toolInvocation of message.toolInvocations) {
if (toolInvocation.state === 'result') {
toolResultIds.push(toolInvocation.toolCallId);
}
}
const sanitizedToolInvocations = message.toolInvocations.filter(
(toolInvocation) =>
toolInvocation.state === 'result' ||
toolResultIds.includes(toolInvocation.toolCallId),
);
return {
...message,
toolInvocations: sanitizedToolInvocations,
};
});
return messagesBySanitizedToolInvocations.filter(
(message) =>
message.content.length > 0 ||
(message.toolInvocations && message.toolInvocations.length > 0),
);
}
export function getMostRecentUserMessage(messages: Array<Message>) {
const userMessages = messages.filter((message) => message.role === 'user'); const userMessages = messages.filter((message) => message.role === 'user');
return userMessages.at(-1); return userMessages.at(-1);
} }
@ -229,3 +151,15 @@ export function getDocumentTimestampByIndex(
return documents[index].createdAt; return documents[index].createdAt;
} }
export function getTrailingMessageId({
messages,
}: {
messages: Array<ResponseMessage>;
}): string | null {
const trailingMessage = messages.at(-1);
if (!trailingMessage) return null;
return trailingMessage.id;
}

View file

@ -19,9 +19,9 @@
"test": "export PLAYWRIGHT=True && pnpm exec playwright test --workers=4" "test": "export PLAYWRIGHT=True && pnpm exec playwright test --workers=4"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/fireworks": "0.1.12", "@ai-sdk/fireworks": "0.1.16",
"@ai-sdk/openai": "1.2.0", "@ai-sdk/openai": "1.2.5",
"@ai-sdk/react": "^1.1.20", "@ai-sdk/react": "^1.1.23",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6", "@codemirror/lang-python": "^6.1.6",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
@ -40,7 +40,7 @@
"@vercel/analytics": "^1.3.1", "@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1", "@vercel/blob": "^0.24.1",
"@vercel/postgres": "^0.10.0", "@vercel/postgres": "^0.10.0",
"ai": "4.1.50", "ai": "4.1.61",
"bcrypt-ts": "^5.0.2", "bcrypt-ts": "^5.0.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",

View file

@ -44,9 +44,9 @@ export default defineConfig({
}, },
/* Configure global timeout for each test */ /* Configure global timeout for each test */
timeout: 30000, timeout: 60 * 1000, // 30 seconds
expect: { expect: {
timeout: 30000, timeout: 60 * 1000,
}, },
/* Configure projects */ /* Configure projects */

97
pnpm-lock.yaml generated
View file

@ -9,14 +9,14 @@ importers:
.: .:
dependencies: dependencies:
'@ai-sdk/fireworks': '@ai-sdk/fireworks':
specifier: 0.1.12 specifier: 0.1.16
version: 0.1.12(zod@3.23.8) version: 0.1.16(zod@3.23.8)
'@ai-sdk/openai': '@ai-sdk/openai':
specifier: 1.2.0 specifier: 1.2.5
version: 1.2.0(zod@3.23.8) version: 1.2.5(zod@3.23.8)
'@ai-sdk/react': '@ai-sdk/react':
specifier: ^1.1.20 specifier: ^1.1.23
version: 1.1.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8) version: 1.1.23(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@codemirror/lang-javascript': '@codemirror/lang-javascript':
specifier: ^6.2.2 specifier: ^6.2.2
version: 6.2.2 version: 6.2.2
@ -72,8 +72,8 @@ importers:
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.0 version: 0.10.0
ai: ai:
specifier: 4.1.50 specifier: 4.1.61
version: 4.1.50(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8) version: 4.1.61(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
bcrypt-ts: bcrypt-ts:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
@ -258,26 +258,26 @@ importers:
packages: packages:
'@ai-sdk/fireworks@0.1.12': '@ai-sdk/fireworks@0.1.16':
resolution: {integrity: sha512-13HGPMRDUvdS7d53stNiVMyQa2S0PiefymYjIaXwz8KhA3ZEfxTyzw+42OL84FdEuPGFMadKkXOdvWSW0YVUDg==} resolution: {integrity: sha512-CQ0A/bVYUQ2VBLxH6RHOW+m+1LlJ9QNf/7hjYFWAmBR6nkwHbrKarNOX9d3Ba08HTotdqxIChORuU2km0LIxFw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0
'@ai-sdk/openai-compatible@0.1.12': '@ai-sdk/openai-compatible@0.1.15':
resolution: {integrity: sha512-2bMhAEeiRz4lbW5ixjGjbPhwyqjtujkjLVpqqtqWvvUDvtUM3cw1go9pqWFgaNKSBDaXRUfi8mkAVrn1yRuY2A==} resolution: {integrity: sha512-iuylARLylTkaVYXKdgA1GeGU2TwANaJ3RlhJqmN0cVO5uEt3qVKvedrzR3jrQIyy9hu3gngsiXfFBMW2UQ7Ntg==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0
'@ai-sdk/openai@1.2.0': '@ai-sdk/openai@1.2.5':
resolution: {integrity: sha512-tzxH6OxKL5ffts4zJPdziQSJGGpSrQcJmuSrE92jCt7pJ4PAU5Dx4tjNNFIU8lSfwarLnywejZEt3Fz0uQZZOQ==} resolution: {integrity: sha512-COK7LzspgQQh5Yq070xfDdVMvp8WX592rXRaMaYNNqu1xpzahxDcM24aF9xgKYWuYH0UMoOw4UmWGwGxr6ygIg==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0
'@ai-sdk/provider-utils@2.1.10': '@ai-sdk/provider-utils@2.1.13':
resolution: {integrity: sha512-4GZ8GHjOFxePFzkl3q42AU0DQOtTQ5w09vmaWUf/pKFXJPizlnzKSUkF0f+VkapIUfDugyMqPMT1ge8XQzVI7Q==} resolution: {integrity: sha512-kLjqsfOdONr6DGcGEntFYM1niXz1H05vyZNf9OAzK+KKKc64izyP4/q/9HX7W4+6g8hm6BnmKxu8vkr6FSOqDg==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0
@ -285,12 +285,12 @@ packages:
zod: zod:
optional: true optional: true
'@ai-sdk/provider@1.0.9': '@ai-sdk/provider@1.0.11':
resolution: {integrity: sha512-jie6ZJT2ZR0uVOVCDc9R2xCX5I/Dum/wEK28lx21PJx6ZnFAN9EzD2WsPhcDWfCgGx3OAZZ0GyM3CEobXpa9LA==} resolution: {integrity: sha512-CPyImHGiT3svyfmvPvAFTianZzWFtm0qK82XjwlQIA1C3IQ2iku/PMQXi7aFyrX0TyMh3VTkJPB03tjU2VXVrw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@ai-sdk/react@1.1.20': '@ai-sdk/react@1.1.23':
resolution: {integrity: sha512-4QOM9fR9SryaRraybckDjrhl1O6XejqELdKmrM5g9y9eLnWAfjwF+W1aN0knkSHzbbjMqN77sy9B9yL8EuJbDw==} resolution: {integrity: sha512-R+PG9ya0GLs6orzt+1MxmjrWFuZM0gVs+l8ihBr1u+42wwkVeojY4CAtQjW4nrfGTVbdJYkl5y+r/VKfjr42aQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc react: ^18 || ^19 || ^19.0.0-rc
@ -301,8 +301,8 @@ packages:
zod: zod:
optional: true optional: true
'@ai-sdk/ui-utils@1.1.16': '@ai-sdk/ui-utils@1.1.19':
resolution: {integrity: sha512-jfblR2yZVISmNK2zyNzJZFtkgX57WDAUQXcmn3XUBJyo8LFsADu+/vYMn5AOyBi9qJT0RBk11PEtIxIqvByw3Q==} resolution: {integrity: sha512-rDHy2uxlPMt3jjS9L6mBrsfhEInZ5BVoWevmD13fsAt2s/XWy2OwwKmgmUQkdLlY4mn/eyeYAfDGK8+5CbOAgg==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0
@ -1651,8 +1651,8 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ai@4.1.50: ai@4.1.61:
resolution: {integrity: sha512-YBNeemrJKDrxoBQd3V9aaxhKm5q5YyRcF7PZE7W0NmLuvsdva/1aQNYTAsxs47gQFdvqfYmlFy4B0E+356OlPA==} resolution: {integrity: sha512-Y9SAyGJEeW23F6C7PSHZXYNEvbH2cqJm0rVW2AoeFaXFT13ttx8rAqs8wz2w466C1UB329yl5PXayFcHqofSEA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc react: ^18 || ^19 || ^19.0.0-rc
@ -3800,52 +3800,52 @@ packages:
snapshots: snapshots:
'@ai-sdk/fireworks@0.1.12(zod@3.23.8)': '@ai-sdk/fireworks@0.1.16(zod@3.23.8)':
dependencies: dependencies:
'@ai-sdk/openai-compatible': 0.1.12(zod@3.23.8) '@ai-sdk/openai-compatible': 0.1.15(zod@3.23.8)
'@ai-sdk/provider': 1.0.9 '@ai-sdk/provider': 1.0.11
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8) '@ai-sdk/provider-utils': 2.1.13(zod@3.23.8)
zod: 3.23.8 zod: 3.23.8
'@ai-sdk/openai-compatible@0.1.12(zod@3.23.8)': '@ai-sdk/openai-compatible@0.1.15(zod@3.23.8)':
dependencies: dependencies:
'@ai-sdk/provider': 1.0.9 '@ai-sdk/provider': 1.0.11
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8) '@ai-sdk/provider-utils': 2.1.13(zod@3.23.8)
zod: 3.23.8 zod: 3.23.8
'@ai-sdk/openai@1.2.0(zod@3.23.8)': '@ai-sdk/openai@1.2.5(zod@3.23.8)':
dependencies: dependencies:
'@ai-sdk/provider': 1.0.9 '@ai-sdk/provider': 1.0.11
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8) '@ai-sdk/provider-utils': 2.1.13(zod@3.23.8)
zod: 3.23.8 zod: 3.23.8
'@ai-sdk/provider-utils@2.1.10(zod@3.23.8)': '@ai-sdk/provider-utils@2.1.13(zod@3.23.8)':
dependencies: dependencies:
'@ai-sdk/provider': 1.0.9 '@ai-sdk/provider': 1.0.11
eventsource-parser: 3.0.0 eventsource-parser: 3.0.0
nanoid: 3.3.8 nanoid: 3.3.8
secure-json-parse: 2.7.0 secure-json-parse: 2.7.0
optionalDependencies: optionalDependencies:
zod: 3.23.8 zod: 3.23.8
'@ai-sdk/provider@1.0.9': '@ai-sdk/provider@1.0.11':
dependencies: dependencies:
json-schema: 0.4.0 json-schema: 0.4.0
'@ai-sdk/react@1.1.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)': '@ai-sdk/react@1.1.23(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
dependencies: dependencies:
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8) '@ai-sdk/provider-utils': 2.1.13(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.16(zod@3.23.8) '@ai-sdk/ui-utils': 1.1.19(zod@3.23.8)
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021) swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
throttleit: 2.1.0 throttleit: 2.1.0
optionalDependencies: optionalDependencies:
react: 19.0.0-rc-45804af1-20241021 react: 19.0.0-rc-45804af1-20241021
zod: 3.23.8 zod: 3.23.8
'@ai-sdk/ui-utils@1.1.16(zod@3.23.8)': '@ai-sdk/ui-utils@1.1.19(zod@3.23.8)':
dependencies: dependencies:
'@ai-sdk/provider': 1.0.9 '@ai-sdk/provider': 1.0.11
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8) '@ai-sdk/provider-utils': 2.1.13(zod@3.23.8)
zod-to-json-schema: 3.24.1(zod@3.23.8) zod-to-json-schema: 3.24.1(zod@3.23.8)
optionalDependencies: optionalDependencies:
zod: 3.23.8 zod: 3.23.8
@ -4953,13 +4953,14 @@ snapshots:
acorn@8.14.0: {} acorn@8.14.0: {}
ai@4.1.50(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8): ai@4.1.61(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
dependencies: dependencies:
'@ai-sdk/provider': 1.0.9 '@ai-sdk/provider': 1.0.11
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8) '@ai-sdk/provider-utils': 2.1.13(zod@3.23.8)
'@ai-sdk/react': 1.1.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8) '@ai-sdk/react': 1.1.23(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.16(zod@3.23.8) '@ai-sdk/ui-utils': 1.1.19(zod@3.23.8)
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
eventsource-parser: 3.0.0
jsondiffpatch: 0.6.0 jsondiffpatch: 0.6.0
optionalDependencies: optionalDependencies:
react: 19.0.0-rc-45804af1-20241021 react: 19.0.0-rc-45804af1-20241021

View file

@ -92,7 +92,7 @@ export class ArtifactPage {
content, content,
attachments, attachments,
async edit(newMessage: string) { async edit(newMessage: string) {
await page.getByTestId('message-edit').click(); await page.getByTestId('message-edit-button').click();
await page.getByTestId('message-editor').fill(newMessage); await page.getByTestId('message-editor').fill(newMessage);
await page.getByTestId('message-editor-send-button').click(); await page.getByTestId('message-editor-send-button').click();
await expect( await expect(

View file

@ -170,7 +170,7 @@ export class ChatPage {
content, content,
attachments, attachments,
async edit(newMessage: string) { async edit(newMessage: string) {
await page.getByTestId('message-edit').click(); await page.getByTestId('message-edit-button').click();
await page.getByTestId('message-editor').fill(newMessage); await page.getByTestId('message-editor').fill(newMessage);
await page.getByTestId('message-editor-send-button').click(); await page.getByTestId('message-editor-send-button').click();
await expect( await expect(