174 lines
5.0 KiB
JavaScript
174 lines
5.0 KiB
JavaScript
// ────────────────────────────────── ChatDrawer.jsx
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { Sheet, SheetTrigger, SheetContent } from "./ui/sheet.js";
|
||
import { Button } from "./ui/button.js";
|
||
import { Input } from "./ui/input.js";
|
||
import { MessageCircle } from "lucide-react";
|
||
|
||
/* ---------------------------------------------------------------
|
||
Streams from /api/chat/free and executes UI-tool callbacks
|
||
----------------------------------------------------------------*/
|
||
export default function ChatDrawer({
|
||
pageContext = "Home",
|
||
snapshot = null,
|
||
}) {
|
||
/* state */
|
||
const [open, setOpen] = useState(false);
|
||
const [prompt, setPrompt] = useState("");
|
||
const [messages, setMessages] = useState([]); // { role, content }
|
||
const listRef = useRef(null);
|
||
|
||
/* auto-scroll */
|
||
useEffect(() => {
|
||
listRef.current &&
|
||
(listRef.current.scrollTop = listRef.current.scrollHeight);
|
||
}, [messages]);
|
||
|
||
/* helper: stream-friendly append */
|
||
const pushAssistant = (chunk) =>
|
||
setMessages((prev) => {
|
||
const last = prev.at(-1);
|
||
if (last?.role === "assistant") {
|
||
const updated = [...prev];
|
||
updated[updated.length - 1] = {
|
||
...last,
|
||
content: last.content + chunk
|
||
};
|
||
return updated;
|
||
}
|
||
return [...prev, { role: "assistant", content: chunk }];
|
||
});
|
||
|
||
/* send prompt */
|
||
async function sendPrompt() {
|
||
const text = prompt.trim();
|
||
if (!text) return;
|
||
|
||
setMessages((m) => [...m, { role: "user", content: text }]);
|
||
setPrompt("");
|
||
|
||
const body = JSON.stringify({
|
||
prompt: text,
|
||
pageContext,
|
||
chatHistory: messages,
|
||
snapshot
|
||
});
|
||
|
||
try {
|
||
const token = localStorage.getItem("token") || "";
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
Accept : "text/event-stream",
|
||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||
};
|
||
|
||
const resp = await fetch("/api/chat/free", {
|
||
method: "POST",
|
||
headers,
|
||
body
|
||
});
|
||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buf = "";
|
||
|
||
/* ─────────────── STREAM LOOP ─────────────── */
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
if (!value) continue;
|
||
|
||
buf += decoder.decode(value, { stream: true });
|
||
|
||
let nl;
|
||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||
const line = buf.slice(0, nl).trim(); // one full line
|
||
buf = buf.slice(nl + 1); // keep remainder
|
||
|
||
/* 2️⃣ normal assistant text */
|
||
if (line) pushAssistant(line + "\n");
|
||
}
|
||
}
|
||
/* ───────── END STREAM LOOP ───────── */
|
||
|
||
if (buf.trim()) pushAssistant(buf);
|
||
} catch (err) {
|
||
console.error("[ChatDrawer] stream error", err);
|
||
pushAssistant("Sorry — something went wrong. Please try again later.");
|
||
}
|
||
}
|
||
|
||
/* Enter submits */
|
||
const handleKeyDown = (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendPrompt();
|
||
}
|
||
};
|
||
|
||
/* UI */
|
||
return (
|
||
<Sheet open={open} onOpenChange={setOpen}>
|
||
{/* floating button */}
|
||
<SheetTrigger>
|
||
<button
|
||
aria-label="Open chat"
|
||
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700"
|
||
>
|
||
<MessageCircle size={24} />
|
||
</button>
|
||
</SheetTrigger>
|
||
|
||
{/* drawer */}
|
||
<SheetContent
|
||
side="right"
|
||
className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]"
|
||
>
|
||
<div className="sticky top-0 z-10 border-b bg-white px-4 py-3 font-semibold">
|
||
{pageContext}
|
||
</div>
|
||
|
||
{/* transcript */}
|
||
<div
|
||
ref={listRef}
|
||
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
|
||
>
|
||
{messages.map((m, i) => (
|
||
<div
|
||
key={i}
|
||
className={
|
||
m.role === "user" ? "text-right" : "text-left text-gray-800"
|
||
}
|
||
>
|
||
{m.content}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* prompt box */}
|
||
<div className="border-t p-4">
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
sendPrompt();
|
||
}}
|
||
className="flex gap-2"
|
||
>
|
||
<Input
|
||
value={prompt}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Ask me anything…"
|
||
className="flex-1"
|
||
/>
|
||
<Button type="submit" disabled={!prompt.trim()}>
|
||
Send
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|