Created ChatMessage.tsx component with: - ChatMessage component for displaying user/assistant messages - Styled differently based on role (user right-aligned, assistant left-aligned) - Framer Motion animations for smooth appearance - Streaming indicator for real-time response display - TypingIndicator component for loading state - WelcomeMessage component for empty chat state - Timestamp formatting utility - Responsive design following existing Tailwind patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
172 lines
4.5 KiB
TypeScript
172 lines
4.5 KiB
TypeScript
'use client';
|
|
|
|
import { motion } from 'framer-motion';
|
|
import { User, Bot } from 'lucide-react';
|
|
|
|
export type ChatRole = 'user' | 'assistant' | 'system';
|
|
|
|
export interface ChatMessageData {
|
|
id?: string;
|
|
role: ChatRole;
|
|
content: string;
|
|
created_at?: string;
|
|
isStreaming?: boolean;
|
|
}
|
|
|
|
interface ChatMessageProps {
|
|
message: ChatMessageData;
|
|
index?: number;
|
|
}
|
|
|
|
/**
|
|
* Formats a timestamp for display
|
|
*/
|
|
function formatTime(timestamp?: string): string {
|
|
if (!timestamp) return '';
|
|
|
|
try {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ChatMessage component for displaying individual chat messages
|
|
* Styled differently based on message role (user vs assistant)
|
|
*/
|
|
export function ChatMessage({ message, index = 0 }: ChatMessageProps) {
|
|
const { role, content, created_at, isStreaming } = message;
|
|
|
|
// Don't render system messages
|
|
if (role === 'system') {
|
|
return null;
|
|
}
|
|
|
|
const isUser = role === 'user';
|
|
const formattedTime = formatTime(created_at);
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.05, duration: 0.2 }}
|
|
className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
|
|
>
|
|
{/* Avatar */}
|
|
<div
|
|
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
|
isUser
|
|
? 'bg-zinc-600/50'
|
|
: 'bg-zinc-800/50 border border-zinc-700'
|
|
}`}
|
|
>
|
|
{isUser ? (
|
|
<User className="w-4 h-4 text-zinc-300" />
|
|
) : (
|
|
<Bot className="w-4 h-4 text-zinc-400" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Message Content */}
|
|
<div
|
|
className={`flex flex-col max-w-[80%] ${
|
|
isUser ? 'items-end' : 'items-start'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`px-4 py-3 rounded-2xl ${
|
|
isUser
|
|
? 'bg-zinc-600/50 text-white rounded-tr-sm'
|
|
: 'bg-zinc-800/50 border border-zinc-800 text-zinc-200 rounded-tl-sm'
|
|
}`}
|
|
>
|
|
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
|
{content}
|
|
{isStreaming && (
|
|
<span className="inline-block w-1.5 h-4 ml-1 bg-zinc-400 animate-pulse rounded-sm" />
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Timestamp */}
|
|
{formattedTime && !isStreaming && (
|
|
<span className="text-xs text-zinc-500 mt-1 px-1">
|
|
{formattedTime}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Typing indicator shown when assistant is generating a response
|
|
*/
|
|
export function TypingIndicator() {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
className="flex gap-3"
|
|
>
|
|
{/* Avatar */}
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center bg-zinc-800/50 border border-zinc-700">
|
|
<Bot className="w-4 h-4 text-zinc-400" />
|
|
</div>
|
|
|
|
{/* Typing dots */}
|
|
<div className="px-4 py-3 rounded-2xl rounded-tl-sm bg-zinc-800/50 border border-zinc-800">
|
|
<div className="flex gap-1.5">
|
|
{[0, 1, 2].map((i) => (
|
|
<motion.span
|
|
key={i}
|
|
className="w-2 h-2 bg-zinc-500 rounded-full"
|
|
animate={{
|
|
opacity: [0.4, 1, 0.4],
|
|
scale: [0.8, 1, 0.8],
|
|
}}
|
|
transition={{
|
|
duration: 1,
|
|
repeat: Infinity,
|
|
delay: i * 0.2,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Welcome message component for empty chat state
|
|
*/
|
|
export function WelcomeMessage() {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex flex-col items-center justify-center py-8 px-4 text-center"
|
|
>
|
|
<div className="w-12 h-12 rounded-full bg-zinc-800/50 border border-zinc-700 flex items-center justify-center mb-4">
|
|
<Bot className="w-6 h-6 text-zinc-400" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-white mb-2">
|
|
Hi, I'm Damjan's AI Assistant
|
|
</h3>
|
|
<p className="text-sm text-zinc-400 max-w-xs">
|
|
I can help you learn about Damjan's skills, services, and experience.
|
|
Feel free to ask me anything!
|
|
</p>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
export default ChatMessage;
|