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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ChatMessageData[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const visitorIdRef = useRef<string>('');
|
||||
|
||||
// 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 */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={toggleChat}
|
||||
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-zinc-100 hover:bg-white text-zinc-900 shadow-lg shadow-black/20 flex items-center justify-center transition-colors"
|
||||
aria-label="Open chat"
|
||||
>
|
||||
<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 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`fixed z-50 bg-zinc-900 border border-zinc-800 shadow-2xl shadow-black/40 flex flex-col overflow-hidden ${
|
||||
isMinimized
|
||||
? 'bottom-6 right-6 w-80 h-14 rounded-full'
|
||||
: 'bottom-6 right-6 w-[380px] h-[600px] max-h-[calc(100vh-3rem)] rounded-2xl sm:max-w-[calc(100vw-3rem)]'
|
||||
}`}
|
||||
style={{
|
||||
// Responsive: full width on very small screens
|
||||
...(typeof window !== 'undefined' && window.innerWidth < 400
|
||||
? { left: '0.75rem', right: '0.75rem', width: 'auto' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 bg-zinc-800/50 border-b border-zinc-800 ${
|
||||
isMinimized ? 'h-full rounded-full' : 'h-14'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-700/50 flex items-center justify-center">
|
||||
<MessageCircle className="w-4 h-4 text-zinc-300" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">AI Assistant</span>
|
||||
{!isMinimized && (
|
||||
<span className="text-xs text-zinc-400">Ask me anything</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Clear Chat Button */}
|
||||
{!isMinimized && messages.length > 0 && (
|
||||
<button
|
||||
onClick={clearChat}
|
||||
className="p-2 rounded-lg hover:bg-zinc-700/50 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Clear chat"
|
||||
title="Clear chat"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className="p-2 rounded-lg hover:bg-zinc-700/50 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container */}
|
||||
{!isMinimized && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Welcome Message for Empty State */}
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<>
|
||||
<WelcomeMessage />
|
||||
|
||||
{/* Suggested Prompts */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-zinc-500 text-center mb-2">
|
||||
Try asking:
|
||||
</p>
|
||||
{SUGGESTED_PROMPTS.map((prompt, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSuggestedPrompt(prompt)}
|
||||
className="w-full px-4 py-2.5 text-sm text-left bg-zinc-800/50 border border-zinc-800 rounded-xl text-zinc-300 hover:bg-zinc-700/50 hover:border-zinc-700 transition-colors"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chat Messages */}
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message.id || index}
|
||||
message={message}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{isLoading && !messages.some((m) => m.isStreaming) && (
|
||||
<TypingIndicator />
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-start gap-2 p-3 bg-red-900/20 border border-red-900/50 rounded-xl"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-xs text-red-500 hover:text-red-400 mt-1"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Scroll anchor */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="p-4 border-t border-zinc-800 bg-zinc-900"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !inputValue.trim()}
|
||||
className="h-11 w-11 bg-zinc-100 hover:bg-white text-zinc-900 rounded-xl flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<p className="text-xs text-zinc-500 text-center mt-2">
|
||||
Messages may be stored to improve service
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chatbot;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Chat components barrel export
|
||||
*/
|
||||
|
||||
export { Chatbot } from './Chatbot';
|
||||
export { ChatMessage, TypingIndicator, WelcomeMessage } from './ChatMessage';
|
||||
export type { ChatMessageData, ChatRole } from './ChatMessage';
|
||||
Reference in New Issue
Block a user