Skip to content

Commit

Permalink
feat: add chat functionality and update UI (#16)
Browse files Browse the repository at this point in the history
* feat: add getFileMessages procedure to retrieve file messages

* feat: add `infinite query` limit constant

* feat: add `--turbo` in dev script

* feat: add ExtendedMessage type to message.ts

* feat: add Message component to handle chat messages

* feat: add pricing page

* refactor: variable name in trpc/index.ts

* refactor: refactor Messages component and update Prisma initialization

* feat: update page title and add blue space icon

* feat: add `react-markdown` library

* feat: update Messages component props

* feat: add date formatting and markdown rendering to Message component

* fix: the UI layout issues and update dependencies

* feat: add @mantine/hooks dependency

* refactor: the Message component to use React.forwardRef

* feat: add message sending functionality and update chat context

* feat: add loading message when AI is thinking

* feat: add intersection observer to fetch next page of messages when scrolling to the bottom
  • Loading branch information
vagnermaltauro authored Jan 5, 2024
1 parent e67b587 commit f089eba
Show file tree
Hide file tree
Showing 15 changed files with 945 additions and 13 deletions.
1 change: 0 additions & 1 deletion app/dashboard/[fileId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export default async function Page({ params }: Readonly<PageProps>) {
return (
<div className="flex-1 justify-between flex flex-col h-[calc(100vh-3.5rem)]">
<div className="mx-auto w-full max-w-8xl grow lg:flex xl:px-2 bg-gray-50">
{/* lef side */}
<div className="flex-1 xl:flex">
<div className="px-4 py-6 sm:px-6 lg:pl-8 xl:flex-1 xl:pl-6">
<PdfRenderer url={file.url} />
Expand Down
12 changes: 10 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ export default function Home() {
<p className="text-sm font-semibold text-gray-700">Doc Quest is now public!</p>
</div>
<h1 className="max-w-4xl text-5xl font-bold md:text-6xl lg:text-7xl">
Chat with your <span className="text-blue-600">documents</span> in seconds.
Chat with your <span className="text-blue-600">documents</span> in{' '}
<span className="whitespace-nowrap">
<span className="inline relative text-primary">
<span className="text-blue-600">seconds.</span>
<span className="absolute inset-x-0 bottom-1 translate-y-full hidden sm:flex">
<Icons.blueSpace style={{ fill: '#2263EB' }} />
</span>
</span>
</span>
</h1>
<p className="mt-5 max-w-prose text-zinc-700 sm:text-lg">
<p className="mt-5 max-w-prose text-zinc-700 sm:text-lg md:pt-4">
Doc Quest allows you to have conversations with any PDF document. Simply upload your file
and start asking questions right away.
</p>
Expand Down
3 changes: 3 additions & 0 deletions app/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function PricingPage() {
return <div>Pricing Page</div>;
}
124 changes: 124 additions & 0 deletions components/chat/chat-context.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as React from 'react';
import { useMutation } from '@tanstack/react-query';

import { INFINIT_QUERY_LIMIT } from '@/config/infinite-query';
import { useToast } from '@/components/ui/use-toast';
import { trpc } from '@/app/_trpc/client';

type StreamResponse = {
addMessage: () => void;
Expand All @@ -24,6 +26,8 @@ interface Props {
export const ChatContextProvider = ({ fileId, children }: Props) => {
const [message, setMessage] = React.useState<string>('');
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const utils = trpc.useContext();
const backupMessage = React.useRef('');
const { toast } = useToast();

const { mutate: sendMessage } = useMutation({
Expand All @@ -42,6 +46,126 @@ export const ChatContextProvider = ({ fileId, children }: Props) => {

return response.body;
},
onMutate: async ({ message }) => {
backupMessage.current = message;
setMessage('');

await utils.getFileMessages.cancel();
const previousMessages = utils.getFileMessages.getInfiniteData();
utils.getFileMessages.setInfiniteData(
{
fileId,
limit: INFINIT_QUERY_LIMIT,
},
(old) => {
if (!old) {
return {
pages: [],
pageParams: [],
};
}

let newPages = [...old.pages];
let latestPage = newPages[0]!;

latestPage.messages = [
{
createdAt: new Date().toISOString(),
id: crypto.randomUUID(),
text: message,
isUserMessage: true,
},
...latestPage.messages,
];

newPages[0] = latestPage;

return {
...old,
pages: newPages,
};
},
);

setIsLoading(true);

return { previousMessages: previousMessages?.pages.flatMap((page) => page.messages) ?? [] };
},
onSuccess: async (stream) => {
setIsLoading(false);

if (!stream) {
return toast({
title: 'There was a problem sending this message',
description: 'Please refresh the page and try again',
variant: 'destructive',
});
}

const reader = stream.getReader();
const decoder = new TextDecoder();
let done = false;

let accResponse = '';
while (!done) {
const { value, done: _done } = await reader.read();
done = _done;
const chunkValue = decoder.decode(value);

accResponse += chunkValue;

utils.getFileMessages.setInfiniteData({ fileId, limit: INFINIT_QUERY_LIMIT }, (old) => {
if (!old) return { pages: [], pageParams: [] };

let isAiResponseCreated = old.pages.some((page) =>
page.messages.some((m) => m.id === 'ai-response'),
);

let updatedPages = old.pages.map((page) => {
if (page === old.pages[0]) {
let updatedMessages;

if (!isAiResponseCreated) {
updatedMessages = [
{
createdAt: new Date().toISOString(),
id: 'ai-response',
text: accResponse,
isUserMessage: false,
},
...page.messages,
];
} else {
updatedMessages = page.messages.map((m) => {
if (m.id === 'ai-response') {
return {
...m,
text: accResponse,
};
}

return m;
});
}

return { ...page, messages: updatedMessages };
}

return page;
});

return { ...old, pages: updatedPages };
});
}
},
onError: (_, __, content) => {
setMessage(backupMessage.current);
utils.getFileMessages.setData({ fileId }, { messages: content?.previousMessages ?? [] });
},
onSettled: async () => {
setIsLoading(false);
await utils.getFileMessages.invalidate({ fileId });
},
});

const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
Expand Down
4 changes: 2 additions & 2 deletions components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function ChatInput({ isDisabled }: ChatInputProps) {

return (
<div className="absolute bottom-0 left-0 w-full">
<form className="mx-2 flex flex-row gap-3 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl">
<div className="mx-2 flex flex-row gap-3 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl">
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="relative flex flex-col w-full flex-grow p-4">
<div className="relative">
Expand Down Expand Up @@ -53,7 +53,7 @@ export function ChatInput({ isDisabled }: ChatInputProps) {
</div>
</div>
</div>
</form>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion components/chat/chat-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function ChatWrapper({ fileId }: ChatWrapperProps) {
<ChatContextProvider fileId={fileId}>
<div className="relative min-h-full bg-zinc-50 flex divide-y divide-zinc-200 flex-col justify-between gap-2">
<div className="flex-1 justify-between flex flex-col mb-28">
<Messages />
<Messages fileId={fileId} />
</div>
<ChatInput />
</div>
Expand Down
78 changes: 78 additions & 0 deletions components/chat/message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react';
import { format } from 'date-fns';
import ReactMarkdown from 'react-markdown';

import { ExtendedMessage } from '@/types/message';
import { cn } from '@/lib/utils';
import { Icons } from '@/components/icons';

interface MessageProps {
message: ExtendedMessage;
isNextMessageSamePerson: boolean;
}

export const Message = React.forwardRef<HTMLDivElement, MessageProps>(
({ message, isNextMessageSamePerson }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-end', {
'justify-end': message.isUserMessage,
})}
>
<div
className={cn('relative flex h-6 w-6 aspect-square items-center justify-center', {
'order-2 bg-zinc-800 rounded-sm': message.isUserMessage,
'order-1 bg-zinc-800 rounded-sm': !message.isUserMessage,
invisible: isNextMessageSamePerson,
})}
>
{message.isUserMessage ? (
<Icons.user className="fill-zinc-200 text-zinc-200 h-3/4 w-3/4" />
) : (
<Icons.bot className="text-white h-3/4 w-3/4" />
)}
</div>
<div
className={cn('flex flex-col space-y-2 text-base max-w-md mx-2', {
'order-1 items-end': message.isUserMessage,
'order-2 items-start': !message.isUserMessage,
})}
>
<div
className={cn('px-4 py-2 rounded-lg inline-block', {
'bg-gray-800 text-white': message.isUserMessage,
'bg-gray-200 text-gray-900': !message.isUserMessage,
'rounded-br-none': !isNextMessageSamePerson && message.isUserMessage,
'rounded-bl-none': !!isNextMessageSamePerson && !message.isUserMessage,
})}
>
{typeof message.text === 'string' ? (
<ReactMarkdown
className={cn('prose', {
'text-zinc-50': message.isUserMessage,
})}
>
{message.text}
</ReactMarkdown>
) : (
message.text
)}
{message.id !== 'loading-message' ? (
<div
className={cn('text-xs select-none mt-2 w-full text-right', {
'text-zinc-500': !message.isUserMessage,
'text-white': message.isUserMessage,
})}
>
{format(new Date(message.createdAt), 'HH:mm')}
</div>
) : null}
</div>
</div>
</div>
);
},
);

Message.displayName = 'Message';
94 changes: 92 additions & 2 deletions components/chat/messages.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,93 @@
export function Messages() {
return <div>messages</div>;
import * as React from 'react';
import { useContext } from 'react';
import { useIntersection } from '@mantine/hooks';
import Skeleton from 'react-loading-skeleton';

import { INFINIT_QUERY_LIMIT } from '@/config/infinite-query';
import { ChatContext } from '@/components/chat/chat-context';
import { Message } from '@/components/chat/message';
import { Icons } from '@/components/icons';
import { trpc } from '@/app/_trpc/client';

interface MessagesProps {
fileId: string;
}

export function Messages({ fileId }: MessagesProps) {
const { isLoading: isAiThinking } = useContext(ChatContext);
const { data, isLoading, fetchNextPage } = trpc.getFileMessages.useInfiniteQuery(
{
fileId,
limit: INFINIT_QUERY_LIMIT,
},
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
keepPreviousData: true,
},
);
const messages = data?.pages.flatMap((page) => page.messages);
const loadingMessage = {
createdAt: new Date().toISOString(),
id: 'loading-message',
isUserMessage: false,
text: (
<span className="flex h=full items-center justify-center">
<Icons.loader2 className="h-4 w-4 animate-spin" />
</span>
),
};
const combinedMessages = [...(isAiThinking ? [loadingMessage] : []), ...(messages ?? [])];
const lastMessageRef = React.useRef<HTMLDivElement>(null);
const { ref, entry } = useIntersection({
root: lastMessageRef.current,
threshold: 1,
});

React.useEffect(() => {
if (entry?.isIntersecting) {
fetchNextPage();
}
}, [entry, fetchNextPage]);

return (
<div className="flex max-h-[calc(100vh-3.5rem-7rem)] border-zinc-200 flex-1 flex-col-reverse gap-4 p-3 overflow-y-auto scrollbar-thumb-black scrollbar-thumb-rounded scrollbar-track-black-lighter scrollbar-w-2 scrolling-touch">
{combinedMessages && combinedMessages.length > 0 ? (
combinedMessages.map((message, index) => {
const isNextMessageSamePerson =
combinedMessages[index - 1]?.isUserMessage === combinedMessages[index]?.isUserMessage;

if (index === combinedMessages.length - 1) {
return (
<Message
ref={ref}
key={message.id}
isNextMessageSamePerson={isNextMessageSamePerson}
message={message}
/>
);
} else
return (
<Message
key={message.id}
isNextMessageSamePerson={isNextMessageSamePerson}
message={message}
/>
);
})
) : isLoading ? (
<div className="w-full flex flex-col gap-2">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-2">
<Icons.messageSquare className="h-8 w-8 text-blue-500" />
<h3 className="font-semibold text-xl">You&apos;re all set!</h3>
<p className="text-zinc-500 text-sm">Ask your first question to get started.</p>
</div>
)}
</div>
);
}
Loading

0 comments on commit f089eba

Please sign in to comment.