feat: add conversation request-response
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user