Spaces:
Running
Running
| "use client"; | |
| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| Send, Sparkles, TrendingUp, Shield, PieChart, | |
| Zap, Copy, ThumbsUp, ThumbsDown, Trash2, Mic, | |
| Paperclip, FileText, ImageIcon, X, Upload, | |
| CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, | |
| FileSpreadsheet, File, | |
| } from "lucide-react"; | |
| import { aiApi, memoryApi, documentsApi } from "@/lib/api"; | |
| import { useAuthStore } from "@/lib/stores/authStore"; | |
| import { useLanguageStore } from "@/lib/stores/languageStore"; | |
| import { useThemeStore } from "@/lib/stores/themeStore"; | |
| // ─── Types ──────────────────────────────────────────────────────────────────── | |
| interface AttachedFile { | |
| file: File; | |
| previewUrl?: string; // for images | |
| status: "pending" | "uploading" | "done" | "error"; | |
| docId?: string; // backend document id after upload | |
| summary?: string; | |
| insights?: string[]; | |
| errorMsg?: string; | |
| } | |
| interface Message { | |
| id: string; | |
| role: "user" | "assistant"; | |
| content: string; | |
| streaming?: boolean; | |
| timestamp: Date; | |
| // doc context attached to this user message | |
| attachments?: Array<{ | |
| name: string; | |
| fileType: string; | |
| docId: string; | |
| summary?: string; | |
| insights?: string[]; | |
| }>; | |
| // if this was a doc-context answer | |
| docMode?: boolean; | |
| } | |
| // ─── Accepted types ─────────────────────────────────────────────────────────── | |
| const ACCEPTED = ".pdf,.docx,.txt,.csv,.png,.jpg,.jpeg,.webp"; | |
| const IMAGE_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; | |
| const DOC_TYPES = [ | |
| "application/pdf", | |
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | |
| "text/plain", | |
| "text/csv", | |
| ]; | |
| // ─── Suggestions per language ───────────────────────────────────────────────── | |
| const SUGGESTIONS = { | |
| en: [ | |
| { icon: TrendingUp, label: "What's my total balance?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" }, | |
| { icon: PieChart, label: "Analyze my spending this month", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" }, | |
| { icon: Shield, label: "Explain my fraud alerts", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" }, | |
| { icon: Zap, label: "Give me a savings nudge", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" }, | |
| ], | |
| hi: [ | |
| { icon: TrendingUp, label: "मेरा कुल बैलेंस क्या है?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" }, | |
| { icon: PieChart, label: "इस महीने मेरा खर्च विश्लेषण करें", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" }, | |
| { icon: Shield, label: "मेरे फ्रॉड अलर्ट समझाएं", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" }, | |
| { icon: Zap, label: "बचत के लिए सुझाव दें", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" }, | |
| ], | |
| mr: [ | |
| { icon: TrendingUp, label: "माझी एकूण शिल्लक किती आहे?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" }, | |
| { icon: PieChart, label: "या महिन्याचा खर्च विश्लेषण करा", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" }, | |
| { icon: Shield, label: "माझे फसवणूक अलर्ट सांगा", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" }, | |
| { icon: Zap, label: "बचतीसाठी सल्ला द्या", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" }, | |
| ], | |
| }; | |
| // ─── File icon helper ───────────────────────────────────────────────────────── | |
| function FileIcon({ type, className }: { type: string; className?: string }) { | |
| if (IMAGE_TYPES.includes(type)) return <ImageIcon className={className} />; | |
| if (type === "text/csv") return <FileSpreadsheet className={className} />; | |
| if (type.includes("pdf")) return <FileText className={className} />; | |
| return <File className={className} />; | |
| } | |
| function fileTypeLabel(type: string, name: string): string { | |
| const ext = name.split(".").pop()?.toUpperCase() ?? "FILE"; | |
| if (IMAGE_TYPES.includes(type)) return "IMAGE"; | |
| return ext; | |
| } | |
| function formatBytes(b: number) { | |
| if (b < 1024) return `${b} B`; | |
| if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; | |
| return `${(b / (1024 * 1024)).toFixed(1)} MB`; | |
| } | |
| // ─── AI Orb ─────────────────────────────────────────────────────────────────── | |
| function AIOrb({ isThinking }: { isThinking: boolean }) { | |
| return ( | |
| <div className="relative flex items-center justify-center"> | |
| <motion.div | |
| animate={{ scale: isThinking ? [1,1.3,1] : [1,1.1,1], opacity: isThinking ? [0.3,0.6,0.3] : [0.2,0.4,0.2] }} | |
| transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }} | |
| className="absolute h-24 w-24 rounded-full border border-emerald-500/30" | |
| /> | |
| <motion.div | |
| animate={{ scale: isThinking ? [1,1.5,1] : [1,1.15,1], opacity: isThinking ? [0.2,0.4,0.2] : [0.1,0.2,0.1] }} | |
| transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut", delay: 0.2 }} | |
| className="absolute h-32 w-32 rounded-full border border-blue-500/20" | |
| /> | |
| <motion.div | |
| animate={{ | |
| boxShadow: isThinking | |
| ? ["0 0 20px rgba(16,185,129,0.6),0 0 60px rgba(59,130,246,0.4)","0 0 40px rgba(16,185,129,0.8),0 0 80px rgba(59,130,246,0.5)","0 0 20px rgba(16,185,129,0.6),0 0 60px rgba(59,130,246,0.4)"] | |
| : ["0 0 20px rgba(16,185,129,0.3),0 0 40px rgba(59,130,246,0.2)","0 0 30px rgba(16,185,129,0.5),0 0 60px rgba(59,130,246,0.3)","0 0 20px rgba(16,185,129,0.3),0 0 40px rgba(59,130,246,0.2)"], | |
| }} | |
| transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }} | |
| className="relative h-16 w-16 rounded-full bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 flex items-center justify-center" | |
| > | |
| <motion.div animate={{ rotate: isThinking ? 360 : 0 }} transition={{ duration: 2, repeat: isThinking ? Infinity : 0, ease: "linear" }}> | |
| <Sparkles className="h-7 w-7 text-white" /> | |
| </motion.div> | |
| </motion.div> | |
| </div> | |
| ); | |
| } | |
| // ─── Attachment Card (inside input area) ───────────────────────────────────── | |
| function AttachmentChip({ file, onRemove, isLight }: { file: AttachedFile; onRemove: () => void; isLight: boolean }) { | |
| const isImg = IMAGE_TYPES.includes(file.file.type); | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.85 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.85 }} | |
| className={`relative flex items-center gap-2 rounded-xl border px-3 py-2 text-xs max-w-[200px] ${ | |
| isLight ? "bg-slate-50 border-slate-200" : "bg-white/8 border-white/10" | |
| }`} | |
| > | |
| {/* Status indicator */} | |
| {file.status === "uploading" && ( | |
| <motion.div | |
| animate={{ rotate: 360 }} | |
| transition={{ duration: 1, repeat: Infinity, ease: "linear" }} | |
| className="h-4 w-4 border-2 border-emerald-500 border-t-transparent rounded-full flex-shrink-0" | |
| /> | |
| )} | |
| {file.status === "done" && <CheckCircle2 className="h-4 w-4 text-emerald-500 flex-shrink-0" />} | |
| {file.status === "error" && <AlertTriangle className="h-4 w-4 text-red-500 flex-shrink-0" />} | |
| {file.status === "pending" && ( | |
| isImg | |
| ? <ImageIcon className="h-4 w-4 text-blue-400 flex-shrink-0" /> | |
| : <FileText className="h-4 w-4 text-purple-400 flex-shrink-0" /> | |
| )} | |
| {/* Preview thumb for images */} | |
| {isImg && file.previewUrl && ( | |
| // eslint-disable-next-line @next/next/no-img-element | |
| <img src={file.previewUrl} alt="preview" className="h-6 w-6 rounded object-cover flex-shrink-0" /> | |
| )} | |
| <span className="truncate font-medium" style={{ color: "var(--fg)", maxWidth: 100 }}> | |
| {file.file.name} | |
| </span> | |
| <span style={{ color: "var(--fg-subtle)" }}>{formatBytes(file.file.size)}</span> | |
| {file.status !== "uploading" && ( | |
| <button | |
| onClick={onRemove} | |
| className="ml-auto flex-shrink-0 rounded-full p-0.5 hover:bg-red-500/10 transition-colors" | |
| > | |
| <X className="h-3 w-3 text-red-400" /> | |
| </button> | |
| )} | |
| </motion.div> | |
| ); | |
| } | |
| // ─── Document Badge (in message bubble) ────────────────────────────────────── | |
| function DocBadge({ | |
| name, fileType, summary, insights, isLight, | |
| }: { | |
| name: string; | |
| fileType: string; | |
| summary?: string; | |
| insights?: string[]; | |
| isLight: boolean; | |
| }) { | |
| const [expanded, setExpanded] = useState(false); | |
| const isImg = ["png", "jpg", "jpeg", "webp"].includes(fileType.toLowerCase()); | |
| return ( | |
| <div | |
| className={`rounded-xl border text-xs overflow-hidden ${ | |
| isLight ? "bg-purple-50 border-purple-200/80" : "bg-purple-500/10 border-purple-500/20" | |
| }`} | |
| > | |
| <div className="flex items-center gap-2 px-3 py-2"> | |
| {isImg | |
| ? <ImageIcon className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" /> | |
| : <FileText className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />} | |
| <span className="font-semibold text-purple-600 truncate flex-1">{name}</span> | |
| <span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${ | |
| isLight ? "bg-purple-100 text-purple-600" : "bg-purple-500/20 text-purple-400" | |
| }`}> | |
| {fileType.toUpperCase()} | |
| </span> | |
| {(summary || (insights && insights.length > 0)) && ( | |
| <button | |
| onClick={() => setExpanded(v => !v)} | |
| className="text-purple-500 ml-1" | |
| > | |
| {expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />} | |
| </button> | |
| )} | |
| </div> | |
| <AnimatePresence> | |
| {expanded && (summary || (insights && insights.length > 0)) && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: "auto", opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className={`border-t px-3 py-2 space-y-1.5 ${ | |
| isLight ? "border-purple-200/80" : "border-purple-500/20" | |
| }`} | |
| > | |
| {summary && ( | |
| <p style={{ color: "var(--fg-muted)" }}>{summary}</p> | |
| )} | |
| {insights && insights.length > 0 && ( | |
| <ul className="space-y-0.5"> | |
| {insights.map((ins, i) => ( | |
| <li key={i} className="flex gap-1.5" style={{ color: "var(--fg-muted)" }}> | |
| <span className="text-purple-500 flex-shrink-0">•</span> | |
| <span>{ins}</span> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |
| // ─── Message Bubble ─────────────────────────────────────────────────────────── | |
| function MessageBubble({ | |
| message, onCopy, userInitial, isLight, | |
| }: { | |
| message: Message; | |
| onCopy: (t: string) => void; | |
| userInitial: string; | |
| isLight: boolean; | |
| }) { | |
| const isUser = message.role === "user"; | |
| const renderContent = (text: string) => | |
| text.split("\n").map((line, i) => { | |
| if (line.startsWith("**") && line.endsWith("**")) | |
| return <p key={i} className="font-semibold" style={{ color: "var(--fg)" }}>{line.slice(2, -2)}</p>; | |
| if (line.startsWith("- ")) | |
| return <p key={i} className="ml-2" style={{ color: "var(--fg-muted)" }}>• {line.slice(2)}</p>; | |
| if (line.startsWith("# ")) | |
| return <p key={i} className="font-bold text-base mt-1" style={{ color: "var(--fg)" }}>{line.slice(2)}</p>; | |
| return ( | |
| <p key={i} className={line === "" ? "h-2" : ""} style={{ color: isUser ? "white" : "var(--fg-muted)" }}> | |
| {line} | |
| </p> | |
| ); | |
| }); | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10, scale: 0.98 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| transition={{ duration: 0.3, ease: "easeOut" }} | |
| className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`} | |
| > | |
| {/* Avatar */} | |
| <div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${ | |
| isUser | |
| ? "bg-gradient-to-br from-blue-500 to-purple-600 text-white" | |
| : "bg-gradient-to-br from-emerald-400 to-cyan-500" | |
| }`}> | |
| {isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />} | |
| </div> | |
| {/* Content */} | |
| <div className={`max-w-[78%] flex flex-col gap-2 ${isUser ? "items-end" : "items-start"}`}> | |
| {/* Attachment badges (user side) */} | |
| {isUser && message.attachments && message.attachments.length > 0 && ( | |
| <div className="flex flex-col gap-1.5 w-full"> | |
| {message.attachments.map((att) => ( | |
| <DocBadge | |
| key={att.docId} | |
| name={att.name} | |
| fileType={att.fileType} | |
| summary={att.summary} | |
| insights={att.insights} | |
| isLight={isLight} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Doc-mode label on AI answer */} | |
| {!isUser && message.docMode && ( | |
| <div className="flex items-center gap-1.5 text-[10px] text-purple-500"> | |
| <FileText className="h-3 w-3" /> | |
| Document Analysis | |
| </div> | |
| )} | |
| {/* Bubble */} | |
| {message.content && ( | |
| <div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${ | |
| isUser | |
| ? "bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-tr-sm" | |
| : isLight | |
| ? "bg-white border border-slate-200 rounded-tl-sm" | |
| : "glass border border-white/10 rounded-tl-sm" | |
| }`}> | |
| <div className="whitespace-pre-wrap">{renderContent(message.content)}</div> | |
| {message.streaming && <span className="streaming-cursor" />} | |
| </div> | |
| )} | |
| {/* Action row for AI messages */} | |
| {!isUser && !message.streaming && message.content && ( | |
| <div className="flex items-center gap-1 px-1"> | |
| {[ | |
| { icon: Copy, label: "Copy", action: () => onCopy(message.content) }, | |
| { icon: ThumbsUp, label: "Good", action: () => {} }, | |
| { icon: ThumbsDown, label: "Bad", action: () => {} }, | |
| ].map(({ icon: Icon, label, action }) => ( | |
| <button | |
| key={label} title={label} onClick={action} | |
| className="p-1 rounded-lg transition-all hover:bg-white/5" | |
| style={{ color: "var(--fg-subtle)" }} | |
| > | |
| <Icon className="h-3 w-3" /> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </motion.div> | |
| ); | |
| } | |
| // ─── Main Chat Page ─────────────────────────────────────────────────────────── | |
| export default function ChatPage() { | |
| const { user } = useAuthStore(); | |
| const { language, t } = useLanguageStore(); | |
| const { theme } = useThemeStore(); | |
| const isLight = theme === "light"; | |
| const userInitial = user?.name?.charAt(0).toUpperCase() ?? "U"; | |
| const suggestions = SUGGESTIONS[language as keyof typeof SUGGESTIONS] ?? SUGGESTIONS.en; | |
| const [sessionId, setSessionId] = useState<string | null>(() => | |
| typeof window !== "undefined" ? localStorage.getItem("bb_chat_session") : null | |
| ); | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [historyLoaded, setHistoryLoaded] = useState(false); | |
| const [input, setInput] = useState(""); | |
| const [isThinking, setIsThinking] = useState(false); | |
| const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const inputRef = useRef<HTMLTextAreaElement>(null); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const abortRef = useRef<AbortController | null>(null); | |
| // Auto-scroll | |
| useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); | |
| // Load history on mount | |
| useEffect(() => { | |
| if (historyLoaded) return; | |
| const load = async () => { | |
| try { | |
| const res = await memoryApi.history(sessionId ?? undefined); | |
| if (res.messages.length > 0) { | |
| setMessages(res.messages.map((m) => ({ | |
| id: m.id, | |
| role: m.role as "user" | "assistant", | |
| content: m.content, | |
| timestamp: new Date(m.created_at), | |
| }))); | |
| if (res.messages[0].session_id) { | |
| setSessionId(res.messages[0].session_id); | |
| localStorage.setItem("bb_chat_session", res.messages[0].session_id); | |
| } | |
| } else { | |
| setMessages([{ | |
| id: "welcome", | |
| role: "assistant", | |
| content: | |
| language === "hi" | |
| ? "नमस्ते! मैं आपका AI वित्तीय सहायक हूँ। आप बैंक स्टेटमेंट, इनवॉइस, CSV या कोई भी दस्तावेज़ अटैच करके उसके बारे में पूछ सकते हैं।\n\nआज आप क्या जानना चाहते हैं?" | |
| : language === "mr" | |
| ? "नमस्कार! मी तुमचा AI आर्थिक सहाय्यक आहे. बँक स्टेटमेंट, इनव्हॉइस किंवा कोणतेही कागदपत्र अटॅच करा आणि प्रश्न विचारा.\n\nआज तुम्हाला काय जाणून घ्यायचे आहे?" | |
| : "Hello! I'm your AI financial assistant.\n\nYou can **attach files** (PDF, DOCX, CSV, images) directly here and ask me anything about them — bank statements, invoices, contracts, and more.\n\nWhat would you like to explore today?", | |
| timestamp: new Date(), | |
| }]); | |
| } | |
| } catch { | |
| setMessages([{ | |
| id: "welcome", | |
| role: "assistant", | |
| content: "Hello! I'm your AI financial assistant. Attach any document and ask me about it, or ask about your finances.", | |
| timestamp: new Date(), | |
| }]); | |
| } | |
| setHistoryLoaded(true); | |
| }; | |
| load(); | |
| }, []); // eslint-disable-line react-hooks/exhaustive-deps | |
| // ── Upload a single file → returns docId + analysis ────────────────────── | |
| const uploadFile = useCallback(async (af: AttachedFile): Promise<AttachedFile> => { | |
| try { | |
| setAttachedFiles((prev) => | |
| prev.map((f) => f.file === af.file ? { ...f, status: "uploading" } : f) | |
| ); | |
| const result = await documentsApi.upload(af.file, language); | |
| const updated: AttachedFile = { | |
| ...af, | |
| status: "done", | |
| docId: result.id, | |
| summary: result.summary, | |
| insights: result.insights, | |
| }; | |
| setAttachedFiles((prev) => | |
| prev.map((f) => f.file === af.file ? updated : f) | |
| ); | |
| return updated; | |
| } catch (err) { | |
| const updated: AttachedFile = { | |
| ...af, | |
| status: "error", | |
| errorMsg: (err as Error).message || "Upload failed", | |
| }; | |
| setAttachedFiles((prev) => | |
| prev.map((f) => f.file === af.file ? updated : f) | |
| ); | |
| return updated; | |
| } | |
| }, [language]); | |
| // ── Add files (from input or drop) ─────────────────────────────────────── | |
| const addFiles = useCallback((files: FileList | File[]) => { | |
| const arr = Array.from(files); | |
| const valid = arr.filter((f) => { | |
| const ok = [...IMAGE_TYPES, ...DOC_TYPES].includes(f.type) || | |
| ["pdf","docx","txt","csv","png","jpg","jpeg","webp"].some(ext => | |
| f.name.toLowerCase().endsWith(`.${ext}`) | |
| ); | |
| return ok && f.size <= 10 * 1024 * 1024; | |
| }); | |
| if (valid.length === 0) return; | |
| const newFiles: AttachedFile[] = valid.map((f) => ({ | |
| file: f, | |
| previewUrl: IMAGE_TYPES.includes(f.type) ? URL.createObjectURL(f) : undefined, | |
| status: "pending", | |
| })); | |
| setAttachedFiles((prev) => { | |
| const updated = [...prev, ...newFiles]; | |
| // Kick off uploads immediately | |
| newFiles.forEach((af) => { | |
| // slight delay so state settles | |
| setTimeout(() => uploadFile(af), 100); | |
| }); | |
| return updated; | |
| }); | |
| }, [uploadFile]); | |
| // ── Drag-and-drop ───────────────────────────────────────────────────────── | |
| const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; | |
| const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; | |
| const handleDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); | |
| }; | |
| // ── Streaming word-by-word ─────────────────────────────────────────────── | |
| const streamWords = useCallback((msgId: string, fullText: string) => { | |
| const words = fullText.split(" "); | |
| let i = 0; | |
| setMessages((prev) => prev.map((m) => m.id === msgId ? { ...m, streaming: true } : m)); | |
| const interval = setInterval(() => { | |
| if (i >= words.length) { | |
| clearInterval(interval); | |
| setMessages((prev) => prev.map((m) => m.id === msgId ? { ...m, streaming: false } : m)); | |
| return; | |
| } | |
| setMessages((prev) => | |
| prev.map((m) => m.id === msgId ? { ...m, content: words.slice(0, i + 1).join(" ") } : m) | |
| ); | |
| i++; | |
| }, 28); | |
| }, []); | |
| // ── Send message ───────────────────────────────────────────────────────── | |
| const sendMessage = useCallback(async (text?: string) => { | |
| const content = (text ?? input).trim(); | |
| // Need either text or a completed doc | |
| const doneDocs = attachedFiles.filter((f) => f.status === "done" && f.docId); | |
| if (!content && doneDocs.length === 0) return; | |
| if (isThinking) return; | |
| abortRef.current?.abort(); | |
| abortRef.current = new AbortController(); | |
| setInput(""); | |
| setIsThinking(true); | |
| // Build user message | |
| const userMsg: Message = { | |
| id: `user-${Date.now()}`, | |
| role: "user", | |
| content, | |
| timestamp: new Date(), | |
| attachments: doneDocs.map((f) => ({ | |
| name: f.file.name, | |
| fileType: f.file.name.split(".").pop() ?? "file", | |
| docId: f.docId!, | |
| summary: f.summary, | |
| insights: f.insights, | |
| })), | |
| }; | |
| setMessages((prev) => [...prev, userMsg]); | |
| // Clear attached files after sending | |
| setAttachedFiles([]); | |
| const aiId = `ai-${Date.now()}`; | |
| setMessages((prev) => [ | |
| ...prev, | |
| { id: aiId, role: "assistant", content: "", streaming: true, timestamp: new Date(), docMode: doneDocs.length > 0 }, | |
| ]); | |
| try { | |
| let responseText = ""; | |
| if (doneDocs.length > 0) { | |
| // ── Document Q&A mode ──────────────────────────────────────────── | |
| const doc = doneDocs[0]; // use first doc for Q&A | |
| const question = content || | |
| (language === "hi" ? "इस दस्तावेज़ का सारांश और मुख्य जानकारी बताएं।" | |
| : language === "mr" ? "या कागदपत्राचा सारांश आणि मुख्य माहिती सांगा." | |
| : "Please summarize this document and give me the key financial insights."); | |
| const res = await documentsApi.chat(doc.docId!, question, language); | |
| responseText = res.answer; | |
| // If multiple docs, answer about remaining ones too | |
| for (const extraDoc of doneDocs.slice(1)) { | |
| const extra = await documentsApi.chat( | |
| extraDoc.docId!, | |
| question, | |
| language | |
| ); | |
| responseText += `\n\n---\n**${extraDoc.file.name}:**\n${extra.answer}`; | |
| } | |
| } else { | |
| // ── Regular financial chat ──────────────────────────────────────── | |
| const res = await aiApi.chat(content, user?.user_id, sessionId ?? undefined, language); | |
| responseText = res.response; | |
| } | |
| setIsThinking(false); | |
| streamWords(aiId, responseText); | |
| // Persist to memory | |
| const msgContent = content || (doneDocs.map(d => `[Document: ${d.file.name}]`).join(", ")); | |
| const sid = sessionId; | |
| const savedUser = await memoryApi.save({ | |
| session_id: sid ?? undefined, | |
| role: "user", | |
| content: msgContent, | |
| session_title: msgContent.slice(0, 40), | |
| }); | |
| const newSid = savedUser.session_id; | |
| if (newSid && newSid !== sessionId) { | |
| setSessionId(newSid); | |
| localStorage.setItem("bb_chat_session", newSid); | |
| } | |
| await memoryApi.save({ session_id: newSid ?? undefined, role: "assistant", content: responseText }); | |
| } catch (err) { | |
| setIsThinking(false); | |
| const errMsg = | |
| (err as Error).message === "Session expired. Please log in again." | |
| ? "Session expired. Please refresh and log in again." | |
| : "⚠️ Could not reach the AI backend. Please try again."; | |
| setMessages((prev) => | |
| prev.map((m) => m.id === aiId ? { ...m, content: errMsg, streaming: false } : m) | |
| ); | |
| } | |
| }, [input, isThinking, attachedFiles, user?.user_id, sessionId, streamWords, language]); | |
| const clearHistory = async () => { | |
| try { | |
| await memoryApi.clear(sessionId ?? undefined); | |
| setSessionId(null); | |
| setAttachedFiles([]); | |
| localStorage.removeItem("bb_chat_session"); | |
| setMessages([{ | |
| id: "welcome", | |
| role: "assistant", | |
| content: "Chat history cleared. How can I help you?", | |
| timestamp: new Date(), | |
| }]); | |
| } catch { /* ignore */ } | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| }; | |
| const isStreaming = messages.some((m) => m.streaming); | |
| const hasUploading = attachedFiles.some((f) => f.status === "uploading"); | |
| const hasDone = attachedFiles.some((f) => f.status === "done"); | |
| const canSend = (input.trim() || hasDone) && !isThinking && !isStreaming && !hasUploading; | |
| const headerBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10"; | |
| const inputBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10"; | |
| return ( | |
| <div | |
| className="flex h-[calc(100vh-4rem)] flex-col -m-8" | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| > | |
| {/* ── Drag overlay ───────────────────────────────────────────────────── */} | |
| <AnimatePresence> | |
| {isDragging && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-emerald-500 bg-emerald-500/10 backdrop-blur-sm pointer-events-none" | |
| > | |
| <Upload className="h-12 w-12 text-emerald-500 mb-3" /> | |
| <p className="text-lg font-semibold text-emerald-500">Drop files here</p> | |
| <p className="text-sm text-emerald-400 mt-1">PDF, DOCX, CSV, TXT, Images — max 10 MB</p> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* ── Header ─────────────────────────────────────────────────────────── */} | |
| <div | |
| className={`flex items-center justify-between border-b backdrop-blur-xl px-6 py-3 flex-shrink-0 ${headerBg}`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" /> | |
| <span className="text-xs text-emerald-500">Live AI · {language.toUpperCase()}</span> | |
| </div> | |
| <div className="h-4 w-px" style={{ background: "var(--border-strong)" }} /> | |
| <div> | |
| <h1 className="text-sm font-semibold" style={{ color: "var(--fg)" }}>BankBot AI Assistant</h1> | |
| <p className="text-[10px]" style={{ color: "var(--fg-subtle)" }}> | |
| Context-aware · Document analysis · Memory enabled | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {/* Upload shortcut */} | |
| <motion.button | |
| whileHover={{ scale: 1.04 }} | |
| whileTap={{ scale: 0.96 }} | |
| onClick={() => fileInputRef.current?.click()} | |
| title="Attach file" | |
| className={`flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition-colors ${ | |
| isLight | |
| ? "border-purple-200 bg-purple-50 text-purple-600 hover:bg-purple-100" | |
| : "border-purple-500/30 bg-purple-500/10 text-purple-400 hover:bg-purple-500/20" | |
| }`} | |
| > | |
| <Paperclip className="h-3.5 w-3.5" /> | |
| Attach | |
| </motion.button> | |
| <button | |
| onClick={clearHistory} | |
| title="Clear history" | |
| className="flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs transition-colors" | |
| style={{ borderColor: "var(--border)", color: "var(--fg-subtle)" }} | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| Clear | |
| </button> | |
| </div> | |
| </div> | |
| {/* ── Messages ───────────────────────────────────────────────────────── */} | |
| <div className="flex-1 overflow-y-auto px-6 py-5 space-y-5"> | |
| {/* Welcome screen */} | |
| {messages.length === 1 && messages[0].id === "welcome" && ( | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.8 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| transition={{ duration: 0.6 }} | |
| className="flex flex-col items-center gap-6 py-6" | |
| > | |
| <AIOrb isThinking={false} /> | |
| <div className="text-center"> | |
| <p className="text-lg font-semibold" style={{ color: "var(--fg)" }}> | |
| Your AI Financial Twin | |
| </p> | |
| <p className="text-sm mt-1" style={{ color: "var(--fg-muted)" }}> | |
| {t("chat_placeholder")} | |
| </p> | |
| </div> | |
| {/* File type shortcuts */} | |
| <div className="flex gap-3 flex-wrap justify-center"> | |
| {[ | |
| { icon: FileText, label: "Bank Statement", ext: ".pdf", color: "text-blue-400", bg: isLight ? "bg-blue-50 border-blue-200" : "bg-blue-500/10 border-blue-500/20" }, | |
| { icon: FileSpreadsheet, label: "CSV / Excel", ext: ".csv", color: "text-emerald-400", bg: isLight ? "bg-emerald-50 border-emerald-200" : "bg-emerald-500/10 border-emerald-500/20" }, | |
| { icon: FileText, label: "Invoice / Doc", ext: ".docx", color: "text-purple-400", bg: isLight ? "bg-purple-50 border-purple-200" : "bg-purple-500/10 border-purple-500/20" }, | |
| { icon: ImageIcon, label: "Statement Image",ext: ".png", color: "text-amber-400", bg: isLight ? "bg-amber-50 border-amber-200" : "bg-amber-500/10 border-amber-500/20" }, | |
| ].map((item) => ( | |
| <motion.button | |
| key={item.label} | |
| whileHover={{ scale: 1.04 }} | |
| whileTap={{ scale: 0.96 }} | |
| onClick={() => fileInputRef.current?.click()} | |
| className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-medium transition-all ${item.bg} ${item.color}`} | |
| > | |
| <item.icon className="h-3.5 w-3.5" /> | |
| {item.label} | |
| </motion.button> | |
| ))} | |
| </div> | |
| {/* Standard suggestions */} | |
| <div className="grid grid-cols-2 gap-2.5 w-full max-w-md"> | |
| {suggestions.map((s) => { | |
| const Icon = s.icon; | |
| return ( | |
| <motion.button | |
| key={s.label} | |
| whileHover={{ scale: 1.03 }} | |
| whileTap={{ scale: 0.97 }} | |
| onClick={() => sendMessage(s.label)} | |
| className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-all ${s.bg} ${s.color} hover:brightness-110`} | |
| > | |
| <Icon className="h-4 w-4 flex-shrink-0" /> | |
| <span className="text-left text-xs leading-snug">{s.label}</span> | |
| </motion.button> | |
| ); | |
| })} | |
| </div> | |
| </motion.div> | |
| )} | |
| {/* Message list */} | |
| {messages.map((msg) => ( | |
| <MessageBubble | |
| key={msg.id} | |
| message={msg} | |
| onCopy={(t) => navigator.clipboard.writeText(t).catch(() => {})} | |
| userInitial={userInitial} | |
| isLight={isLight} | |
| /> | |
| ))} | |
| {/* Thinking indicator */} | |
| <AnimatePresence> | |
| {isThinking && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| className="flex gap-3" | |
| > | |
| <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-cyan-500"> | |
| <Sparkles className="h-4 w-4 text-white" /> | |
| </div> | |
| <div className={`rounded-2xl rounded-tl-sm px-4 py-3 border ${ | |
| isLight ? "bg-white border-slate-200" : "glass border-white/10" | |
| }`}> | |
| <div className="flex gap-1 items-center h-4"> | |
| {[0, 1, 2].map((i) => ( | |
| <motion.div | |
| key={i} | |
| animate={{ y: [0, -4, 0] }} | |
| transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} | |
| className="h-1.5 w-1.5 rounded-full bg-emerald-400" | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* ── Quick suggestions (post-first-message) ─────────────────────────── */} | |
| {messages.length > 1 && ( | |
| <div className="flex gap-2 px-6 pb-2 overflow-x-auto flex-shrink-0"> | |
| {suggestions.map((s) => { | |
| const Icon = s.icon; | |
| return ( | |
| <button | |
| key={s.label} | |
| onClick={() => sendMessage(s.label)} | |
| disabled={isStreaming || isThinking} | |
| className={`flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all disabled:opacity-40 ${s.bg} ${s.color} hover:brightness-110`} | |
| > | |
| <Icon className="h-3 w-3" /> | |
| {s.label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* ── Input area ─────────────────────────────────────────────────────── */} | |
| <div className={`flex-shrink-0 border-t backdrop-blur-xl px-6 py-3 ${inputBg}`}> | |
| {/* Attached files preview */} | |
| <AnimatePresence> | |
| {attachedFiles.length > 0 && ( | |
| <motion.div | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: "auto" }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="flex flex-wrap gap-2 mb-3" | |
| > | |
| {attachedFiles.map((af, i) => ( | |
| <AttachmentChip | |
| key={i} | |
| file={af} | |
| isLight={isLight} | |
| onRemove={() => { | |
| if (af.previewUrl) URL.revokeObjectURL(af.previewUrl); | |
| setAttachedFiles((prev) => prev.filter((_, idx) => idx !== i)); | |
| }} | |
| /> | |
| ))} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Input row */} | |
| <div | |
| className={`flex items-end gap-2 rounded-2xl border px-3 py-2.5 transition-all focus-within:border-emerald-500/50 ${ | |
| isLight ? "border-slate-200 bg-white" : "border-white/10 bg-white/5" | |
| }`} | |
| > | |
| {/* Attach button */} | |
| <button | |
| title="Attach file (PDF, DOCX, CSV, image)" | |
| onClick={() => fileInputRef.current?.click()} | |
| className="flex-shrink-0 p-1.5 rounded-xl transition-colors hover:bg-purple-500/10 mb-0.5" | |
| style={{ color: attachedFiles.length > 0 ? "#a855f7" : "var(--fg-subtle)" }} | |
| > | |
| <Paperclip className="h-4 w-4" /> | |
| </button> | |
| {/* Hidden file input */} | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept={ACCEPTED} | |
| multiple | |
| className="hidden" | |
| onChange={(e) => { if (e.target.files) { addFiles(e.target.files); e.target.value = ""; } }} | |
| /> | |
| {/* Text area */} | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| attachedFiles.length > 0 | |
| ? (attachedFiles.some(f => f.status === "uploading") | |
| ? "Uploading…" | |
| : "Ask about the attached document…") | |
| : t("chat_placeholder") | |
| } | |
| rows={1} | |
| disabled={isStreaming || isThinking} | |
| className="flex-1 resize-none bg-transparent text-sm focus:outline-none leading-relaxed max-h-28 disabled:opacity-50" | |
| style={{ minHeight: "24px", color: "var(--fg)", caretColor: "var(--fg)" }} | |
| /> | |
| {/* Right controls */} | |
| <div className="flex items-center gap-1.5 mb-0.5 flex-shrink-0"> | |
| <button | |
| title="Voice input" | |
| className="p-1.5 rounded-xl transition-colors" | |
| style={{ color: "var(--fg-subtle)" }} | |
| > | |
| <Mic className="h-4 w-4" /> | |
| </button> | |
| <motion.button | |
| whileHover={{ scale: canSend ? 1.05 : 1 }} | |
| whileTap={{ scale: canSend ? 0.95 : 1 }} | |
| onClick={() => sendMessage()} | |
| disabled={!canSend} | |
| title="Send" | |
| className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity" | |
| > | |
| <Send className="h-3.5 w-3.5" /> | |
| </motion.button> | |
| </div> | |
| </div> | |
| {/* Footer hint */} | |
| <p className="text-center text-[10px] mt-2" style={{ color: "var(--fg-subtle)" }}> | |
| Attach PDF · DOCX · CSV · TXT · Images · AI responses are informational, not financial advice | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |