From b029500553f5a2c5a29b16bdd862dc9c2bc15f7a Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 01:35:54 -0600 Subject: [PATCH] unify ai and live chat --- web/app/ai-chat/page.js | 963 ++++++++++++++++++++++++++++++---------- 1 file changed, 724 insertions(+), 239 deletions(-) diff --git a/web/app/ai-chat/page.js b/web/app/ai-chat/page.js index 32bb38e9..4e7ff875 100644 --- a/web/app/ai-chat/page.js +++ b/web/app/ai-chat/page.js @@ -2,105 +2,225 @@ import dynamic from "next/dynamic"; import { useState, useEffect, useRef, useCallback } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; const API_BASE = ""; +const POLL_INTERVAL = 2500; function AiChatPage() { const { user, token, loading: authLoading } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); + const conversationIdParam = searchParams.get("id"); + const [conversation, setConversation] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); + const [loadingConv, setLoadingConv] = useState(true); const [error, setError] = useState(null); - const [switchingToHuman, setSwitchingToHuman] = useState(false); - const [humanRequested, setHumanRequested] = useState(false); + const [conversations, setConversations] = useState([]); + const [convsLoading, setConvsLoading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); const messagesEndRef = useRef(null); + const messagesAreaRef = useRef(null); const inputRef = useRef(null); + const pollRef = useRef(null); + const lastMessageIdRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { if (!authLoading && !user) { - router.push("/login?next=" + encodeURIComponent("/ai-chat")); + router.push("/login?next=" + encodeURIComponent("/ai-chat" + (conversationIdParam ? `?id=${conversationIdParam}` : ""))); } - }, [authLoading, user, router]); + }, [authLoading, user, router, conversationIdParam]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + const area = messagesAreaRef.current; + if (!area) return; + const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150; + if (nearBottom) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } }, [messages]); - const buildHistory = useCallback(() => { + const fetchMessages = useCallback(async (convId) => { + if (!token || !convId) return; + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) { + setMessages(data); + if (data.length > 0) lastMessageIdRef.current = data[data.length - 1].id; + } + } catch { + // silent + } + }, [token]); - return messages.map((m) => ({ - role: m.role, - content: m.content, - })); - }, [messages]); + const fetchConversation = useCallback(async (convId) => { + if (!token || !convId) return null; + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + const data = await res.json(); + setConversation(data); + return data; + } catch { + return null; + } + }, [token]); + + const fetchConversations = useCallback(async () => { + if (!token) return; + setConvsLoading(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + setConversations(Array.isArray(data) ? data : (data.content ?? [])); + } catch { + // silent + } finally { + setConvsLoading(false); + } + }, [token]); + + const startPolling = useCallback((convId) => { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(async () => { + if (!token || !convId) return; + try { + const [msgsRes, convRes] = await Promise.all([ + fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + headers: { Authorization: `Bearer ${token}` }, + }), + fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ]); + if (msgsRes.ok) { + const data = await msgsRes.json(); + if (Array.isArray(data)) { + const lastId = data.length > 0 ? data[data.length - 1].id : null; + if (lastId !== lastMessageIdRef.current) { + lastMessageIdRef.current = lastId; + setMessages(data); + } + } + } + if (convRes.ok) { + const convData = await convRes.json(); + setConversation(convData); + } + } catch { + // silent + } + }, POLL_INTERVAL); + }, [token]); + + useEffect(() => { + if (!token || authLoading) return; + + async function init() { + setLoadingConv(true); + setError(null); + + let convId = conversationIdParam ? Number(conversationIdParam) : null; + + if (!convId) { + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const list = await res.json(); + const openAi = Array.isArray(list) + ? list.find((c) => c.status === "OPEN" && c.mode === "AUTOMATED") + : null; + if (openAi) convId = openAi.id; + } + } catch { + setError("Failed to load conversations."); + } + } + + if (!convId) { + // Auto-create a new AI conversation + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }), + }); + if (res.ok) { + const conv = await res.json(); + convId = conv.id; + } + } catch { + // silent + } + } + + if (!convId) { + await fetchConversations(); + setLoadingConv(false); + return; + } + + await Promise.all([ + fetchConversation(convId), + fetchMessages(convId), + fetchConversations(), + ]); + setLoadingConv(false); + startPolling(convId); + router.replace(`/ai-chat?id=${convId}`, { scroll: false }); + } + + init(); + + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations, router]); async function handleSend(e) { e?.preventDefault(); const text = input.trim(); - if (!text || sending) return; - - setInput(""); - setError(null); - setSending(true); - - const userMsg = { role: "user", content: text, id: Date.now() }; - setMessages((prev) => [...prev, userMsg]); - - try { - const res = await fetch(`${API_BASE}/api/v1/ai-chat/message`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - message: text, - history: buildHistory(), - }), - }); - - if (res.status === 401 || res.status === 403) { - router.push("/login?next=" + encodeURIComponent("/ai-chat")); - return; - } - - const data = await res.json(); - - if (!res.ok || !data.success) { - setError(data.error || "Failed to get a response. Please try again."); - return; - } - - const aiMsg = { role: "assistant", content: data.message, id: Date.now() + 1 }; - setMessages((prev) => [...prev, aiMsg]); - } catch { - setError("Network error. Please check your connection and try again."); - } finally { - setSending(false); - inputRef.current?.focus(); + if ((!text && !selectedFile) || sending || !conversation) return; + if (selectedFile) { + await handleSendAttachment(text); + } else { + await handleSendText(text); } } - async function handleHumanChat() { - if (switchingToHuman || !token) return; - setSwitchingToHuman(true); + async function handleSendText(text) { + setInput(""); + setSending(true); setError(null); try { - const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - message: "Hi, I'd like to speak with a real support agent.", - }), + body: JSON.stringify({ content: text }), }); if (res.status === 401) { @@ -108,46 +228,78 @@ function AiChatPage() { return; } - const data = await res.json().catch(() => null); - if (!res.ok) { - setError(data?.message || "Could not connect to live support. Please try again."); + const data = await res.json().catch(() => null); + setError(data?.message || "Failed to send message."); + setInput(text); return; } - const conv = data; + const msg = await res.json(); + setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + lastMessageIdRef.current = msg.id; + } catch { + setError("Network error. Please try again."); + setInput(text); + } finally { + setSending(false); + inputRef.current?.focus(); + } + } - //Flag conversation staff, also should notify them - await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); + async function handleSendAttachment(optionalText) { + setSending(true); + setError(null); + const file = selectedFile; + setSelectedFile(null); + setInput(""); - try { - for (const msg of messages) { - const prefix = msg.role === "assistant" ? "[AI Assistant] " : "[Customer] "; - await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ content: prefix + msg.content }), - }); + try { + const formData = new FormData(); + formData.append("file", file); + if (optionalText) formData.append("content", optionalText); + + const res = await fetch( + `${API_BASE}/api/v1/chat/conversations/${conversation.id}/attachments`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, } - } catch { - // Navigate anyway — agent has partial context + ); + + if (res.status === 401) { + router.push("/login?next=" + encodeURIComponent("/ai-chat")); + return; } - setHumanRequested(true); - router.push(`/chat?id=${conv.id}`); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.message || "Failed to send attachment."); + setSelectedFile(file); + setInput(optionalText); + return; + } + + const msg = await res.json(); + setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + lastMessageIdRef.current = msg.id; } catch { - setError("Network error. Could not connect to live support."); + setError("Network error. Please try again."); + setSelectedFile(file); + setInput(optionalText); } finally { - setSwitchingToHuman(false); + setSending(false); + inputRef.current?.focus(); } } + function handleFileChange(e) { + const file = e.target.files?.[0]; + if (file) setSelectedFile(file); + e.target.value = ""; + } + function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -155,7 +307,63 @@ function AiChatPage() { } } - if (authLoading) { + async function handleNewConversation() { + if (pollRef.current) clearInterval(pollRef.current); + setError(null); + setLoadingConv(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.message || "Failed to start a conversation."); + setLoadingConv(false); + return; + } + const conv = await res.json(); + setConversation(conv); + await Promise.all([fetchMessages(conv.id), fetchConversations()]); + setLoadingConv(false); + startPolling(conv.id); + router.replace(`/ai-chat?id=${conv.id}`, { scroll: false }); + } catch { + setError("Network error. Please try again."); + setLoadingConv(false); + } + } + + async function switchConversation(convId) { + if (pollRef.current) clearInterval(pollRef.current); + setMessages([]); + setError(null); + setLoadingConv(true); + await fetchConversation(convId); + await fetchMessages(convId); + setLoadingConv(false); + startPolling(convId); + router.replace(`/ai-chat?id=${convId}`, { scroll: false }); + } + + async function handleSwitchToHuman() { + if (!conversation) return; + try { + await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}/request-human`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + router.push(`/chat?id=${conversation.id}`); + } catch { + setError("Could not connect to live support. Please try again."); + } + } + + if (authLoading || loadingConv) { return (

Loading...

@@ -165,135 +373,230 @@ function AiChatPage() { if (!user) return null; + const isEscalated = conversation?.mode === "HUMAN"; + const isClosed = conversation?.status === "CLOSED"; + return (

AI Pet Assistant

-

- Ask me anything about pet care, adoption advice, or your pets! -

+

Ask me anything about pet care, adoption advice, or your pets!

-
-
-
-
🐾
-
-
Leon's Pet Assistant
-
- Online -
-
+
+
+
+ All Conversations
- + ))} +
+
-
- {messages.length === 0 && ( -
-
🐾
-

- Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. - Ask me about pet recommendations, care tips, supplies, or anything pet-related! -

+
+ {!conversation ? ( +
+
🐾
+

No active conversation

+

Start a new conversation with the AI assistant.

+ {error &&
{error}
} + +
- )} - - {messages.map((msg) => ( -
- {msg.role === "assistant" && ( -
🐾
- )} -
- {msg.content.split("\n").map((line, i) => ( - - {line} - {i < msg.content.split("\n").length - 1 &&
} -
- ))} + ) : ( +
+
+
+
🐾
+
+
Leon's Pet Assistant
+
+ Online +
+
+
+
+ {!isEscalated && !isClosed && ( + + )} + +
- {msg.role === "user" && ( -
- {user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} + +
+ {messages.length === 0 && ( +
+
🐾
+

+ Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. + Ask me about pet recommendations, care tips, supplies, or anything pet-related! +

+
+ )} + + {messages.map((msg) => { + const isOwn = msg.senderId === user.id; + return ( +
+ {!isOwn &&
🐾
} +
+ {msg.content && msg.content.split("\n").map((line, i, arr) => ( + + {line} + {i < arr.length - 1 &&
} +
+ ))} + {msg.attachmentUrl && ( + + )} +
+ {msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} +
+
+ {isOwn && ( +
+ {user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} +
+ )} +
+ ); + })} +
+
+ + {error && ( +
+ {error} +
)} -
- ))} - {sending && ( -
-
🐾
-
- - - -
+ {isClosed ? ( +
+ This conversation has been closed. + +
+ ) : ( +
+ + {selectedFile && ( +
+ 📎 {selectedFile.name} + +
+ )} +
+ +