diff --git a/frontend/src/components/conversation-bubble.tsx b/frontend/src/components/conversation-bubble.tsx new file mode 100644 index 0000000..5c4268d --- /dev/null +++ b/frontend/src/components/conversation-bubble.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; +import { tw } from "@/utils/tailwind"; +import { cva, type VariantProps } from "class-variance-authority"; +import { LoaderCircleIcon } from "lucide-react"; + +const bubbleVariants = cva(tw`px-4 py-2 rounded max-w-2/3`, { + variants: { + type: { + question: tw`bg-blue-500 text-gray-200 self-start`, + response: tw`bg-neutral-300 text-gray-950 self-end`, + }, + active: { + false: tw`grayscale-100 opacity-90`, + true: null, + }, + }, + defaultVariants: { + active: true, + }, +}); + +interface Props extends VariantProps { + content: string; +} +const ConversationBubble = ({ type, active, content }: Readonly) => { + return ( +
+ {content ? content : } +
+ ); +}; + +export default ConversationBubble; diff --git a/frontend/src/routes/conversation.$sessionId.tsx b/frontend/src/routes/conversation.$sessionId.tsx index e80e7c3..f12fa8f 100644 --- a/frontend/src/routes/conversation.$sessionId.tsx +++ b/frontend/src/routes/conversation.$sessionId.tsx @@ -4,7 +4,17 @@ import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { getConversationHistory } from "@/services/get-conversation-history"; import { createFileRoute, notFound } from "@tanstack/react-router"; -import { SendHorizontalIcon } from "lucide-react"; +import { LoaderCircleIcon, SendHorizontalIcon } from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; +import { streamingFetch } from "@/utils/streaming-fetch"; +import { env } from "@/env"; +import ConversationBubble from "@/components/conversation-bubble"; + +interface ConversationData { + key: React.Key; + content: string; + type: "question" | "response"; +} export const Route = createFileRoute("/conversation/$sessionId")({ component: RouteComponent, @@ -18,27 +28,156 @@ export const Route = createFileRoute("/conversation/$sessionId")({ }); function RouteComponent() { - const historyData = Route.useLoaderData(); + const { history, title } = Route.useLoaderData(); const { sessionId } = Route.useParams(); + const [inputValue, setInputValue] = useState(""); + const [isBusy, setIsBusy] = useState(false); + const [conversationPieces, setConversationPieces] = useState< + ConversationData[] + >([]); + const scrollContainer = useRef(null); + + const scrollToBottom = () => { + setTimeout(() => { + if (scrollContainer.current) { + scrollContainer.current.scrollTo({ + top: scrollContainer.current.scrollHeight, + behavior: "smooth", + }); + } + }, 100); + }; + + // Auto-scroll when conversation pieces change + useEffect(() => { + scrollToBottom(); + }, [conversationPieces]); + + const handleSendQuestion = async () => { + if (!inputValue || isBusy) { + return; + } + const question = inputValue.trim(); + setIsBusy(true); + setInputValue(""); + + setConversationPieces((state) => [ + ...state, + { + key: new Date().getTime(), + content: question, + type: "question", + }, + ]); + + try { + setConversationPieces((state) => [ + ...state, + { + key: new Date().getTime(), + content: "", + type: "response", + }, + ]); + scrollToBottom(); + + let idx = 0; + for await (const chunk of streamingFetch(() => + fetch(`${env.VITE_API_URL}/ask-conversation`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ question, session_id: sessionId }), + }) + )) { + setConversationPieces((state) => { + const stateCopy = [...state]; + const lastItem = stateCopy.pop()!; + return [ + ...stateCopy, + { + key: new Date().getTime(), + content: idx++ === 0 ? chunk : lastItem.content + chunk, + type: "response", + }, + ]; + }); + scrollToBottom(); + } + } catch (err) { + console.error("There was an error receiving chunks", err); + } finally { + setIsBusy(false); + } + }; return ( -
-
- - - {historyData.title} +
+
+ + + {title} - -
- {/*
*/} + +
+ {history.map((h, idx) => ( + + + + + ))} + {!!history.length && ( +
+ + + End of history + +
+ )} + {!!conversationPieces.length && ( + <> + {conversationPieces.map((c) => ( + + ))} + + )}
-
- -
diff --git a/frontend/src/utils/streaming-fetch.ts b/frontend/src/utils/streaming-fetch.ts new file mode 100644 index 0000000..0a3c6bc --- /dev/null +++ b/frontend/src/utils/streaming-fetch.ts @@ -0,0 +1,18 @@ +async function* streamingFetch(fetchcall: () => Promise) { + const response = await fetchcall(); + // Attach Reader + if (!response || !response.body) { + throw new Error("Empty response"); + } + const reader = response.body.getReader(); + while (true) { + // wait for next encoded chunk + const { done, value } = await reader.read(); + // check if stream is done + if (done) break; + // Decodes data chunk and yields it + yield new TextDecoder().decode(value); + } +} + +export { streamingFetch }; diff --git a/frontend/src/utils/tailwind.ts b/frontend/src/utils/tailwind.ts new file mode 100644 index 0000000..4763c46 --- /dev/null +++ b/frontend/src/utils/tailwind.ts @@ -0,0 +1,19 @@ +/* A nice to have VScode autocompletion feature + +Make sure you have the Tailwind CSS IntelliSense extension installed in VSCode +https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss + +Open the settings (json) file via CMD + SHIFT + P (macOS) or CTRL + SHIFT + P (Windows) +Add the below entry to the json file +{ + ... + "tailwindCSS.experimental.classRegex": ["tw`([^`]*)"], +} + +Allows you to have tailwind auto complete while typing outside of the className attribute +By using the prefix tw`YOUR_CLASS_NAME_HERE` + +Without the extension and settings adjustment, it'll still be interpreted as a string literal (backward compatible) +*/ +export const tw = (strings: TemplateStringsArray, ...substitution: unknown[]) => + String.raw({ raw: strings }, ...substitution);