|
|
'use client'; |
|
|
|
|
|
import { useState, useRef, useEffect } from 'react'; |
|
|
import type { Message } from '@/types'; |
|
|
import ReactMarkdown from 'react-markdown'; |
|
|
import remarkGfm from 'remark-gfm'; |
|
|
import Image from 'next/image'; |
|
|
|
|
|
interface ChatInterfaceProps { |
|
|
messages: Message[]; |
|
|
onSendMessage: (message: string, imageUrl?: string) => void; |
|
|
isGenerating: boolean; |
|
|
isAuthenticated?: boolean; |
|
|
supportsImages?: boolean; |
|
|
} |
|
|
|
|
|
export default function ChatInterface({ messages, onSendMessage, isGenerating, isAuthenticated = false, supportsImages = false }: ChatInterfaceProps) { |
|
|
const [input, setInput] = useState(''); |
|
|
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null); |
|
|
const [uploadedImageFile, setUploadedImageFile] = useState<File | null>(null); |
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
const messagesEndRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
const scrollToBottom = () => { |
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
scrollToBottom(); |
|
|
}, [messages]); |
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => { |
|
|
e.preventDefault(); |
|
|
if (input.trim() && !isGenerating) { |
|
|
onSendMessage(input, uploadedImageUrl || undefined); |
|
|
setInput(''); |
|
|
setUploadedImageUrl(null); |
|
|
setUploadedImageFile(null); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
|
const file = e.target.files?.[0]; |
|
|
if (file) { |
|
|
|
|
|
const reader = new FileReader(); |
|
|
reader.onload = (event) => { |
|
|
const imageUrl = event.target?.result as string; |
|
|
setUploadedImageUrl(imageUrl); |
|
|
setUploadedImageFile(file); |
|
|
}; |
|
|
reader.readAsDataURL(file); |
|
|
} |
|
|
}; |
|
|
|
|
|
const removeImage = () => { |
|
|
setUploadedImageUrl(null); |
|
|
setUploadedImageFile(null); |
|
|
if (fileInputRef.current) { |
|
|
fileInputRef.current.value = ''; |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="flex flex-col h-full bg-[#000000]"> |
|
|
{/* Messages */} |
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3"> |
|
|
{messages.length === 0 ? ( |
|
|
<div className="text-center text-[#86868b] mt-12"> |
|
|
{isAuthenticated ? ( |
|
|
<> |
|
|
<p className="text-lg font-medium text-[#f5f5f7]">Start a conversation</p> |
|
|
<p className="text-sm mt-2 text-[#86868b]">Describe what you want to build</p> |
|
|
</> |
|
|
) : ( |
|
|
<> |
|
|
<p className="text-lg font-medium text-[#f5f5f7]">Sign in to get started</p> |
|
|
<p className="text-sm mt-2 text-[#86868b]">Use Dev Login or sign in with Hugging Face</p> |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
) : ( |
|
|
messages.map((message, index) => ( |
|
|
<div |
|
|
key={index} |
|
|
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`} |
|
|
> |
|
|
<div |
|
|
className={`max-w-[85%] rounded-2xl px-4 py-3 ${ |
|
|
message.role === 'user' |
|
|
? 'bg-white text-black' |
|
|
: 'bg-[#2d2d2f] text-[#f5f5f7]' |
|
|
}`} |
|
|
> |
|
|
{message.image_url && message.role === 'user' && ( |
|
|
<div className="mb-2"> |
|
|
<Image |
|
|
src={message.image_url} |
|
|
alt="Uploaded image" |
|
|
width={200} |
|
|
height={200} |
|
|
className="rounded-lg object-cover max-w-full h-auto" |
|
|
unoptimized |
|
|
/> |
|
|
</div> |
|
|
)} |
|
|
<div className="text-sm leading-relaxed"> |
|
|
{message.role === 'assistant' ? ( |
|
|
<ReactMarkdown |
|
|
remarkPlugins={[remarkGfm]} |
|
|
className="prose prose-invert prose-sm max-w-none [&>p]:my-0 [&>ul]:my-1 [&>ol]:my-1" |
|
|
components={{ |
|
|
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> |
|
|
}} |
|
|
> |
|
|
{message.content} |
|
|
</ReactMarkdown> |
|
|
) : ( |
|
|
<p className="whitespace-pre-wrap break-words">{message.content}</p> |
|
|
)} |
|
|
</div> |
|
|
{message.timestamp && ( |
|
|
<div className="text-[10px] opacity-40 mt-2 text-right"> |
|
|
{new Date(message.timestamp).toLocaleTimeString()} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
)) |
|
|
)} |
|
|
<div ref={messagesEndRef} /> |
|
|
</div> |
|
|
|
|
|
{} |
|
|
<div className="border-t border-[#424245]/30 p-3 bg-[#000000]"> |
|
|
{} |
|
|
{uploadedImageUrl && ( |
|
|
<div className="mb-2 relative inline-block"> |
|
|
<Image |
|
|
src={uploadedImageUrl} |
|
|
alt="Upload preview" |
|
|
width={120} |
|
|
height={120} |
|
|
className="rounded-lg object-cover" |
|
|
unoptimized |
|
|
/> |
|
|
<button |
|
|
type="button" |
|
|
onClick={removeImage} |
|
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full hover:bg-red-600 transition-all flex items-center justify-center text-xs font-bold" |
|
|
> |
|
|
× |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<form onSubmit={handleSubmit} className="flex items-center gap-2"> |
|
|
<input |
|
|
type="text" |
|
|
value={input} |
|
|
onChange={(e) => setInput(e.target.value)} |
|
|
placeholder={isAuthenticated ? "Message AnyCoder..." : "Sign in first..."} |
|
|
disabled={isGenerating || !isAuthenticated} |
|
|
className="flex-1 px-4 py-2.5 bg-[#2d2d2f] text-[#f5f5f7] text-sm border border-[#424245]/50 rounded-full focus:outline-none focus:border-[#424245] disabled:opacity-40 disabled:cursor-not-allowed placeholder-[#86868b]" |
|
|
/> |
|
|
|
|
|
{} |
|
|
{supportsImages && ( |
|
|
<> |
|
|
<input |
|
|
ref={fileInputRef} |
|
|
type="file" |
|
|
accept="image/*" |
|
|
onChange={handleImageUpload} |
|
|
className="hidden" |
|
|
disabled={isGenerating || !isAuthenticated} |
|
|
/> |
|
|
<button |
|
|
type="button" |
|
|
onClick={() => fileInputRef.current?.click()} |
|
|
disabled={isGenerating || !isAuthenticated} |
|
|
className="p-2.5 bg-[#2d2d2f] text-[#f5f5f7] rounded-full hover:bg-[#424245] disabled:opacity-40 disabled:cursor-not-allowed transition-all active:scale-95 flex-shrink-0" |
|
|
title="Upload image" |
|
|
> |
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /> |
|
|
</svg> |
|
|
</button> |
|
|
</> |
|
|
)} |
|
|
|
|
|
<button |
|
|
type="submit" |
|
|
disabled={isGenerating || !input.trim() || !isAuthenticated} |
|
|
className="p-2.5 bg-white text-black rounded-full hover:bg-[#f5f5f7] disabled:bg-[#2d2d2f] disabled:text-[#86868b] disabled:cursor-not-allowed transition-all active:scale-95 flex-shrink-0" |
|
|
> |
|
|
{isGenerating ? ( |
|
|
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> |
|
|
</svg> |
|
|
) : ( |
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14M12 5l7 7-7 7" /> |
|
|
</svg> |
|
|
)} |
|
|
</button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
|