Spaces:
Sleeping
Sleeping
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
- 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,
|
|
|
|
|
|
|
|
|
|
| 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?",
|
| 26 |
-
{ icon: PieChart, label: "Analyze my spending this month",
|
| 27 |
-
{ icon: Shield, label: "Explain my fraud alerts",
|
| 28 |
-
{ icon: Zap, label: "Give me a savings nudge",
|
| 29 |
],
|
| 30 |
hi: [
|
| 31 |
-
{ icon: TrendingUp, label: "मेरा कुल बैलेंस क्या है?",
|
| 32 |
-
{ icon: PieChart, label: "इस महीने मेरा खर्च विश्लेषण करें",
|
| 33 |
-
{ icon: Shield, label: "मेरे फ्रॉड अलर्ट समझाएं",
|
| 34 |
-
{ icon: Zap, label: "बचत के लिए सुझाव दें",
|
| 35 |
],
|
| 36 |
mr: [
|
| 37 |
-
{ icon: TrendingUp, label: "माझी एकूण शिल्लक किती आहे?",
|
| 38 |
-
{ icon: PieChart, label: "या महिन्याचा खर्च विश्लेषण करा",
|
| 39 |
-
{ icon: Shield, label: "माझे फसवणूक अलर्ट सांगा",
|
| 40 |
-
{ icon: Zap, label: "बचतीसाठी सल्ला द्या",
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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={{
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const isUser = message.role === "user";
|
|
|
|
| 71 |
const renderContent = (text: string) =>
|
| 72 |
text.split("\n").map((line, i) => {
|
| 73 |
-
if (line.startsWith("**") && line.endsWith("**"))
|
| 74 |
-
|
| 75 |
-
if (line.startsWith("
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
});
|
| 78 |
|
| 79 |
return (
|
| 80 |
-
<motion.div
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${
|
| 83 |
-
isUser
|
|
|
|
|
|
|
| 84 |
}`}>
|
| 85 |
{isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
|
| 86 |
</div>
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
<div className="flex items-center gap-1 px-1">
|
| 96 |
-
{[
|
| 97 |
-
{ icon:
|
| 98 |
-
{ icon:
|
|
|
|
| 99 |
].map(({ icon: Icon, label, action }) => (
|
| 100 |
-
<button
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 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 }
|
| 115 |
-
const { language, t }
|
| 116 |
-
const { theme }
|
| 117 |
-
const isLight
|
| 118 |
-
const userInitial
|
| 119 |
-
const suggestions
|
| 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
|
| 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
|
|
|
|
| 132 |
|
| 133 |
// Auto-scroll
|
| 134 |
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
|
| 135 |
|
| 136 |
-
// Load
|
| 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:
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
| 164 |
timestamp: new Date(),
|
| 165 |
}]);
|
| 166 |
}
|
| 167 |
} catch {
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
}
|
| 171 |
setHistoryLoaded(true);
|
| 172 |
};
|
| 173 |
load();
|
| 174 |
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
| 175 |
|
| 176 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
| 188 |
i++;
|
| 189 |
}, 28);
|
| 190 |
}, []);
|
| 191 |
|
| 192 |
-
// Send message
|
| 193 |
const sendMessage = useCallback(async (text?: string) => {
|
| 194 |
const content = (text ?? input).trim();
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
abortRef.current?.abort();
|
| 197 |
abortRef.current = new AbortController();
|
| 198 |
setInput("");
|
| 199 |
setIsThinking(true);
|
| 200 |
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
setMessages((prev) => [...prev, userMsg]);
|
| 203 |
|
|
|
|
|
|
|
|
|
|
| 204 |
const aiId = `ai-${Date.now()}`;
|
| 205 |
-
setMessages((prev) => [
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
try {
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
setIsThinking(false);
|
| 210 |
-
streamWords(aiId,
|
| 211 |
|
| 212 |
-
// Persist
|
|
|
|
| 213 |
const sid = sessionId;
|
| 214 |
-
const savedUser = await memoryApi.save({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 221 |
} catch (err) {
|
| 222 |
setIsThinking(false);
|
| 223 |
-
const errMsg =
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
| 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([{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
const headerBg = isLight ? "bg-white/90 border-black/8" : "bg-black/20 border-white/10";
|
| 246 |
-
const inputBg
|
| 247 |
|
| 248 |
return (
|
| 249 |
-
<div
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 258 |
<div>
|
| 259 |
-
<h1 className="text-
|
| 260 |
-
<p className="text-
|
|
|
|
|
|
|
| 261 |
</div>
|
| 262 |
</div>
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
</div>
|
| 268 |
|
| 269 |
-
{/* Messages */}
|
| 270 |
-
<div className="flex-1 overflow-y-auto px-
|
|
|
|
|
|
|
| 271 |
{messages.length === 1 && messages[0].id === "welcome" && (
|
| 272 |
-
<motion.div
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
<AIOrb isThinking={false} />
|
| 275 |
<div className="text-center">
|
| 276 |
-
<p className="text-lg font-semibold"
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
</div>
|
| 279 |
-
|
|
|
|
|
|
|
| 280 |
{suggestions.map((s) => {
|
| 281 |
const Icon = s.icon;
|
| 282 |
return (
|
| 283 |
-
<motion.button
|
|
|
|
|
|
|
|
|
|
| 284 |
onClick={() => sendMessage(s.label)}
|
| 285 |
-
className={`flex items-center gap-2 rounded-xl border px-
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
</motion.button>
|
| 288 |
);
|
| 289 |
})}
|
|
@@ -291,65 +767,168 @@ export default function ChatPage() {
|
|
| 291 |
</motion.div>
|
| 292 |
)}
|
| 293 |
|
|
|
|
| 294 |
{messages.map((msg) => (
|
| 295 |
-
<MessageBubble
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
))}
|
| 297 |
|
|
|
|
| 298 |
<AnimatePresence>
|
| 299 |
{isThinking && (
|
| 300 |
-
<motion.div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
| 305 |
<div className="flex gap-1 items-center h-4">
|
| 306 |
-
{[0,1,2].map(i => (
|
| 307 |
-
<motion.div
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 321 |
{suggestions.map((s) => {
|
| 322 |
const Icon = s.icon;
|
| 323 |
return (
|
| 324 |
-
<button
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
</button>
|
| 328 |
);
|
| 329 |
})}
|
| 330 |
</div>
|
| 331 |
)}
|
| 332 |
|
| 333 |
-
{/* Input */}
|
| 334 |
-
<div className={`flex-shrink-0 border-t backdrop-blur-xl px-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
disabled={isStreaming || isThinking}
|
| 340 |
-
className="flex-1 resize-none bg-transparent text-sm
|
| 341 |
-
style={{ minHeight: "24px" }}
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
<Send className="h-3.5 w-3.5" />
|
| 348 |
</motion.button>
|
| 349 |
</div>
|
| 350 |
</div>
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
| 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 · AI responses are informational, not financial advice
|
| 932 |
</p>
|
| 933 |
</div>
|
| 934 |
</div>
|