Add message actions (#482)

This commit is contained in:
Jeremy 2024-11-05 17:15:51 +03:00 committed by GitHub
parent 94f563f179
commit 171914941e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1011 additions and 150 deletions

View file

@ -1,8 +1,29 @@
'use server'; 'use server';
import { CoreMessage, CoreUserMessage, generateText } from 'ai';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { customModel } from '@/ai';
export async function saveModelId(model: string) { export async function saveModelId(model: string) {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set('model-id', model); cookieStore.set('model-id', model);
} }
export async function generateTitleFromUserMessage({
message,
}: {
message: CoreUserMessage;
}) {
const { text: title } = await generateText({
model: customModel('gpt-3.5-turbo'),
system: `\n
- you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 80 characters long
- the title should be a summary of the user's message
- do not use quotes or colons`,
prompt: JSON.stringify(message),
});
return title;
}

View file

@ -1,6 +1,5 @@
import { import {
convertToCoreMessages, convertToCoreMessages,
generateObject,
Message, Message,
StreamData, StreamData,
streamObject, streamObject,
@ -18,10 +17,17 @@ import {
getDocumentById, getDocumentById,
saveChat, saveChat,
saveDocument, saveDocument,
saveMessages,
saveSuggestions, saveSuggestions,
} from '@/db/queries'; } from '@/db/queries';
import { Suggestion } from '@/db/schema'; import { Suggestion } from '@/db/schema';
import { generateUUID, sanitizeResponseMessages } from '@/lib/utils'; import {
generateUUID,
getMostRecentUserMessage,
sanitizeResponseMessages,
} from '@/lib/utils';
import { generateTitleFromUserMessage } from '../../actions';
export const maxDuration = 60; export const maxDuration = 60;
@ -49,7 +55,7 @@ export async function POST(request: Request) {
const session = await auth(); const session = await auth();
if (!session) { if (!session || !session.user || !session.user.id) {
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
@ -60,6 +66,25 @@ export async function POST(request: Request) {
} }
const coreMessages = convertToCoreMessages(messages); const coreMessages = convertToCoreMessages(messages);
const userMessage = getMostRecentUserMessage(coreMessages);
if (!userMessage) {
return new Response('No user message found', { status: 400 });
}
const chat = await getChatById({ id });
if (!chat) {
const title = await generateTitleFromUserMessage({ message: userMessage });
await saveChat({ id, userId: session.user.id, title });
}
await saveMessages({
messages: [
{ ...userMessage, id: generateUUID(), createdAt: new Date(), chatId: id },
],
});
const streamingData = new StreamData(); const streamingData = new StreamData();
const result = await streamText({ const result = await streamText({
@ -298,13 +323,26 @@ export async function POST(request: Request) {
const responseMessagesWithoutIncompleteToolCalls = const responseMessagesWithoutIncompleteToolCalls =
sanitizeResponseMessages(responseMessages); sanitizeResponseMessages(responseMessages);
await saveChat({ await saveMessages({
id, messages: responseMessagesWithoutIncompleteToolCalls.map(
messages: [ (message) => {
...coreMessages, const messageId = generateUUID();
...responseMessagesWithoutIncompleteToolCalls,
], if (message.role === 'assistant') {
userId: session.user.id, streamingData.appendMessageAnnotation({
messageIdFromServer: messageId,
});
}
return {
id: messageId,
chatId: id,
role: message.role,
content: message.content,
createdAt: new Date(),
};
}
),
}); });
} catch (error) { } catch (error) {
console.error('Failed to save chat'); console.error('Failed to save chat');

View file

@ -0,0 +1,48 @@
import { auth } from '@/app/(auth)/auth';
import { getVotesByChatId, voteMessage } from '@/db/queries';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get('chatId');
if (!chatId) {
return new Response('chatId is required', { status: 400 });
}
const session = await auth();
if (!session || !session.user || !session.user.email) {
return new Response('Unauthorized', { status: 401 });
}
const votes = await getVotesByChatId({ id: chatId });
return Response.json(votes, { status: 200 });
}
export async function PATCH(request: Request) {
const {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: 'up' | 'down' } =
await request.json();
if (!chatId || !messageId || !type) {
return new Response('messageId and type are required', { status: 400 });
}
const session = await auth();
if (!session || !session.user || !session.user.email) {
return new Response('Unauthorized', { status: 401 });
}
await voteMessage({
chatId,
messageId,
type: type,
});
return new Response('Message voted', { status: 200 });
}

View file

@ -5,25 +5,18 @@ import { notFound } from 'next/navigation';
import { DEFAULT_MODEL_NAME, models } from '@/ai/models'; import { DEFAULT_MODEL_NAME, models } from '@/ai/models';
import { auth } from '@/app/(auth)/auth'; import { auth } from '@/app/(auth)/auth';
import { Chat as PreviewChat } from '@/components/custom/chat'; import { Chat as PreviewChat } from '@/components/custom/chat';
import { getChatById } from '@/db/queries'; import { getChatById, getMessagesByChatId } from '@/db/queries';
import { Chat } from '@/db/schema';
import { convertToUIMessages } from '@/lib/utils'; import { convertToUIMessages } from '@/lib/utils';
export default async function Page(props: { params: Promise<any> }) { export default async function Page(props: { params: Promise<any> }) {
const params = await props.params; const params = await props.params;
const { id } = params; const { id } = params;
const chatFromDb = await getChatById({ id }); const chat = await getChatById({ id });
if (!chatFromDb) { if (!chat) {
notFound(); notFound();
} }
// type casting
const chat: Chat = {
...chatFromDb,
messages: convertToUIMessages(chatFromDb.messages as Array<CoreMessage>),
};
const session = await auth(); const session = await auth();
if (!session || !session.user) { if (!session || !session.user) {
@ -34,6 +27,10 @@ export default async function Page(props: { params: Promise<any> }) {
return notFound(); return notFound();
} }
const messagesFromDb = await getMessagesByChatId({
id,
});
const cookieStore = await cookies(); const cookieStore = await cookies();
const modelIdFromCookie = cookieStore.get('model-id')?.value; const modelIdFromCookie = cookieStore.get('model-id')?.value;
const selectedModelId = const selectedModelId =
@ -43,7 +40,7 @@ export default async function Page(props: { params: Promise<any> }) {
return ( return (
<PreviewChat <PreviewChat
id={chat.id} id={chat.id}
initialMessages={chat.messages} initialMessages={convertToUIMessages(messagesFromDb)}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
/> />
); );

View file

@ -42,7 +42,7 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<span className="text-lg font-semibold px-2">Chatbot</span> <span className="text-lg font-semibold px-2">Chatbot</span>
</Link> </Link>
<BetterTooltip content="New Chat" align="start"> <BetterTooltip content="New Chat" align="start">
<Button variant="ghost" className="p-2 h-fit"> <Button variant="ghost" className="p-2 h-fit" asChild>
<Link href="/" onClick={() => setOpenMobile(false)}> <Link href="/" onClick={() => setOpenMobile(false)}>
<PlusIcon /> <PlusIcon />
</Link> </Link>

View file

@ -17,15 +17,14 @@ import {
useWindowSize, useWindowSize,
} from 'usehooks-ts'; } from 'usehooks-ts';
import { Document, Suggestion } from '@/db/schema'; import { Document, Suggestion, Vote } from '@/db/schema';
import { fetcher } from '@/lib/utils'; import { fetcher } from '@/lib/utils';
import { DiffView } from './diffview'; import { DiffView } from './diffview';
import { DocumentSkeleton } from './document-skeleton'; import { DocumentSkeleton } from './document-skeleton';
import { Editor } from './editor'; import { Editor } from './editor';
import { CopyIcon, CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons'; import { CopyIcon, CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons';
import { Markdown } from './markdown'; import { PreviewMessage } from './message';
import { Message as PreviewMessage } from './message';
import { MultimodalInput } from './multimodal-input'; import { MultimodalInput } from './multimodal-input';
import { Toolbar } from './toolbar'; import { Toolbar } from './toolbar';
import { useScrollToBottom } from './use-scroll-to-bottom'; import { useScrollToBottom } from './use-scroll-to-bottom';
@ -46,6 +45,7 @@ export interface UICanvas {
} }
export function Canvas({ export function Canvas({
chatId,
input, input,
setInput, setInput,
handleSubmit, handleSubmit,
@ -58,7 +58,9 @@ export function Canvas({
setCanvas, setCanvas,
messages, messages,
setMessages, setMessages,
votes,
}: { }: {
chatId: string;
input: string; input: string;
setInput: (input: string) => void; setInput: (input: string) => void;
isLoading: boolean; isLoading: boolean;
@ -69,6 +71,7 @@ export function Canvas({
setCanvas: Dispatch<SetStateAction<UICanvas>>; setCanvas: Dispatch<SetStateAction<UICanvas>>;
messages: Array<Message>; messages: Array<Message>;
setMessages: Dispatch<SetStateAction<Array<Message>>>; setMessages: Dispatch<SetStateAction<Array<Message>>>;
votes: Array<Vote> | undefined;
append: ( append: (
message: Message | CreateMessage, message: Message | CreateMessage,
chatRequestOptions?: ChatRequestOptions chatRequestOptions?: ChatRequestOptions
@ -286,15 +289,19 @@ export function Canvas({
ref={messagesContainerRef} ref={messagesContainerRef}
className="flex flex-col gap-4 h-full items-center overflow-y-scroll px-4 pt-20" className="flex flex-col gap-4 h-full items-center overflow-y-scroll px-4 pt-20"
> >
{messages.map((message) => ( {messages.map((message, index) => (
<PreviewMessage <PreviewMessage
chatId={chatId}
key={message.id} key={message.id}
role={message.role} message={message}
content={message.content}
attachments={message.experimental_attachments}
toolInvocations={message.toolInvocations}
canvas={canvas} canvas={canvas}
setCanvas={setCanvas} setCanvas={setCanvas}
isLoading={isLoading && index === messages.length - 1}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/> />
))} ))}
@ -306,6 +313,7 @@ export function Canvas({
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4"> <form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
<MultimodalInput <MultimodalInput
chatId={chatId}
input={input} input={input}
setInput={setInput} setInput={setInput}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}

View file

@ -4,11 +4,14 @@ import { Attachment, Message } from 'ai';
import { useChat } from 'ai/react'; import { useChat } from 'ai/react';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { useState } from 'react'; import { useState } from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { useWindowSize } from 'usehooks-ts'; import { useWindowSize } from 'usehooks-ts';
import { ChatHeader } from '@/components/custom/chat-header'; import { ChatHeader } from '@/components/custom/chat-header';
import { Message as PreviewMessage } from '@/components/custom/message'; import { PreviewMessage } from '@/components/custom/message';
import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom'; import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom';
import { Vote } from '@/db/schema';
import { fetcher } from '@/lib/utils';
import { Canvas, UICanvas } from './canvas'; import { Canvas, UICanvas } from './canvas';
import { CanvasStreamHandler } from './canvas-stream-handler'; import { CanvasStreamHandler } from './canvas-stream-handler';
@ -24,6 +27,8 @@ export function Chat({
initialMessages: Array<Message>; initialMessages: Array<Message>;
selectedModelId: string; selectedModelId: string;
}) { }) {
const { mutate } = useSWRConfig();
const { const {
messages, messages,
setMessages, setMessages,
@ -38,7 +43,7 @@ export function Chat({
body: { id, modelId: selectedModelId }, body: { id, modelId: selectedModelId },
initialMessages, initialMessages,
onFinish: () => { onFinish: () => {
window.history.replaceState({}, '', `/chat/${id}`); mutate('/api/history');
}, },
}); });
@ -59,6 +64,11 @@ export function Chat({
}, },
}); });
const { data: votes } = useSWR<Array<Vote>>(
`/api/vote?chatId=${id}`,
fetcher
);
const [messagesContainerRef, messagesEndRef] = const [messagesContainerRef, messagesEndRef] =
useScrollToBottom<HTMLDivElement>(); useScrollToBottom<HTMLDivElement>();
@ -74,15 +84,19 @@ export function Chat({
> >
{messages.length === 0 && <Overview />} {messages.length === 0 && <Overview />}
{messages.map((message) => ( {messages.map((message, index) => (
<PreviewMessage <PreviewMessage
key={message.id} key={message.id}
role={message.role} chatId={id}
content={message.content} message={message}
attachments={message.experimental_attachments}
toolInvocations={message.toolInvocations}
canvas={canvas} canvas={canvas}
setCanvas={setCanvas} setCanvas={setCanvas}
isLoading={isLoading && messages.length - 1 === index}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/> />
))} ))}
@ -93,6 +107,7 @@ export function Chat({
</div> </div>
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl"> <form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
<MultimodalInput <MultimodalInput
chatId={id}
input={input} input={input}
setInput={setInput} setInput={setInput}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
@ -110,6 +125,7 @@ export function Chat({
<AnimatePresence> <AnimatePresence>
{canvas && canvas.isVisible && ( {canvas && canvas.isVisible && (
<Canvas <Canvas
chatId={id}
input={input} input={input}
setInput={setInput} setInput={setInput}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
@ -122,6 +138,7 @@ export function Chat({
setCanvas={setCanvas} setCanvas={setCanvas}
messages={messages} messages={messages}
setMessages={setMessages} setMessages={setMessages}
votes={votes}
/> />
)} )}
</AnimatePresence> </AnimatePresence>

View file

@ -763,7 +763,7 @@ export const CopyIcon = ({ size = 16 }: { size?: number }) => (
</svg> </svg>
); );
export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => ( export const ThumbUpIcon = ({ size = 16 }: { size?: number }) => (
<svg <svg
height={size} height={size}
strokeLinejoin="round" strokeLinejoin="round"
@ -772,6 +772,42 @@ export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => (
style={{ color: 'currentcolor' }} style={{ color: 'currentcolor' }}
> >
<path <path
fillRule="evenodd"
clipRule="evenodd"
d="M6.89531 2.23972C6.72984 2.12153 6.5 2.23981 6.5 2.44315V5.25001C6.5 6.21651 5.7165 7.00001 4.75 7.00001H2.5V13.5H12.1884C12.762 13.5 13.262 13.1096 13.4011 12.5532L14.4011 8.55318C14.5984 7.76425 14.0017 7.00001 13.1884 7.00001H9.25H8.5V6.25001V3.51458C8.5 3.43384 8.46101 3.35807 8.39531 3.31114L6.89531 2.23972ZM5 2.44315C5 1.01975 6.6089 0.191779 7.76717 1.01912L9.26717 2.09054C9.72706 2.41904 10 2.94941 10 3.51458V5.50001H13.1884C14.9775 5.50001 16.2903 7.18133 15.8563 8.91698L14.8563 12.917C14.5503 14.1412 13.4503 15 12.1884 15H1.75H1V14.25V6.25001V5.50001H1.75H4.75C4.88807 5.50001 5 5.38808 5 5.25001V2.44315Z"
fill="currentColor"
></path>
</svg>
);
export const ThumbDownIcon = ({ size = 16 }: { size?: number }) => (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.89531 13.7603C6.72984 13.8785 6.5 13.7602 6.5 13.5569V10.75C6.5 9.7835 5.7165 9 4.75 9H2.5V2.5H12.1884C12.762 2.5 13.262 2.89037 13.4011 3.44683L14.4011 7.44683C14.5984 8.23576 14.0017 9 13.1884 9H9.25H8.5V9.75V12.4854C8.5 12.5662 8.46101 12.6419 8.39531 12.6889L6.89531 13.7603ZM5 13.5569C5 14.9803 6.6089 15.8082 7.76717 14.9809L9.26717 13.9095C9.72706 13.581 10 13.0506 10 12.4854V10.5H13.1884C14.9775 10.5 16.2903 8.81868 15.8563 7.08303L14.8563 3.08303C14.5503 1.85882 13.4503 1 12.1884 1H1.75H1V1.75V9.75V10.5H1.75H4.75C4.88807 10.5 5 10.6119 5 10.75V13.5569Z"
fill="currentColor"
></path>
</svg>
);
export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0607 6.74999L11.5303 7.28032L8.7071 10.1035C8.31657 10.4941 7.68341 10.4941 7.29288 10.1035L4.46966 7.28032L3.93933 6.74999L4.99999 5.68933L5.53032 6.21966L7.99999 8.68933L10.4697 6.21966L11 5.68933L12.0607 6.74999Z" d="M12.0607 6.74999L11.5303 7.28032L8.7071 10.1035C8.31657 10.4941 7.68341 10.4941 7.29288 10.1035L4.46966 7.28032L3.93933 6.74999L4.99999 5.68933L5.53032 6.21966L7.99999 8.68933L10.4697 6.21966L11 5.68933L12.0607 6.74999Z"
fill="currentColor" fill="currentColor"
></path> ></path>

View file

@ -0,0 +1,166 @@
import { Message } from 'ai';
import { toast } from 'sonner';
import { useSWRConfig } from 'swr';
import { useCopyToClipboard } from 'usehooks-ts';
import { Vote } from '@/db/schema';
import { getMessageIdFromAnnotations } from '@/lib/utils';
import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons';
import { Button } from '../ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
export function MessageActions({
chatId,
message,
vote,
isLoading,
}: {
chatId: string;
message: Message;
vote: Vote | undefined;
isLoading: boolean;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) return null;
if (message.role === 'user') return null;
if (message.toolInvocations && message.toolInvocations.length > 0)
return null;
return (
<TooltipProvider delayDuration={0}>
<div className="flex flex-row gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground"
variant="outline"
onClick={async () => {
await copyToClipboard(message.content as string);
toast.success('Copied to clipboard!');
}}
>
<CopyIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
disabled={vote && vote.isUpvoted}
variant="outline"
onClick={async () => {
const messageId = getMessageIdFromAnnotations(message);
const upvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId,
type: 'up',
}),
});
toast.promise(upvote, {
loading: 'Upvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: true,
},
];
},
{ revalidate: false }
);
return 'Upvoted Response!';
},
error: 'Failed to upvote response.',
});
}}
>
<ThumbUpIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Upvote Response</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
variant="outline"
disabled={vote && !vote.isUpvoted}
onClick={async () => {
const messageId = getMessageIdFromAnnotations(message);
const downvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId,
type: 'down',
}),
});
toast.promise(downvote, {
loading: 'Downvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: false,
},
];
},
{ revalidate: false }
);
return 'Downvoted Response!';
},
error: 'Failed to downvote response.',
});
}}
>
<ThumbDownIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Downvote Response</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
}

View file

@ -1,42 +1,45 @@
'use client'; 'use client';
import { Attachment, ToolInvocation } from 'ai'; import { Message } from 'ai';
import cx from 'classnames'; import cx from 'classnames';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Sparkles } from 'lucide-react'; import { Sparkles } from 'lucide-react';
import { Dispatch, ReactNode, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { Vote } from '@/db/schema';
import { UICanvas } from './canvas'; import { UICanvas } from './canvas';
import { DocumentToolCall, DocumentToolResult } from './document'; import { DocumentToolCall, DocumentToolResult } from './document';
import { Markdown } from './markdown'; import { Markdown } from './markdown';
import { MessageActions } from './message-actions';
import { PreviewAttachment } from './preview-attachment'; import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather'; import { Weather } from './weather';
export const Message = ({ export const PreviewMessage = ({
role, chatId,
content, message,
toolInvocations,
attachments,
canvas, canvas,
setCanvas, setCanvas,
vote,
isLoading,
}: { }: {
role: string; chatId: string;
content: string | ReactNode; message: Message;
toolInvocations: Array<ToolInvocation> | undefined;
attachments?: Array<Attachment>;
canvas: UICanvas; canvas: UICanvas;
setCanvas: Dispatch<SetStateAction<UICanvas>>; setCanvas: Dispatch<SetStateAction<UICanvas>>;
vote: Vote | undefined;
isLoading: boolean;
}) => { }) => {
return ( return (
<motion.div <motion.div
className="w-full mx-auto max-w-3xl px-4 group/message " className="w-full mx-auto max-w-3xl px-4 group/message "
initial={{ y: 5, opacity: 0 }} initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
data-role={role} data-role={message.role}
> >
<div <div
className={cx( className={cx(
'flex gap-4 group-data-[role=user]/message:px-5 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-3.5 rounded-xl', 'flex gap-4 group-data-[role=user]/message:px-3 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-2 rounded-xl',
{ {
'group-data-[role=user]/message:bg-muted': !canvas, 'group-data-[role=user]/message:bg-muted': !canvas,
'group-data-[role=user]/message:bg-zinc-300 dark:group-data-[role=user]/message:bg-zinc-800': 'group-data-[role=user]/message:bg-zinc-300 dark:group-data-[role=user]/message:bg-zinc-800':
@ -44,21 +47,22 @@ export const Message = ({
} }
)} )}
> >
{role === 'assistant' && ( {message.role === 'assistant' && (
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border"> <div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
<Sparkles className="size-4" /> <Sparkles className="size-4" />
</div> </div>
)} )}
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{content && ( {message.content && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Markdown>{content as string}</Markdown> <Markdown>{message.content as string}</Markdown>
</div> </div>
)} )}
{toolInvocations && toolInvocations.length > 0 && ( {message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{toolInvocations.map((toolInvocation) => { {message.toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state, args } = toolInvocation; const { toolName, toolCallId, state, args } = toolInvocation;
if (state === 'result') { if (state === 'result') {
@ -121,9 +125,9 @@ export const Message = ({
</div> </div>
)} )}
{attachments && ( {message.experimental_attachments && (
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{attachments.map((attachment) => ( {message.experimental_attachments.map((attachment) => (
<PreviewAttachment <PreviewAttachment
key={attachment.url} key={attachment.url}
attachment={attachment} attachment={attachment}
@ -131,6 +135,14 @@ export const Message = ({
))} ))}
</div> </div>
)} )}
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
/>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View file

@ -36,6 +36,7 @@ const suggestedActions = [
]; ];
export function MultimodalInput({ export function MultimodalInput({
chatId,
input, input,
setInput, setInput,
isLoading, isLoading,
@ -48,6 +49,7 @@ export function MultimodalInput({
handleSubmit, handleSubmit,
className, className,
}: { }: {
chatId: string;
input: string; input: string;
setInput: (value: string) => void; setInput: (value: string) => void;
isLoading: boolean; isLoading: boolean;
@ -114,6 +116,8 @@ export function MultimodalInput({
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]); const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
const submitForm = useCallback(() => { const submitForm = useCallback(() => {
window.history.replaceState({}, '', `/chat/${chatId}`);
handleSubmit(undefined, { handleSubmit(undefined, {
experimental_attachments: attachments, experimental_attachments: attachments,
}); });
@ -124,7 +128,14 @@ export function MultimodalInput({
if (width && width > 768) { if (width && width > 768) {
textareaRef.current?.focus(); textareaRef.current?.focus();
} }
}, [attachments, handleSubmit, setAttachments, setLocalStorageInput, width]); }, [
attachments,
handleSubmit,
setAttachments,
setLocalStorageInput,
width,
chatId,
]);
const uploadFile = async (file: File) => { const uploadFile = async (file: File) => {
const formData = new FormData(); const formData = new FormData();

View file

@ -39,7 +39,7 @@ import {
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { Chat } from '@/db/schema'; import { Chat } from '@/db/schema';
import { fetcher, getTitleFromChat } from '@/lib/utils'; import { fetcher } from '@/lib/utils';
type GroupedChats = { type GroupedChats = {
today: Chat[]; today: Chat[];
@ -63,7 +63,7 @@ const ChatItem = ({
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}> <SidebarMenuButton asChild isActive={isActive}>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}> <Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
<span>{getTitleFromChat(chat)}</span> <span>{chat.title}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
<DropdownMenu modal={true}> <DropdownMenu modal={true}>

View file

@ -1,11 +1,20 @@
"server-only"; 'server-only';
import { genSaltSync, hashSync } from "bcrypt-ts"; import { genSaltSync, hashSync } from 'bcrypt-ts';
import { and, asc, desc, eq, gt } from "drizzle-orm"; import { and, asc, desc, eq, gt } from 'drizzle-orm';
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from "postgres"; import postgres from 'postgres';
import { user, chat, User, document, Suggestion } from "./schema"; import {
user,
chat,
User,
document,
Suggestion,
Message,
message,
vote,
} from './schema';
// Optionally, if not using email/pass login, you can // Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth // use the Drizzle adapter for Auth.js / NextAuth
@ -17,7 +26,7 @@ export async function getUser(email: string): Promise<Array<User>> {
try { try {
return await db.select().from(user).where(eq(user.email, email)); return await db.select().from(user).where(eq(user.email, email));
} catch (error) { } catch (error) {
console.error("Failed to get user from database"); console.error('Failed to get user from database');
throw error; throw error;
} }
} }
@ -29,40 +38,29 @@ export async function createUser(email: string, password: string) {
try { try {
return await db.insert(user).values({ email, password: hash }); return await db.insert(user).values({ email, password: hash });
} catch (error) { } catch (error) {
console.error("Failed to create user in database"); console.error('Failed to create user in database');
throw error; throw error;
} }
} }
export async function saveChat({ export async function saveChat({
id, id,
messages,
userId, userId,
title,
}: { }: {
id: string; id: string;
messages: any;
userId: string; userId: string;
title: string;
}) { }) {
try { try {
const selectedChats = await db.select().from(chat).where(eq(chat.id, id));
if (selectedChats.length > 0) {
return await db
.update(chat)
.set({
messages: JSON.stringify(messages),
})
.where(eq(chat.id, id));
}
return await db.insert(chat).values({ return await db.insert(chat).values({
id, id,
createdAt: new Date(), createdAt: new Date(),
messages: JSON.stringify(messages),
userId, userId,
title,
}); });
} catch (error) { } catch (error) {
console.error("Failed to save chat in database"); console.error('Failed to save chat in database');
throw error; throw error;
} }
} }
@ -71,7 +69,7 @@ export async function deleteChatById({ id }: { id: string }) {
try { try {
return await db.delete(chat).where(eq(chat.id, id)); return await db.delete(chat).where(eq(chat.id, id));
} catch (error) { } catch (error) {
console.error("Failed to delete chat by id from database"); console.error('Failed to delete chat by id from database');
throw error; throw error;
} }
} }
@ -84,7 +82,7 @@ export async function getChatsByUserId({ id }: { id: string }) {
.where(eq(chat.userId, id)) .where(eq(chat.userId, id))
.orderBy(desc(chat.createdAt)); .orderBy(desc(chat.createdAt));
} catch (error) { } catch (error) {
console.error("Failed to get chats by user from database"); console.error('Failed to get chats by user from database');
throw error; throw error;
} }
} }
@ -94,7 +92,71 @@ export async function getChatById({ id }: { id: string }) {
const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id)); const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id));
return selectedChat; return selectedChat;
} catch (error) { } catch (error) {
console.error("Failed to get chat by id from database"); console.error('Failed to get chat by id from database');
throw error;
}
}
export async function saveMessages({ messages }: { messages: Array<Message> }) {
try {
return await db.insert(message).values(messages);
} catch (error) {
console.error('Failed to save messages in database', error);
throw error;
}
}
export async function getMessagesByChatId({ id }: { id: string }) {
try {
return await db
.select()
.from(message)
.where(eq(message.chatId, id))
.orderBy(asc(message.createdAt));
} catch (error) {
console.error('Failed to get messages by chat id from database', error);
throw error;
}
}
export async function voteMessage({
chatId,
messageId,
type,
}: {
chatId: string;
messageId: string;
type: 'up' | 'down';
}) {
try {
const [existingVote] = await db
.select()
.from(vote)
.where(and(eq(vote.messageId, messageId)));
if (existingVote) {
return await db
.update(vote)
.set({ isUpvoted: type === 'up' ? true : false })
.where(and(eq(vote.messageId, messageId), eq(vote.chatId, chatId)));
} else {
return await db.insert(vote).values({
chatId,
messageId,
isUpvoted: type === 'up' ? true : false,
});
}
} catch (error) {
console.error('Failed to upvote message in database', error);
throw error;
}
}
export async function getVotesByChatId({ id }: { id: string }) {
try {
return await db.select().from(vote).where(eq(vote.chatId, id));
} catch (error) {
console.error('Failed to get votes by chat id from database', error);
throw error; throw error;
} }
} }
@ -119,7 +181,7 @@ export async function saveDocument({
createdAt: new Date(), createdAt: new Date(),
}); });
} catch (error) { } catch (error) {
console.error("Failed to save document in database"); console.error('Failed to save document in database');
throw error; throw error;
} }
} }
@ -134,7 +196,7 @@ export async function getDocumentsById({ id }: { id: string }) {
return documents; return documents;
} catch (error) { } catch (error) {
console.error("Failed to get document by id from database"); console.error('Failed to get document by id from database');
throw error; throw error;
} }
} }
@ -149,7 +211,7 @@ export async function getDocumentById({ id }: { id: string }) {
return selectedDocument; return selectedDocument;
} catch (error) { } catch (error) {
console.error("Failed to get document by id from database"); console.error('Failed to get document by id from database');
throw error; throw error;
} }
} }
@ -167,7 +229,7 @@ export async function deleteDocumentsByIdAfterTimestamp({
.where(and(eq(document.id, id), gt(document.createdAt, timestamp))); .where(and(eq(document.id, id), gt(document.createdAt, timestamp)));
} catch (error) { } catch (error) {
console.error( console.error(
"Failed to delete documents by id after timestamp from database", 'Failed to delete documents by id after timestamp from database'
); );
throw error; throw error;
} }
@ -181,7 +243,7 @@ export async function saveSuggestions({
try { try {
return await db.insert(Suggestion).values(suggestions); return await db.insert(Suggestion).values(suggestions);
} catch (error) { } catch (error) {
console.error("Failed to save suggestions in database"); console.error('Failed to save suggestions in database');
throw error; throw error;
} }
} }
@ -198,7 +260,7 @@ export async function getSuggestionsByDocumentId({
.where(and(eq(Suggestion.documentId, documentId))); .where(and(eq(Suggestion.documentId, documentId)));
} catch (error) { } catch (error) {
console.error( console.error(
"Failed to get suggestions by document version from database", 'Failed to get suggestions by document version from database'
); );
throw error; throw error;
} }

View file

@ -1,4 +1,3 @@
import { Message } from 'ai';
import { InferSelectModel } from 'drizzle-orm'; import { InferSelectModel } from 'drizzle-orm';
import { import {
pgTable, pgTable,
@ -23,15 +22,45 @@ export type User = InferSelectModel<typeof user>;
export const chat = pgTable('Chat', { export const chat = pgTable('Chat', {
id: uuid('id').primaryKey().notNull().defaultRandom(), id: uuid('id').primaryKey().notNull().defaultRandom(),
createdAt: timestamp('createdAt').notNull(), createdAt: timestamp('createdAt').notNull(),
messages: json('messages').notNull(), title: text('title').notNull(),
userId: uuid('userId') userId: uuid('userId')
.notNull() .notNull()
.references(() => user.id), .references(() => user.id),
}); });
export type Chat = Omit<InferSelectModel<typeof chat>, 'messages'> & { export type Chat = InferSelectModel<typeof chat>;
messages: Array<Message>;
}; export const message = 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 Message = InferSelectModel<typeof message>;
export const vote = pgTable(
'Vote',
{
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>;
export const document = pgTable( export const document = pgTable(
'Document', 'Document',

View file

@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS "Message" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"chatId" uuid NOT NULL,
"role" varchar NOT NULL,
"content" json NOT NULL,
"createdAt" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "Vote" (
"chatId" uuid NOT NULL,
"messageId" uuid NOT NULL,
"isUpvoted" boolean NOT NULL,
CONSTRAINT "Vote_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
);
--> statement-breakpoint
ALTER TABLE "Chat" ADD COLUMN "title" text NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Message" ADD CONSTRAINT "Message_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" ADD CONSTRAINT "Vote_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" ADD CONSTRAINT "Vote_messageId_Message_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "Chat" DROP COLUMN IF EXISTS "messages";

View file

@ -0,0 +1,377 @@
{
"id": "b5d8e862-936f-4419-a50f-97be3e7fe665",
"prevId": "f3d3437c-4735-4c91-80af-1014048a904e",
"version": "7",
"dialect": "postgresql",
"tables": {
"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.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
}
},
"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
},
"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": {
"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.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": {
"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

@ -15,6 +15,13 @@
"when": 1730207363999, "when": 1730207363999,
"tag": "0001_sparkling_blue_marvel", "tag": "0001_sparkling_blue_marvel",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1730725226313,
"tag": "0002_wandering_riptide",
"breakpoints": true
} }
] ]
} }

View file

@ -2,14 +2,13 @@ import {
CoreAssistantMessage, CoreAssistantMessage,
CoreMessage, CoreMessage,
CoreToolMessage, CoreToolMessage,
generateId,
Message, Message,
ToolInvocation, ToolInvocation,
} from 'ai'; } from 'ai';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Chat, Document } from '@/db/schema'; import { Message as DBMessage, Document } from '@/db/schema';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -86,7 +85,7 @@ function addToolMessageToChat({
} }
export function convertToUIMessages( export function convertToUIMessages(
messages: Array<CoreMessage> messages: Array<DBMessage>
): Array<Message> { ): Array<Message> {
return messages.reduce((chatMessages: Array<Message>, message) => { return messages.reduce((chatMessages: Array<Message>, message) => {
if (message.role === 'tool') { if (message.role === 'tool') {
@ -117,8 +116,8 @@ export function convertToUIMessages(
} }
chatMessages.push({ chatMessages.push({
id: generateId(), id: message.id,
role: message.role, role: message.role as Message['role'],
content: textContent, content: textContent,
toolInvocations, toolInvocations,
}); });
@ -127,29 +126,6 @@ export function convertToUIMessages(
}, []); }, []);
} }
export function getTitleFromChat(chat: Chat) {
const messages = convertToUIMessages(chat.messages as Array<CoreMessage>);
const firstMessage = messages[0];
if (!firstMessage) {
return 'Untitled';
}
return firstMessage.content;
}
const emptyAssistantMessage = [
{
role: 'assistant',
content: [
{
type: 'text',
text: '',
},
],
},
];
export function sanitizeResponseMessages( export function sanitizeResponseMessages(
messages: Array<CoreToolMessage | CoreAssistantMessage> messages: Array<CoreToolMessage | CoreAssistantMessage>
): Array<CoreToolMessage | CoreAssistantMessage> { ): Array<CoreToolMessage | CoreAssistantMessage> {
@ -222,6 +198,11 @@ export function sanitizeUIMessages(messages: Array<Message>): Array<Message> {
); );
} }
export function getMostRecentUserMessage(messages: Array<CoreMessage>) {
const userMessages = messages.filter((message) => message.role === 'user');
return userMessages.at(-1);
}
export function getDocumentTimestampByIndex( export function getDocumentTimestampByIndex(
documents: Array<Document>, documents: Array<Document>,
index: number index: number
@ -231,3 +212,13 @@ export function getDocumentTimestampByIndex(
return documents[index].createdAt; return documents[index].createdAt;
} }
export function getMessageIdFromAnnotations(message: Message) {
if (!message.annotations) return message.id;
const [annotation] = message.annotations;
if (!annotation) return message.id;
// @ts-expect-error messageIdFromServer is not defined in MessageAnnotation
return annotation.messageIdFromServer;
}

View file

@ -23,7 +23,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": "3.4.27", "ai": "3.4.32",
"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",

52
pnpm-lock.yaml generated
View file

@ -48,8 +48,8 @@ dependencies:
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.0 version: 0.10.0
ai: ai:
specifier: 3.4.27 specifier: 3.4.32
version: 3.4.27(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8) version: 3.4.32(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8)
bcrypt-ts: bcrypt-ts:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
@ -271,8 +271,8 @@ packages:
json-schema: 0.4.0 json-schema: 0.4.0
dev: false dev: false
/@ai-sdk/react@0.0.68(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8): /@ai-sdk/react@0.0.70(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
resolution: {integrity: sha512-dD7cm2UsPWkuWg+qKRXjF+sNLVcUzWUnV25FxvEliJP7I2ajOpq8c+/xyGlm+YodyvAB0fX+oSODOeIWi7lCKg==} resolution: {integrity: sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc react: ^18 || ^19 || ^19.0.0-rc
@ -284,14 +284,15 @@ packages:
optional: true optional: true
dependencies: dependencies:
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8) '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
react: 19.0.0-rc-45804af1-20241021 react: 19.0.0-rc-45804af1-20241021
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
zod: 3.23.8 zod: 3.23.8
dev: false dev: false
/@ai-sdk/solid@0.0.53(zod@3.23.8): /@ai-sdk/solid@0.0.54(zod@3.23.8):
resolution: {integrity: sha512-0yXkwTE75QKdmz40CBtAFy3sQdUnn/TNMTkTE2xfqC9YN7Ixql472TtC+3h6s4dPjRJm5bNnGJAWHwjT2PBmTw==} resolution: {integrity: sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
solid-js: ^1.7.7 solid-js: ^1.7.7
@ -300,13 +301,13 @@ packages:
optional: true optional: true
dependencies: dependencies:
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8) '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
transitivePeerDependencies: transitivePeerDependencies:
- zod - zod
dev: false dev: false
/@ai-sdk/svelte@0.0.56(svelte@5.1.3)(zod@3.23.8): /@ai-sdk/svelte@0.0.57(svelte@5.1.3)(zod@3.23.8):
resolution: {integrity: sha512-EmBHGxVkmC6Ugc2O3tH6+F0udYKUhdlqokKAdO3zZihpNCj4qC5msyzqbhRqX0415tD1eJib5SX2Sva47CHmLA==} resolution: {integrity: sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 svelte: ^3.0.0 || ^4.0.0 || ^5.0.0
@ -315,15 +316,15 @@ packages:
optional: true optional: true
dependencies: dependencies:
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8) '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
sswr: 2.1.0(svelte@5.1.3) sswr: 2.1.0(svelte@5.1.3)
svelte: 5.1.3 svelte: 5.1.3
transitivePeerDependencies: transitivePeerDependencies:
- zod - zod
dev: false dev: false
/@ai-sdk/ui-utils@0.0.49(zod@3.23.8): /@ai-sdk/ui-utils@0.0.50(zod@3.23.8):
resolution: {integrity: sha512-urg0KYrfJmfEBSva9d132YRxAVmdU12ISGVlOV7yJkL86NPaU15qcRRWpOJqmMl4SJYkyZGyL1Rw9/GtLVurKw==} resolution: {integrity: sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0
@ -339,8 +340,8 @@ packages:
zod-to-json-schema: 3.23.5(zod@3.23.8) zod-to-json-schema: 3.23.5(zod@3.23.8)
dev: false dev: false
/@ai-sdk/vue@0.0.58(vue@3.5.12)(zod@3.23.8): /@ai-sdk/vue@0.0.59(vue@3.5.12)(zod@3.23.8):
resolution: {integrity: sha512-8cuIekJV+jYz68Z+EDp8Df1WNiBEO1NOUGNCy+5gqIi+j382YjuhZfzC78zbzg0PndfF5JzcXhWPqmcc0loUQA==} resolution: {integrity: sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
vue: ^3.3.4 vue: ^3.3.4
@ -349,7 +350,7 @@ packages:
optional: true optional: true
dependencies: dependencies:
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8) '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
swrv: 1.0.4(vue@3.5.12) swrv: 1.0.4(vue@3.5.12)
vue: 3.5.12(typescript@5.6.3) vue: 3.5.12(typescript@5.6.3)
transitivePeerDependencies: transitivePeerDependencies:
@ -2505,8 +2506,8 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
/ai@3.4.27(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8): /ai@3.4.32(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8):
resolution: {integrity: sha512-xM5EuCSnqnZ2+3VeIkghj5Tgu+SttOkUHsCBhZrspt7EqI1+kesEeLxJMk/9vLpb9jakF4Fd0+P7wirUPRWDRg==} resolution: {integrity: sha512-d5Im7kWsjw1T/IFfYPH4KDLUn4W+XIZqip34q7wcyVBAKLcjGSbJh+5F7Ktc1MIzThHUPh/NUzbHlDDNhTlCgA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
openai: ^4.42.0 openai: ^4.42.0
@ -2528,11 +2529,11 @@ packages:
dependencies: dependencies:
'@ai-sdk/provider': 0.0.26 '@ai-sdk/provider': 0.0.26
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
'@ai-sdk/react': 0.0.68(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8) '@ai-sdk/react': 0.0.70(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@ai-sdk/solid': 0.0.53(zod@3.23.8) '@ai-sdk/solid': 0.0.54(zod@3.23.8)
'@ai-sdk/svelte': 0.0.56(svelte@5.1.3)(zod@3.23.8) '@ai-sdk/svelte': 0.0.57(svelte@5.1.3)(zod@3.23.8)
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8) '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
'@ai-sdk/vue': 0.0.58(vue@3.5.12)(zod@3.23.8) '@ai-sdk/vue': 0.0.59(vue@3.5.12)(zod@3.23.8)
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
eventsource-parser: 1.1.2 eventsource-parser: 1.1.2
json-schema: 0.4.0 json-schema: 0.4.0
@ -6417,6 +6418,11 @@ packages:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
/throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
dev: false
/to-regex-range@5.0.1: /to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}