Bankbot / frontend /src /app /chat /page.tsx
mohsin-devs's picture
feat: merge document analysis into AI chat
ee6a36e
Raw
History Blame Contribute Delete
41.8 kB
"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 &nbsp;·&nbsp; AI responses are informational, not financial advice
</p>
</div>
</div>
);
}