Files
group-2-threaded-project-pe…/web/app/ai-chat/page.js
2026-04-20 19:31:55 -06:00

1311 lines
41 KiB
JavaScript

"use client";
import dynamic from "next/dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { createStompClient } from "@/lib/chatSocket";
const API_BASE = "";
//Checks if a filename looks like an image based on its extension
function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
}
//Shows an image inline or a download link for other file types
function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name);
useEffect(() => {
if (!url || !token) return;
let objectUrl;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((blob) => {
if (blob) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
})
.catch(() => {});
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [url, token]);
if (isImage) {
return (
<div style={{ marginTop: "0.5rem" }}>
{blobUrl ? (
<img
src={blobUrl}
alt={name || "attachment"}
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
onClick={() => window.open(blobUrl, "_blank")}
/>
) : (
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image</span>
)}
</div>
);
}
return (
<div style={{ marginTop: "0.4rem" }}>
<a
href="#"
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
onClick={(e) => {
e.preventDefault();
if (!blobUrl) return;
const a = document.createElement("a");
a.href = blobUrl;
a.download = name || "attachment";
a.click();
}}
>
📎 {name || "Attachment"}
</a>
</div>
);
}
//Returns true when the screen width is below 640px
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 640);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
return isMobile;
}
//AI chat page with a conversation sidebar, supports switching to a human agent and sending file attachments
function AiChatPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const conversationIdParam = searchParams.get("id");
const isMobile = useIsMobile();
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 [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false);
const [closedExpanded, setClosedExpanded] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [botTyping, setBotTyping] = useState(false);
const [switchingConv, setSwitchingConv] = useState(false);
const messagesEndRef = useRef(null);
const messagesAreaRef = useRef(null);
const inputRef = useRef(null);
const stompRef = useRef(null);
const lastMessageIdRef = useRef(null);
const fileInputRef = useRef(null);
const lastScrolledIdRef = useRef(null);
const initialLoadDoneRef = useRef(false);
const botTypingTimeoutRef = useRef(null);
useEffect(() => {
if (!authLoading && !user) {
router.push("/login?next=" + encodeURIComponent("/ai-chat" + (conversationIdParam ? `?id=${conversationIdParam}` : "")));
}
}, [authLoading, user, router, conversationIdParam]);
useEffect(() => {
if (messages.length === 0) return;
const lastMsg = messages[messages.length - 1];
if (lastMsg.id === lastScrolledIdRef.current) return;
if (!initialLoadDoneRef.current) return;
lastScrolledIdRef.current = lastMsg.id;
const area = messagesAreaRef.current;
if (!area) return;
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
if (nearBottom) {
area.scrollTop = area.scrollHeight;
}
}, [messages]);
useEffect(() => {
if (!botTyping) return;
const area = messagesAreaRef.current;
if (!area) return;
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
if (nearBottom) area.scrollTop = area.scrollHeight;
}, [botTyping]);
//Loads all messages for a conversation and scrolls to the bottom
const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return;
initialLoadDoneRef.current = false;
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;
lastScrolledIdRef.current = data[data.length - 1].id;
}
setTimeout(() => {
const area = messagesAreaRef.current;
if (area) area.scrollTop = area.scrollHeight;
initialLoadDoneRef.current = true;
}, 50);
}
} catch {
}
}, [token]);
//Fetches a single conversation's details and stores it
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]);
//Connects the WebSocket and subscribes to new messages and conversation updates
const connectStomp = useCallback((convId) => {
if (stompRef.current) {
stompRef.current.deactivate();
stompRef.current = null;
}
const client = createStompClient(token);
client.onConnect = () => {
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
try {
const msg = JSON.parse(frame.body);
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
lastMessageIdRef.current = msg.id;
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
setBotTyping(false);
} catch { /* silent */ }
});
const convTopic = user?.role === "CUSTOMER"
? `/user/queue/chat/conversations`
: `/topic/chat/conversations`;
client.subscribe(convTopic, (frame) => {
try {
const conv = JSON.parse(frame.body);
if (conv.id === convId) setConversation(conv);
setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
} catch { /* silent */ }
});
};
stompRef.current = client;
client.activate();
}, [token, user?.role]);
useEffect(() => {
if (!token || authLoading) return;
let stale = false;
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 (stale) return;
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 {
if (stale) return;
setError("Failed to load conversations.");
}
}
if (!convId) {
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({}),
});
if (stale) return;
if (res.ok) {
const conv = await res.json();
convId = conv.id;
}
} catch {
if (stale) return;
}
}
if (!convId) {
await fetchConversations();
if (stale) return;
setLoadingConv(false);
return;
}
await Promise.all([
fetchConversation(convId),
fetchMessages(convId),
fetchConversations(),
]);
if (stale) return;
setLoadingConv(false);
connectStomp(convId);
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
}
init();
return () => {
stale = true;
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
};
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]);
//Decides whether to send a text message or an attachment
async function handleSend(e) {
e?.preventDefault();
const text = input.trim();
if ((!text && !selectedFile) || sending || !conversation) return;
if (selectedFile) {
await handleSendAttachment(text);
} else {
await handleSendText(text);
}
}
//Sends a plain text message and shows a bot typing indicator
async function handleSendText(text) {
setInput("");
setSending(true);
setError(null);
try {
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({ content: text }),
});
if (res.status === 401) {
router.push("/login?next=" + encodeURIComponent("/ai-chat"));
return;
}
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.message || "Failed to send message.");
setInput(text);
return;
}
const msg = await res.json();
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
lastMessageIdRef.current = msg.id;
if (!isEscalated) {
setBotTyping(true);
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
botTypingTimeoutRef.current = setTimeout(() => setBotTyping(false), 30000);
}
} catch {
setError("Network error. Please try again.");
setInput(text);
} finally {
setSending(false);
inputRef.current?.focus();
}
}
//Uploads a file as an attachment, with an optional caption
async function handleSendAttachment(optionalText) {
setSending(true);
setError(null);
const file = selectedFile;
setSelectedFile(null);
setInput("");
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,
}
);
if (res.status === 401) {
router.push("/login?next=" + encodeURIComponent("/ai-chat"));
return;
}
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. Please try again.");
setSelectedFile(file);
setInput(optionalText);
} finally {
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();
handleSend();
}
}
//Creates a new AI conversation and navigates to it
async function handleNewConversation() {
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
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({}),
});
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);
connectStomp(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 (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setMessages([]);
setError(null);
setBotTyping(false);
await Promise.all([fetchConversation(convId), fetchMessages(convId)]);
connectStomp(convId);
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
}
//Requests a human agent for the current conversation
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}` },
});
setConversation((prev) => prev ? { ...prev, mode: "HUMAN" } : prev);
} catch {
setError("Could not connect to live support. Please try again.");
}
}
//Closes the current conversation so no more messages can be sent
async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return;
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ status: "CLOSED" }),
});
if (!res.ok) return;
const updated = await res.json();
setConversation(updated);
await fetchConversations();
} catch {
}
}
if (authLoading || loadingConv) {
return (
<main style={s.page}>
<p style={s.loading}>Loading...</p>
</main>
);
}
if (!user) return null;
const isEscalated = conversation?.mode === "HUMAN";
const isClosed = conversation?.status === "CLOSED";
const hasStaff = !!conversation?.staffId;
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
return (
<main style={s.page}>
<section style={s.hero}>
<h1 style={s.heroTitle}>AI Pet Assistant</h1>
<p style={s.heroSubtitle}>Ask me anything about pet care, adoption advice, or your pets!</p>
<div style={s.titleDecoration} />
</section>
<section style={s.chatSection}>
<div style={{ display: "flex", flexDirection: isMobile ? "column" : "row", gap: "1rem", alignItems: "flex-start" }}>
<div style={{ ...s.sidebar, width: isMobile ? "100%" : 230, maxHeight: isMobile ? 260 : "calc(100vh - 220px)" }}>
<div style={s.sidebarHeader}>
<span style={s.sidebarTitle}>All Conversations</span>
</div>
{convsLoading && <p style={s.sidebarEmpty}>Loading...</p>}
{!convsLoading && conversations.length === 0 && (
<p style={s.sidebarEmpty}>No conversations yet.</p>
)}
<div style={{ overflowY: "auto", flex: 1 }}>
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
</div>
<div style={s.convItemBottom}>
<span style={{ ...s.convItemMode, display: "flex", alignItems: "center", gap: "0.25rem" }}>
<img src={conv.mode === "HUMAN" ? "/bootstrap/person-fill.svg" : "/bootstrap/robot.svg"} alt="" style={{ width: 12, height: 12 }} />
{conv.mode === "HUMAN" ? "Live" : "AI"}
</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</div>
{conversations.some(c => c.status === "CLOSED") && (
<>
<button style={s.closedSectionToggle} onClick={() => setClosedExpanded(p => !p)}>
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
<span>{closedExpanded ? "▲" : "▼"}</span>
</button>
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
</div>
<div style={s.convItemBottom}>
<span style={{ ...s.convItemMode, display: "flex", alignItems: "center", gap: "0.25rem" }}>
<img src={conv.mode === "HUMAN" ? "/bootstrap/person-fill.svg" : "/bootstrap/robot.svg"} alt="" style={{ width: 12, height: 12 }} />
{conv.mode === "HUMAN" ? "Live" : "AI"}
</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</>
)}
<button style={s.newConvSidebarBtn} onClick={handleNewConversation}>
+ New Conversation
</button>
</div>
<div style={{ flex: 1, minWidth: 0, width: isMobile ? "100%" : undefined }}>
{!conversation ? (
<div style={s.noConvCard}>
<div style={s.noConvIcon}><img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "3rem", height: "3rem", opacity: 0.6 }} /></div>
<h2 style={s.noConvTitle}>No active conversation</h2>
<p style={s.noConvText}>Start a new conversation with the AI assistant.</p>
{error && <div style={s.errorInline}>{error}</div>}
<button style={s.startBtn} onClick={handleNewConversation}>
Start a Conversation
</button>
<button style={s.backBtn} onClick={() => router.push("/chat")}>
Live Support
</button>
</div>
) : (
<div style={s.chatCard}>
<div style={{ ...s.chatHeader, flexDirection: isMobile ? "column" : "row", alignItems: isMobile ? "flex-start" : "center", gap: isMobile ? "0.6rem" : 0 }}>
<div style={s.chatHeaderLeft}>
<div style={isEscalated ? s.agentAvatar : s.aiAvatar}>{isEscalated ? "👤" : <img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%", filter: "brightness(0) invert(1)" }} />}</div>
<div>
<div style={s.chatHeaderTitle}>
{isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"}
</div>
<div style={{ ...s.chatHeaderStatus, color: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }}>
<span style={{ ...s.statusDot, background: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }} />
{isClosed ? "Conversation closed" : isEscalated && hasStaff ? "Support agent connected" : isEscalated ? "Waiting for a support agent..." : "Online"}
</div>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", width: isMobile ? "100%" : undefined }}>
{!isEscalated && !isClosed && (
<button style={isMobile ? { ...s.humanBtn, flex: 1 } : s.humanBtn} onClick={handleSwitchToHuman} title="Connect with a human support agent">
Chat with a Real Person
</button>
)}
{!isClosed && (
<button style={isMobile ? { ...s.closeConvBtn, flex: 1 } : s.closeConvBtn} onClick={handleCloseConversation} title="Close this conversation">
Close Chat
</button>
)}
</div>
</div>
{isEscalated && !hasStaff && !hasStaffMessage && !isClosed && (
<div style={s.waitingBanner}>
<span style={s.waitingSpinner} />
A support agent will be with you shortly. You can send messages while you wait.
</div>
)}
<div style={s.messagesArea} ref={messagesAreaRef}>
{messages.length === 0 && (
<div style={s.emptyState}>
<div style={s.emptyIcon}>{isEscalated ? "💬" : <img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "3rem", height: "3rem", opacity: 0.6 }} />}</div>
<p style={s.emptyText}>
{isEscalated ? "Your conversation has started. A support agent will join soon." : `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!`}
</p>
</div>
)}
{messages.map((msg) => {
const isOwn = msg.senderId === user.id;
return (
<div
key={msg.id}
style={{
...s.messageRow,
...(isOwn ? s.messageRowUser : s.messageRowAgent),
}}
>
{!isOwn && <div style={isEscalated ? s.agentAvatarSmall : s.aiAvatarSmall}>{isEscalated ? "👤" : <img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%", filter: "brightness(0) invert(1)" }} />}</div>}
<div
style={{
...s.messageBubble,
...(isOwn ? s.bubbleUser : s.bubbleAgent),
}}
>
{msg.content && msg.content.split("\n").map((line, i, arr) => (
<span key={i}>
{line}
{i < arr.length - 1 && <br />}
</span>
))}
{msg.attachmentUrl && (
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
)}
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
</div>
</div>
{isOwn && (
<div style={s.userAvatarSmall}>
{user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
</div>
)}
</div>
);
})}
{botTyping && !isEscalated && (
<div style={{ ...s.messageRow, ...s.messageRowAgent }}>
<div style={s.aiAvatarSmall}><img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%", filter: "brightness(0) invert(1)" }} /></div>
<div style={{ ...s.messageBubble, ...s.bubbleAgent, display: "flex", alignItems: "center", gap: "4px", padding: "0.6rem 0.9rem" }}>
<span className="fc-dot" />
<span className="fc-dot" style={{ animationDelay: "0.2s" }} />
<span className="fc-dot" style={{ animationDelay: "0.4s" }} />
</div>
</div>
)}
{switchingConv && (
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
Loading messages
</div>
)}
<div ref={messagesEndRef} />
</div>
{error && (
<div style={s.errorBar}>
{error}
<button style={s.errorClose} onClick={() => setError(null)}></button>
</div>
)}
{isClosed ? (
<div style={s.closedBanner}>
This conversation has been closed.
<button style={s.newConvBtn} onClick={handleNewConversation}>
Start New Conversation
</button>
</div>
) : (
<form style={s.inputArea} onSubmit={handleSend}>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
{selectedFile && (
<div style={s.filePreview}>
<span style={s.filePreviewName}>📎 {selectedFile.name}</span>
<button
type="button"
style={s.filePreviewRemove}
onClick={() => setSelectedFile(null)}
>
</button>
</div>
)}
<div style={s.inputRow}>
<button
type="button"
style={s.attachBtn}
onClick={() => fileInputRef.current?.click()}
disabled={sending}
title="Attach a file"
>
📎
</button>
<textarea
ref={inputRef}
style={s.textarea}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={selectedFile ? "Add a caption (optional)..." : "Ask me about pet care, adoption, supplies..."}
rows={1}
disabled={sending}
maxLength={2000}
/>
<button
type="submit"
style={{
...s.sendBtn,
...((!input.trim() && !selectedFile) || sending ? s.sendBtnDisabled : {}),
}}
disabled={(!input.trim() && !selectedFile) || sending}
>
{sending ? "..." : "Send"}
</button>
</div>
</form>
)}
</div>
)}
</div>
</div>
</section>
</main>
);
}
const s = {
page: {
minHeight: "100vh",
background: "#fafaf8",
fontFamily: "inherit",
},
loading: {
textAlign: "center",
padding: "4rem",
color: "#888",
fontSize: "1rem",
},
hero: {
background: "linear-gradient(135deg, #333 0%, #555 100%)",
padding: "2.5rem 1.5rem 2rem",
textAlign: "center",
color: "white",
},
heroTitle: {
fontSize: "clamp(1.6rem, 4vw, 2.4rem)",
fontWeight: 800,
margin: 0,
letterSpacing: "-0.5px",
},
heroSubtitle: {
fontSize: "clamp(0.9rem, 2vw, 1.1rem)",
marginTop: "0.5rem",
opacity: 0.85,
},
titleDecoration: {
width: 60,
height: 4,
background: "rgba(255,255,255,0.4)",
borderRadius: 2,
margin: "1rem auto 0",
},
chatSection: {
maxWidth: 1060,
margin: "0 auto",
padding: "1.5rem 1rem 2rem",
},
sidebar: {
flexShrink: 0,
background: "white",
borderRadius: 16,
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
minHeight: 200,
},
sidebarHeader: {
display: "flex",
alignItems: "center",
padding: "0.85rem 1rem",
borderBottom: "1px solid #f0f0f0",
flexShrink: 0,
},
sidebarTitle: { fontWeight: 700, fontSize: "0.88rem", color: "#333" },
sidebarEmpty: {
color: "#aaa",
fontSize: "0.82rem",
padding: "1rem",
textAlign: "center",
margin: 0,
},
convItem: {
display: "flex",
flexDirection: "column",
gap: "0.2rem",
padding: "0.65rem 1rem",
background: "white",
border: "none",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: "#f0f0f0",
textAlign: "left",
cursor: "pointer",
width: "100%",
},
convItemActive: { background: "#f8f8f8" },
convItemTop: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.4rem",
},
convItemBottom: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
convItemSubject: {
fontSize: "0.82rem",
fontWeight: 600,
color: "#222",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
},
convItemMode: { fontSize: "0.7rem", color: "#999" },
convItemDate: { fontSize: "0.68rem", color: "#bbb" },
convStatusBadge: {
fontSize: "0.62rem",
fontWeight: 700,
borderRadius: 20,
padding: "0.1rem 0.45rem",
flexShrink: 0,
textTransform: "uppercase",
letterSpacing: "0.04em",
},
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" },
convItemClosed: { opacity: 0.7 },
closedSectionToggle: {
width: "100%",
background: "#f5f5f5",
border: "none",
borderTop: "1px solid #e8e8e8",
padding: "0.5rem 1rem",
fontSize: "0.78rem",
fontWeight: 600,
color: "#666",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
newConvSidebarBtn: {
margin: "0.65rem 1rem",
background: "#333",
color: "white",
border: "none",
borderRadius: 8,
padding: "0.5rem 0.75rem",
fontSize: "0.8rem",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
},
chatCard: {
background: "white",
borderRadius: 16,
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
height: "calc(100vh - 220px)",
minHeight: 400,
},
chatHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1rem 1.25rem",
borderBottom: "1px solid #f0f0f0",
background: "#fff",
flexShrink: 0,
},
chatHeaderLeft: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
aiAvatar: {
width: 44,
height: 44,
borderRadius: "50%",
background: "linear-gradient(135deg, #444, #666)",
border: "3px solid #666",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.3rem",
flexShrink: 0,
},
chatHeaderTitle: {
fontWeight: 700,
fontSize: "1rem",
color: "#1a1a1a",
},
chatHeaderStatus: {
display: "flex",
alignItems: "center",
gap: "0.35rem",
fontSize: "0.8rem",
color: "#4CAF50",
marginTop: 2,
},
statusDot: {
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: "#4CAF50",
},
humanBtn: {
background: "white",
border: "2px solid #ff8c00",
color: "#ff8c00",
borderRadius: 8,
padding: "0.3rem 0.65rem",
fontSize: "0.72rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
liveBtn: {
background: "white",
border: "2px solid #555",
color: "#555",
borderRadius: 8,
padding: "0.45rem 0.9rem",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
agentAvatar: {
width: 44,
height: 44,
borderRadius: "50%",
background: "linear-gradient(135deg, #444, #666)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.3rem",
flexShrink: 0,
},
agentAvatarSmall: {
width: 30,
height: 30,
borderRadius: "50%",
background: "#e0e0e0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.9rem",
flexShrink: 0,
},
closeConvBtn: {
background: "white",
border: "2px solid #c0392b",
color: "#c0392b",
borderRadius: 8,
padding: "0.3rem 0.65rem",
fontSize: "0.72rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
waitingBanner: {
background: "#fff8f0",
borderBottom: "1px solid #ffe0b2",
padding: "0.6rem 1.25rem",
fontSize: "0.85rem",
color: "#7c4a00",
display: "flex",
alignItems: "center",
gap: "0.5rem",
flexShrink: 0,
},
waitingSpinner: {
display: "inline-block",
width: 12,
height: 12,
borderRadius: "50%",
border: "2px solid #ff8c00",
borderTopColor: "transparent",
animation: "spin 0.8s linear infinite",
flexShrink: 0,
},
noConvCard: {
background: "white",
borderRadius: 16,
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
padding: "3rem 2rem",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "1rem",
},
noConvIcon: { fontSize: "3rem" },
noConvTitle: { fontSize: "1.4rem", fontWeight: 700, color: "#1a1a1a", margin: 0 },
noConvText: { color: "#666", fontSize: "0.95rem", maxWidth: 360 },
errorInline: {
background: "#fff0f0",
color: "#c0392b",
border: "1px solid #ffd0d0",
borderRadius: 8,
padding: "0.6rem 1rem",
fontSize: "0.875rem",
width: "100%",
maxWidth: 360,
},
startBtn: {
background: "#333",
color: "white",
border: "none",
borderRadius: 10,
padding: "0.7rem 2rem",
fontSize: "0.95rem",
fontWeight: 600,
cursor: "pointer",
},
backBtn: {
background: "none",
border: "1.5px solid #ff8c00",
color: "#ff8c00",
borderRadius: 10,
padding: "0.6rem 1.5rem",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
},
messagesArea: {
flex: 1,
overflowY: "auto",
padding: "1.25rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
emptyState: {
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "2rem",
margin: "auto",
},
emptyIcon: { fontSize: "3rem", marginBottom: "1rem" },
emptyText: {
color: "#666",
fontSize: "0.95rem",
maxWidth: 400,
lineHeight: 1.6,
},
messageRow: {
display: "flex",
alignItems: "flex-end",
gap: "0.5rem",
},
messageRowUser: { flexDirection: "row-reverse" },
messageRowAgent: { flexDirection: "row" },
aiAvatarSmall: {
width: 30,
height: 30,
borderRadius: "50%",
background: "linear-gradient(135deg, #444, #666)",
border: "3px solid #666",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.85rem",
flexShrink: 0,
},
userAvatarSmall: {
width: 30,
height: 30,
borderRadius: "50%",
background: "#ff8c00",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.8rem",
fontWeight: 700,
flexShrink: 0,
},
messageBubble: {
maxWidth: "72%",
padding: "0.65rem 0.9rem",
borderRadius: 14,
fontSize: "0.92rem",
lineHeight: 1.55,
wordBreak: "break-word",
},
bubbleUser: {
background: "#ff8c00",
color: "white",
borderBottomRightRadius: 4,
},
bubbleAgent: {
background: "#f4f4f4",
color: "#1a1a1a",
borderBottomLeftRadius: 4,
},
timestamp: {
fontSize: "0.7rem",
color: "#aaa",
marginTop: "0.3rem",
textAlign: "left",
},
timestampUser: { textAlign: "right", color: "rgba(255,255,255,0.7)" },
attachment: { marginTop: "0.4rem" },
attachmentLink: {
color: "inherit",
fontSize: "0.85rem",
opacity: 0.85,
},
errorBar: {
background: "#fff0f0",
borderTop: "1px solid #ffd0d0",
color: "#c0392b",
padding: "0.65rem 1.25rem",
fontSize: "0.875rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
},
errorClose: {
background: "none",
border: "none",
color: "#c0392b",
cursor: "pointer",
fontSize: "0.9rem",
padding: "0 0.25rem",
},
closedBanner: {
background: "#f5f5f5",
borderTop: "1px solid #e0e0e0",
color: "#666",
padding: "0.85rem 1.25rem",
fontSize: "0.875rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
},
newConvBtn: {
background: "#333",
color: "white",
border: "none",
borderRadius: 8,
padding: "0.4rem 1rem",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
},
inputArea: {
display: "flex",
flexDirection: "column",
gap: "0.4rem",
padding: "0.85rem 1.25rem",
borderTop: "1px solid #f0f0f0",
background: "#fff",
flexShrink: 0,
},
inputRow: {
display: "flex",
gap: "0.6rem",
alignItems: "flex-end",
},
attachBtn: {
background: "none",
border: "1.5px solid #e0e0e0",
borderRadius: 8,
padding: "0.5rem 0.6rem",
fontSize: "1rem",
cursor: "pointer",
flexShrink: 0,
color: "#666",
lineHeight: 1,
},
filePreview: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
background: "#f8f8f8",
border: "1px solid #e8e8e8",
borderRadius: 8,
padding: "0.35rem 0.75rem",
},
filePreviewName: {
fontSize: "0.82rem",
color: "#555",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
filePreviewRemove: {
background: "none",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8rem",
padding: "0 0.15rem",
flexShrink: 0,
},
textarea: {
flex: 1,
border: "1.5px solid #e0e0e0",
borderRadius: 10,
padding: "0.6rem 0.85rem",
fontSize: "0.92rem",
resize: "none",
outline: "none",
fontFamily: "inherit",
lineHeight: 1.5,
maxHeight: 120,
overflowY: "auto",
},
sendBtn: {
background: "#333",
color: "white",
border: "none",
borderRadius: 10,
padding: "0.6rem 1.2rem",
fontSize: "0.92rem",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
},
sendBtnDisabled: {
background: "#aaa",
cursor: "not-allowed",
},
};
export default dynamic(() => Promise.resolve(AiChatPage), { ssr: false });