AeThex-OS/client/src/components/Chatbot.tsx
2026-01-03 23:56:43 -07:00

251 lines
8.7 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageCircle, X, Send, Bot, User, Loader2 } from "lucide-react";
import { useLocation } from "wouter";
import { isMobile } from "@/lib/platform";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
export function Chatbot() {
const [location] = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([
{
id: "welcome",
role: "assistant",
content: "AEGIS ONLINE. Security protocols initialized. Neural link established. How can I assist with your security operations today?",
timestamp: new Date(),
},
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Load chat history when opening the chatbot
useEffect(() => {
if (isOpen) {
loadChatHistory();
}
}, [isOpen]);
const loadChatHistory = async () => {
try {
const response = await fetch("/api/chat/history", {
method: "GET",
credentials: "include",
});
if (response.ok) {
const data = await response.json();
if (data.history && data.history.length > 0) {
const historyMessages: Message[] = data.history.map((msg: any) => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: new Date(msg.created_at),
}));
// Replace welcome message with loaded history
setMessages(prev => [...historyMessages, ...prev.slice(1)]);
}
}
} catch (error) {
console.error("Failed to load chat history:", error);
}
};
// Don't render chatbot on the OS page - it has its own environment
if (location === "/os" || isMobile()) {
return null;
}
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
try {
const conversationHistory = messages.slice(-10).map(m => ({
role: m.role,
content: m.content
}));
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
message: userMessage.content,
history: conversationHistory
}),
});
if (!response.ok) throw new Error("Failed to get response");
const data = await response.json();
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: data.response || "I apologize, but I'm having trouble responding right now. Please try again.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: "I'm sorry, I encountered an error. Please try again in a moment.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<>
{/* Chat Button */}
<AnimatePresence>
{!isOpen && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
onClick={() => setIsOpen(true)}
className="fixed bottom-6 left-6 z-50 bg-secondary text-background p-4 rounded-full shadow-lg hover:bg-secondary/90 transition-colors"
data-testid="button-open-chatbot"
>
<MessageCircle className="w-6 h-6" />
</motion.button>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="fixed bottom-6 left-6 z-50 w-96 max-w-[calc(100vw-3rem)] h-[500px] max-h-[70vh] bg-card border border-white/10 shadow-2xl flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-red-500/20 rounded-full flex items-center justify-center">
<Bot className="w-4 h-4 text-red-500" />
</div>
<div>
<div className="text-sm font-bold text-white">AEGIS</div>
<div className="text-xs text-red-500 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse" /> Security Active
</div>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-white transition-colors"
data-testid="button-close-chatbot"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === "user" ? "flex-row-reverse" : ""}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
message.role === "user" ? "bg-primary/20" : "bg-secondary/20"
}`}
>
{message.role === "user" ? (
<User className="w-4 h-4 text-primary" />
) : (
<Bot className="w-4 h-4 text-secondary" />
)}
</div>
<div
className={`max-w-[75%] p-3 text-sm ${
message.role === "user"
? "bg-primary/10 text-white border border-primary/30"
: "bg-white/5 text-muted-foreground border border-white/10"
}`}
>
{message.content}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 bg-secondary/20 rounded-full flex items-center justify-center">
<Bot className="w-4 h-4 text-secondary" />
</div>
<div className="bg-white/5 border border-white/10 p-3 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t border-white/10">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask me anything..."
className="flex-1 bg-black/20 border border-white/10 px-4 py-2 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:border-secondary/50"
data-testid="input-chat-message"
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isLoading}
className="bg-secondary text-background px-4 py-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary/90 transition-colors"
data-testid="button-send-message"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}