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 { Separator } from "@/components/ui/separator";
|
||||||
import { getConversationHistory } from "@/services/get-conversation-history";
|
import { getConversationHistory } from "@/services/get-conversation-history";
|
||||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/conversation/$sessionId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -18,27 +28,156 @@ export const Route = createFileRoute("/conversation/$sessionId")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const historyData = Route.useLoaderData();
|
const { history, title } = Route.useLoaderData();
|
||||||
const { sessionId } = Route.useParams();
|
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 (
|
return (
|
||||||
<div className="flex justify-center p-3 h-full">
|
<div className="flex justify-center p-3 h-screen">
|
||||||
<div className="flex flex-col w-xl gap-3">
|
<div className="flex flex-col w-4xl gap-3 h-full max-h-full">
|
||||||
<Card className="flex-1 flex flex-col">
|
<Card
|
||||||
<CardHeader>
|
className="flex flex-col min-h-0 overflow-hidden"
|
||||||
<CardTitle>{historyData.title}</CardTitle>
|
style={{ height: "calc(100vh - 160px)" }}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex-shrink-0">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
<Separator orientation="horizontal" />
|
<Separator orientation="horizontal" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 max-h-full">
|
<CardContent className="flex-1 min-h-0 p-4 overflow-hidden">
|
||||||
<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
|
||||||
{/* <div className="w-[150px] h-[3500px] bg-amber-400"></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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 flex-shrink-0">
|
||||||
<Input className="flex-1" placeholder="Start typing..." />
|
<Input
|
||||||
<Button size="icon">
|
className="flex-1"
|
||||||
<SendHorizontalIcon />
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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