From 171914941eb6b3093b9d1c0de690bfac43e2fb29 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 5 Nov 2024 17:15:51 +0300 Subject: [PATCH] Add message actions (#482) --- app/(chat)/actions.ts | 21 ++ app/(chat)/api/chat/route.ts | 58 +++- app/(chat)/api/vote/route.ts | 48 ++++ app/(chat)/chat/[id]/page.tsx | 19 +- components/custom/app-sidebar.tsx | 2 +- components/custom/canvas.tsx | 24 +- components/custom/chat.tsx | 31 +- components/custom/icons.tsx | 38 ++- components/custom/message-actions.tsx | 166 +++++++++++ components/custom/message.tsx | 52 ++-- components/custom/multimodal-input.tsx | 13 +- components/custom/sidebar-history.tsx | 4 +- db/queries.ts | 126 ++++++--- db/schema.ts | 39 ++- lib/drizzle/0002_wandering_riptide.sql | 35 +++ lib/drizzle/meta/0002_snapshot.json | 377 +++++++++++++++++++++++++ lib/drizzle/meta/_journal.json | 7 + lib/utils.ts | 47 ++- package.json | 2 +- pnpm-lock.yaml | 52 ++-- 20 files changed, 1011 insertions(+), 150 deletions(-) create mode 100644 app/(chat)/api/vote/route.ts create mode 100644 components/custom/message-actions.tsx create mode 100644 lib/drizzle/0002_wandering_riptide.sql create mode 100644 lib/drizzle/meta/0002_snapshot.json diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts index bbc5550..1887e4e 100644 --- a/app/(chat)/actions.ts +++ b/app/(chat)/actions.ts @@ -1,8 +1,29 @@ 'use server'; +import { CoreMessage, CoreUserMessage, generateText } from 'ai'; import { cookies } from 'next/headers'; +import { customModel } from '@/ai'; + export async function saveModelId(model: string) { const cookieStore = await cookies(); 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; +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 22eedbe..50c31f2 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,6 +1,5 @@ import { convertToCoreMessages, - generateObject, Message, StreamData, streamObject, @@ -18,10 +17,17 @@ import { getDocumentById, saveChat, saveDocument, + saveMessages, saveSuggestions, } from '@/db/queries'; 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; @@ -49,7 +55,7 @@ export async function POST(request: Request) { const session = await auth(); - if (!session) { + if (!session || !session.user || !session.user.id) { return new Response('Unauthorized', { status: 401 }); } @@ -60,6 +66,25 @@ export async function POST(request: Request) { } 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 result = await streamText({ @@ -298,13 +323,26 @@ export async function POST(request: Request) { const responseMessagesWithoutIncompleteToolCalls = sanitizeResponseMessages(responseMessages); - await saveChat({ - id, - messages: [ - ...coreMessages, - ...responseMessagesWithoutIncompleteToolCalls, - ], - userId: session.user.id, + await saveMessages({ + messages: responseMessagesWithoutIncompleteToolCalls.map( + (message) => { + const messageId = generateUUID(); + + if (message.role === 'assistant') { + streamingData.appendMessageAnnotation({ + messageIdFromServer: messageId, + }); + } + + return { + id: messageId, + chatId: id, + role: message.role, + content: message.content, + createdAt: new Date(), + }; + } + ), }); } catch (error) { console.error('Failed to save chat'); diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts new file mode 100644 index 0000000..42b1701 --- /dev/null +++ b/app/(chat)/api/vote/route.ts @@ -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 }); +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 3eb7a96..859e328 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -5,25 +5,18 @@ import { notFound } from 'next/navigation'; import { DEFAULT_MODEL_NAME, models } from '@/ai/models'; import { auth } from '@/app/(auth)/auth'; import { Chat as PreviewChat } from '@/components/custom/chat'; -import { getChatById } from '@/db/queries'; -import { Chat } from '@/db/schema'; +import { getChatById, getMessagesByChatId } from '@/db/queries'; import { convertToUIMessages } from '@/lib/utils'; export default async function Page(props: { params: Promise }) { const params = await props.params; const { id } = params; - const chatFromDb = await getChatById({ id }); + const chat = await getChatById({ id }); - if (!chatFromDb) { + if (!chat) { notFound(); } - // type casting - const chat: Chat = { - ...chatFromDb, - messages: convertToUIMessages(chatFromDb.messages as Array), - }; - const session = await auth(); if (!session || !session.user) { @@ -34,6 +27,10 @@ export default async function Page(props: { params: Promise }) { return notFound(); } + const messagesFromDb = await getMessagesByChatId({ + id, + }); + const cookieStore = await cookies(); const modelIdFromCookie = cookieStore.get('model-id')?.value; const selectedModelId = @@ -43,7 +40,7 @@ export default async function Page(props: { params: Promise }) { return ( ); diff --git a/components/custom/app-sidebar.tsx b/components/custom/app-sidebar.tsx index ae687ef..964209d 100644 --- a/components/custom/app-sidebar.tsx +++ b/components/custom/app-sidebar.tsx @@ -42,7 +42,7 @@ export function AppSidebar({ user }: { user: User | undefined }) { Chatbot - + + Copy + + + + + + + Upvote Response + + + + + + + Downvote Response + + + + ); +} diff --git a/components/custom/message.tsx b/components/custom/message.tsx index 284b52e..9934c72 100644 --- a/components/custom/message.tsx +++ b/components/custom/message.tsx @@ -1,42 +1,45 @@ 'use client'; -import { Attachment, ToolInvocation } from 'ai'; +import { Message } from 'ai'; import cx from 'classnames'; import { motion } from 'framer-motion'; 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 { DocumentToolCall, DocumentToolResult } from './document'; import { Markdown } from './markdown'; +import { MessageActions } from './message-actions'; import { PreviewAttachment } from './preview-attachment'; import { Weather } from './weather'; -export const Message = ({ - role, - content, - toolInvocations, - attachments, +export const PreviewMessage = ({ + chatId, + message, canvas, setCanvas, + vote, + isLoading, }: { - role: string; - content: string | ReactNode; - toolInvocations: Array | undefined; - attachments?: Array; + chatId: string; + message: Message; canvas: UICanvas; setCanvas: Dispatch>; + vote: Vote | undefined; + isLoading: boolean; }) => { return (
- {role === 'assistant' && ( + {message.role === 'assistant' && (
)} +
- {content && ( + {message.content && (
- {content as string} + {message.content as string}
)} - {toolInvocations && toolInvocations.length > 0 && ( + {message.toolInvocations && message.toolInvocations.length > 0 && (
- {toolInvocations.map((toolInvocation) => { + {message.toolInvocations.map((toolInvocation) => { const { toolName, toolCallId, state, args } = toolInvocation; if (state === 'result') { @@ -121,9 +125,9 @@ export const Message = ({
)} - {attachments && ( + {message.experimental_attachments && (
- {attachments.map((attachment) => ( + {message.experimental_attachments.map((attachment) => ( )} + +
diff --git a/components/custom/multimodal-input.tsx b/components/custom/multimodal-input.tsx index 7198f41..53a4ed7 100644 --- a/components/custom/multimodal-input.tsx +++ b/components/custom/multimodal-input.tsx @@ -36,6 +36,7 @@ const suggestedActions = [ ]; export function MultimodalInput({ + chatId, input, setInput, isLoading, @@ -48,6 +49,7 @@ export function MultimodalInput({ handleSubmit, className, }: { + chatId: string; input: string; setInput: (value: string) => void; isLoading: boolean; @@ -114,6 +116,8 @@ export function MultimodalInput({ const [uploadQueue, setUploadQueue] = useState>([]); const submitForm = useCallback(() => { + window.history.replaceState({}, '', `/chat/${chatId}`); + handleSubmit(undefined, { experimental_attachments: attachments, }); @@ -124,7 +128,14 @@ export function MultimodalInput({ if (width && width > 768) { textareaRef.current?.focus(); } - }, [attachments, handleSubmit, setAttachments, setLocalStorageInput, width]); + }, [ + attachments, + handleSubmit, + setAttachments, + setLocalStorageInput, + width, + chatId, + ]); const uploadFile = async (file: File) => { const formData = new FormData(); diff --git a/components/custom/sidebar-history.tsx b/components/custom/sidebar-history.tsx index 951a112..8217aa6 100644 --- a/components/custom/sidebar-history.tsx +++ b/components/custom/sidebar-history.tsx @@ -39,7 +39,7 @@ import { useSidebar, } from '@/components/ui/sidebar'; import { Chat } from '@/db/schema'; -import { fetcher, getTitleFromChat } from '@/lib/utils'; +import { fetcher } from '@/lib/utils'; type GroupedChats = { today: Chat[]; @@ -63,7 +63,7 @@ const ChatItem = ({ setOpenMobile(false)}> - {getTitleFromChat(chat)} + {chat.title} diff --git a/db/queries.ts b/db/queries.ts index c24ae6a..5772c0c 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,11 +1,20 @@ -"server-only"; +'server-only'; -import { genSaltSync, hashSync } from "bcrypt-ts"; -import { and, asc, desc, eq, gt } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; +import { genSaltSync, hashSync } from 'bcrypt-ts'; +import { and, asc, desc, eq, gt } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +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 // use the Drizzle adapter for Auth.js / NextAuth @@ -17,7 +26,7 @@ export async function getUser(email: string): Promise> { try { return await db.select().from(user).where(eq(user.email, email)); } catch (error) { - console.error("Failed to get user from database"); + console.error('Failed to get user from database'); throw error; } } @@ -29,40 +38,29 @@ export async function createUser(email: string, password: string) { try { return await db.insert(user).values({ email, password: hash }); } catch (error) { - console.error("Failed to create user in database"); + console.error('Failed to create user in database'); throw error; } } export async function saveChat({ id, - messages, userId, + title, }: { id: string; - messages: any; userId: string; + title: string; }) { 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({ id, createdAt: new Date(), - messages: JSON.stringify(messages), userId, + title, }); } catch (error) { - console.error("Failed to save chat in database"); + console.error('Failed to save chat in database'); throw error; } } @@ -71,7 +69,7 @@ export async function deleteChatById({ id }: { id: string }) { try { return await db.delete(chat).where(eq(chat.id, id)); } catch (error) { - console.error("Failed to delete chat by id from database"); + console.error('Failed to delete chat by id from database'); throw error; } } @@ -84,7 +82,7 @@ export async function getChatsByUserId({ id }: { id: string }) { .where(eq(chat.userId, id)) .orderBy(desc(chat.createdAt)); } catch (error) { - console.error("Failed to get chats by user from database"); + console.error('Failed to get chats by user from database'); 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)); return selectedChat; } 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 }) { + 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; } } @@ -119,7 +181,7 @@ export async function saveDocument({ createdAt: new Date(), }); } catch (error) { - console.error("Failed to save document in database"); + console.error('Failed to save document in database'); throw error; } } @@ -134,7 +196,7 @@ export async function getDocumentsById({ id }: { id: string }) { return documents; } catch (error) { - console.error("Failed to get document by id from database"); + console.error('Failed to get document by id from database'); throw error; } } @@ -149,7 +211,7 @@ export async function getDocumentById({ id }: { id: string }) { return selectedDocument; } catch (error) { - console.error("Failed to get document by id from database"); + console.error('Failed to get document by id from database'); throw error; } } @@ -167,7 +229,7 @@ export async function deleteDocumentsByIdAfterTimestamp({ .where(and(eq(document.id, id), gt(document.createdAt, timestamp))); } catch (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; } @@ -181,7 +243,7 @@ export async function saveSuggestions({ try { return await db.insert(Suggestion).values(suggestions); } catch (error) { - console.error("Failed to save suggestions in database"); + console.error('Failed to save suggestions in database'); throw error; } } @@ -198,7 +260,7 @@ export async function getSuggestionsByDocumentId({ .where(and(eq(Suggestion.documentId, documentId))); } catch (error) { console.error( - "Failed to get suggestions by document version from database", + 'Failed to get suggestions by document version from database' ); throw error; } diff --git a/db/schema.ts b/db/schema.ts index 0378a06..b2c0347 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,4 +1,3 @@ -import { Message } from 'ai'; import { InferSelectModel } from 'drizzle-orm'; import { pgTable, @@ -23,15 +22,45 @@ export type User = InferSelectModel; export const chat = pgTable('Chat', { id: uuid('id').primaryKey().notNull().defaultRandom(), createdAt: timestamp('createdAt').notNull(), - messages: json('messages').notNull(), + title: text('title').notNull(), userId: uuid('userId') .notNull() .references(() => user.id), }); -export type Chat = Omit, 'messages'> & { - messages: Array; -}; +export type Chat = InferSelectModel; + +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; + +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; export const document = pgTable( 'Document', diff --git a/lib/drizzle/0002_wandering_riptide.sql b/lib/drizzle/0002_wandering_riptide.sql new file mode 100644 index 0000000..cfe3fc4 --- /dev/null +++ b/lib/drizzle/0002_wandering_riptide.sql @@ -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"; \ No newline at end of file diff --git a/lib/drizzle/meta/0002_snapshot.json b/lib/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..64f03af --- /dev/null +++ b/lib/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/lib/drizzle/meta/_journal.json b/lib/drizzle/meta/_journal.json index 1ce0a75..686d54e 100644 --- a/lib/drizzle/meta/_journal.json +++ b/lib/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1730207363999, "tag": "0001_sparkling_blue_marvel", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1730725226313, + "tag": "0002_wandering_riptide", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index e99f363..828dd1b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,14 +2,13 @@ import { CoreAssistantMessage, CoreMessage, CoreToolMessage, - generateId, Message, ToolInvocation, } from 'ai'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { Chat, Document } from '@/db/schema'; +import { Message as DBMessage, Document } from '@/db/schema'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -86,7 +85,7 @@ function addToolMessageToChat({ } export function convertToUIMessages( - messages: Array + messages: Array ): Array { return messages.reduce((chatMessages: Array, message) => { if (message.role === 'tool') { @@ -117,8 +116,8 @@ export function convertToUIMessages( } chatMessages.push({ - id: generateId(), - role: message.role, + id: message.id, + role: message.role as Message['role'], content: textContent, toolInvocations, }); @@ -127,29 +126,6 @@ export function convertToUIMessages( }, []); } -export function getTitleFromChat(chat: Chat) { - const messages = convertToUIMessages(chat.messages as Array); - const firstMessage = messages[0]; - - if (!firstMessage) { - return 'Untitled'; - } - - return firstMessage.content; -} - -const emptyAssistantMessage = [ - { - role: 'assistant', - content: [ - { - type: 'text', - text: '', - }, - ], - }, -]; - export function sanitizeResponseMessages( messages: Array ): Array { @@ -222,6 +198,11 @@ export function sanitizeUIMessages(messages: Array): Array { ); } +export function getMostRecentUserMessage(messages: Array) { + const userMessages = messages.filter((message) => message.role === 'user'); + return userMessages.at(-1); +} + export function getDocumentTimestampByIndex( documents: Array, index: number @@ -231,3 +212,13 @@ export function getDocumentTimestampByIndex( 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; +} diff --git a/package.json b/package.json index 9835e0d..562c795 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@vercel/analytics": "^1.3.1", "@vercel/blob": "^0.24.1", "@vercel/postgres": "^0.10.0", - "ai": "3.4.27", + "ai": "3.4.32", "bcrypt-ts": "^5.0.2", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 415dd1f..cc76d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ dependencies: specifier: ^0.10.0 version: 0.10.0 ai: - specifier: 3.4.27 - version: 3.4.27(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8) + specifier: 3.4.32 + 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: specifier: ^5.0.2 version: 5.0.2 @@ -271,8 +271,8 @@ packages: json-schema: 0.4.0 dev: false - /@ai-sdk/react@0.0.68(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8): - resolution: {integrity: sha512-dD7cm2UsPWkuWg+qKRXjF+sNLVcUzWUnV25FxvEliJP7I2ajOpq8c+/xyGlm+YodyvAB0fX+oSODOeIWi7lCKg==} + /@ai-sdk/react@0.0.70(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8): + resolution: {integrity: sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -284,14 +284,15 @@ packages: optional: true dependencies: '@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 swr: 2.2.5(react@19.0.0-rc-45804af1-20241021) + throttleit: 2.1.0 zod: 3.23.8 dev: false - /@ai-sdk/solid@0.0.53(zod@3.23.8): - resolution: {integrity: sha512-0yXkwTE75QKdmz40CBtAFy3sQdUnn/TNMTkTE2xfqC9YN7Ixql472TtC+3h6s4dPjRJm5bNnGJAWHwjT2PBmTw==} + /@ai-sdk/solid@0.0.54(zod@3.23.8): + resolution: {integrity: sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==} engines: {node: '>=18'} peerDependencies: solid-js: ^1.7.7 @@ -300,13 +301,13 @@ packages: optional: true dependencies: '@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: - zod dev: false - /@ai-sdk/svelte@0.0.56(svelte@5.1.3)(zod@3.23.8): - resolution: {integrity: sha512-EmBHGxVkmC6Ugc2O3tH6+F0udYKUhdlqokKAdO3zZihpNCj4qC5msyzqbhRqX0415tD1eJib5SX2Sva47CHmLA==} + /@ai-sdk/svelte@0.0.57(svelte@5.1.3)(zod@3.23.8): + resolution: {integrity: sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==} engines: {node: '>=18'} peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 @@ -315,15 +316,15 @@ packages: optional: true dependencies: '@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) svelte: 5.1.3 transitivePeerDependencies: - zod dev: false - /@ai-sdk/ui-utils@0.0.49(zod@3.23.8): - resolution: {integrity: sha512-urg0KYrfJmfEBSva9d132YRxAVmdU12ISGVlOV7yJkL86NPaU15qcRRWpOJqmMl4SJYkyZGyL1Rw9/GtLVurKw==} + /@ai-sdk/ui-utils@0.0.50(zod@3.23.8): + resolution: {integrity: sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 @@ -339,8 +340,8 @@ packages: zod-to-json-schema: 3.23.5(zod@3.23.8) dev: false - /@ai-sdk/vue@0.0.58(vue@3.5.12)(zod@3.23.8): - resolution: {integrity: sha512-8cuIekJV+jYz68Z+EDp8Df1WNiBEO1NOUGNCy+5gqIi+j382YjuhZfzC78zbzg0PndfF5JzcXhWPqmcc0loUQA==} + /@ai-sdk/vue@0.0.59(vue@3.5.12)(zod@3.23.8): + resolution: {integrity: sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==} engines: {node: '>=18'} peerDependencies: vue: ^3.3.4 @@ -349,7 +350,7 @@ packages: optional: true dependencies: '@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) vue: 3.5.12(typescript@5.6.3) transitivePeerDependencies: @@ -2505,8 +2506,8 @@ packages: engines: {node: '>=0.4.0'} 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): - resolution: {integrity: sha512-xM5EuCSnqnZ2+3VeIkghj5Tgu+SttOkUHsCBhZrspt7EqI1+kesEeLxJMk/9vLpb9jakF4Fd0+P7wirUPRWDRg==} + /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-d5Im7kWsjw1T/IFfYPH4KDLUn4W+XIZqip34q7wcyVBAKLcjGSbJh+5F7Ktc1MIzThHUPh/NUzbHlDDNhTlCgA==} engines: {node: '>=18'} peerDependencies: openai: ^4.42.0 @@ -2528,11 +2529,11 @@ packages: dependencies: '@ai-sdk/provider': 0.0.26 '@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/solid': 0.0.53(zod@3.23.8) - '@ai-sdk/svelte': 0.0.56(svelte@5.1.3)(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.49(zod@3.23.8) - '@ai-sdk/vue': 0.0.58(vue@3.5.12)(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.54(zod@3.23.8) + '@ai-sdk/svelte': 0.0.57(svelte@5.1.3)(zod@3.23.8) + '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8) + '@ai-sdk/vue': 0.0.59(vue@3.5.12)(zod@3.23.8) '@opentelemetry/api': 1.9.0 eventsource-parser: 1.1.2 json-schema: 0.4.0 @@ -6417,6 +6418,11 @@ packages: dependencies: 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: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'}