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