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:
2026-01-25 01:01:12 +01:00
co-authored by Claude Opus 4.5
parent a1cfc33dea
commit abbf5f88f5
2 changed files with 448 additions and 0 deletions
+441
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
/**
* Chat components barrel export
*/
export { Chatbot } from './Chatbot';
export { ChatMessage, TypingIndicator, WelcomeMessage } from './ChatMessage';
export type { ChatMessageData, ChatRole } from './ChatMessage';