feat: add conversation request-response
This commit is contained in:
33
frontend/src/components/conversation-bubble.tsx
Normal file
33
frontend/src/components/conversation-bubble.tsx
Normal file
@@ -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<typeof bubbleVariants> {
|
||||
content: string;
|
||||
}
|
||||
const ConversationBubble = ({ type, active, content }: Readonly<Props>) => {
|
||||
return (
|
||||
<div className={cn(bubbleVariants({ type, active }))}>
|
||||
{content ? content : <LoaderCircleIcon className="animate-spin" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationBubble;
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex justify-center p-3 h-full">
|
||||
<div className="flex flex-col w-xl gap-3">
|
||||
<Card className="flex-1 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>{historyData.title}</CardTitle>
|
||||
<div className="flex justify-center p-3 h-screen">
|
||||
<div className="flex flex-col w-4xl gap-3 h-full max-h-full">
|
||||
<Card
|
||||
className="flex flex-col min-h-0 overflow-hidden"
|
||||
style={{ height: "calc(100vh - 160px)" }}
|
||||
>
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Separator orientation="horizontal" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 max-h-full">
|
||||
<div className="flex flex-col rounded-sm bg-neutral-50 h-full max-h-full border border-neutral-200 shadow-2xs overflow-y-auto">
|
||||
{/* <div className="w-[150px] h-[3500px] bg-amber-400"></div> */}
|
||||
<CardContent className="flex-1 min-h-0 p-4 overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainer}
|
||||
className="flex flex-col gap-2 rounded-sm bg-neutral-50 h-full border border-neutral-200 shadow-2xs overflow-y-auto p-2"
|
||||
>
|
||||
{history.map((h, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<ConversationBubble
|
||||
type="question"
|
||||
active={false}
|
||||
content={h.question}
|
||||
/>
|
||||
<ConversationBubble
|
||||
type="response"
|
||||
active={false}
|
||||
content={h.answer}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{!!history.length && (
|
||||
<div className="flex flex-col items-center">
|
||||
<Separator orientation="horizontal" />
|
||||
<span className="text-xs text-neutral-400">
|
||||
End of history
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!!conversationPieces.length && (
|
||||
<>
|
||||
{conversationPieces.map((c) => (
|
||||
<ConversationBubble
|
||||
key={c.key}
|
||||
type={c.type}
|
||||
content={c.content}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex gap-3">
|
||||
<Input className="flex-1" placeholder="Start typing..." />
|
||||
<Button size="icon">
|
||||
<div className="flex gap-3 flex-shrink-0">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="Start typing..."
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<Button size="icon" disabled={isBusy} onClick={handleSendQuestion}>
|
||||
{isBusy ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<SendHorizontalIcon />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
18
frontend/src/utils/streaming-fetch.ts
Normal file
18
frontend/src/utils/streaming-fetch.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
async function* streamingFetch(fetchcall: () => Promise<Response>) {
|
||||
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 };
|
||||
19
frontend/src/utils/tailwind.ts
Normal file
19
frontend/src/utils/tailwind.ts
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user