187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
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 { 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,
|
|
loader: async ({ params }) => {
|
|
const data = await getConversationHistory(params.sessionId);
|
|
if (!data) {
|
|
throw notFound();
|
|
}
|
|
return data;
|
|
},
|
|
});
|
|
|
|
function RouteComponent() {
|
|
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-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 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 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>
|
|
</div>
|
|
);
|
|
}
|