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

@@ -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">
<SendHorizontalIcon />
<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>