From abbf5f88f5b05159748489b0b0cdcf58b1927167 Mon Sep 17 00:00:00 2001 From: Damjan Savic Date: Sun, 25 Jan 2026 01:01:12 +0100 Subject: [PATCH] auto-claude: subtask-5-2 - Create main Chatbot component with floating UI and message handling - Created Chatbot.tsx with floating chat bubble UI - Implemented streaming SSE message handling - Added visitor ID persistence via localStorage for session continuity - Integrated with /api/chat endpoint for AI responses - Added suggested prompts for empty chat state - Implemented error handling with retry functionality - Added clear chat functionality - Created responsive design with mobile support - Created index.ts barrel export for chat components Co-Authored-By: Claude Opus 4.5 --- src/components/chat/Chatbot.tsx | 441 ++++++++++++++++++++++++++++++++ src/components/chat/index.ts | 7 + 2 files changed, 448 insertions(+) create mode 100644 src/components/chat/Chatbot.tsx create mode 100644 src/components/chat/index.ts diff --git a/src/components/chat/Chatbot.tsx b/src/components/chat/Chatbot.tsx new file mode 100644 index 0000000..0016d51 --- /dev/null +++ b/src/components/chat/Chatbot.tsx @@ -0,0 +1,441 @@ +'use client'; + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { MessageCircle, X, Send, Loader2, AlertCircle, RotateCcw } from 'lucide-react'; +import { ChatMessage, ChatMessageData, TypingIndicator, WelcomeMessage } from './ChatMessage'; + +// Generate a unique visitor ID for session persistence +function generateVisitorId(): string { + // Check if we already have an ID in localStorage + if (typeof window !== 'undefined') { + const existingId = localStorage.getItem('chatbot_visitor_id'); + if (existingId) { + return existingId; + } + // Generate a new UUID-like ID + const newId = `visitor_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; + localStorage.setItem('chatbot_visitor_id', newId); + return newId; + } + return `visitor_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; +} + +// Suggested prompts for empty chat state +const SUGGESTED_PROMPTS = [ + 'What services do you offer?', + 'Tell me about your experience', + 'What technologies do you work with?', +]; + +interface StreamChunk { + content?: string; + done?: boolean; + error?: string; + sessionId?: string; +} + +export function Chatbot() { + // UI state + const [isOpen, setIsOpen] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); + + // Chat state + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [sessionId, setSessionId] = useState(null); + + // Refs + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const visitorIdRef = useRef(''); + + // Initialize visitor ID on mount + useEffect(() => { + visitorIdRef.current = generateVisitorId(); + }, []); + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + // Focus input when chat opens + useEffect(() => { + if (isOpen && !isMinimized && inputRef.current) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isOpen, isMinimized]); + + // Load chat history when opening + const loadChatHistory = useCallback(async () => { + if (!visitorIdRef.current) return; + + try { + const response = await fetch(`/api/chat?visitorId=${encodeURIComponent(visitorIdRef.current)}`); + if (response.ok) { + const data = await response.json(); + if (data.messages && data.messages.length > 0) { + setMessages(data.messages); + } + if (data.sessionId) { + setSessionId(data.sessionId); + } + } + } catch { + // Silently fail - user can still start a new conversation + } + }, []); + + // Load history when chat opens + useEffect(() => { + if (isOpen) { + loadChatHistory(); + } + }, [isOpen, loadChatHistory]); + + // Send a message to the API + const sendMessage = useCallback(async (messageText: string) => { + if (!messageText.trim() || isLoading) return; + + const userMessage: ChatMessageData = { + id: `user_${Date.now()}`, + role: 'user', + content: messageText.trim(), + created_at: new Date().toISOString(), + }; + + // Add user message immediately + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setError(null); + setIsLoading(true); + + // Create placeholder for streaming response + const assistantMessageId = `assistant_${Date.now()}`; + const assistantMessage: ChatMessageData = { + id: assistantMessageId, + role: 'assistant', + content: '', + isStreaming: true, + }; + + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: messageText.trim(), + visitorId: visitorIdRef.current, + sessionId: sessionId, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to send message'); + } + + // Add assistant message placeholder for streaming + setMessages((prev) => [...prev, assistantMessage]); + + // Handle streaming response + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response stream available'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE events + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data: StreamChunk = JSON.parse(line.slice(6)); + + if (data.error) { + throw new Error(data.error); + } + + if (data.sessionId && !sessionId) { + setSessionId(data.sessionId); + } + + if (data.content) { + // Update the streaming message content + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessageId + ? { ...msg, content: msg.content + data.content } + : msg + ) + ); + } + + if (data.done) { + // Mark message as no longer streaming + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessageId + ? { ...msg, isStreaming: false, created_at: new Date().toISOString() } + : msg + ) + ); + } + } catch (parseError) { + // Ignore parse errors for incomplete chunks + } + } + } + } + } catch (err) { + // Remove the placeholder message on error + setMessages((prev) => prev.filter((msg) => msg.id !== assistantMessageId)); + setError(err instanceof Error ? err.message : 'Failed to send message'); + } finally { + setIsLoading(false); + } + }, [isLoading, sessionId]); + + // Handle form submission + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sendMessage(inputValue); + }; + + // Handle suggested prompt click + const handleSuggestedPrompt = (prompt: string) => { + sendMessage(prompt); + }; + + // Clear chat history + const clearChat = () => { + setMessages([]); + setError(null); + setSessionId(null); + // Generate new visitor ID for fresh session + const newId = `visitor_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; + localStorage.setItem('chatbot_visitor_id', newId); + visitorIdRef.current = newId; + }; + + // Toggle chat open/close + const toggleChat = () => { + if (isOpen) { + setIsOpen(false); + setIsMinimized(false); + } else { + setIsOpen(true); + setIsMinimized(false); + } + }; + + return ( + <> + {/* Floating Action Button */} + + {!isOpen && ( + + + + )} + + + {/* Chat Window */} + + {isOpen && ( + + {/* Header */} +
+
+
+ +
+
+ AI Assistant + {!isMinimized && ( + Ask me anything + )} +
+
+ +
+ {/* Clear Chat Button */} + {!isMinimized && messages.length > 0 && ( + + )} + + {/* Close Button */} + +
+
+ + {/* Messages Container */} + {!isMinimized && ( + <> +
+ {/* Welcome Message for Empty State */} + {messages.length === 0 && !isLoading && ( + <> + + + {/* Suggested Prompts */} +
+

+ Try asking: +

+ {SUGGESTED_PROMPTS.map((prompt, index) => ( + + ))} +
+ + )} + + {/* Chat Messages */} + {messages.map((message, index) => ( + + ))} + + {/* Typing Indicator */} + {isLoading && !messages.some((m) => m.isStreaming) && ( + + )} + + {/* Error Message */} + {error && ( + + +
+

{error}

+ +
+
+ )} + + {/* Scroll anchor */} +
+
+ + {/* Input Form */} +
+
+ setInputValue(e.target.value)} + placeholder="Type a message..." + disabled={isLoading} + className="flex-1 h-11 px-4 bg-zinc-800/50 border border-zinc-800 rounded-xl text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50" + aria-label="Chat message input" + /> + +
+ + {/* Privacy note */} +

+ Messages may be stored to improve service +

+
+ + )} + + )} + + + ); +} + +export default Chatbot; diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts new file mode 100644 index 0000000..a84dddb --- /dev/null +++ b/src/components/chat/index.ts @@ -0,0 +1,7 @@ +/** + * Chat components barrel export + */ + +export { Chatbot } from './Chatbot'; +export { ChatMessage, TypingIndicator, WelcomeMessage } from './ChatMessage'; +export type { ChatMessageData, ChatRole } from './ChatMessage';