mohsin-devs commited on
Commit
ee6a36e
·
1 Parent(s): 7782325

feat: merge document analysis into AI chat

Browse files

- File attach button in input bar (Paperclip icon) for PDF, DOCX, CSV, TXT, images
- Drag-and-drop files directly onto the chat window
- Header 'Attach' shortcut button + file-type quick-action chips on welcome screen
- Instant upload to /api/documents/upload on attach — shows spinner, checkmark, error
- AttachmentChip preview in input with filename, size, status, remove button
- Image files show thumbnail preview
- After send: DocBadge appears in user message with expand/collapse for summary+insights
- AI answer auto-dispatched to /api/documents/chat for doc Q&A
- Multiple docs supported in one message — answers concatenated per doc
- Falls back to regular financial chat when no doc attached
- Full light/dark mode support throughout

Files changed (1) hide show
  1. frontend/src/app/chat/page.tsx +710 -131
frontend/src/app/chat/page.tsx CHANGED
@@ -4,59 +4,120 @@ import { useState, useRef, useEffect, useCallback } from "react";
4
  import { motion, AnimatePresence } from "framer-motion";
5
  import {
6
  Send, Sparkles, TrendingUp, Shield, PieChart,
7
- Zap, Copy, ThumbsUp, ThumbsDown, Trash2, Mic, Paperclip,
 
 
 
8
  } from "lucide-react";
9
- import { aiApi, memoryApi } from "@/lib/api";
10
  import { useAuthStore } from "@/lib/stores/authStore";
11
  import { useLanguageStore } from "@/lib/stores/languageStore";
12
  import { useThemeStore } from "@/lib/stores/themeStore";
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  interface Message {
15
  id: string;
16
  role: "user" | "assistant";
17
  content: string;
18
  streaming?: boolean;
19
  timestamp: Date;
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
 
 
 
 
 
 
 
 
 
 
22
  // ─── Suggestions per language ─────────────────────────────────────────────────
23
  const SUGGESTIONS = {
24
  en: [
25
- { icon: TrendingUp, label: "What's my total balance?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
26
- { icon: PieChart, label: "Analyze my spending this month", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
27
- { icon: Shield, label: "Explain my fraud alerts", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
28
- { icon: Zap, label: "Give me a savings nudge", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
29
  ],
30
  hi: [
31
- { icon: TrendingUp, label: "मेरा कुल बैलेंस क्या है?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
32
- { icon: PieChart, label: "इस महीने मेरा खर्च विश्लेषण करें", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
33
- { icon: Shield, label: "मेरे फ्रॉड अलर्ट समझाएं", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
34
- { icon: Zap, label: "बचत के लिए सुझाव दें", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
35
  ],
36
  mr: [
37
- { icon: TrendingUp, label: "माझी एकूण शिल्लक किती आहे?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
38
- { icon: PieChart, label: "या महिन्याचा खर्च विश्लेषण करा", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
39
- { icon: Shield, label: "माझे फसवणूक अलर्ट सांगा", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
40
- { icon: Zap, label: "बचतीसाठी सल्ला द्या", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
41
  ],
42
  };
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  // ─── AI Orb ───────────────────────────────────────────────────────────────────
45
  function AIOrb({ isThinking }: { isThinking: boolean }) {
46
  return (
47
  <div className="relative flex items-center justify-center">
48
- <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] }}
 
49
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
50
- className="absolute h-24 w-24 rounded-full border border-emerald-500/30" />
51
- <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] }}
 
 
52
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut", delay: 0.2 }}
53
- className="absolute h-32 w-32 rounded-full border border-blue-500/20" />
 
54
  <motion.div
55
- animate={{ boxShadow: isThinking
56
- ? ["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)"]
57
- : ["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)"] }}
 
 
58
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
59
- 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">
 
60
  <motion.div animate={{ rotate: isThinking ? 360 : 0 }} transition={{ duration: 2, repeat: isThinking ? Infinity : 0, ease: "linear" }}>
61
  <Sparkles className="h-7 w-7 text-white" />
62
  </motion.div>
@@ -65,40 +126,221 @@ function AIOrb({ isThinking }: { isThinking: boolean }) {
65
  );
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  // ─── Message Bubble ───────────────────────────────────────────────────────────
69
- function MessageBubble({ message, onCopy, userInitial }: { message: Message; onCopy: (t: string) => void; userInitial: string }) {
 
 
 
 
 
 
 
70
  const isUser = message.role === "user";
 
71
  const renderContent = (text: string) =>
72
  text.split("\n").map((line, i) => {
73
- if (line.startsWith("**") && line.endsWith("**")) return <p key={i} className="font-semibold text-white">{line.slice(2,-2)}</p>;
74
- if (line.startsWith("- ")) return <p key={i} className="ml-2">• {line.slice(2)}</p>;
75
- if (line.startsWith("# ")) return <p key={i} className="font-bold text-white text-base mt-1">{line.slice(2)}</p>;
76
- return <p key={i} className={line === "" ? "h-2" : ""}>{line}</p>;
 
 
 
 
 
 
 
77
  });
78
 
79
  return (
80
- <motion.div initial={{ opacity: 0, y: 10, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }}
81
- transition={{ duration: 0.3, ease: "easeOut" }} className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}>
 
 
 
 
 
82
  <div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${
83
- isUser ? "bg-gradient-to-br from-blue-500 to-purple-600 text-white" : "bg-gradient-to-br from-emerald-400 to-cyan-500"
 
 
84
  }`}>
85
  {isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
86
  </div>
87
- <div className={`max-w-[75%] flex flex-col gap-1 ${isUser ? "items-end" : "items-start"}`}>
88
- <div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${
89
- isUser ? "bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-tr-sm" : "glass border border-white/10 text-zinc-100 rounded-tl-sm"
90
- }`}>
91
- <div className="whitespace-pre-wrap">{renderContent(message.content)}</div>
92
- {message.streaming && <span className="streaming-cursor" />}
93
- </div>
94
- {!isUser && !message.streaming && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  <div className="flex items-center gap-1 px-1">
96
- {[{ icon: Copy, label: "Copy", action: () => onCopy(message.content) },
97
- { icon: ThumbsUp, label: "Good", action: () => {} },
98
- { icon: ThumbsDown, label: "Bad", action: () => {} }
 
99
  ].map(({ icon: Icon, label, action }) => (
100
- <button key={label} title={label} onClick={action}
101
- className="p-1 rounded-lg text-zinc-600 hover:text-zinc-400 hover:bg-white/5 transition-all">
 
 
 
102
  <Icon className="h-3 w-3" />
103
  </button>
104
  ))}
@@ -111,29 +353,32 @@ function MessageBubble({ message, onCopy, userInitial }: { message: Message; onC
111
 
112
  // ─── Main Chat Page ───────────────────────────────────────────────────────────
113
  export default function ChatPage() {
114
- const { user } = useAuthStore();
115
- const { language, t } = useLanguageStore();
116
- const { theme } = useThemeStore();
117
- const isLight = theme === "light";
118
- const userInitial = user?.name?.charAt(0).toUpperCase() ?? "U";
119
- const suggestions = SUGGESTIONS[language as keyof typeof SUGGESTIONS] ?? SUGGESTIONS.en;
120
-
121
- // Active session ID — persisted across refreshes via localStorage
122
  const [sessionId, setSessionId] = useState<string | null>(() =>
123
  typeof window !== "undefined" ? localStorage.getItem("bb_chat_session") : null
124
  );
 
 
 
 
 
 
125
 
126
- const [messages, setMessages] = useState<Message[]>([]);
127
- const [historyLoaded, setHistoryLoaded] = useState(false);
128
- const [input, setInput] = useState("");
129
- const [isThinking, setIsThinking] = useState(false);
130
  const messagesEndRef = useRef<HTMLDivElement>(null);
131
- const abortRef = useRef<AbortController | null>(null);
 
132
 
133
  // Auto-scroll
134
  useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
135
 
136
- // Load persisted history on mount
137
  useEffect(() => {
138
  if (historyLoaded) return;
139
  const load = async () => {
@@ -146,34 +391,106 @@ export default function ChatPage() {
146
  content: m.content,
147
  timestamp: new Date(m.created_at),
148
  })));
149
- // Restore session id from first message
150
  if (res.messages[0].session_id) {
151
  setSessionId(res.messages[0].session_id);
152
  localStorage.setItem("bb_chat_session", res.messages[0].session_id);
153
  }
154
  } else {
155
- // Show welcome message only if no history
156
  setMessages([{
157
  id: "welcome",
158
  role: "assistant",
159
- content: language === "hi"
160
- ? "नमस्ते! मैं आपका AI वित्तीय सहायक हूँ। आपके खाते, लेनदेन और लक्ष्यों की पूरी जानकारी मेरे पास है।\n\nआज आप क्या जानना चाहते हैं?"
161
- : language === "mr"
162
- ? "नमस्कार! मी तुमचा AI आर्थिक सहाय्यक आहे. तुमच्या खाती, व्यवहार आणि उद्दिष्टांची संपूर्ण माहिती माझ्याकडे आहे.\n\nआज तुम्हाला काय जाणून घ्यायचे आहे?"
163
- : "Hello! I'm your AI financial assistant with full context of your accounts, spending patterns, and goals.\n\nWhat would you like to explore today?",
 
164
  timestamp: new Date(),
165
  }]);
166
  }
167
  } catch {
168
- // If memory API fails, show welcome
169
- setMessages([{ id: "welcome", role: "assistant", content: "Hello! I'm your AI financial assistant. What would you like to explore today?", timestamp: new Date() }]);
 
 
 
 
170
  }
171
  setHistoryLoaded(true);
172
  };
173
  load();
174
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
175
 
176
- // Simulated word-by-word streaming
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  const streamWords = useCallback((msgId: string, fullText: string) => {
178
  const words = fullText.split(" ");
179
  let i = 0;
@@ -184,55 +501,124 @@ export default function ChatPage() {
184
  setMessages((prev) => prev.map((m) => m.id === msgId ? { ...m, streaming: false } : m));
185
  return;
186
  }
187
- setMessages((prev) => prev.map((m) => m.id === msgId ? { ...m, content: words.slice(0, i + 1).join(" ") } : m));
 
 
188
  i++;
189
  }, 28);
190
  }, []);
191
 
192
- // Send message
193
  const sendMessage = useCallback(async (text?: string) => {
194
  const content = (text ?? input).trim();
195
- if (!content || isThinking) return;
 
 
 
 
 
196
  abortRef.current?.abort();
197
  abortRef.current = new AbortController();
198
  setInput("");
199
  setIsThinking(true);
200
 
201
- const userMsg: Message = { id: `user-${Date.now()}`, role: "user", content, timestamp: new Date() };
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  setMessages((prev) => [...prev, userMsg]);
203
 
 
 
 
204
  const aiId = `ai-${Date.now()}`;
205
- setMessages((prev) => [...prev, { id: aiId, role: "assistant", content: "", streaming: true, timestamp: new Date() }]);
 
 
 
206
 
207
  try {
208
- const res = await aiApi.chat(content, user?.user_id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  setIsThinking(false);
210
- streamWords(aiId, res.response);
211
 
212
- // Persist both messages to backend memory
 
213
  const sid = sessionId;
214
- const savedUser = await memoryApi.save({ session_id: sid ?? undefined, role: "user", content, session_title: content.slice(0, 40) });
 
 
 
 
 
215
  const newSid = savedUser.session_id;
216
  if (newSid && newSid !== sessionId) {
217
  setSessionId(newSid);
218
  localStorage.setItem("bb_chat_session", newSid);
219
  }
220
- await memoryApi.save({ session_id: newSid ?? undefined, role: "assistant", content: res.response });
 
221
  } catch (err) {
222
  setIsThinking(false);
223
- const errMsg = (err as Error).message === "Session expired. Please log in again."
224
- ? "Session expired. Please refresh and log in again."
225
- : "⚠️ Could not reach the AI backend. Please try again.";
226
- setMessages((prev) => prev.map((m) => m.id === aiId ? { ...m, content: errMsg, streaming: false } : m));
 
 
 
227
  }
228
- }, [input, isThinking, user?.user_id, sessionId, streamWords]);
229
 
230
  const clearHistory = async () => {
231
  try {
232
  await memoryApi.clear(sessionId ?? undefined);
233
  setSessionId(null);
 
234
  localStorage.removeItem("bb_chat_session");
235
- setMessages([{ id: "welcome", role: "assistant", content: "Chat history cleared. How can I help you?", timestamp: new Date() }]);
 
 
 
 
 
236
  } catch { /* ignore */ }
237
  };
238
 
@@ -240,50 +626,140 @@ export default function ChatPage() {
240
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
241
  };
242
 
243
- const isStreaming = messages.some((m) => m.streaming);
 
 
 
244
 
245
  const headerBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10";
246
- const inputBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10";
247
 
248
  return (
249
- <div className="flex h-[calc(100vh-4rem)] flex-col -m-8">
250
- {/* Header */}
251
- <div className={`flex items-center justify-between border-b backdrop-blur-xl px-8 py-4 flex-shrink-0 ${headerBg}`}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  <div className="flex items-center gap-3">
253
  <div className="flex items-center gap-2">
254
  <div className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
255
  <span className="text-xs text-emerald-500">Live AI · {language.toUpperCase()}</span>
256
  </div>
257
- <div className="h-4 w-px bg-white/10" />
258
  <div>
259
- <h1 className="text-base font-semibold">BankBot AI Assistant</h1>
260
- <p className="text-xs text-zinc-500">Context-aware · Groq-powered · Memory enabled</p>
 
 
261
  </div>
262
  </div>
263
- <button onClick={clearHistory} title="Clear history"
264
- className="flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-400 hover:border-red-500/30 transition-colors">
265
- <Trash2 className="h-3.5 w-3.5" />Clear
266
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  </div>
268
 
269
- {/* Messages */}
270
- <div className="flex-1 overflow-y-auto px-8 py-6 space-y-5">
 
 
271
  {messages.length === 1 && messages[0].id === "welcome" && (
272
- <motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.6 }}
273
- className="flex flex-col items-center gap-6 py-8">
 
 
 
 
274
  <AIOrb isThinking={false} />
275
  <div className="text-center">
276
- <p className="text-lg font-semibold">Your AI Financial Twin</p>
277
- <p className="text-sm text-zinc-400 mt-1">{t("chat_placeholder")}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  </div>
279
- <div className="grid grid-cols-2 gap-3 w-full max-w-md">
 
 
280
  {suggestions.map((s) => {
281
  const Icon = s.icon;
282
  return (
283
- <motion.button key={s.label} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}
 
 
 
284
  onClick={() => sendMessage(s.label)}
285
- className={`flex items-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium transition-all ${s.bg} ${s.color} hover:brightness-110`}>
286
- <Icon className="h-4 w-4" />{s.label}
 
 
287
  </motion.button>
288
  );
289
  })}
@@ -291,65 +767,168 @@ export default function ChatPage() {
291
  </motion.div>
292
  )}
293
 
 
294
  {messages.map((msg) => (
295
- <MessageBubble key={msg.id} message={msg} onCopy={(t) => navigator.clipboard.writeText(t).catch(() => {})} userInitial={userInitial} />
 
 
 
 
 
 
296
  ))}
297
 
 
298
  <AnimatePresence>
299
  {isThinking && (
300
- <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="flex gap-3">
 
 
 
 
 
301
  <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">
302
  <Sparkles className="h-4 w-4 text-white" />
303
  </div>
304
- <div className="glass rounded-2xl rounded-tl-sm px-4 py-3 border border-white/10">
 
 
305
  <div className="flex gap-1 items-center h-4">
306
- {[0,1,2].map(i => (
307
- <motion.div key={i} animate={{ y: [0,-4,0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i*0.15 }}
308
- className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
 
 
 
 
309
  ))}
310
  </div>
311
  </div>
312
  </motion.div>
313
  )}
314
  </AnimatePresence>
 
315
  <div ref={messagesEndRef} />
316
  </div>
317
 
318
- {/* Quick suggestions */}
319
  {messages.length > 1 && (
320
- <div className="flex gap-2 px-8 pb-2 overflow-x-auto flex-shrink-0">
321
  {suggestions.map((s) => {
322
  const Icon = s.icon;
323
  return (
324
- <button key={s.label} onClick={() => sendMessage(s.label)} disabled={isStreaming || isThinking}
325
- 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`}>
326
- <Icon className="h-3 w-3" />{s.label}
 
 
 
 
 
327
  </button>
328
  );
329
  })}
330
  </div>
331
  )}
332
 
333
- {/* Input */}
334
- <div className={`flex-shrink-0 border-t backdrop-blur-xl px-8 py-4 ${inputBg}`}>
335
- <div className={`flex items-end gap-3 rounded-2xl border px-4 py-3 focus-within:border-emerald-500/40 transition-all ${isLight ? "border-black/10 bg-white" : "border-white/10 bg-white/5"}`}>
336
- <button className="text-zinc-500 hover:text-zinc-300 transition-colors mb-0.5"><Paperclip className="h-4 w-4" /></button>
337
- <textarea ref={useRef<HTMLTextAreaElement>(null)} value={input} onChange={(e) => setInput(e.target.value)}
338
- onKeyDown={handleKeyDown} placeholder={t("chat_placeholder")} rows={1}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  disabled={isStreaming || isThinking}
340
- className="flex-1 resize-none bg-transparent text-sm placeholder:text-zinc-500 focus:outline-none leading-relaxed max-h-32 disabled:opacity-50"
341
- style={{ minHeight: "24px" }} />
342
- <div className="flex items-center gap-2 mb-0.5">
343
- <button className="text-zinc-500 hover:text-zinc-300 transition-colors"><Mic className="h-4 w-4" /></button>
344
- <motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} onClick={() => sendMessage()}
345
- disabled={!input.trim() || isThinking || isStreaming}
346
- 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">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  <Send className="h-3.5 w-3.5" />
348
  </motion.button>
349
  </div>
350
  </div>
351
- <p className="text-center text-xs text-zinc-600 mt-2">
352
- AI responses are for informational purposes only. Not financial advice.
 
 
353
  </p>
354
  </div>
355
  </div>
 
4
  import { motion, AnimatePresence } from "framer-motion";
5
  import {
6
  Send, Sparkles, TrendingUp, Shield, PieChart,
7
+ Zap, Copy, ThumbsUp, ThumbsDown, Trash2, Mic,
8
+ Paperclip, FileText, ImageIcon, X, Upload,
9
+ CheckCircle2, AlertTriangle, ChevronDown, ChevronUp,
10
+ FileSpreadsheet, File,
11
  } from "lucide-react";
12
+ import { aiApi, memoryApi, documentsApi } from "@/lib/api";
13
  import { useAuthStore } from "@/lib/stores/authStore";
14
  import { useLanguageStore } from "@/lib/stores/languageStore";
15
  import { useThemeStore } from "@/lib/stores/themeStore";
16
 
17
+ // ─── Types ────────────────────────────────────────────────────────────────────
18
+ interface AttachedFile {
19
+ file: File;
20
+ previewUrl?: string; // for images
21
+ status: "pending" | "uploading" | "done" | "error";
22
+ docId?: string; // backend document id after upload
23
+ summary?: string;
24
+ insights?: string[];
25
+ errorMsg?: string;
26
+ }
27
+
28
  interface Message {
29
  id: string;
30
  role: "user" | "assistant";
31
  content: string;
32
  streaming?: boolean;
33
  timestamp: Date;
34
+ // doc context attached to this user message
35
+ attachments?: Array<{
36
+ name: string;
37
+ fileType: string;
38
+ docId: string;
39
+ summary?: string;
40
+ insights?: string[];
41
+ }>;
42
+ // if this was a doc-context answer
43
+ docMode?: boolean;
44
  }
45
 
46
+ // ─── Accepted types ───────────────────────────────────────────────────────────
47
+ const ACCEPTED = ".pdf,.docx,.txt,.csv,.png,.jpg,.jpeg,.webp";
48
+ const IMAGE_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
49
+ const DOC_TYPES = [
50
+ "application/pdf",
51
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
52
+ "text/plain",
53
+ "text/csv",
54
+ ];
55
+
56
  // ─── Suggestions per language ─────────────────────────────────────────────────
57
  const SUGGESTIONS = {
58
  en: [
59
+ { icon: TrendingUp, label: "What's my total balance?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
60
+ { icon: PieChart, label: "Analyze my spending this month", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
61
+ { icon: Shield, label: "Explain my fraud alerts", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
62
+ { icon: Zap, label: "Give me a savings nudge", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
63
  ],
64
  hi: [
65
+ { icon: TrendingUp, label: "मेरा कुल बैलेंस क्या है?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
66
+ { icon: PieChart, label: "इस महीने मेरा खर्च विश्लेषण करें", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
67
+ { icon: Shield, label: "मेरे फ्रॉड अलर्ट समझाएं", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
68
+ { icon: Zap, label: "बचत के लिए सुझाव दें", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
69
  ],
70
  mr: [
71
+ { icon: TrendingUp, label: "माझी एकूण शिल्लक किती आहे?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
72
+ { icon: PieChart, label: "या महिन्याचा खर्च विश्लेषण करा", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
73
+ { icon: Shield, label: "माझे फसवणूक अलर्ट सांगा", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
74
+ { icon: Zap, label: "बचतीसाठी सल्ला द्या", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
75
  ],
76
  };
77
 
78
+ // ─── File icon helper ─────────────────────────────────────────────────────────
79
+ function FileIcon({ type, className }: { type: string; className?: string }) {
80
+ if (IMAGE_TYPES.includes(type)) return <ImageIcon className={className} />;
81
+ if (type === "text/csv") return <FileSpreadsheet className={className} />;
82
+ if (type.includes("pdf")) return <FileText className={className} />;
83
+ return <File className={className} />;
84
+ }
85
+
86
+ function fileTypeLabel(type: string, name: string): string {
87
+ const ext = name.split(".").pop()?.toUpperCase() ?? "FILE";
88
+ if (IMAGE_TYPES.includes(type)) return "IMAGE";
89
+ return ext;
90
+ }
91
+
92
+ function formatBytes(b: number) {
93
+ if (b < 1024) return `${b} B`;
94
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
95
+ return `${(b / (1024 * 1024)).toFixed(1)} MB`;
96
+ }
97
+
98
  // ─── AI Orb ───────────────────────────────────────────────────────────────────
99
  function AIOrb({ isThinking }: { isThinking: boolean }) {
100
  return (
101
  <div className="relative flex items-center justify-center">
102
+ <motion.div
103
+ 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] }}
104
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
105
+ className="absolute h-24 w-24 rounded-full border border-emerald-500/30"
106
+ />
107
+ <motion.div
108
+ 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] }}
109
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut", delay: 0.2 }}
110
+ className="absolute h-32 w-32 rounded-full border border-blue-500/20"
111
+ />
112
  <motion.div
113
+ animate={{
114
+ boxShadow: isThinking
115
+ ? ["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)"]
116
+ : ["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)"],
117
+ }}
118
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
119
+ 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"
120
+ >
121
  <motion.div animate={{ rotate: isThinking ? 360 : 0 }} transition={{ duration: 2, repeat: isThinking ? Infinity : 0, ease: "linear" }}>
122
  <Sparkles className="h-7 w-7 text-white" />
123
  </motion.div>
 
126
  );
127
  }
128
 
129
+ // ─── Attachment Card (inside input area) ─────────────────────────────────────
130
+ function AttachmentChip({ file, onRemove, isLight }: { file: AttachedFile; onRemove: () => void; isLight: boolean }) {
131
+ const isImg = IMAGE_TYPES.includes(file.file.type);
132
+ return (
133
+ <motion.div
134
+ initial={{ opacity: 0, scale: 0.85 }}
135
+ animate={{ opacity: 1, scale: 1 }}
136
+ exit={{ opacity: 0, scale: 0.85 }}
137
+ className={`relative flex items-center gap-2 rounded-xl border px-3 py-2 text-xs max-w-[200px] ${
138
+ isLight ? "bg-slate-50 border-slate-200" : "bg-white/8 border-white/10"
139
+ }`}
140
+ >
141
+ {/* Status indicator */}
142
+ {file.status === "uploading" && (
143
+ <motion.div
144
+ animate={{ rotate: 360 }}
145
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
146
+ className="h-4 w-4 border-2 border-emerald-500 border-t-transparent rounded-full flex-shrink-0"
147
+ />
148
+ )}
149
+ {file.status === "done" && <CheckCircle2 className="h-4 w-4 text-emerald-500 flex-shrink-0" />}
150
+ {file.status === "error" && <AlertTriangle className="h-4 w-4 text-red-500 flex-shrink-0" />}
151
+ {file.status === "pending" && (
152
+ isImg
153
+ ? <ImageIcon className="h-4 w-4 text-blue-400 flex-shrink-0" />
154
+ : <FileText className="h-4 w-4 text-purple-400 flex-shrink-0" />
155
+ )}
156
+
157
+ {/* Preview thumb for images */}
158
+ {isImg && file.previewUrl && (
159
+ // eslint-disable-next-line @next/next/no-img-element
160
+ <img src={file.previewUrl} alt="preview" className="h-6 w-6 rounded object-cover flex-shrink-0" />
161
+ )}
162
+
163
+ <span className="truncate font-medium" style={{ color: "var(--fg)", maxWidth: 100 }}>
164
+ {file.file.name}
165
+ </span>
166
+ <span style={{ color: "var(--fg-subtle)" }}>{formatBytes(file.file.size)}</span>
167
+
168
+ {file.status !== "uploading" && (
169
+ <button
170
+ onClick={onRemove}
171
+ className="ml-auto flex-shrink-0 rounded-full p-0.5 hover:bg-red-500/10 transition-colors"
172
+ >
173
+ <X className="h-3 w-3 text-red-400" />
174
+ </button>
175
+ )}
176
+ </motion.div>
177
+ );
178
+ }
179
+
180
+ // ─── Document Badge (in message bubble) ──────────────────────────────────────
181
+ function DocBadge({
182
+ name, fileType, summary, insights, isLight,
183
+ }: {
184
+ name: string;
185
+ fileType: string;
186
+ summary?: string;
187
+ insights?: string[];
188
+ isLight: boolean;
189
+ }) {
190
+ const [expanded, setExpanded] = useState(false);
191
+ const isImg = ["png", "jpg", "jpeg", "webp"].includes(fileType.toLowerCase());
192
+
193
+ return (
194
+ <div
195
+ className={`rounded-xl border text-xs overflow-hidden ${
196
+ isLight ? "bg-purple-50 border-purple-200/80" : "bg-purple-500/10 border-purple-500/20"
197
+ }`}
198
+ >
199
+ <div className="flex items-center gap-2 px-3 py-2">
200
+ {isImg
201
+ ? <ImageIcon className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
202
+ : <FileText className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />}
203
+ <span className="font-semibold text-purple-600 truncate flex-1">{name}</span>
204
+ <span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
205
+ isLight ? "bg-purple-100 text-purple-600" : "bg-purple-500/20 text-purple-400"
206
+ }`}>
207
+ {fileType.toUpperCase()}
208
+ </span>
209
+ {(summary || (insights && insights.length > 0)) && (
210
+ <button
211
+ onClick={() => setExpanded(v => !v)}
212
+ className="text-purple-500 ml-1"
213
+ >
214
+ {expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
215
+ </button>
216
+ )}
217
+ </div>
218
+ <AnimatePresence>
219
+ {expanded && (summary || (insights && insights.length > 0)) && (
220
+ <motion.div
221
+ initial={{ height: 0, opacity: 0 }}
222
+ animate={{ height: "auto", opacity: 1 }}
223
+ exit={{ height: 0, opacity: 0 }}
224
+ transition={{ duration: 0.2 }}
225
+ className={`border-t px-3 py-2 space-y-1.5 ${
226
+ isLight ? "border-purple-200/80" : "border-purple-500/20"
227
+ }`}
228
+ >
229
+ {summary && (
230
+ <p style={{ color: "var(--fg-muted)" }}>{summary}</p>
231
+ )}
232
+ {insights && insights.length > 0 && (
233
+ <ul className="space-y-0.5">
234
+ {insights.map((ins, i) => (
235
+ <li key={i} className="flex gap-1.5" style={{ color: "var(--fg-muted)" }}>
236
+ <span className="text-purple-500 flex-shrink-0">•</span>
237
+ <span>{ins}</span>
238
+ </li>
239
+ ))}
240
+ </ul>
241
+ )}
242
+ </motion.div>
243
+ )}
244
+ </AnimatePresence>
245
+ </div>
246
+ );
247
+ }
248
+
249
  // ─── Message Bubble ───────────────────────────────────────────────────────────
250
+ function MessageBubble({
251
+ message, onCopy, userInitial, isLight,
252
+ }: {
253
+ message: Message;
254
+ onCopy: (t: string) => void;
255
+ userInitial: string;
256
+ isLight: boolean;
257
+ }) {
258
  const isUser = message.role === "user";
259
+
260
  const renderContent = (text: string) =>
261
  text.split("\n").map((line, i) => {
262
+ if (line.startsWith("**") && line.endsWith("**"))
263
+ return <p key={i} className="font-semibold" style={{ color: "var(--fg)" }}>{line.slice(2, -2)}</p>;
264
+ if (line.startsWith("- "))
265
+ return <p key={i} className="ml-2" style={{ color: "var(--fg-muted)" }}>{line.slice(2)}</p>;
266
+ if (line.startsWith("# "))
267
+ return <p key={i} className="font-bold text-base mt-1" style={{ color: "var(--fg)" }}>{line.slice(2)}</p>;
268
+ return (
269
+ <p key={i} className={line === "" ? "h-2" : ""} style={{ color: isUser ? "white" : "var(--fg-muted)" }}>
270
+ {line}
271
+ </p>
272
+ );
273
  });
274
 
275
  return (
276
+ <motion.div
277
+ initial={{ opacity: 0, y: 10, scale: 0.98 }}
278
+ animate={{ opacity: 1, y: 0, scale: 1 }}
279
+ transition={{ duration: 0.3, ease: "easeOut" }}
280
+ className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
281
+ >
282
+ {/* Avatar */}
283
  <div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${
284
+ isUser
285
+ ? "bg-gradient-to-br from-blue-500 to-purple-600 text-white"
286
+ : "bg-gradient-to-br from-emerald-400 to-cyan-500"
287
  }`}>
288
  {isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
289
  </div>
290
+
291
+ {/* Content */}
292
+ <div className={`max-w-[78%] flex flex-col gap-2 ${isUser ? "items-end" : "items-start"}`}>
293
+ {/* Attachment badges (user side) */}
294
+ {isUser && message.attachments && message.attachments.length > 0 && (
295
+ <div className="flex flex-col gap-1.5 w-full">
296
+ {message.attachments.map((att) => (
297
+ <DocBadge
298
+ key={att.docId}
299
+ name={att.name}
300
+ fileType={att.fileType}
301
+ summary={att.summary}
302
+ insights={att.insights}
303
+ isLight={isLight}
304
+ />
305
+ ))}
306
+ </div>
307
+ )}
308
+
309
+ {/* Doc-mode label on AI answer */}
310
+ {!isUser && message.docMode && (
311
+ <div className="flex items-center gap-1.5 text-[10px] text-purple-500">
312
+ <FileText className="h-3 w-3" />
313
+ Document Analysis
314
+ </div>
315
+ )}
316
+
317
+ {/* Bubble */}
318
+ {message.content && (
319
+ <div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${
320
+ isUser
321
+ ? "bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-tr-sm"
322
+ : isLight
323
+ ? "bg-white border border-slate-200 rounded-tl-sm"
324
+ : "glass border border-white/10 rounded-tl-sm"
325
+ }`}>
326
+ <div className="whitespace-pre-wrap">{renderContent(message.content)}</div>
327
+ {message.streaming && <span className="streaming-cursor" />}
328
+ </div>
329
+ )}
330
+
331
+ {/* Action row for AI messages */}
332
+ {!isUser && !message.streaming && message.content && (
333
  <div className="flex items-center gap-1 px-1">
334
+ {[
335
+ { icon: Copy, label: "Copy", action: () => onCopy(message.content) },
336
+ { icon: ThumbsUp, label: "Good", action: () => {} },
337
+ { icon: ThumbsDown, label: "Bad", action: () => {} },
338
  ].map(({ icon: Icon, label, action }) => (
339
+ <button
340
+ key={label} title={label} onClick={action}
341
+ className="p-1 rounded-lg transition-all hover:bg-white/5"
342
+ style={{ color: "var(--fg-subtle)" }}
343
+ >
344
  <Icon className="h-3 w-3" />
345
  </button>
346
  ))}
 
353
 
354
  // ─── Main Chat Page ───────────────────────────────────────────────────────────
355
  export default function ChatPage() {
356
+ const { user } = useAuthStore();
357
+ const { language, t } = useLanguageStore();
358
+ const { theme } = useThemeStore();
359
+ const isLight = theme === "light";
360
+ const userInitial = user?.name?.charAt(0).toUpperCase() ?? "U";
361
+ const suggestions = SUGGESTIONS[language as keyof typeof SUGGESTIONS] ?? SUGGESTIONS.en;
362
+
 
363
  const [sessionId, setSessionId] = useState<string | null>(() =>
364
  typeof window !== "undefined" ? localStorage.getItem("bb_chat_session") : null
365
  );
366
+ const [messages, setMessages] = useState<Message[]>([]);
367
+ const [historyLoaded, setHistoryLoaded] = useState(false);
368
+ const [input, setInput] = useState("");
369
+ const [isThinking, setIsThinking] = useState(false);
370
+ const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
371
+ const [isDragging, setIsDragging] = useState(false);
372
 
373
+ const inputRef = useRef<HTMLTextAreaElement>(null);
 
 
 
374
  const messagesEndRef = useRef<HTMLDivElement>(null);
375
+ const fileInputRef = useRef<HTMLInputElement>(null);
376
+ const abortRef = useRef<AbortController | null>(null);
377
 
378
  // Auto-scroll
379
  useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
380
 
381
+ // Load history on mount
382
  useEffect(() => {
383
  if (historyLoaded) return;
384
  const load = async () => {
 
391
  content: m.content,
392
  timestamp: new Date(m.created_at),
393
  })));
 
394
  if (res.messages[0].session_id) {
395
  setSessionId(res.messages[0].session_id);
396
  localStorage.setItem("bb_chat_session", res.messages[0].session_id);
397
  }
398
  } else {
 
399
  setMessages([{
400
  id: "welcome",
401
  role: "assistant",
402
+ content:
403
+ language === "hi"
404
+ ? "नमस्ते! मैं आपका AI वित्तीय सहायक हूँ। आप बैंक स्टेटमेंट, इनवॉइस, CSV या कोई भी दस्तावेज़ अटैच करके उसके बारे में पूछ सकते हैं।\n\nआज आप क्या जानना चाहते हैं?"
405
+ : language === "mr"
406
+ ? "नमस्कार! मी तुमचा AI आर्थिक सहाय्यक आहे. बँक स्टेटमेंट, इनव्हॉइस किंवा कोणतेही कागदपत्र अटॅच करा आणि प्रश्न विचारा.\n\nआज तुम्हाला काय जाणून घ्यायचे आहे?"
407
+ : "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?",
408
  timestamp: new Date(),
409
  }]);
410
  }
411
  } catch {
412
+ setMessages([{
413
+ id: "welcome",
414
+ role: "assistant",
415
+ content: "Hello! I'm your AI financial assistant. Attach any document and ask me about it, or ask about your finances.",
416
+ timestamp: new Date(),
417
+ }]);
418
  }
419
  setHistoryLoaded(true);
420
  };
421
  load();
422
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
423
 
424
+ // ── Upload a single file → returns docId + analysis ──────────────────────
425
+ const uploadFile = useCallback(async (af: AttachedFile): Promise<AttachedFile> => {
426
+ try {
427
+ setAttachedFiles((prev) =>
428
+ prev.map((f) => f.file === af.file ? { ...f, status: "uploading" } : f)
429
+ );
430
+ const result = await documentsApi.upload(af.file, language);
431
+ const updated: AttachedFile = {
432
+ ...af,
433
+ status: "done",
434
+ docId: result.id,
435
+ summary: result.summary,
436
+ insights: result.insights,
437
+ };
438
+ setAttachedFiles((prev) =>
439
+ prev.map((f) => f.file === af.file ? updated : f)
440
+ );
441
+ return updated;
442
+ } catch (err) {
443
+ const updated: AttachedFile = {
444
+ ...af,
445
+ status: "error",
446
+ errorMsg: (err as Error).message || "Upload failed",
447
+ };
448
+ setAttachedFiles((prev) =>
449
+ prev.map((f) => f.file === af.file ? updated : f)
450
+ );
451
+ return updated;
452
+ }
453
+ }, [language]);
454
+
455
+ // ── Add files (from input or drop) ───────────────────────────────────────
456
+ const addFiles = useCallback((files: FileList | File[]) => {
457
+ const arr = Array.from(files);
458
+ const valid = arr.filter((f) => {
459
+ const ok = [...IMAGE_TYPES, ...DOC_TYPES].includes(f.type) ||
460
+ ["pdf","docx","txt","csv","png","jpg","jpeg","webp"].some(ext =>
461
+ f.name.toLowerCase().endsWith(`.${ext}`)
462
+ );
463
+ return ok && f.size <= 10 * 1024 * 1024;
464
+ });
465
+ if (valid.length === 0) return;
466
+
467
+ const newFiles: AttachedFile[] = valid.map((f) => ({
468
+ file: f,
469
+ previewUrl: IMAGE_TYPES.includes(f.type) ? URL.createObjectURL(f) : undefined,
470
+ status: "pending",
471
+ }));
472
+
473
+ setAttachedFiles((prev) => {
474
+ const updated = [...prev, ...newFiles];
475
+ // Kick off uploads immediately
476
+ newFiles.forEach((af) => {
477
+ // slight delay so state settles
478
+ setTimeout(() => uploadFile(af), 100);
479
+ });
480
+ return updated;
481
+ });
482
+ }, [uploadFile]);
483
+
484
+ // ── Drag-and-drop ─────────────────────────────────────────────────────────
485
+ const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
486
+ const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
487
+ const handleDrop = (e: React.DragEvent) => {
488
+ e.preventDefault();
489
+ setIsDragging(false);
490
+ if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
491
+ };
492
+
493
+ // ── Streaming word-by-word ───────────────────────────────────────────────
494
  const streamWords = useCallback((msgId: string, fullText: string) => {
495
  const words = fullText.split(" ");
496
  let i = 0;
 
501
  setMessages((prev) => prev.map((m) => m.id === msgId ? { ...m, streaming: false } : m));
502
  return;
503
  }
504
+ setMessages((prev) =>
505
+ prev.map((m) => m.id === msgId ? { ...m, content: words.slice(0, i + 1).join(" ") } : m)
506
+ );
507
  i++;
508
  }, 28);
509
  }, []);
510
 
511
+ // ── Send message ─────────────────────────────────────────────────────────
512
  const sendMessage = useCallback(async (text?: string) => {
513
  const content = (text ?? input).trim();
514
+
515
+ // Need either text or a completed doc
516
+ const doneDocs = attachedFiles.filter((f) => f.status === "done" && f.docId);
517
+ if (!content && doneDocs.length === 0) return;
518
+ if (isThinking) return;
519
+
520
  abortRef.current?.abort();
521
  abortRef.current = new AbortController();
522
  setInput("");
523
  setIsThinking(true);
524
 
525
+ // Build user message
526
+ const userMsg: Message = {
527
+ id: `user-${Date.now()}`,
528
+ role: "user",
529
+ content,
530
+ timestamp: new Date(),
531
+ attachments: doneDocs.map((f) => ({
532
+ name: f.file.name,
533
+ fileType: f.file.name.split(".").pop() ?? "file",
534
+ docId: f.docId!,
535
+ summary: f.summary,
536
+ insights: f.insights,
537
+ })),
538
+ };
539
  setMessages((prev) => [...prev, userMsg]);
540
 
541
+ // Clear attached files after sending
542
+ setAttachedFiles([]);
543
+
544
  const aiId = `ai-${Date.now()}`;
545
+ setMessages((prev) => [
546
+ ...prev,
547
+ { id: aiId, role: "assistant", content: "", streaming: true, timestamp: new Date(), docMode: doneDocs.length > 0 },
548
+ ]);
549
 
550
  try {
551
+ let responseText = "";
552
+
553
+ if (doneDocs.length > 0) {
554
+ // ── Document Q&A mode ────────────────────────────────────────────
555
+ const doc = doneDocs[0]; // use first doc for Q&A
556
+ const question = content ||
557
+ (language === "hi" ? "इस दस्तावेज़ का सारांश और मुख्य जानकारी बताएं।"
558
+ : language === "mr" ? "या कागदपत्राचा सारांश आणि मुख्य माहिती सांगा."
559
+ : "Please summarize this document and give me the key financial insights.");
560
+
561
+ const res = await documentsApi.chat(doc.docId!, question, language);
562
+ responseText = res.answer;
563
+
564
+ // If multiple docs, answer about remaining ones too
565
+ for (const extraDoc of doneDocs.slice(1)) {
566
+ const extra = await documentsApi.chat(
567
+ extraDoc.docId!,
568
+ question,
569
+ language
570
+ );
571
+ responseText += `\n\n---\n**${extraDoc.file.name}:**\n${extra.answer}`;
572
+ }
573
+ } else {
574
+ // ── Regular financial chat ────────────────────────────────────────
575
+ const res = await aiApi.chat(content, user?.user_id, sessionId ?? undefined, language);
576
+ responseText = res.response;
577
+ }
578
+
579
  setIsThinking(false);
580
+ streamWords(aiId, responseText);
581
 
582
+ // Persist to memory
583
+ const msgContent = content || (doneDocs.map(d => `[Document: ${d.file.name}]`).join(", "));
584
  const sid = sessionId;
585
+ const savedUser = await memoryApi.save({
586
+ session_id: sid ?? undefined,
587
+ role: "user",
588
+ content: msgContent,
589
+ session_title: msgContent.slice(0, 40),
590
+ });
591
  const newSid = savedUser.session_id;
592
  if (newSid && newSid !== sessionId) {
593
  setSessionId(newSid);
594
  localStorage.setItem("bb_chat_session", newSid);
595
  }
596
+ await memoryApi.save({ session_id: newSid ?? undefined, role: "assistant", content: responseText });
597
+
598
  } catch (err) {
599
  setIsThinking(false);
600
+ const errMsg =
601
+ (err as Error).message === "Session expired. Please log in again."
602
+ ? "Session expired. Please refresh and log in again."
603
+ : "⚠️ Could not reach the AI backend. Please try again.";
604
+ setMessages((prev) =>
605
+ prev.map((m) => m.id === aiId ? { ...m, content: errMsg, streaming: false } : m)
606
+ );
607
  }
608
+ }, [input, isThinking, attachedFiles, user?.user_id, sessionId, streamWords, language]);
609
 
610
  const clearHistory = async () => {
611
  try {
612
  await memoryApi.clear(sessionId ?? undefined);
613
  setSessionId(null);
614
+ setAttachedFiles([]);
615
  localStorage.removeItem("bb_chat_session");
616
+ setMessages([{
617
+ id: "welcome",
618
+ role: "assistant",
619
+ content: "Chat history cleared. How can I help you?",
620
+ timestamp: new Date(),
621
+ }]);
622
  } catch { /* ignore */ }
623
  };
624
 
 
626
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
627
  };
628
 
629
+ const isStreaming = messages.some((m) => m.streaming);
630
+ const hasUploading = attachedFiles.some((f) => f.status === "uploading");
631
+ const hasDone = attachedFiles.some((f) => f.status === "done");
632
+ const canSend = (input.trim() || hasDone) && !isThinking && !isStreaming && !hasUploading;
633
 
634
  const headerBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10";
635
+ const inputBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10";
636
 
637
  return (
638
+ <div
639
+ className="flex h-[calc(100vh-4rem)] flex-col -m-8"
640
+ onDragOver={handleDragOver}
641
+ onDragLeave={handleDragLeave}
642
+ onDrop={handleDrop}
643
+ >
644
+ {/* ── Drag overlay ───────────────────────────────────────────────────── */}
645
+ <AnimatePresence>
646
+ {isDragging && (
647
+ <motion.div
648
+ initial={{ opacity: 0 }}
649
+ animate={{ opacity: 1 }}
650
+ exit={{ opacity: 0 }}
651
+ 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"
652
+ >
653
+ <Upload className="h-12 w-12 text-emerald-500 mb-3" />
654
+ <p className="text-lg font-semibold text-emerald-500">Drop files here</p>
655
+ <p className="text-sm text-emerald-400 mt-1">PDF, DOCX, CSV, TXT, Images — max 10 MB</p>
656
+ </motion.div>
657
+ )}
658
+ </AnimatePresence>
659
+
660
+ {/* ── Header ─────────────────────────────────────────────────────────── */}
661
+ <div
662
+ className={`flex items-center justify-between border-b backdrop-blur-xl px-6 py-3 flex-shrink-0 ${headerBg}`}
663
+ >
664
  <div className="flex items-center gap-3">
665
  <div className="flex items-center gap-2">
666
  <div className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
667
  <span className="text-xs text-emerald-500">Live AI · {language.toUpperCase()}</span>
668
  </div>
669
+ <div className="h-4 w-px" style={{ background: "var(--border-strong)" }} />
670
  <div>
671
+ <h1 className="text-sm font-semibold" style={{ color: "var(--fg)" }}>BankBot AI Assistant</h1>
672
+ <p className="text-[10px]" style={{ color: "var(--fg-subtle)" }}>
673
+ Context-aware · Document analysis · Memory enabled
674
+ </p>
675
  </div>
676
  </div>
677
+
678
+ <div className="flex items-center gap-2">
679
+ {/* Upload shortcut */}
680
+ <motion.button
681
+ whileHover={{ scale: 1.04 }}
682
+ whileTap={{ scale: 0.96 }}
683
+ onClick={() => fileInputRef.current?.click()}
684
+ title="Attach file"
685
+ className={`flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition-colors ${
686
+ isLight
687
+ ? "border-purple-200 bg-purple-50 text-purple-600 hover:bg-purple-100"
688
+ : "border-purple-500/30 bg-purple-500/10 text-purple-400 hover:bg-purple-500/20"
689
+ }`}
690
+ >
691
+ <Paperclip className="h-3.5 w-3.5" />
692
+ Attach
693
+ </motion.button>
694
+
695
+ <button
696
+ onClick={clearHistory}
697
+ title="Clear history"
698
+ className="flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs transition-colors"
699
+ style={{ borderColor: "var(--border)", color: "var(--fg-subtle)" }}
700
+ >
701
+ <Trash2 className="h-3.5 w-3.5" />
702
+ Clear
703
+ </button>
704
+ </div>
705
  </div>
706
 
707
+ {/* ── Messages ───────────────────────────────────────────────────────── */}
708
+ <div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
709
+
710
+ {/* Welcome screen */}
711
  {messages.length === 1 && messages[0].id === "welcome" && (
712
+ <motion.div
713
+ initial={{ opacity: 0, scale: 0.8 }}
714
+ animate={{ opacity: 1, scale: 1 }}
715
+ transition={{ duration: 0.6 }}
716
+ className="flex flex-col items-center gap-6 py-6"
717
+ >
718
  <AIOrb isThinking={false} />
719
  <div className="text-center">
720
+ <p className="text-lg font-semibold" style={{ color: "var(--fg)" }}>
721
+ Your AI Financial Twin
722
+ </p>
723
+ <p className="text-sm mt-1" style={{ color: "var(--fg-muted)" }}>
724
+ {t("chat_placeholder")}
725
+ </p>
726
+ </div>
727
+
728
+ {/* File type shortcuts */}
729
+ <div className="flex gap-3 flex-wrap justify-center">
730
+ {[
731
+ { 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" },
732
+ { 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" },
733
+ { 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" },
734
+ { 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" },
735
+ ].map((item) => (
736
+ <motion.button
737
+ key={item.label}
738
+ whileHover={{ scale: 1.04 }}
739
+ whileTap={{ scale: 0.96 }}
740
+ onClick={() => fileInputRef.current?.click()}
741
+ className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-medium transition-all ${item.bg} ${item.color}`}
742
+ >
743
+ <item.icon className="h-3.5 w-3.5" />
744
+ {item.label}
745
+ </motion.button>
746
+ ))}
747
  </div>
748
+
749
+ {/* Standard suggestions */}
750
+ <div className="grid grid-cols-2 gap-2.5 w-full max-w-md">
751
  {suggestions.map((s) => {
752
  const Icon = s.icon;
753
  return (
754
+ <motion.button
755
+ key={s.label}
756
+ whileHover={{ scale: 1.03 }}
757
+ whileTap={{ scale: 0.97 }}
758
  onClick={() => sendMessage(s.label)}
759
+ 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`}
760
+ >
761
+ <Icon className="h-4 w-4 flex-shrink-0" />
762
+ <span className="text-left text-xs leading-snug">{s.label}</span>
763
  </motion.button>
764
  );
765
  })}
 
767
  </motion.div>
768
  )}
769
 
770
+ {/* Message list */}
771
  {messages.map((msg) => (
772
+ <MessageBubble
773
+ key={msg.id}
774
+ message={msg}
775
+ onCopy={(t) => navigator.clipboard.writeText(t).catch(() => {})}
776
+ userInitial={userInitial}
777
+ isLight={isLight}
778
+ />
779
  ))}
780
 
781
+ {/* Thinking indicator */}
782
  <AnimatePresence>
783
  {isThinking && (
784
+ <motion.div
785
+ initial={{ opacity: 0, y: 10 }}
786
+ animate={{ opacity: 1, y: 0 }}
787
+ exit={{ opacity: 0, y: -10 }}
788
+ className="flex gap-3"
789
+ >
790
  <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">
791
  <Sparkles className="h-4 w-4 text-white" />
792
  </div>
793
+ <div className={`rounded-2xl rounded-tl-sm px-4 py-3 border ${
794
+ isLight ? "bg-white border-slate-200" : "glass border-white/10"
795
+ }`}>
796
  <div className="flex gap-1 items-center h-4">
797
+ {[0, 1, 2].map((i) => (
798
+ <motion.div
799
+ key={i}
800
+ animate={{ y: [0, -4, 0] }}
801
+ transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
802
+ className="h-1.5 w-1.5 rounded-full bg-emerald-400"
803
+ />
804
  ))}
805
  </div>
806
  </div>
807
  </motion.div>
808
  )}
809
  </AnimatePresence>
810
+
811
  <div ref={messagesEndRef} />
812
  </div>
813
 
814
+ {/* ── Quick suggestions (post-first-message) ─────────────────────────── */}
815
  {messages.length > 1 && (
816
+ <div className="flex gap-2 px-6 pb-2 overflow-x-auto flex-shrink-0">
817
  {suggestions.map((s) => {
818
  const Icon = s.icon;
819
  return (
820
+ <button
821
+ key={s.label}
822
+ onClick={() => sendMessage(s.label)}
823
+ disabled={isStreaming || isThinking}
824
+ 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`}
825
+ >
826
+ <Icon className="h-3 w-3" />
827
+ {s.label}
828
  </button>
829
  );
830
  })}
831
  </div>
832
  )}
833
 
834
+ {/* ── Input area ─────────────────────────────────────────────────────── */}
835
+ <div className={`flex-shrink-0 border-t backdrop-blur-xl px-6 py-3 ${inputBg}`}>
836
+
837
+ {/* Attached files preview */}
838
+ <AnimatePresence>
839
+ {attachedFiles.length > 0 && (
840
+ <motion.div
841
+ initial={{ opacity: 0, height: 0 }}
842
+ animate={{ opacity: 1, height: "auto" }}
843
+ exit={{ opacity: 0, height: 0 }}
844
+ className="flex flex-wrap gap-2 mb-3"
845
+ >
846
+ {attachedFiles.map((af, i) => (
847
+ <AttachmentChip
848
+ key={i}
849
+ file={af}
850
+ isLight={isLight}
851
+ onRemove={() => {
852
+ if (af.previewUrl) URL.revokeObjectURL(af.previewUrl);
853
+ setAttachedFiles((prev) => prev.filter((_, idx) => idx !== i));
854
+ }}
855
+ />
856
+ ))}
857
+ </motion.div>
858
+ )}
859
+ </AnimatePresence>
860
+
861
+ {/* Input row */}
862
+ <div
863
+ className={`flex items-end gap-2 rounded-2xl border px-3 py-2.5 transition-all focus-within:border-emerald-500/50 ${
864
+ isLight ? "border-slate-200 bg-white" : "border-white/10 bg-white/5"
865
+ }`}
866
+ >
867
+ {/* Attach button */}
868
+ <button
869
+ title="Attach file (PDF, DOCX, CSV, image)"
870
+ onClick={() => fileInputRef.current?.click()}
871
+ className="flex-shrink-0 p-1.5 rounded-xl transition-colors hover:bg-purple-500/10 mb-0.5"
872
+ style={{ color: attachedFiles.length > 0 ? "#a855f7" : "var(--fg-subtle)" }}
873
+ >
874
+ <Paperclip className="h-4 w-4" />
875
+ </button>
876
+
877
+ {/* Hidden file input */}
878
+ <input
879
+ ref={fileInputRef}
880
+ type="file"
881
+ accept={ACCEPTED}
882
+ multiple
883
+ className="hidden"
884
+ onChange={(e) => { if (e.target.files) { addFiles(e.target.files); e.target.value = ""; } }}
885
+ />
886
+
887
+ {/* Text area */}
888
+ <textarea
889
+ ref={inputRef}
890
+ value={input}
891
+ onChange={(e) => setInput(e.target.value)}
892
+ onKeyDown={handleKeyDown}
893
+ placeholder={
894
+ attachedFiles.length > 0
895
+ ? (attachedFiles.some(f => f.status === "uploading")
896
+ ? "Uploading…"
897
+ : "Ask about the attached document…")
898
+ : t("chat_placeholder")
899
+ }
900
+ rows={1}
901
  disabled={isStreaming || isThinking}
902
+ className="flex-1 resize-none bg-transparent text-sm focus:outline-none leading-relaxed max-h-28 disabled:opacity-50"
903
+ style={{ minHeight: "24px", color: "var(--fg)", caretColor: "var(--fg)" }}
904
+ />
905
+
906
+ {/* Right controls */}
907
+ <div className="flex items-center gap-1.5 mb-0.5 flex-shrink-0">
908
+ <button
909
+ title="Voice input"
910
+ className="p-1.5 rounded-xl transition-colors"
911
+ style={{ color: "var(--fg-subtle)" }}
912
+ >
913
+ <Mic className="h-4 w-4" />
914
+ </button>
915
+
916
+ <motion.button
917
+ whileHover={{ scale: canSend ? 1.05 : 1 }}
918
+ whileTap={{ scale: canSend ? 0.95 : 1 }}
919
+ onClick={() => sendMessage()}
920
+ disabled={!canSend}
921
+ title="Send"
922
+ 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"
923
+ >
924
  <Send className="h-3.5 w-3.5" />
925
  </motion.button>
926
  </div>
927
  </div>
928
+
929
+ {/* Footer hint */}
930
+ <p className="text-center text-[10px] mt-2" style={{ color: "var(--fg-subtle)" }}>
931
+ Attach PDF · DOCX · CSV · TXT · Images &nbsp;·&nbsp; AI responses are informational, not financial advice
932
  </p>
933
  </div>
934
  </div>