feat: add conversation request-response

This commit is contained in:
2025-09-24 12:45:34 +02:00
parent 97a5506e15
commit 64f2aa449d
4 changed files with 223 additions and 14 deletions

View 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;

View File

@@ -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"
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 /> <SendHorizontalIcon />
)}
</Button> </Button>
</div> </div>
</div> </div>

View 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 };

View 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);